C6: Runtime kernel owns data, status, error — adapters subscribe

The kernel is no longer a blind refetch pipe. Each context entry has:
  { data, status: idle|loading|success|error, error }

registerContext() returns { getState, subscribe, refetch, unregister }.
Adapters subscribe to state changes via callbacks. The kernel does
the fetch and notifies subscribers with the new state.

React adapter uses useSyncExternalStore for tear-free reads.
Vue adapter uses ref + subscribe callback.
Svelte adapter uses readable store backed by kernel subscription.

All three adapters also get:
- Mutation hooks with { mutate, isPending, error } (fixes H5)
- Vue: onServerPrefetch for Nuxt SSR (fixes M9)
- Svelte: readable store auto-cleans up on unsubscribe (fixes H9)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 12:38:53 -04:00
parent 07f1c7842c
commit bb88fd984b
7 changed files with 586 additions and 361 deletions

View File

@@ -3,11 +3,7 @@
*
* Zero framework dependencies. React, Vue, Svelte — all import from here.
*
* Four concerns:
* 1. Configuration — baseUrl, auth headers, CSRF
* 2. Context registry — mounted providers register for invalidation
* 3. Invalidation — microtask-batched, scoped or broad
* 4. Fetch — mizanFetch (GET context bundles) + mizanCall (POST mutations)
* The kernel owns the data. Adapters subscribe and render.
*/
// === Error ===
@@ -54,12 +50,6 @@ function getCSRFToken(): string | null {
let _sessionReady: Promise<void> | null = null
/**
* Initialize a session (fetches CSRF cookie from GET /session/).
* Called automatically on first fetch if not called explicitly.
* No-op if a CSRF cookie already exists.
* Retries on failure — resets so next call tries again.
*/
export function initSession(): Promise<void> {
if (_sessionReady) return _sessionReady
@@ -76,39 +66,109 @@ export function initSession(): Promise<void> {
if (attempt < 2) await new Promise(r => setTimeout(r, (attempt + 1) * 100))
}
// All retries failed — reset so next call tries again
_sessionReady = null
})()
return _sessionReady
}
// === Context Registry ===
// === Context State ===
export type RefetchFn = () => void
export type ContextStatus = 'idle' | 'loading' | 'success' | 'error'
export interface ContextState<T = any> {
data: T | null
status: ContextStatus
error: Error | null
}
type Listener = () => void
type ParamKey = string
interface ContextEntry {
params: Record<string, any>
refetch: RefetchFn
state: ContextState
listeners: Set<Listener>
fetchFn: () => Promise<any>
}
const contexts: Map<string, Map<ParamKey, ContextEntry>> = new Map()
/** Deterministic JSON key for params — sorted to avoid order-dependency */
function stableKey(params: Record<string, any>): string {
return JSON.stringify(params, Object.keys(params).sort())
}
/**
* Register a context instance. The kernel owns the fetch lifecycle.
*
* Returns { getState, subscribe, refetch, unregister }.
* Adapters call subscribe() to get notified on state changes.
*/
export function registerContext(
name: string,
params: Record<string, any>,
refetch: RefetchFn,
): () => void {
fetchFn: () => Promise<any>,
initialData?: any,
): {
getState: () => ContextState
subscribe: (listener: Listener) => () => void
refetch: () => Promise<void>
unregister: () => void
} {
if (!contexts.has(name)) contexts.set(name, new Map())
const key = stableKey(params)
contexts.get(name)!.set(key, { params, refetch })
return () => contexts.get(name)?.delete(key)
const map = contexts.get(name)!
// Reuse existing entry if same key is re-registered (React Strict Mode)
let entry = map.get(key)
if (!entry) {
entry = {
params,
state: {
data: initialData ?? null,
status: initialData ? 'success' : 'idle',
error: null,
},
listeners: new Set(),
fetchFn,
}
map.set(key, entry)
} else {
// Update fetchFn in case closure changed
entry.fetchFn = fetchFn
}
const self = entry
function notify() {
self.listeners.forEach(l => l())
}
async function refetch() {
self.state = { ...self.state, status: 'loading', error: null }
notify()
try {
const data = await self.fetchFn()
self.state = { data, status: 'success', error: null }
} catch (e) {
self.state = { ...self.state, status: 'error', error: e as Error }
}
notify()
}
return {
getState: () => self.state,
subscribe: (listener: Listener) => {
self.listeners.add(listener)
return () => self.listeners.delete(listener)
},
refetch,
unregister: () => {
self.listeners.clear()
map.delete(key)
},
}
}
// === Invalidation ===
@@ -130,18 +190,42 @@ export function invalidate(context: string, params?: Record<string, any>): void
}
function flush(): void {
// Broad invalidations — refetch all instances
for (const name of pending) {
const entries = contexts.get(name)
if (entries) entries.forEach(entry => entry.refetch())
if (entries) {
entries.forEach(entry => {
entry.state = { ...entry.state, status: 'loading', error: null }
entry.listeners.forEach(l => l())
entry.fetchFn().then(data => {
entry.state = { data, status: 'success', error: null }
entry.listeners.forEach(l => l())
}).catch(err => {
entry.state = { ...entry.state, status: 'error', error: err }
entry.listeners.forEach(l => l())
})
})
}
}
// Scoped invalidations — refetch matching params
for (const { context: name, params } of pendingScoped) {
if (pending.has(name)) continue
const entries = contexts.get(name)
if (!entries) continue
const key = stableKey(params)
const entry = entries.get(key)
if (entry) entry.refetch()
if (entry) {
entry.state = { ...entry.state, status: 'loading', error: null }
entry.listeners.forEach(l => l())
entry.fetchFn().then(data => {
entry.state = { data, status: 'success', error: null }
entry.listeners.forEach(l => l())
}).catch(err => {
entry.state = { ...entry.state, status: 'error', error: err }
entry.listeners.forEach(l => l())
})
}
}
pending.clear()
@@ -159,7 +243,6 @@ async function fetchWithRetry(
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(input, init)
// Don't retry client errors (4xx) — only server/network errors
if (res.ok || (res.status >= 400 && res.status < 500)) return res
if (attempt >= retries) return res
} catch (e) {
@@ -209,7 +292,6 @@ export async function mizanCall(
const headers = await resolveHeaders()
headers['Content-Type'] = 'application/json'
// Mutations are not retried — they are not idempotent
const res = await fetch(`${config.baseUrl}/call/`, {
method: 'POST',
headers,