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:
2026-05-17 18:29:06 -04:00
parent 43bcf3f26f
commit 7fb0c4a400
22 changed files with 1142 additions and 56 deletions

View File

@@ -142,6 +142,58 @@ def current_user(request) -> UserOutput:
)
# ─── Merge protocol fixtures ────────────────────────────────────────────────
class MorphGroupMeta(BaseModel):
"""Group summary — narrower shape than MorphLayer. Listed alongside
morph_layers so the server's slot resolver has to discriminate by
return-type rather than by bundle order."""
id: int
label: str
count: int
class MorphLayer(BaseModel):
id: int
group_id: int
label: str
value: float
_morph_groups: list[MorphGroupMeta] = [
MorphGroupMeta(id=1, label="face", count=2),
]
_morph_layers: list[MorphLayer] = [
MorphLayer(id=1, group_id=1, label="brow", value=0.0),
MorphLayer(id=2, group_id=1, label="jaw", value=0.0),
]
@client(context="morphs")
def morph_groups(request) -> list[MorphGroupMeta]:
"""Summary-shape slot — server must route MorphLayer mutations away from here."""
return list(_morph_groups)
@client(context="morphs")
def morph_layers(request) -> list[MorphLayer]:
"""Detailed-shape slot — server routes MorphLayer mutations here."""
return list(_morph_layers)
@client(merge="morphs")
def set_morph_value(request, id: int, value: float) -> MorphLayer:
"""Mutation that returns the changed row; kernel splices into morph_layers."""
for layer in _morph_layers:
if layer.id == id:
layer.value = value
return layer
raise ValueError(f"unknown morph layer id={id}")
# ─── Registration ───────────────────────────────────────────────────────────
@@ -156,6 +208,9 @@ register(not_implemented_fn, "not_implemented_fn")
register(buggy_fn, "buggy_fn")
register(permission_check_fn, "permission_check_fn")
register(current_user, "current_user")
register(morph_groups, "morph_groups")
register(morph_layers, "morph_layers")
register(set_morph_value, "set_morph_value")
# ─── App ────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,16 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { morphGroupsOutput, morphLayersOutput } from '../types'
export interface MorphsContextData {
morph_groups: morphGroupsOutput
morph_layers: morphLayersOutput
}
export type MorphsContextParams = Record<string, never>
export function fetchMorphsContext(params: MorphsContextParams): Promise<MorphsContextData> {
return mizanFetch('morphs', params)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { setMorphValueInput, setMorphValueOutput } from '../types'
export function callSetMorphValue(args: setMorphValueInput): Promise<setMorphValueOutput> {
return mizanCall('set_morph_value', args)
}

View File

@@ -3,6 +3,7 @@
export * from './types'
export { fetchGlobalContext, type GlobalContextData, type GlobalContextParams } from './contexts/global'
export { fetchMorphsContext, type MorphsContextData, type MorphsContextParams } from './contexts/morphs'
export { callEcho } from './functions/echo'
export { callAdd } from './functions/add'
@@ -14,6 +15,7 @@ export { callVerifiedOnly } from './functions/verifiedOnly'
export { callNotImplementedFn } from './functions/notImplementedFn'
export { callBuggyFn } from './functions/buggyFn'
export { callPermissionCheckFn } from './functions/permissionCheckFn'
export { callSetMorphValue } from './functions/setMorphValue'
// Stage 2 framework adapter
export * from './react'

View File

@@ -22,7 +22,7 @@ import {
type ContextState,
} from '@mizan/base'
import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, callEcho, callAdd, callMultiply, callWhoami, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callNotImplementedFn, callBuggyFn, callPermissionCheckFn } from './index'
import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, fetchMorphsContext, type MorphsContextData, type MorphsContextParams, callEcho, callAdd, callMultiply, callWhoami, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callNotImplementedFn, callBuggyFn, callPermissionCheckFn, callSetMorphValue, type currentUserOutput, type morphGroupsOutput, type morphLayersOutput } from './index'
// Internal — runs inside a Provider, registers with the kernel exactly once.
function useContextSubscription<T>(
@@ -94,6 +94,29 @@ export function useCurrentUser(): currentUserOutput | null {
return useGlobalContext().data?.current_user ?? null
}
// ── Morphs Context ──
const MorphsCtx = createContext<ContextState<MorphsContextData> | null>(null)
export function MorphsContext({ children }: { children: ReactNode }) {
const state = useContextSubscription('morphs', {}, () => fetchMorphsContext({} as any))
return <MorphsCtx.Provider value={state}>{children}</MorphsCtx.Provider>
}
export function useMorphsContext(): ContextState<MorphsContextData> {
const ctx = useContext(MorphsCtx)
if (!ctx) throw new Error('useMorphsContext requires <MorphsContext>')
return ctx
}
export function useMorphGroups(): morphGroupsOutput | null {
return useMorphsContext().data?.morph_groups ?? null
}
export function useMorphLayers(): morphLayersOutput | null {
return useMorphsContext().data?.morph_layers ?? null
}
export function useEcho() {
return useMutation<Parameters<typeof callEcho>[0], Awaited<ReturnType<typeof callEcho>>>(callEcho)
}
@@ -134,11 +157,17 @@ export function usePermissionCheckFn() {
return useMutation<Parameters<typeof callPermissionCheckFn>[0], Awaited<ReturnType<typeof callPermissionCheckFn>>>(callPermissionCheckFn)
}
export function useSetMorphValue() {
return useMutation<Parameters<typeof callSetMorphValue>[0], Awaited<ReturnType<typeof callSetMorphValue>>>(callSetMorphValue)
}
// ── MizanContext root provider ──
export interface MizanContextProps {
/** Base URL for protocol endpoints. Defaults to "/api/mizan". */
baseUrl?: string
/** Set to `false` for backends without a `/session/` endpoint (e.g. FastAPI). */
session?: boolean
children: ReactNode
}
@@ -146,10 +175,13 @@ export interface MizanContextProps {
* Root provider — calls configure() once and mounts the global context (if defined).
* Must wrap any component using Mizan-generated hooks.
*/
export function MizanContext({ baseUrl, children }: MizanContextProps) {
export function MizanContext({ baseUrl, session, children }: MizanContextProps) {
const configured = useRef(false)
if (!configured.current) {
if (baseUrl) configure({ baseUrl })
const opts: Parameters<typeof configure>[0] = {}
if (baseUrl !== undefined) opts.baseUrl = baseUrl
if (session !== undefined) opts.session = session
if (Object.keys(opts).length > 0) configure(opts)
configured.current = true
}
return <GlobalContextProvider>{children}</GlobalContextProvider>

View File

@@ -327,6 +327,92 @@
"isContext": "global"
}
}
},
"/mizan/morph_groups": {
"post": {
"summary": "Summary-shape slot — server must route MorphLayer mutations away from here.",
"operationId": "morphGroups",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/morphGroupsOutput"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": "morphs"
}
}
},
"/mizan/morph_layers": {
"post": {
"summary": "Detailed-shape slot — server routes MorphLayer mutations here.",
"operationId": "morphLayers",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/morphLayersOutput"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": "morphs"
}
}
},
"/mizan/set_morph_value": {
"post": {
"summary": "Mutation that returns the changed row; kernel splices into morph_layers.",
"operationId": "setMorphValue",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/setMorphValueInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/setMorphValueOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
}
},
"components": {
@@ -344,6 +430,58 @@
"type": "object",
"title": "HTTPValidationError"
},
"MorphGroupMeta": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"label": {
"type": "string",
"title": "Label"
},
"count": {
"type": "integer",
"title": "Count"
}
},
"type": "object",
"required": [
"id",
"label",
"count"
],
"title": "MorphGroupMeta",
"description": "Group summary — narrower shape than MorphLayer. Listed alongside\nmorph_layers so the server's slot resolver has to discriminate by\nreturn-type rather than by bundle order."
},
"MorphLayer": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"group_id": {
"type": "integer",
"title": "Group Id"
},
"label": {
"type": "string",
"title": "Label"
},
"value": {
"type": "number",
"title": "Value"
}
},
"type": "object",
"required": [
"id",
"group_id",
"label",
"value"
],
"title": "MorphLayer"
},
"ValidationError": {
"properties": {
"loc": {
@@ -477,6 +615,20 @@
],
"title": "echoOutput"
},
"morphGroupsOutput": {
"items": {
"$ref": "#/components/schemas/MorphGroupMeta"
},
"type": "array",
"title": "morphGroupsOutput"
},
"morphLayersOutput": {
"items": {
"$ref": "#/components/schemas/MorphLayer"
},
"type": "array",
"title": "morphLayersOutput"
},
"multiplyInput": {
"properties": {
"x": {
@@ -547,6 +699,52 @@
],
"title": "permissionCheckFnOutput"
},
"setMorphValueInput": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"value": {
"type": "number",
"title": "Value"
}
},
"type": "object",
"required": [
"id",
"value"
],
"title": "setMorphValueInput"
},
"setMorphValueOutput": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"group_id": {
"type": "integer",
"title": "Group Id"
},
"label": {
"type": "string",
"title": "Label"
},
"value": {
"type": "number",
"title": "Value"
}
},
"type": "object",
"required": [
"id",
"group_id",
"label",
"value"
],
"title": "setMorphValueOutput"
},
"staffOnlyOutput": {
"properties": {
"message": {
@@ -743,6 +941,45 @@
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "morph_groups",
"camelName": "morphGroups",
"hasInput": false,
"inputType": null,
"outputType": "morphGroupsOutput",
"transport": "http",
"isContext": "morphs",
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "morph_layers",
"camelName": "morphLayers",
"hasInput": false,
"inputType": null,
"outputType": "morphLayersOutput",
"transport": "http",
"isContext": "morphs",
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "set_morph_value",
"camelName": "setMorphValue",
"hasInput": true,
"inputType": "setMorphValueInput",
"outputType": "setMorphValueOutput",
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null,
"merge": [
"morphs"
]
}
],
"x-mizan-contexts": {
@@ -751,6 +988,13 @@
"current_user"
],
"params": {}
},
"morphs": {
"functions": [
"morph_groups",
"morph_layers"
],
"params": {}
}
}
}

