From bb88fd984ba0d8e242646454174ee0ab7992702e Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Tue, 7 Apr 2026 12:38:53 -0400 Subject: [PATCH] =?UTF-8?q?C6:=20Runtime=20kernel=20owns=20data,=20status,?= =?UTF-8?q?=20error=20=E2=80=94=20adapters=20subscribe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../harness/src/api/react.tsx | 139 ++++++---- .../harness/src/api/svelte.ts | 93 +++---- .../django-react-site/harness/src/api/vue.ts | 259 +++++++++++++----- .../generate/generator/lib/adapters/react.mjs | 162 ++++++----- .../generator/lib/adapters/svelte.mjs | 63 ++--- .../generate/generator/lib/adapters/vue.mjs | 101 ++++--- packages/mizan-runtime/src/index.ts | 130 +++++++-- 7 files changed, 586 insertions(+), 361 deletions(-) diff --git a/examples/django-react-site/harness/src/api/react.tsx b/examples/django-react-site/harness/src/api/react.tsx index 86df1ae..894c700 100644 --- a/examples/django-react-site/harness/src/api/react.tsx +++ b/examples/django-react-site/harness/src/api/react.tsx @@ -2,117 +2,142 @@ // AUTO-GENERATED by mizan — do not edit -import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' -import { registerContext, mizanCall, mizanFetch } from '@mizan/runtime' +import { createContext, useContext, useState, useEffect, useCallback, useRef, useSyncExternalStore, type ReactNode } from 'react' +import { registerContext, mizanFetch, mizanCall, type ContextState } from '@mizan/runtime' import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, fetchLocalContext, type LocalContextData, type LocalContextParams, callEcho, callAdd, callWhoami, callHttpOnlyEcho, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callMultiply, callNotImplementedFn, callBuggyFn, callPermissionCheckFn, callWsWhoami, callJwtObtain, callJwtRefresh } from '../index' -// Global context — fetched once at app init -const GlobalCtx = createContext(null) +// Subscribe to kernel state via useSyncExternalStore +function useContextState( + name: string, + params: Record, + fetchFn: () => Promise, + initialData?: T, +): ContextState { + const ref = useRef | null>(null) -export function GlobalContextProvider({ children }: { children: ReactNode }) { - const [data, setData] = useState(() => { - if (typeof window === 'undefined') return null - const ssr = (window as any).__MIZAN_SSR_DATA__ - return ssr ?? null - }) + if (!ref.current) { + ref.current = registerContext(name, params, fetchFn, initialData) + } - const refetch = useCallback(async () => { - const result = await fetchGlobalContext({} as any) - setData(result) - }, []) + const handle = ref.current - useEffect(() => { if (!data) refetch() }, [data, refetch]) - useEffect(() => registerContext('global', {}, refetch), [refetch]) + // Fetch on mount if no data + useEffect(() => { + if (handle.getState().status === 'idle') handle.refetch() + return () => handle.unregister() + }, [handle]) - return {children} + return useSyncExternalStore( + handle.subscribe, + handle.getState, + handle.getState, + ) } -export function useCurrentUser(): currentUserOutput { - const ctx = useContext(GlobalCtx) - if (!ctx) throw new Error('useCurrentUser requires GlobalContextProvider') - return ctx.current_user +// Mutation hook with loading/error state +function useMutation( + callFn: (args: TArgs) => Promise, +): { mutate: (args: TArgs) => Promise; isPending: boolean; error: Error | null } { + const [isPending, setIsPending] = useState(false) + const [error, setError] = useState(null) + + const mutate = useCallback(async (args: TArgs) => { + setIsPending(true) + setError(null) + try { + const result = await callFn(args) + return result + } catch (e) { + setError(e as Error) + throw e + } finally { + setIsPending(false) + } + }, [callFn]) + + return { mutate, isPending, error } } -// Local context -const LocalCtx = createContext(null) +// ── Global Context ── -export function LocalContext({ children, ...params }: LocalContextParams & { children: ReactNode }) { - const [data, setData] = useState(() => { - if (typeof window === 'undefined') return null - const ssr = (window as any).__MIZAN_SSR_DATA__ - if (ssr?.greet !== undefined) return ssr - return null - }) - - const refetch = useCallback(async () => { - const result = await fetchLocalContext(params) - setData(result) - }, [params.name]) - - useEffect(() => { refetch() }, [refetch]) - useEffect(() => registerContext('local', params, refetch), [params.name, refetch]) - - return {children} +export function useGlobalContext(): ContextState { + const ssrData = typeof window !== 'undefined' ? (window as any).__MIZAN_SSR_DATA__ : null + return useContextState('global', {}, () => fetchGlobalContext({} as any), ssrData) } -export function useGreet(): greetOutput | null { - const ctx = useContext(LocalCtx) - return ctx?.greet ?? null +export function useCurrentUser(): currentUserOutput | null { + const state = useGlobalContext() + return state.data?.current_user ?? null +} + +// ── Local Context ── + +export function useLocalContext(params: LocalContextParams): ContextState { + const ssrData = typeof window !== 'undefined' ? (window as any).__MIZAN_SSR_DATA__ : null + return useContextState('local', params, () => fetchLocalContext(params), ssrData) +} + +export function useGreet(params: LocalContextParams): greetOutput | null { + const state = useLocalContext(params) + return state.data?.greet ?? null } export function useEcho() { - return useCallback((args: Parameters[0]) => callEcho(args), []) + return useMutation[0], Awaited>>(callEcho) } export function useAdd() { - return useCallback((args: Parameters[0]) => callAdd(args), []) + return useMutation[0], Awaited>>(callAdd) } export function useWhoami() { - return useCallback(() => callWhoami(), []) + return useMutation>>(() => callWhoami() as any) } export function useHttpOnlyEcho() { - return useCallback((args: Parameters[0]) => callHttpOnlyEcho(args), []) + return useMutation[0], Awaited>>(callHttpOnlyEcho) } export function useStaffOnly() { - return useCallback(() => callStaffOnly(), []) + return useMutation>>(() => callStaffOnly() as any) } export function useSuperuserOnly() { - return useCallback(() => callSuperuserOnly(), []) + return useMutation>>(() => callSuperuserOnly() as any) } export function useVerifiedOnly() { - return useCallback(() => callVerifiedOnly(), []) + return useMutation>>(() => callVerifiedOnly() as any) } export function useMultiply() { - return useCallback((args: Parameters[0]) => callMultiply(args), []) + return useMutation[0], Awaited>>(callMultiply) } export function useNotImplementedFn() { - return useCallback(() => callNotImplementedFn(), []) + return useMutation>>(() => callNotImplementedFn() as any) } export function useBuggyFn() { - return useCallback(() => callBuggyFn(), []) + return useMutation>>(() => callBuggyFn() as any) } export function usePermissionCheckFn() { - return useCallback((args: Parameters[0]) => callPermissionCheckFn(args), []) + return useMutation[0], Awaited>>(callPermissionCheckFn) } export function useWsWhoami() { - return useCallback(() => callWsWhoami(), []) + return useMutation>>(() => callWsWhoami() as any) } export function useJwtObtain() { - return useCallback(() => callJwtObtain(), []) + return useMutation>>(() => callJwtObtain() as any) } export function useJwtRefresh() { - return useCallback((args: Parameters[0]) => callJwtRefresh(args), []) + return useMutation[0], Awaited>>(callJwtRefresh) } + +export type { ContextState } from '@mizan/runtime' +export { configure, initSession, MizanError } from '@mizan/runtime' diff --git a/examples/django-react-site/harness/src/api/svelte.ts b/examples/django-react-site/harness/src/api/svelte.ts index 9b87a8c..4c9ac7a 100644 --- a/examples/django-react-site/harness/src/api/svelte.ts +++ b/examples/django-react-site/harness/src/api/svelte.ts @@ -1,67 +1,52 @@ // AUTO-GENERATED by mizan — do not edit -import { writable, derived, type Readable } from 'svelte/store' -import { registerContext } from '@mizan/runtime' +import { readable, type Readable } from 'svelte/store' +import { registerContext, type ContextState } from '@mizan/runtime' import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, fetchLocalContext, type LocalContextData, type LocalContextParams, callEcho, callAdd, callWhoami, callHttpOnlyEcho, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callMultiply, callNotImplementedFn, callBuggyFn, callPermissionCheckFn, callWsWhoami, callJwtObtain, callJwtRefresh } from '../index' -// Global context export function createGlobalContext() { - const data = writable(null) - const loading = writable(true) + const store = readable>( + { data: null, status: 'idle', error: null }, + (set) => { + const handle = registerContext('global', {} as any, () => fetchGlobalContext({} as any)) + const unsub = handle.subscribe(() => set(handle.getState())) + handle.refetch() + return () => { unsub(); handle.unregister() } + }, + ) - const refetch = async () => { - loading.set(true) - const result = await fetchGlobalContext({} as any) - data.set(result) - loading.set(false) - } - - refetch() - const unregister = registerContext('global', {}, refetch) - - return { - data, - loading, - currentUser: derived(data, $d => $d?.current_user ?? null) as Readable, - destroy: unregister, - } + return store } -// Local context export function createLocalContext(params: LocalContextParams) { - const data = writable(null) - const loading = writable(true) + const store = readable>( + { data: null, status: 'idle', error: null }, + (set) => { + const handle = registerContext('local', params, () => fetchLocalContext(params)) + const unsub = handle.subscribe(() => set(handle.getState())) + handle.refetch() + return () => { unsub(); handle.unregister() } + }, + ) - const refetch = async () => { - loading.set(true) - const result = await fetchLocalContext(params) - data.set(result) - loading.set(false) - } - - refetch() - const unregister = registerContext('local', params, refetch) - - return { - data, - loading, - greet: derived(data, $d => $d?.greet ?? null) as Readable, - destroy: unregister, - } + return store } -export { callEcho } from '../functions/echo' -export { callAdd } from '../functions/add' -export { callWhoami } from '../functions/whoami' -export { callHttpOnlyEcho } from '../functions/httpOnlyEcho' -export { callStaffOnly } from '../functions/staffOnly' -export { callSuperuserOnly } from '../functions/superuserOnly' -export { callVerifiedOnly } from '../functions/verifiedOnly' -export { callMultiply } from '../functions/multiply' -export { callNotImplementedFn } from '../functions/notImplementedFn' -export { callBuggyFn } from '../functions/buggyFn' -export { callPermissionCheckFn } from '../functions/permissionCheckFn' -export { callWsWhoami } from '../functions/wsWhoami' -export { callJwtObtain } from '../functions/jwtObtain' -export { callJwtRefresh } from '../functions/jwtRefresh' +export { callEcho } from '../index' +export { callAdd } from '../index' +export { callWhoami } from '../index' +export { callHttpOnlyEcho } from '../index' +export { callStaffOnly } from '../index' +export { callSuperuserOnly } from '../index' +export { callVerifiedOnly } from '../index' +export { callMultiply } from '../index' +export { callNotImplementedFn } from '../index' +export { callBuggyFn } from '../index' +export { callPermissionCheckFn } from '../index' +export { callWsWhoami } from '../index' +export { callJwtObtain } from '../index' +export { callJwtRefresh } from '../index' + +export type { ContextState } from '@mizan/runtime' +export { configure, initSession, MizanError } from '@mizan/runtime' diff --git a/examples/django-react-site/harness/src/api/vue.ts b/examples/django-react-site/harness/src/api/vue.ts index 2dfaac6..b75cde2 100644 --- a/examples/django-react-site/harness/src/api/vue.ts +++ b/examples/django-react-site/harness/src/api/vue.ts @@ -1,96 +1,229 @@ // AUTO-GENERATED by mizan — do not edit -import { ref, computed, watch, onMounted, onUnmounted, provide, inject, type Ref, type ComputedRef, type InjectionKey } from 'vue' -import { registerContext } from '@mizan/runtime' +import { ref, computed, onMounted, onUnmounted, onServerPrefetch, type ComputedRef } from 'vue' +import { registerContext, type ContextState } from '@mizan/runtime' import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, fetchLocalContext, type LocalContextData, type LocalContextParams, callEcho, callAdd, callWhoami, callHttpOnlyEcho, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callMultiply, callNotImplementedFn, callBuggyFn, callPermissionCheckFn, callWsWhoami, callJwtObtain, callJwtRefresh } from '../index' -// Global context -const GlobalKey: InjectionKey<{ data: Ref, loading: Ref }> = Symbol('global') +export function useGlobalContext() { + const state = ref>({ data: null, status: 'idle', error: null }) + let handle: ReturnType | null = null -export function provideGlobalContext() { - const data = ref(null) - const loading = ref(true) - - const refetch = async () => { - loading.value = true - try { - data.value = await fetchGlobalContext({} as any) - } catch (e) { console.error('[mizan] global fetch failed:', e) } - loading.value = false - } - - let unregister: (() => void) | null = null onMounted(() => { - refetch() - unregister = registerContext('global', {}, refetch) + handle = registerContext('global', {} as any, () => fetchGlobalContext({} as any)) + handle.subscribe(() => { state.value = handle!.getState() }) + handle.refetch() }) - onUnmounted(() => { unregister?.() }) - provide(GlobalKey, { data, loading }) -} + onServerPrefetch(async () => { + handle = registerContext('global', {} as any, () => fetchGlobalContext({} as any)) + await handle.refetch() + state.value = handle.getState() + }) -export function useCurrentUser(): ComputedRef { - const ctx = inject(GlobalKey) - if (!ctx) throw new Error('useCurrentUser requires provideGlobalContext in a parent') - return computed(() => ctx.data.value?.current_user ?? null) -} + onUnmounted(() => { handle?.unregister() }) -// Local context -const LocalKey: InjectionKey<{ data: Ref, loading: Ref }> = Symbol('local') - -export function provideLocalContext(params: { name: string }) { - const data = ref(null) - const loading = ref(true) - - const refetch = async () => { - loading.value = true - try { - data.value = await fetchLocalContext(params as any) - } catch (e) { console.error('[mizan] local fetch failed:', e) } - loading.value = false + return { + state, + currentUser: computed(() => state.value.data?.current_user ?? null) as ComputedRef, + loading: computed(() => state.value.status === 'loading'), + error: computed(() => state.value.error), } +} + +export function useLocalContext(params: LocalContextParams) { + const state = ref>({ data: null, status: 'idle', error: null }) + let handle: ReturnType | null = null - let unregister: (() => void) | null = null onMounted(() => { - refetch() - unregister = registerContext('local', params, refetch) + handle = registerContext('local', params, () => fetchLocalContext(params)) + handle.subscribe(() => { state.value = handle!.getState() }) + handle.refetch() }) - onUnmounted(() => { unregister?.() }) - provide(LocalKey, { data, loading }) + onServerPrefetch(async () => { + handle = registerContext('local', params, () => fetchLocalContext(params)) + await handle.refetch() + state.value = handle.getState() + }) + + onUnmounted(() => { handle?.unregister() }) + + return { + state, + greet: computed(() => state.value.data?.greet ?? null) as ComputedRef, + loading: computed(() => state.value.status === 'loading'), + error: computed(() => state.value.error), + } } -export function useGreet(): ComputedRef { - const ctx = inject(LocalKey) - if (!ctx) throw new Error('useGreet requires provideLocalContext in a parent') - return computed(() => ctx.data.value?.greet ?? null) +export function useEcho() { + const isPending = ref(false) + const error = ref(null) + async function mutate(args: Parameters[0]) { + isPending.value = true; error.value = null + try { return await callEcho(args) } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } } -export const useEcho = callEcho +export function useAdd() { + const isPending = ref(false) + const error = ref(null) + async function mutate(args: Parameters[0]) { + isPending.value = true; error.value = null + try { return await callAdd(args) } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useAdd = callAdd +export function useWhoami() { + const isPending = ref(false) + const error = ref(null) + async function mutate() { + isPending.value = true; error.value = null + try { return await callWhoami() } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useWhoami = callWhoami +export function useHttpOnlyEcho() { + const isPending = ref(false) + const error = ref(null) + async function mutate(args: Parameters[0]) { + isPending.value = true; error.value = null + try { return await callHttpOnlyEcho(args) } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useHttpOnlyEcho = callHttpOnlyEcho +export function useStaffOnly() { + const isPending = ref(false) + const error = ref(null) + async function mutate() { + isPending.value = true; error.value = null + try { return await callStaffOnly() } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useStaffOnly = callStaffOnly +export function useSuperuserOnly() { + const isPending = ref(false) + const error = ref(null) + async function mutate() { + isPending.value = true; error.value = null + try { return await callSuperuserOnly() } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useSuperuserOnly = callSuperuserOnly +export function useVerifiedOnly() { + const isPending = ref(false) + const error = ref(null) + async function mutate() { + isPending.value = true; error.value = null + try { return await callVerifiedOnly() } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useVerifiedOnly = callVerifiedOnly +export function useMultiply() { + const isPending = ref(false) + const error = ref(null) + async function mutate(args: Parameters[0]) { + isPending.value = true; error.value = null + try { return await callMultiply(args) } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useMultiply = callMultiply +export function useNotImplementedFn() { + const isPending = ref(false) + const error = ref(null) + async function mutate() { + isPending.value = true; error.value = null + try { return await callNotImplementedFn() } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useNotImplementedFn = callNotImplementedFn +export function useBuggyFn() { + const isPending = ref(false) + const error = ref(null) + async function mutate() { + isPending.value = true; error.value = null + try { return await callBuggyFn() } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useBuggyFn = callBuggyFn +export function usePermissionCheckFn() { + const isPending = ref(false) + const error = ref(null) + async function mutate(args: Parameters[0]) { + isPending.value = true; error.value = null + try { return await callPermissionCheckFn(args) } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const usePermissionCheckFn = callPermissionCheckFn +export function useWsWhoami() { + const isPending = ref(false) + const error = ref(null) + async function mutate() { + isPending.value = true; error.value = null + try { return await callWsWhoami() } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useWsWhoami = callWsWhoami +export function useJwtObtain() { + const isPending = ref(false) + const error = ref(null) + async function mutate() { + isPending.value = true; error.value = null + try { return await callJwtObtain() } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useJwtObtain = callJwtObtain +export function useJwtRefresh() { + const isPending = ref(false) + const error = ref(null) + async function mutate(args: Parameters[0]) { + isPending.value = true; error.value = null + try { return await callJwtRefresh(args) } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} -export const useJwtRefresh = callJwtRefresh +export type { ContextState } from '@mizan/runtime' +export { configure, initSession, MizanError } from '@mizan/runtime' diff --git a/packages/mizan-django/generate/generator/lib/adapters/react.mjs b/packages/mizan-django/generate/generator/lib/adapters/react.mjs index f58739b..601dc5a 100644 --- a/packages/mizan-django/generate/generator/lib/adapters/react.mjs +++ b/packages/mizan-django/generate/generator/lib/adapters/react.mjs @@ -1,5 +1,8 @@ /** * React Stage 2 — Generates hooks + context providers from Stage 1 output. + * + * Generated providers subscribe to the runtime kernel for state. + * The kernel owns data, status, and error. React just renders. */ function pascalCase(str) { @@ -19,8 +22,8 @@ export function generateReactAdapter(schema) { '', '// AUTO-GENERATED by mizan — do not edit', '', - "import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'", - "import { registerContext, mizanCall, mizanFetch } from '@mizan/runtime'", + "import { createContext, useContext, useState, useEffect, useCallback, useRef, useSyncExternalStore, type ReactNode } from 'react'", + "import { registerContext, mizanFetch, mizanCall, type ContextState } from '@mizan/runtime'", '', ] @@ -38,103 +41,114 @@ export function generateReactAdapter(schema) { lines.push('') } - // ── Global context hooks ──────────────────────────────────────────── + // ── Helper hook: subscribe to kernel state ────────────────────────── - if (globalContexts.length > 0) { - const p = pascalCase('global') + lines.push('// Subscribe to kernel state via useSyncExternalStore') + lines.push('function useContextState(') + lines.push(' name: string,') + lines.push(' params: Record,') + lines.push(' fetchFn: () => Promise,') + lines.push(' initialData?: T,') + lines.push('): ContextState {') + lines.push(' const ref = useRef | null>(null)') + lines.push('') + lines.push(' if (!ref.current) {') + lines.push(' ref.current = registerContext(name, params, fetchFn, initialData)') + lines.push(' }') + lines.push('') + lines.push(' const handle = ref.current') + lines.push('') + lines.push(' // Fetch on mount if no data') + lines.push(' useEffect(() => {') + lines.push(" if (handle.getState().status === 'idle') handle.refetch()") + lines.push(' return () => handle.unregister()') + lines.push(' }, [handle])') + lines.push('') + lines.push(' return useSyncExternalStore(') + lines.push(' handle.subscribe,') + lines.push(' handle.getState,') + lines.push(' handle.getState,') + lines.push(' )') + lines.push('}') + lines.push('') - lines.push(`// Global context — fetched once at app init`) - lines.push(`const GlobalCtx = createContext<${p}ContextData | null>(null)`) - lines.push('') + // ── Mutation hook helper ──────────────────────────────────────────── - lines.push(`export function GlobalContextProvider({ children }: { children: ReactNode }) {`) - lines.push(` const [data, setData] = useState<${p}ContextData | null>(() => {`) - lines.push(` if (typeof window === 'undefined') return null`) - lines.push(` const ssr = (window as any).__MIZAN_SSR_DATA__`) - lines.push(` return ssr ?? null`) - lines.push(` })`) - lines.push('') - lines.push(` const refetch = useCallback(async () => {`) - lines.push(` const result = await fetch${p}Context({} as any)`) - lines.push(` setData(result)`) - lines.push(` }, [])`) - lines.push('') - lines.push(` useEffect(() => { if (!data) refetch() }, [data, refetch])`) - lines.push(` useEffect(() => registerContext('global', {}, refetch), [refetch])`) - lines.push('') - lines.push(` return {children}`) - lines.push('}') - lines.push('') + lines.push('// Mutation hook with loading/error state') + lines.push('function useMutation(') + lines.push(' callFn: (args: TArgs) => Promise,') + lines.push('): { mutate: (args: TArgs) => Promise; isPending: boolean; error: Error | null } {') + lines.push(' const [isPending, setIsPending] = useState(false)') + lines.push(' const [error, setError] = useState(null)') + lines.push('') + lines.push(' const mutate = useCallback(async (args: TArgs) => {') + lines.push(' setIsPending(true)') + lines.push(' setError(null)') + lines.push(' try {') + lines.push(' const result = await callFn(args)') + lines.push(' return result') + lines.push(' } catch (e) {') + lines.push(' setError(e as Error)') + lines.push(' throw e') + lines.push(' } finally {') + lines.push(' setIsPending(false)') + lines.push(' }') + lines.push(' }, [callFn])') + lines.push('') + lines.push(' return { mutate, isPending, error }') + lines.push('}') + lines.push('') - for (const fn of globalContexts) { - const hookPascal = pascalCase(fn.camelName) - lines.push(`export function use${hookPascal}(): ${fn.outputType} {`) - lines.push(` const ctx = useContext(GlobalCtx)`) - lines.push(` if (!ctx) throw new Error('use${hookPascal} requires GlobalContextProvider')`) - lines.push(` return ctx.${fn.name}`) - lines.push('}') - lines.push('') - } - } + // ── Context hooks ─────────────────────────────────────────────────── - // ── Named context providers ───────────────────────────────────────── - - for (const [ctxName, ctxMeta] of namedContexts) { + for (const [ctxName, ctxMeta] of Object.entries(contextGroups)) { const p = pascalCase(ctxName) const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) const paramEntries = Object.entries(ctxMeta.params || {}) - lines.push(`// ${p} context`) - lines.push(`const ${p}Ctx = createContext<${p}ContextData | null>(null)`) + lines.push(`// ── ${p} Context ──`) lines.push('') - // Provider - lines.push(`export function ${p}Context({ children, ...params }: ${p}ContextParams & { children: ReactNode }) {`) - lines.push(` const [data, setData] = useState<${p}ContextData | null>(() => {`) - lines.push(` if (typeof window === 'undefined') return null`) - lines.push(` const ssr = (window as any).__MIZAN_SSR_DATA__`) - if (ctxFunctions.length > 0) { - lines.push(` if (ssr?.${ctxFunctions[0].name} !== undefined) return ssr`) + // Hook that returns the full kernel state + if (paramEntries.length > 0) { + lines.push(`export function use${p}Context(params: ${p}ContextParams): ContextState<${p}ContextData> {`) + lines.push(` const ssrData = typeof window !== 'undefined' ? (window as any).__MIZAN_SSR_DATA__ : null`) + lines.push(` return useContextState('${ctxName}', params, () => fetch${p}Context(params), ssrData)`) + } else { + lines.push(`export function use${p}Context(): ContextState<${p}ContextData> {`) + lines.push(` const ssrData = typeof window !== 'undefined' ? (window as any).__MIZAN_SSR_DATA__ : null`) + lines.push(` return useContextState('${ctxName}', {}, () => fetch${p}Context({} as any), ssrData)`) } - lines.push(` return null`) - lines.push(` })`) - lines.push('') - lines.push(` const refetch = useCallback(async () => {`) - lines.push(` const result = await fetch${p}Context(params)`) - lines.push(` setData(result)`) - - const deps = paramEntries.map(([pName]) => `params.${pName}`) - lines.push(` }, [${deps.join(', ')}])`) - lines.push('') - lines.push(` useEffect(() => { if (!data) refetch() }, [data, refetch])`) - lines.push(` useEffect(() => registerContext('${ctxName}', params, refetch), [${deps.join(', ')}, refetch])`) - lines.push('') - lines.push(` return <${p}Ctx.Provider value={data}>{children}`) lines.push('}') lines.push('') - // Hooks + // Convenience hooks for individual data fields for (const fn of ctxFunctions) { const hookPascal = pascalCase(fn.camelName) - lines.push(`export function use${hookPascal}(): ${fn.outputType} | null {`) - lines.push(` const ctx = useContext(${p}Ctx)`) - lines.push(` return ctx?.${fn.name} ?? null`) + if (paramEntries.length > 0) { + lines.push(`export function use${hookPascal}(params: ${p}ContextParams): ${fn.outputType} | null {`) + lines.push(` const state = use${p}Context(params)`) + } else { + lines.push(`export function use${hookPascal}(): ${fn.outputType} | null {`) + lines.push(` const state = use${p}Context()`) + } + lines.push(` return state.data?.${fn.name} ?? null`) lines.push('}') lines.push('') } } - // ── Mutation hooks ────────────────────────────────────────────────── + // ── Mutation hooks (with loading/error) ────────────────────────────── for (const fn of mutations) { const p = pascalCase(fn.camelName) if (fn.hasInput) { lines.push(`export function use${p}() {`) - lines.push(` return useCallback((args: Parameters[0]) => call${p}(args), [])`) + lines.push(` return useMutation[0], Awaited>>(call${p})`) lines.push('}') } else { lines.push(`export function use${p}() {`) - lines.push(` return useCallback(() => call${p}(), [])`) + lines.push(` return useMutation>>(() => call${p}() as any)`) lines.push('}') } lines.push('') @@ -146,15 +160,21 @@ export function generateReactAdapter(schema) { const p = pascalCase(fn.camelName) if (fn.hasInput) { lines.push(`export function use${p}() {`) - lines.push(` return useCallback((args: Parameters[0]) => call${p}(args), [])`) + lines.push(` return useMutation[0], Awaited>>(call${p})`) lines.push('}') } else { lines.push(`export function use${p}() {`) - lines.push(` return useCallback(() => call${p}(), [])`) + lines.push(` return useMutation>>(() => call${p}() as any)`) lines.push('}') } lines.push('') } + // ── Re-export runtime types ───────────────────────────────────────── + + lines.push("export type { ContextState } from '@mizan/runtime'") + lines.push("export { configure, initSession, MizanError } from '@mizan/runtime'") + lines.push('') + return lines.join('\n') } diff --git a/packages/mizan-django/generate/generator/lib/adapters/svelte.mjs b/packages/mizan-django/generate/generator/lib/adapters/svelte.mjs index 9269865..6b501c2 100644 --- a/packages/mizan-django/generate/generator/lib/adapters/svelte.mjs +++ b/packages/mizan-django/generate/generator/lib/adapters/svelte.mjs @@ -1,5 +1,7 @@ /** * Svelte Stage 2 — Generates stores from Stage 1 output. + * + * Subscribes to the kernel for state. Returns readable stores. */ function pascalCase(str) { @@ -15,12 +17,11 @@ export function generateSvelteAdapter(schema) { const lines = [ '// AUTO-GENERATED by mizan — do not edit', '', - "import { writable, derived, type Readable } from 'svelte/store'", - "import { registerContext } from '@mizan/runtime'", + "import { readable, type Readable } from 'svelte/store'", + "import { registerContext, type ContextState } from '@mizan/runtime'", '', ] - // Stage 1 imports const stage1Imports = [] for (const [ctxName] of Object.entries(contextGroups)) { const p = pascalCase(ctxName) @@ -34,14 +35,11 @@ export function generateSvelteAdapter(schema) { lines.push('') } - // ── Context stores ────────────────────────────────────────────────── - for (const [ctxName, ctxMeta] of Object.entries(contextGroups)) { const p = pascalCase(ctxName) const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) const paramEntries = Object.entries(ctxMeta.params || {}) - - lines.push(`// ${p} context`) + const paramsArg = paramEntries.length > 0 ? 'params' : '{} as any' if (paramEntries.length > 0) { lines.push(`export function create${p}Context(params: ${p}ContextParams) {`) @@ -49,49 +47,32 @@ export function generateSvelteAdapter(schema) { lines.push(`export function create${p}Context() {`) } - lines.push(` const data = writable<${p}ContextData | null>(null)`) - lines.push(` const loading = writable(true)`) + // Use readable store backed by kernel subscription + lines.push(` const store = readable>(`) + lines.push(` { data: null, status: 'idle', error: null },`) + lines.push(` (set) => {`) + lines.push(` const handle = registerContext('${ctxName}', ${paramsArg}, () => fetch${p}Context(${paramsArg}))`) + lines.push(` const unsub = handle.subscribe(() => set(handle.getState()))`) + lines.push(` handle.refetch()`) + lines.push(` return () => { unsub(); handle.unregister() }`) + lines.push(` },`) + lines.push(` )`) lines.push('') - lines.push(` const refetch = async () => {`) - lines.push(` loading.set(true)`) - if (paramEntries.length > 0) { - lines.push(` const result = await fetch${p}Context(params)`) - } else { - lines.push(` const result = await fetch${p}Context({} as any)`) - } - lines.push(` data.set(result)`) - lines.push(` loading.set(false)`) - lines.push(` }`) - lines.push('') - lines.push(` refetch()`) - if (paramEntries.length > 0) { - lines.push(` const unregister = registerContext('${ctxName}', params, refetch)`) - } else { - lines.push(` const unregister = registerContext('${ctxName}', {}, refetch)`) - } - lines.push('') - - // Derived stores for each function - lines.push(` return {`) - lines.push(` data,`) - lines.push(` loading,`) - for (const fn of ctxFunctions) { - const camel = fn.camelName - lines.push(` ${camel}: derived(data, $d => $d?.${fn.name} ?? null) as Readable<${fn.outputType} | null>,`) - } - lines.push(` destroy: unregister,`) - lines.push(` }`) + lines.push(` return store`) lines.push('}') lines.push('') } - // ── Mutation + function exports ───────────────────────────────────── - + // Re-export mutations as-is from Stage 1 for (const fn of [...mutations, ...plainFns]) { const p = pascalCase(fn.camelName) - lines.push(`export { call${p} } from '../${fn.affects ? 'mutations' : 'functions'}/${fn.camelName}'`) + lines.push(`export { call${p} } from '../index'`) } lines.push('') + lines.push("export type { ContextState } from '@mizan/runtime'") + lines.push("export { configure, initSession, MizanError } from '@mizan/runtime'") + lines.push('') + return lines.join('\n') } diff --git a/packages/mizan-django/generate/generator/lib/adapters/vue.mjs b/packages/mizan-django/generate/generator/lib/adapters/vue.mjs index 853e949..22626ef 100644 --- a/packages/mizan-django/generate/generator/lib/adapters/vue.mjs +++ b/packages/mizan-django/generate/generator/lib/adapters/vue.mjs @@ -1,5 +1,7 @@ /** * Vue Stage 2 — Generates composables from Stage 1 output. + * + * Subscribes to the kernel for state. Vue reactivity wraps kernel notifications. */ function pascalCase(str) { @@ -15,12 +17,11 @@ export function generateVueAdapter(schema) { const lines = [ '// AUTO-GENERATED by mizan — do not edit', '', - "import { ref, computed, watch, onMounted, onUnmounted, provide, inject, type Ref, type ComputedRef, type InjectionKey } from 'vue'", - "import { registerContext } from '@mizan/runtime'", + "import { ref, computed, onMounted, onUnmounted, onServerPrefetch, type ComputedRef } from 'vue'", + "import { registerContext, type ContextState } from '@mizan/runtime'", '', ] - // Stage 1 imports const stage1Imports = [] for (const [ctxName] of Object.entries(contextGroups)) { const p = pascalCase(ctxName) @@ -34,72 +35,70 @@ export function generateVueAdapter(schema) { lines.push('') } - // ── Context composables ───────────────────────────────────────────── - for (const [ctxName, ctxMeta] of Object.entries(contextGroups)) { const p = pascalCase(ctxName) const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) const paramEntries = Object.entries(ctxMeta.params || {}) + const paramsArg = paramEntries.length > 0 ? 'params' : '{} as any' - lines.push(`// ${p} context`) - lines.push(`const ${p}Key: InjectionKey<{ data: Ref<${p}ContextData | null>, loading: Ref }> = Symbol('${ctxName}')`) - lines.push('') + if (paramEntries.length > 0) { + lines.push(`export function use${p}Context(params: ${p}ContextParams) {`) + } else { + lines.push(`export function use${p}Context() {`) + } - // Provider composable - if (paramEntries.length > 0) { - lines.push(`export function provide${p}Context(params: { ${paramEntries.map(([k, v]) => `${k}: ${v.type === 'integer' || v.type === 'number' ? 'number' : 'string'}`).join(', ')} }) {`) - } else { - lines.push(`export function provide${p}Context() {`) - } - lines.push(` const data = ref<${p}ContextData | null>(null)`) - lines.push(` const loading = ref(true)`) + lines.push(` const state = ref>({ data: null, status: 'idle', error: null })`) + lines.push(` let handle: ReturnType | null = null`) lines.push('') - lines.push(` const refetch = async () => {`) - lines.push(` loading.value = true`) - lines.push(` try {`) - if (paramEntries.length > 0) { - lines.push(` data.value = await fetch${p}Context(params as any)`) - } else { - lines.push(` data.value = await fetch${p}Context({} as any)`) - } - lines.push(` } catch (e) { console.error('[mizan] ${ctxName} fetch failed:', e) }`) - lines.push(` loading.value = false`) - lines.push(` }`) - lines.push('') - lines.push(` let unregister: (() => void) | null = null`) lines.push(` onMounted(() => {`) - lines.push(` refetch()`) - if (paramEntries.length > 0) { - lines.push(` unregister = registerContext('${ctxName}', params, refetch)`) - } else { - lines.push(` unregister = registerContext('${ctxName}', {}, refetch)`) - } + lines.push(` handle = registerContext('${ctxName}', ${paramsArg}, () => fetch${p}Context(${paramsArg}))`) + lines.push(` handle.subscribe(() => { state.value = handle!.getState() })`) + lines.push(` handle.refetch()`) lines.push(` })`) - lines.push(` onUnmounted(() => { unregister?.() })`) lines.push('') - lines.push(` provide(${p}Key, { data, loading })`) + lines.push(` onServerPrefetch(async () => {`) + lines.push(` handle = registerContext('${ctxName}', ${paramsArg}, () => fetch${p}Context(${paramsArg}))`) + lines.push(` await handle.refetch()`) + lines.push(` state.value = handle.getState()`) + lines.push(` })`) + lines.push('') + lines.push(` onUnmounted(() => { handle?.unregister() })`) + lines.push('') + lines.push(` return {`) + lines.push(` state,`) + for (const fn of ctxFunctions) { + lines.push(` ${fn.camelName}: computed(() => state.value.data?.${fn.name} ?? null) as ComputedRef<${fn.outputType} | null>,`) + } + lines.push(` loading: computed(() => state.value.status === 'loading'),`) + lines.push(` error: computed(() => state.value.error),`) + lines.push(` }`) lines.push('}') lines.push('') - - // Consumer composables - for (const fn of ctxFunctions) { - const hookPascal = pascalCase(fn.camelName) - lines.push(`export function use${hookPascal}(): ComputedRef<${fn.outputType} | null> {`) - lines.push(` const ctx = inject(${p}Key)`) - lines.push(` if (!ctx) throw new Error('use${hookPascal} requires provide${p}Context in a parent')`) - lines.push(` return computed(() => ctx.data.value?.${fn.name} ?? null)`) - lines.push('}') - lines.push('') - } } - // ── Mutation composables ──────────────────────────────────────────── - for (const fn of [...mutations, ...plainFns]) { const p = pascalCase(fn.camelName) - lines.push(`export const use${p} = call${p}`) + lines.push(`export function use${p}() {`) + lines.push(` const isPending = ref(false)`) + lines.push(` const error = ref(null)`) + if (fn.hasInput) { + lines.push(` async function mutate(args: Parameters[0]) {`) + } else { + lines.push(` async function mutate() {`) + } + lines.push(` isPending.value = true; error.value = null`) + lines.push(` try { return await call${p}(${fn.hasInput ? 'args' : ''}) }`) + lines.push(` catch (e) { error.value = e as Error; throw e }`) + lines.push(` finally { isPending.value = false }`) + lines.push(` }`) + lines.push(` return { mutate, isPending, error }`) + lines.push('}') lines.push('') } + lines.push("export type { ContextState } from '@mizan/runtime'") + lines.push("export { configure, initSession, MizanError } from '@mizan/runtime'") + lines.push('') + return lines.join('\n') } diff --git a/packages/mizan-runtime/src/index.ts b/packages/mizan-runtime/src/index.ts index 8d27eb4..2ab88b0 100644 --- a/packages/mizan-runtime/src/index.ts +++ b/packages/mizan-runtime/src/index.ts @@ -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 | 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 { if (_sessionReady) return _sessionReady @@ -76,39 +66,109 @@ export function initSession(): Promise { 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 { + data: T | null + status: ContextStatus + error: Error | null +} + +type Listener = () => void type ParamKey = string interface ContextEntry { params: Record - refetch: RefetchFn + state: ContextState + listeners: Set + fetchFn: () => Promise } const contexts: Map> = new Map() -/** Deterministic JSON key for params — sorted to avoid order-dependency */ function stableKey(params: Record): 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, - refetch: RefetchFn, -): () => void { + fetchFn: () => Promise, + initialData?: any, +): { + getState: () => ContextState + subscribe: (listener: Listener) => () => void + refetch: () => Promise + 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): 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,