Flatten to three packages + extract mizan-runtime
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>
This commit is contained in:
145
packages/mizan-runtime/index.ts
Normal file
145
packages/mizan-runtime/index.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user