View File

@@ -188,6 +188,57 @@ export interface paths {
patch?: never;
trace?: never;
};
"/mizan/morph_groups": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Summary-shape slot — server must route MorphLayer mutations away from here. */
post: operations["morphGroups"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/morph_layers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Detailed-shape slot — server routes MorphLayer mutations here. */
post: operations["morphLayers"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/set_morph_value": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Mutation that returns the changed row; kernel splices into morph_layers. */
post: operations["setMorphValue"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -197,6 +248,31 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/**
* MorphGroupMeta
* @description Group summary — narrower shape than MorphLayer. Listed alongside
* morph_layers so the server's slot resolver has to discriminate by
* return-type rather than by bundle order.
*/
MorphGroupMeta: {
/** Id */
id: number;
/** Label */
label: string;
/** Count */
count: number;
};
/** MorphLayer */
MorphLayer: {
/** Id */
id: number;
/** Group Id */
group_id: number;
/** Label */
label: string;
/** Value */
value: number;
};
/** ValidationError */
ValidationError: {
/** Location */
@@ -249,6 +325,10 @@ export interface components {
/** Message */
message: string;
};
/** morphGroupsOutput */
morphGroupsOutput: components["schemas"]["MorphGroupMeta"][];
/** morphLayersOutput */
morphLayersOutput: components["schemas"]["MorphLayer"][];
/** multiplyInput */
multiplyInput: {
/** X */
@@ -276,6 +356,24 @@ export interface components {
/** Message */
message: string;
};
/** setMorphValueInput */
setMorphValueInput: {
/** Id */
id: number;
/** Value */
value: number;
};
/** setMorphValueOutput */
setMorphValueOutput: {
/** Id */
id: number;
/** Group Id */
group_id: number;
/** Label */
label: string;
/** Value */
value: number;
};
/** staffOnlyOutput */
staffOnlyOutput: {
/** Message */
@@ -584,11 +682,86 @@ export interface operations {
};
};
};
morphGroups: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["morphGroupsOutput"];
};
};
};
};
morphLayers: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["morphLayersOutput"];
};
};
};
};
setMorphValue: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["setMorphValueInput"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["setMorphValueOutput"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}
// Convenience type exports
export type HTTPValidationError = components["schemas"]["HTTPValidationError"]
export type MorphGroupMeta = components["schemas"]["MorphGroupMeta"]
export type MorphLayer = components["schemas"]["MorphLayer"]
export type ValidationError = components["schemas"]["ValidationError"]
export type addInput = components["schemas"]["addInput"]
export type addOutput = components["schemas"]["addOutput"]
@@ -596,11 +769,15 @@ export type buggyFnOutput = components["schemas"]["buggyFnOutput"]
export type currentUserOutput = components["schemas"]["currentUserOutput"]
export type echoInput = components["schemas"]["echoInput"]
export type echoOutput = components["schemas"]["echoOutput"]
export type morphGroupsOutput = components["schemas"]["morphGroupsOutput"]
export type morphLayersOutput = components["schemas"]["morphLayersOutput"]
export type multiplyInput = components["schemas"]["multiplyInput"]
export type multiplyOutput = components["schemas"]["multiplyOutput"]
export type notImplementedFnOutput = components["schemas"]["notImplementedFnOutput"]
export type permissionCheckFnInput = components["schemas"]["permissionCheckFnInput"]
export type permissionCheckFnOutput = components["schemas"]["permissionCheckFnOutput"]
export type setMorphValueInput = components["schemas"]["setMorphValueInput"]
export type setMorphValueOutput = components["schemas"]["setMorphValueOutput"]
export type staffOnlyOutput = components["schemas"]["staffOnlyOutput"]
export type superuserOnlyOutput = components["schemas"]["superuserOnlyOutput"]
export type verifiedOnlyOutput = components["schemas"]["verifiedOnlyOutput"]

View File

@@ -11,6 +11,7 @@ import { useState, useEffect } from 'react'
import {
MizanContext,
MorphsContext,
useEcho,
useAdd,
useMultiply,
@@ -22,6 +23,9 @@ import {
useBuggyFn,
usePermissionCheckFn,
useCurrentUser,
useMorphGroups,
useMorphLayers,
useSetMorphValue,
MizanError,
useMizan,
} from './api'
@@ -51,6 +55,7 @@ export function Fixtures() {
case 'permission-error': return <PermissionError_ />
case 'permission-success': return <PermissionSuccess />
case 'context-current-user': return <ContextCurrentUser />
case 'merge-morph': return <MergeMorph />
default: return <div data-testid="ready">Harness ready. Set #hash.</div>
}
}
@@ -130,3 +135,29 @@ function ContextCurrentUser() {
return <div>loading context...</div>
}
}
function MergeMorph() {
return (
<MorphsContext>
<MergeMorphInner />
</MorphsContext>
)
}
function MergeMorphInner() {
const groups = useMorphGroups()
const layers = useMorphLayers()
const { mutate } = useSetMorphValue()
const [fired, setFired] = useState(false)
useEffect(() => {
if (layers && groups && !fired) {
setFired(true)
mutate({ id: 1, value: 0.75 })
}
}, [layers, groups, fired, mutate])
if (layers === null || groups === null) return <div>loading...</div>
return <pre data-testid="result">{JSON.stringify({ groups, layers })}</pre>
}

View File

@@ -4,7 +4,7 @@ import { Fixtures } from './fixtures'
function App() {
return (
<MizanContext baseUrl="/api/mizan">
<MizanContext baseUrl="/api/mizan" session={false}>
<Fixtures />
</MizanContext>
)

View File

@@ -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)
})
})