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:
@@ -137,3 +137,56 @@ test.describe('generated context hooks', () => {
|
||||
expect(result.email).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Session gate ───────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('session gate', () => {
|
||||
test('no /session/ requests fire when configured with session={false}', async ({ page }) => {
|
||||
const sessionCalls: string[] = []
|
||||
page.on('request', (req) => {
|
||||
const url = req.url()
|
||||
if (url.includes('/session/')) sessionCalls.push(url)
|
||||
})
|
||||
await fixture(page, 'echo')
|
||||
await getResult(page)
|
||||
expect(sessionCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Merge protocol ─────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('merge protocol', () => {
|
||||
test('@client(merge=...) routes to the correct slot, leaves siblings untouched, no refetch', async ({ page }) => {
|
||||
// The morphs context bundles two list slots:
|
||||
// morph_groups: list[MorphGroupMeta] — {id, label, count}
|
||||
// morph_layers: list[MorphLayer] — {id, group_id, label, value}
|
||||
// set_morph_value returns MorphLayer. Server-side slot resolution
|
||||
// (via mizan_core.type_utils.types_match_for_merge) must route to
|
||||
// morph_layers and leave morph_groups intact. A kernel-side heuristic
|
||||
// would have to guess between the two id-bearing list slots.
|
||||
const morphsFetches: string[] = []
|
||||
page.on('request', (req) => {
|
||||
const url = req.url()
|
||||
if (url.includes('/api/mizan/ctx/morphs/')) morphsFetches.push(url)
|
||||
})
|
||||
|
||||
await page.goto(`${BASE}#merge-morph`)
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('[data-testid="result"]')
|
||||
if (!el) return false
|
||||
try {
|
||||
const data = JSON.parse(el.textContent!)
|
||||
return data.layers?.some((l: any) => l.id === 1 && l.value === 0.75)
|
||||
} catch { return false }
|
||||
}, { timeout: 5000 })
|
||||
|
||||
const result = await getResult(page)
|
||||
const layer = result.layers.find((l: any) => l.id === 1)
|
||||
expect(layer.value).toBe(0.75)
|
||||
expect(layer.label).toBe('brow')
|
||||
// Sibling slot is unchanged — the server didn't route MorphLayer into morph_groups.
|
||||
expect(result.groups).toEqual([{ id: 1, label: 'face', count: 2 }])
|
||||
// Initial mount fetches once; merge path must not trigger a refetch.
|
||||
expect(morphsFetches.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user