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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user