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:
2026-04-02 15:47:17 -04:00
parent b28ee72c67
commit 787f90fd12
141 changed files with 167 additions and 15 deletions

View 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
}

View File

@@ -0,0 +1,12 @@
{
"name": "@mizan/runtime",
"version": "0.1.0",
"description": "Mizan client state engine — framework-agnostic context registry, invalidation, and fetch.",
"type": "module",
"main": "index.ts",
"types": "index.ts",
"exports": {
".": "./index.ts"
},
"license": "MIT"
}