The socket carries invalidation on both backends, and a transport to ride it

mizan-django's socket sent a bare result, so a mutation called over it dropped
its invalidation while the same mutation over HTTP applied it. Both backends
now put the {result, invalidate, merge} envelope in `data` — the shape the
Tauri and webview transports already document — so mizanCall applies
server-driven invalidation identically whichever transport carried the call.
Two channel tests pinned the bare-result shape and move with the contract.

FastAPI gains a `ctx` action: without it a socket transport cannot fetch a
context bundle, and every app needs an HTTP connection beside the socket.

@mizan/ws-transport implements MizanTransport over one connection — RPC and
context bundles correlated by id, channel subscriptions re-sent on reconnect,
in-flight calls rejected when the socket closes under them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 00:54:35 -04:00
parent e0fc46058c
commit b5a95e8dcc
5 changed files with 294 additions and 11 deletions

View File

@@ -18,7 +18,7 @@ Protocol:
{"channel": "chat", "params": {"room": "general"}, "type": "DjangoMessage", "data": {...}}
# RPC responses
{"id": "request-id", "ok": true, "data": {...}}
{"id": "request-id", "ok": true, "data": {"result": {...}, "invalidate": [...]}}
{"id": "request-id", "ok": false, "error": {...}}
{"error": "..."}
@@ -390,7 +390,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
Protocol:
Request: {"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
Response: {"id": "request-id", "ok": true, "data": {...}}
Response: {"id": "request-id", "ok": true, "data": {"result":..., "invalidate":[...]}}
or: {"id": "request-id", "ok": false, "error": {...}}
Security:
@@ -485,13 +485,23 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
}
)
else:
await self.send_json(
{
"id": request_id,
"ok": True,
"data": result.data,
}
# the same {result, invalidate, merge} envelope the HTTP RPC path builds, so a
# mutation sent over the socket invalidates exactly as one sent over HTTP
from mizan.client.executor import _resolve_invalidation, _resolve_merges
data = {"result": result.data}
invalidate = await sync_to_async(_resolve_invalidation, thread_sensitive=True)(
fn_class, args
)
merges = await sync_to_async(_resolve_merges, thread_sensitive=True)(
fn_class, args, result.data
)
if invalidate:
data["invalidate"] = invalidate
if merges:
data["merge"] = merges
await self.send_json({"id": request_id, "ok": True, "data": data})
async def channel_message(self, event: dict):
"""

View File

@@ -1000,7 +1000,8 @@ class WebSocketRPCTests(TestCase):
self.assertEqual(response["id"], "test-123")
self.assertTrue(response["ok"])
self.assertEqual(response["data"]["echo"], "Echo: Hello")
# data is the {result, invalidate, merge} envelope, as on the HTTP RPC path
self.assertEqual(response["data"]["result"]["echo"], "Echo: Hello")
def test_handle_rpc_with_multiple_args(self):
"""_handle_rpc should handle functions with multiple arguments."""
@@ -1029,7 +1030,7 @@ class WebSocketRPCTests(TestCase):
response = consumer.sent_messages[0]
self.assertTrue(response["ok"])
self.assertEqual(response["data"]["result"], 8)
self.assertEqual(response["data"]["result"]["result"], 8)
def test_handle_rpc_function_not_found(self):
"""_handle_rpc should return error for unknown function."""