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

@@ -6,6 +6,7 @@ The WebSocket endpoint — channel subscriptions and RPC over one connection.
{"action": "unsubscribe", "channel": "chat", "params": {...}}
{"action": "message", "channel": "chat", "params": {...}, "data": {...}}
{"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
{"action": "ctx", "id": "request-id", "context": "name", "params": {...}}
Server sends:
{"channel": "chat", "params": {...}, "type": "...", "data": {...}}
@@ -29,7 +30,7 @@ from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from mizan_core.registry import get_function
from mizan_core.registry import get_context_groups, get_function
from mizan_fastapi import channels
from mizan_fastapi.executor import (
MizanError,
@@ -162,11 +163,41 @@ async def _rpc(socket: WebSocket, body: dict[str, Any]) -> None:
await socket.send_json({"id": request_id, "ok": True, "data": data})
async def _ctx(socket: WebSocket, body: dict[str, Any]) -> None:
"""A context bundle over the socket, so a client needs no second connection to read."""
request_id = body.get("id")
name = body.get("context") or ""
fn_names = get_context_groups().get(name)
if not fn_names:
await socket.send_json(
{
"id": request_id,
"ok": False,
"error": {"code": "NOT_FOUND", "message": f"Context '{name}' not found"},
}
)
return
params = body.get("params") or {}
request = _SocketRequest(socket)
try:
bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
except MizanError as e:
payload: dict[str, Any] = {"code": e.code.value, "message": e.message}
if e.details:
payload["details"] = e.details
await socket.send_json({"id": request_id, "ok": False, "error": payload})
return
await socket.send_json({"id": request_id, "ok": True, "data": bundled})
_ACTIONS = {
"subscribe": _subscribe,
"unsubscribe": _unsubscribe,
"message": _message,
"rpc": _rpc,
"ctx": _ctx,
}