packages/
mizan-runtime/ Framework-agnostic state engine (~150 lines)
Context registry, batched invalidation, fetch primitives
mizan-django/ Django server adapter (was packages/mizan-rpc/adapters/django/)
Codegen moved to mizan-django/generate/
mizan-react/ React adapter (was packages/mizan-csr/adapters/react/)
Removed premature abstractions: mizan-ast, mizan-schema, mizan-rpc,
mizan-csr, mizan-ssr stub packages. The actual architecture is three
concrete packages, not five abstract layers.
mizan-runtime implements the v1 spec: registerContext with params,
scoped invalidation via microtask batching, server-driven invalidation
from mutation responses, mizanFetch for context bundles, mizanCall for
mutations.
264 Django + 33 React tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
146 lines
4.0 KiB
TypeScript
146 lines
4.0 KiB
TypeScript
/**
|
|
* Mizan Runtime — The client state engine.
|
|
*
|
|
* Framework-agnostic. React, Vue, Svelte, Solid — all wrap this.
|
|
*
|
|
* Three concerns:
|
|
* 1. Context registry — mounted providers register here for invalidation
|
|
* 2. Invalidation — batched via microtask, supports scoped params
|
|
* 3. Fetch — mizanFetch (GET context bundles) + mizanCall (POST mutations)
|
|
*/
|
|
|
|
// === Types ===
|
|
|
|
export class MizanError extends Error {
|
|
constructor(public status: number, public body: string) {
|
|
super(`Mizan call failed (${status})`)
|
|
}
|
|
}
|
|
|
|
export type RefetchFn = () => void
|
|
type ParamKey = string // JSON.stringify of params
|
|
|
|
interface ContextEntry {
|
|
params: Record<string, any>
|
|
refetch: RefetchFn
|
|
}
|
|
|
|
// === Configuration ===
|
|
|
|
let config = {
|
|
baseUrl: '/api/mizan',
|
|
getHeaders: (): Record<string, string> => ({}),
|
|
}
|
|
|
|
export function configure(opts: Partial<typeof config>) {
|
|
Object.assign(config, opts)
|
|
}
|
|
|
|
// === Context Registry ===
|
|
// Mounted context providers register here. Unmounted ones deregister.
|
|
|
|
const contexts: Map<string, Map<ParamKey, ContextEntry>> = new Map()
|
|
|
|
export function registerContext(
|
|
name: string,
|
|
params: Record<string, any>,
|
|
refetch: RefetchFn,
|
|
): () => void {
|
|
if (!contexts.has(name)) contexts.set(name, new Map())
|
|
const key = JSON.stringify(params)
|
|
contexts.get(name)!.set(key, { params, refetch })
|
|
return () => contexts.get(name)!.delete(key)
|
|
}
|
|
|
|
// === Invalidation ===
|
|
// Batched via microtask. Multiple invalidations in the same tick coalesce.
|
|
|
|
const pending: Set<string> = new Set()
|
|
const pendingScoped: Map<string, Record<string, any>> = new Map()
|
|
let scheduled = false
|
|
|
|
export function invalidate(context: string, params?: Record<string, any>) {
|
|
if (params) {
|
|
pendingScoped.set(context, params)
|
|
} else {
|
|
pending.add(context)
|
|
}
|
|
if (!scheduled) {
|
|
scheduled = true
|
|
queueMicrotask(flush)
|
|
}
|
|
}
|
|
|
|
function flush() {
|
|
// Broad invalidations — refetch all instances of context
|
|
for (const name of pending) {
|
|
const entries = contexts.get(name)
|
|
if (entries) entries.forEach(entry => entry.refetch())
|
|
}
|
|
|
|
// Scoped invalidations — refetch only matching params
|
|
for (const [name, params] of pendingScoped) {
|
|
if (pending.has(name)) continue // already refetched all
|
|
const entries = contexts.get(name)
|
|
if (!entries) continue
|
|
const key = JSON.stringify(params)
|
|
const entry = entries.get(key)
|
|
if (entry) entry.refetch()
|
|
}
|
|
|
|
pending.clear()
|
|
pendingScoped.clear()
|
|
scheduled = false
|
|
}
|
|
|
|
// === Fetch ===
|
|
|
|
export async function mizanFetch(
|
|
contextName: string,
|
|
params?: Record<string, any>,
|
|
): Promise<any> {
|
|
const url = new URL(`${config.baseUrl}/ctx/${contextName}/`, globalThis.location?.origin ?? 'http://localhost')
|
|
if (params) {
|
|
for (const [k, v] of Object.entries(params)) {
|
|
url.searchParams.set(k, String(v))
|
|
}
|
|
}
|
|
const res = await fetch(url.toString(), {
|
|
headers: { ...config.getHeaders(), 'Accept': 'application/json' },
|
|
credentials: 'same-origin',
|
|
})
|
|
if (!res.ok) throw new MizanError(res.status, await res.text())
|
|
return res.json()
|
|
}
|
|
|
|
export async function mizanCall(
|
|
functionName: string,
|
|
args: Record<string, any>,
|
|
): Promise<any> {
|
|
const res = await fetch(`${config.baseUrl}/call/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
...config.getHeaders(),
|
|
'Content-Type': 'application/json',
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ function: functionName, args }),
|
|
})
|
|
if (!res.ok) throw new MizanError(res.status, await res.text())
|
|
|
|
const data = await res.json()
|
|
|
|
// Server-driven invalidation
|
|
if (data.invalidate) {
|
|
for (const entry of data.invalidate) {
|
|
if (typeof entry === 'string') {
|
|
invalidate(entry)
|
|
} else {
|
|
invalidate(entry.context, entry.params)
|
|
}
|
|
}
|
|
}
|
|
|
|
return data.result
|
|
}
|