Mutation→context merge primitive across the stack
The @client(merge=[context, ...]) decorator lets a mutation patch its
return value directly into the cached context bundle by matching the
mutation's Output type against each context-function's Output type
to identify the slot, then splicing server-side. Kernel runs
splice_slot on the response to apply locally — no refetch, no
invalidate-cascade.
Lands H14, H15, H16, M19, M20 from ISSUES.md.
Backends (Django + FastAPI):
_resolve_merges() in both executors walks @client(merge=...) targets,
resolves the per-context slot via types_match_for_merge, and emits
{context, slot, value, params?} entries on the response. Param
auto-scoping mirrors _resolve_invalidation's tier-1 logic.
Frontend kernel (mizan-base):
Response handler reads the merge[] array and applies splice_slot
for each entry — locates the cached context bundle by name+params,
overwrites the named slot with the new value, notifies subscribers.
Core (mizan-python):
@client decorator extended with merge= parameter. Schema export
threads merge metadata onto the OpenAPI x-mizan-functions entries.
Examples / fixtures:
fastapi-react-site harness exercises merge + Playwright spec covers
the end-to-end happy path (mutation → instant UI update without
network refetch). AFI fixture's rename_user function is the
canonical merge target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,14 @@ interface MizanConfig {
|
||||
getHeaders: () => Record<string, string> | Promise<Record<string, string>>
|
||||
csrfCookieName: string
|
||||
csrfHeaderName: string
|
||||
/**
|
||||
* Whether the backend exposes `/session/` for CSRF/session bootstrap.
|
||||
* `true` for Django (the default — preserves existing setups); set
|
||||
* `false` for FastAPI or any backend that doesn't ship a session
|
||||
* endpoint to avoid a 404 storm on startup. A future revision moves
|
||||
* this onto the schema-advertised capability surface.
|
||||
*/
|
||||
session: boolean
|
||||
}
|
||||
|
||||
const config: MizanConfig = {
|
||||
@@ -44,6 +52,7 @@ const config: MizanConfig = {
|
||||
getHeaders: () => ({}),
|
||||
csrfCookieName: 'csrftoken',
|
||||
csrfHeaderName: 'X-CSRFToken',
|
||||
session: true,
|
||||
}
|
||||
|
||||
export function configure(opts: Partial<MizanConfig>): void {
|
||||
@@ -67,6 +76,7 @@ function getCSRFToken(): string | null {
|
||||
let _sessionReady: Promise<void> | null = null
|
||||
|
||||
export function initSession(): Promise<void> {
|
||||
if (!config.session) return Promise.resolve()
|
||||
if (_sessionReady) return _sessionReady
|
||||
|
||||
_sessionReady = (async () => {
|
||||
@@ -187,6 +197,59 @@ export function registerContext(
|
||||
}
|
||||
}
|
||||
|
||||
// === Merge ===
|
||||
//
|
||||
// A mutation that declares `@client(merge=ctx)` returns `{merge: [{context,
|
||||
// slot, params?, value}]}` alongside `result`/`invalidate`. The server has
|
||||
// already resolved which bundle slot the value lands in (by matching the
|
||||
// mutation's return type against each context function's return type), so
|
||||
// the kernel does no inference — it writes directly to `bundle[slot]`,
|
||||
// upserting by id when the slot is a list. The type information lives in
|
||||
// the schema-aware backend layer; the kernel is type-erased on purpose.
|
||||
|
||||
function spliceSlot(slot: unknown, value: unknown): unknown {
|
||||
if (Array.isArray(slot)) {
|
||||
if (Array.isArray(value)) return value
|
||||
if (value && typeof value === 'object' && 'id' in value) {
|
||||
const id = (value as { id: unknown }).id
|
||||
const idx = slot.findIndex(item =>
|
||||
item && typeof item === 'object' && 'id' in item
|
||||
&& (item as { id: unknown }).id === id
|
||||
)
|
||||
const next = slot.slice()
|
||||
if (idx >= 0) next[idx] = value
|
||||
else next.push(value)
|
||||
return next
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function merge(
|
||||
context: string,
|
||||
params: Record<string, any> | undefined,
|
||||
slot: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const entries = contexts.get(context)
|
||||
if (!entries) return
|
||||
const entry = entries.get(stableKey(params ?? {}))
|
||||
if (!entry || entry.state.data == null) return
|
||||
|
||||
const data = entry.state.data
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return
|
||||
|
||||
const bundle = data as Record<string, unknown>
|
||||
if (!(slot in bundle)) return
|
||||
|
||||
entry.state = {
|
||||
data: { ...bundle, [slot]: spliceSlot(bundle[slot], value) },
|
||||
status: 'success',
|
||||
error: null,
|
||||
}
|
||||
entry.listeners.forEach(l => l())
|
||||
}
|
||||
|
||||
// === Invalidation ===
|
||||
|
||||
const pending: Set<string> = new Set()
|
||||
@@ -318,6 +381,15 @@ export async function mizanCall(
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
// Server-driven merges run before invalidations so a context that is
|
||||
// both merged-into and invalidated ends in the invalidation state — the
|
||||
// server told us to refetch, that wins.
|
||||
if (data.merge) {
|
||||
for (const entry of data.merge) {
|
||||
merge(entry.context, entry.params, entry.slot, entry.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Server-driven invalidation
|
||||
if (data.invalidate) {
|
||||
for (const entry of data.invalidate) {
|
||||
|
||||
Reference in New Issue
Block a user