From af7e22ffc1cd5e52132bfb3fef6622ae6964cf43 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Wed, 1 Apr 2026 20:59:53 -0400 Subject: [PATCH] Rewrite codegen for named contexts, mutation hooks, and Mizan naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generator (react/src/generator/lib/mizan.mjs): - Rename djarea.mjs → mizan.mjs - MizanContext replaces DjangoContext (legacy alias kept) - Global contexts fetched via bundled GET /ctx/global/ through inner GlobalContextLoader component - Named context providers generated per context group with param elevation (required/optional props from x-mizan-contexts) - Mutation hooks auto-invalidate affected contexts on success - SSR hydration uses single GET /ctx/global/ instead of N POSTs - Output files: generated.provider.tsx, generated.server.ts Runtime (react/src/context.tsx): - Add setContextData() for bundle splitting without refetch - Add request() for auth-transparent HTTP from generated code Index generator updated for new export names and named contexts. Co-Authored-By: Claude Opus 4.6 (1M context) --- react/src/context.tsx | 47 +- react/src/generator/cli.mjs | 4 +- react/src/generator/lib/index.mjs | 83 ++-- .../generator/lib/{djarea.mjs => mizan.mjs} | 415 +++++++++++++----- 4 files changed, 397 insertions(+), 152 deletions(-) rename react/src/generator/lib/{djarea.mjs => mizan.mjs} (59%) diff --git a/react/src/context.tsx b/react/src/context.tsx index 0961902..d2d961f 100644 --- a/react/src/context.tsx +++ b/react/src/context.tsx @@ -165,6 +165,19 @@ export interface MizanContextValue { * Base URL for HTTP calls (for use by generated context providers). */ baseUrl: string + + /** + * Set context data directly without triggering a network request. + * Used by generated providers that fetch bundled responses. + */ + setContextData: (name: string, data: unknown) => void + + /** + * Make an authenticated HTTP request. + * Handles JWT Bearer or session cookie auth automatically. + * Waits for session init before making the request. + */ + request: (method: string, path: string, data?: unknown) => Promise } export interface MizanProviderProps { @@ -539,6 +552,36 @@ export function MizanProvider({ [invalidateContext] ) + // Set context data directly (used by generated providers that fetch bundles) + const setContextData = useCallback( + (name: string, data: unknown) => { + setContextStore(prev => { + const next = { ...prev, [name]: data } + const listeners = contextListenersRef.current.get(name) + if (listeners) { + listeners.forEach(listener => { + try { + listener(data) + } catch (e) { + console.error(`[mizan] Context listener error for '${name}':`, e) + } + }) + } + return next + }) + }, + [] + ) + + // Auth-transparent HTTP request (used by generated context providers) + const request = useCallback( + async (method: string, path: string, data?: unknown): Promise => { + await sessionRef.current!.promise + return httpClient.request(method, path, data) + }, + [httpClient] + ) + const value = useMemo( () => ({ call, @@ -554,8 +597,10 @@ export function MizanProvider({ invalidateFunctions, registerContextProvider, baseUrl, + setContextData, + request, }), - [call, getContext, refreshContext, refreshAllContexts, status, isRPCAvailable, onPush, onContextChange, invalidateContext, invalidateFunctions, registerContextProvider, baseUrl] + [call, getContext, refreshContext, refreshAllContexts, status, isRPCAvailable, onPush, onContextChange, invalidateContext, invalidateFunctions, registerContextProvider, baseUrl, setContextData, request] ) return ( diff --git a/react/src/generator/cli.mjs b/react/src/generator/cli.mjs index cb607f5..56cf42a 100755 --- a/react/src/generator/cli.mjs +++ b/react/src/generator/cli.mjs @@ -123,9 +123,9 @@ async function generate(config, options = {}) { const mizanTypesPath = outputPath.replace(/\.ts$/, '.mizan.ts') const fullMizanTypesPath = path.resolve(frontendDir, mizanTypesPath) - const mizanProviderPath = outputPath.replace(/\.ts$/, '.django.tsx') + const mizanProviderPath = outputPath.replace(/\.ts$/, '.provider.tsx') const fullMizanProviderPath = path.resolve(frontendDir, mizanProviderPath) - const mizanServerPath = outputPath.replace(/\.ts$/, '.django.server.ts') + const mizanServerPath = outputPath.replace(/\.ts$/, '.server.ts') const fullMizanServerPath = path.resolve(frontendDir, mizanServerPath) const mizanFormsPath = outputPath.replace(/\.ts$/, '.forms.ts') const fullMizanFormsPath = path.resolve(frontendDir, mizanFormsPath) diff --git a/react/src/generator/lib/index.mjs b/react/src/generator/lib/index.mjs index 97531ff..8a19ac8 100644 --- a/react/src/generator/lib/index.mjs +++ b/react/src/generator/lib/index.mjs @@ -5,27 +5,19 @@ * from the generated files for clean imports. */ -/** - * Extract context hooks from mizan schema. - * Returns hook names in PascalCase (e.g., useAuthStatus, useUser). - */ -function extractContextHooks(mizanSchema) { - const functions = mizanSchema?.['x-mizan-functions'] || [] - const contexts = functions.filter(fn => fn.isContext) +function pascalCase(str) { + return str.charAt(0).toUpperCase() + str.slice(1) +} - return contexts.map(ctx => { - const pascal = ctx.camelName.charAt(0).toUpperCase() + ctx.camelName.slice(1) - return `use${pascal}` - }).sort() +function toPascalCase(str) { + return str + .split(/[.\-_]/) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') } /** * Generate the consolidated index.ts file. - * - * @param {Object} options - Generation options - * @param {Object} options.channelsSchema - Channels schema (optional) - * @param {Object} options.mizanSchema - mizan schema (optional) - * @returns {string} Generated index.ts content */ export function generateIndex({ channelsSchema, mizanSchema }) { const lines = [ @@ -37,11 +29,10 @@ export function generateIndex({ channelsSchema, mizanSchema }) { ' * @example', ' * ```tsx', ' * import {', - ' * DjangoContext,', - ' * useUser,', + ' * MizanContext,', + ' * useCurrentUser,', ' * useEcho,', ' * useChatChannel,', - ' * DjangoError,', ' * } from \'@/api\'', ' * ```', ' */', @@ -51,54 +42,75 @@ export function generateIndex({ channelsSchema, mizanSchema }) { '', ] - // ========================================================================== - // mizan Provider & Hooks (from generated.django.tsx) - // ========================================================================== - const functions = mizanSchema?.['x-mizan-functions'] || [] + const contextGroups = mizanSchema?.['x-mizan-contexts'] || {} const hasMizan = functions.length > 0 if (hasMizan) { - const contextHooks = extractContextHooks(mizanSchema) - const contexts = functions.filter(fn => fn.isContext) + const globalContexts = functions.filter(fn => fn.isContext === 'global') const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm) + const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global') lines.push('// =============================================================================') lines.push('// mizan Provider & Hooks') lines.push('// =============================================================================') lines.push('') - // Server exports (getDjangoHydration runs in server components) - if (contexts.length > 0) { + // Server exports + if (globalContexts.length > 0) { lines.push('export {') + lines.push(' getMizanHydration,') lines.push(' getDjangoHydration,') + lines.push(' type MizanHydrationData,') lines.push(' type DjangoHydration,') - lines.push("} from './generated.django.server'") + lines.push("} from './generated.server'") lines.push('') } // Client exports lines.push('export {') lines.push(' // Provider') + lines.push(' MizanContext,') + lines.push(' type MizanContextProps,') lines.push(' DjangoContext,') lines.push(' type DjangoContextProps,') - if (contexts.length > 0) { + // Global context hooks + if (globalContexts.length > 0) { lines.push('') - lines.push(' // Context hooks') - for (const hookName of contextHooks) { - lines.push(` ${hookName},`) + lines.push(' // Global context hooks') + for (const ctx of globalContexts) { + const hookPascal = pascalCase(ctx.camelName) + lines.push(` use${hookPascal},`) } lines.push('') lines.push(' // Refresh hooks') + lines.push(' useMizanRefresh,') lines.push(' useDjangoRefresh,') } + // Named context providers and hooks + if (namedContextEntries.length > 0) { + lines.push('') + lines.push(' // Named context providers') + for (const [ctxName, ctxMeta] of namedContextEntries) { + const ctxPascal = toPascalCase(ctxName) + lines.push(` ${ctxPascal}Context,`) + // Hooks for this context's functions + const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) + for (const fn of ctxFunctions) { + const hookPascal = pascalCase(fn.camelName) + lines.push(` use${hookPascal},`) + } + } + } + + // Function hooks (mutations + plain) if (regularFunctions.length > 0) { lines.push('') lines.push(' // Function hooks') for (const fn of regularFunctions) { - const pascal = fn.camelName.charAt(0).toUpperCase() + fn.camelName.slice(1) + const pascal = pascalCase(fn.camelName) lines.push(` use${pascal},`) } } @@ -112,12 +124,12 @@ export function generateIndex({ channelsSchema, mizanSchema }) { lines.push(' type ConnectionStatus,') lines.push(' type PushMessage,') lines.push(' type PushListener,') - lines.push("} from './generated.django'") + lines.push("} from './generated.provider'") lines.push('') } // ========================================================================== - // Channel Hooks (from generated.channels.hooks.tsx) + // Channel Hooks // ========================================================================== const channels = channelsSchema?.['x-mizan-channels'] || [] @@ -134,7 +146,6 @@ export function generateIndex({ channelsSchema, mizanSchema }) { lines.push("} from './generated.channels.hooks'") lines.push('') - // Channel types lines.push('// =============================================================================') lines.push('// Channel Types') lines.push('// =============================================================================') diff --git a/react/src/generator/lib/djarea.mjs b/react/src/generator/lib/mizan.mjs similarity index 59% rename from react/src/generator/lib/djarea.mjs rename to react/src/generator/lib/mizan.mjs index 73b97d6..a647797 100644 --- a/react/src/generator/lib/djarea.mjs +++ b/react/src/generator/lib/mizan.mjs @@ -108,7 +108,7 @@ export async function generateMizanTypes(schema) { const functions = schema['x-mizan-functions'] || [] if (functions.length > 0) { - lines.push('export const DJANGO_FUNCTIONS = {') + lines.push('export const MIZAN_FUNCTIONS = {') for (const fn of functions) { lines.push(` ${fn.camelName}: {`) lines.push(` name: '${fn.name}',`) @@ -119,7 +119,7 @@ export async function generateMizanTypes(schema) { } lines.push('} as const') } else { - lines.push('export const DJANGO_FUNCTIONS = {} as const') + lines.push('export const MIZAN_FUNCTIONS = {} as const') } lines.push('') @@ -127,25 +127,57 @@ export async function generateMizanTypes(schema) { return lines.join('\n') } +/** + * Extract unique context names from an affects array. + * Both context-level and function-level affects resolve to context names. + */ +function getAffectedContexts(affects) { + const contexts = new Set() + for (const target of affects) { + if (target.type === 'context') { + contexts.add(target.name) + } else if (target.type === 'function' && target.context) { + contexts.add(target.context) + } + } + return [...contexts] +} + +/** + * Map JSON schema type string to TypeScript type. + */ +function jsonTypeToTS(type) { + if (type === 'integer' || type === 'number') return 'number' + if (type === 'boolean') return 'boolean' + return 'string' +} + /** * Generate the React provider that wraps MizanProvider with typed hooks. * * The generated provider: - * - Wraps MizanProvider (from mizan library) - * - Passes context names for auto-fetch - * - Provides typed hooks for contexts and functions + * - MizanContext: Root provider with global context bundled fetch + * - Named context providers: + * - Mutation hooks with auto-invalidation + * - Plain function hooks */ export function generateMizanProvider(schema, options = {}) { const { hasChannels = false } = options const functions = schema['x-mizan-functions'] || [] + const contextGroups = schema['x-mizan-contexts'] || {} if (functions.length === 0) { return null } - // Separate contexts from regular functions - const contexts = functions.filter(fn => fn.isContext) + // Partition functions + const globalContexts = functions.filter(fn => fn.isContext === 'global') const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm) + const mutationFunctions = regularFunctions.filter(fn => fn.affects) + const plainFunctions = regularFunctions.filter(fn => !fn.affects) + + // Named context groups (everything except 'global') + const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global') // Collect type imports const typeImports = [] @@ -166,10 +198,11 @@ export function generateMizanProvider(schema, options = {}) { '// Regenerate with: npm run schemas', '', '// This file provides typed wrappers around the mizan library.', - '// - DjangoContext: Typed provider wrapping MizanProvider', - '// - Typed hooks: useAuthStatus(), useUser(), etc.', + '// - MizanContext: Root provider with global context', + '// - Named context providers: ', + '// - Typed hooks with auto-invalidation', '', - "import { type ReactNode, useCallback } from 'react'", + "import { type ReactNode, useCallback, useState, useEffect, useRef, createContext, useContext } from 'react'", "import {", " MizanProvider,", " useMizan,", @@ -180,7 +213,6 @@ export function generateMizanProvider(schema, options = {}) { "} from 'mizan'", ...(hasChannels ? [ "import { ChannelProvider, ChannelConnection } from 'mizan/channels'", - "import { useRef } from 'react'", ] : []), '', ] @@ -191,7 +223,7 @@ export function generateMizanProvider(schema, options = {}) { } // ============================================================================ - // Hydration types + // Hydration types (global contexts only) // ============================================================================ lines.push('// ============================================================================') @@ -199,20 +231,19 @@ export function generateMizanProvider(schema, options = {}) { lines.push('// ============================================================================') lines.push('') - if (contexts.length > 0) { - lines.push('/** Typed hydration data for SSR */') - lines.push('export interface DjangoHydration {') - for (const ctx of contexts) { + if (globalContexts.length > 0) { + lines.push('/** Typed hydration data for SSR (global contexts only) */') + lines.push('export interface MizanHydrationData {') + for (const ctx of globalContexts) { lines.push(` ${ctx.camelName}?: ${ctx.outputType}`) } lines.push('}') lines.push('') - lines.push('/** Convert typed hydration to mizan format */') - lines.push('function toMizanHydration(hydration?: DjangoHydration): MizanHydration | undefined {') + lines.push('function toMizanHydration(hydration?: MizanHydrationData): MizanHydration | undefined {') lines.push(' if (!hydration) return undefined') lines.push(' const result: MizanHydration = {}') - for (const ctx of contexts) { + for (const ctx of globalContexts) { lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`) } lines.push(' return result') @@ -221,50 +252,81 @@ export function generateMizanProvider(schema, options = {}) { } // ============================================================================ - // Provider + // Global Context Loader (inner component, fetches GET /ctx/global/) + // ============================================================================ + + if (globalContexts.length > 0) { + lines.push('// ============================================================================') + lines.push('// Global Context Loader') + lines.push('// ============================================================================') + lines.push('') + lines.push('function GlobalContextLoader({ children }: { children: ReactNode }) {') + lines.push(' const mizan = useMizan()') + lines.push(' const loaded = useRef(false)') + lines.push('') + lines.push(' useEffect(() => {') + lines.push(' if (loaded.current) return') + lines.push(' loaded.current = true') + lines.push('') + lines.push(' ;(async () => {') + lines.push(' await mizan.whenReady') + lines.push(' try {') + lines.push(" const response = await mizan.request('GET', `${mizan.baseUrl}/ctx/global/`)") + lines.push(' const result = await response.json()') + lines.push(' if (!result.error) {') + lines.push(' for (const [name, data] of Object.entries(result.data)) {') + lines.push(' mizan.setContextData(name, data)') + lines.push(' }') + lines.push(' }') + lines.push(' } catch (e) {') + lines.push(" console.error('[MizanContext] Global context fetch failed:', e)") + lines.push(' }') + lines.push(' })()') + lines.push(' }, [mizan])') + lines.push('') + lines.push(' return <>{children}') + lines.push('}') + lines.push('') + } + + // ============================================================================ + // Root Provider (MizanContext) // ============================================================================ lines.push('// ============================================================================') - lines.push('// Provider') + lines.push('// Root Provider') lines.push('// ============================================================================') lines.push('') - lines.push('export interface DjangoContextProps {') + lines.push('export interface MizanContextProps {') lines.push(' children: ReactNode') - if (contexts.length > 0) { - lines.push(' /** SSR hydration data */') - lines.push(' hydration?: DjangoHydration') + if (globalContexts.length > 0) { + lines.push(' /** SSR hydration data (global contexts only) */') + lines.push(' hydration?: MizanHydrationData') } lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */') lines.push(' wsUrl?: string') - lines.push(' /** Base URL for HTTP fallback (default: /api/mizan) */') + lines.push(' /** Base URL for HTTP calls (default: /api/mizan) */') lines.push(' baseUrl?: string') lines.push('}') lines.push('') - // Context names array for MizanProvider - const contextNames = contexts.map(ctx => `'${ctx.name}'`).join(', ') - lines.push('/**') - lines.push(' * Typed Django context provider.') - lines.push(' *') - lines.push(' * Wraps MizanProvider with:') - lines.push(' * - Typed hydration') - lines.push(' * - Auto-fetch for registered contexts') + lines.push(' * Root mizan provider. Mount at your app root.') lines.push(' *') lines.push(' * Usage:') - lines.push(' * ') + lines.push(' * ') lines.push(' * ') - lines.push(' * ') + lines.push(' * ') lines.push(' */') - lines.push('export function DjangoContext({') + lines.push('export function MizanContext({') lines.push(' children,') - if (contexts.length > 0) { + if (globalContexts.length > 0) { lines.push(' hydration,') } lines.push(' wsUrl,') lines.push(' baseUrl,') - lines.push('}: DjangoContextProps) {') + lines.push('}: MizanContextProps) {') if (hasChannels) { lines.push(' const connectionRef = useRef(null)') @@ -274,11 +336,11 @@ export function generateMizanProvider(schema, options = {}) { lines.push('') } + // Build the JSX tree lines.push(' return (') lines.push(' 0) { + if (globalContexts.length > 0) { lines.push(' hydration={toMizanHydration(hydration)}') - lines.push(` contexts={[${contextNames}]}`) } lines.push(' wsUrl={wsUrl}') lines.push(' baseUrl={baseUrl}') @@ -287,12 +349,18 @@ export function generateMizanProvider(schema, options = {}) { } lines.push(' >') + // Inner content: GlobalContextLoader wraps children if needed + let innerContent = '{children}' + if (globalContexts.length > 0) { + innerContent = `{children}` + } + if (hasChannels) { - lines.push(' ') - lines.push(' {children}') - lines.push(' ') + lines.push(` `) + lines.push(` ${innerContent}`) + lines.push(` `) } else { - lines.push(' {children}') + lines.push(` ${innerContent}`) } lines.push(' ') @@ -300,78 +368,200 @@ export function generateMizanProvider(schema, options = {}) { lines.push('}') lines.push('') + // Legacy alias + lines.push('/** @deprecated Use MizanContext instead */') + lines.push('export const DjangoContext = MizanContext') + lines.push('/** @deprecated Use MizanContextProps instead */') + lines.push('export type DjangoContextProps = MizanContextProps') + if (globalContexts.length > 0) { + lines.push('/** @deprecated Use MizanHydrationData instead */') + lines.push('export type DjangoHydration = MizanHydrationData') + } + lines.push('') + // ============================================================================ - // Context Hooks + // Global Context Hooks // ============================================================================ - if (contexts.length > 0) { + if (globalContexts.length > 0) { lines.push('// ============================================================================') - lines.push('// Context Hooks (typed wrappers)') + lines.push('// Global Context Hooks') lines.push('// ============================================================================') lines.push('') - for (const ctx of contexts) { + for (const ctx of globalContexts) { const pascal = pascalCase(ctx.camelName) - lines.push(`/**`) - lines.push(` * Get ${ctx.name} context data.`) - lines.push(` * @throws if context not loaded yet`) - lines.push(` */`) + lines.push(`/** Get ${ctx.name} context data. @throws if not loaded yet */`) lines.push(`export function use${pascal}(): ${ctx.outputType} {`) lines.push(` const data = useMizanContext<${ctx.outputType}>('${ctx.name}')`) - lines.push(` if (data === undefined) {`) - lines.push(` throw new Error('use${pascal}: context not loaded yet')`) - lines.push(` }`) + lines.push(` if (data === undefined) throw new Error('use${pascal}: context not loaded yet')`) lines.push(` return data`) lines.push(`}`) lines.push('') } - // Refresh hooks - lines.push('/**') - lines.push(' * Get context refresh functions without subscribing to data changes.') - lines.push(' * Use this in components that only need to trigger refreshes.') - lines.push(' */') - lines.push('export function useDjangoRefresh() {') - lines.push(' const { refreshContext, refreshAllContexts } = useMizan()') + lines.push('/** Refresh functions for global contexts. */') + lines.push('export function useMizanRefresh() {') + lines.push(' const { invalidateContext } = useMizan()') lines.push(' return {') - for (const ctx of contexts) { + for (const ctx of globalContexts) { const pascal = pascalCase(ctx.camelName) - lines.push(` refresh${pascal}: () => refreshContext('${ctx.name}'),`) + lines.push(` refresh${pascal}: () => invalidateContext('${ctx.name}'),`) } - lines.push(' refreshAll: refreshAllContexts,') lines.push(' }') lines.push('}') lines.push('') + + // Legacy alias + lines.push('/** @deprecated Use useMizanRefresh instead */') + lines.push('export const useDjangoRefresh = useMizanRefresh') + lines.push('') } // ============================================================================ - // Function Hooks + // Named Context Providers // ============================================================================ - if (regularFunctions.length > 0) { + if (namedContextEntries.length > 0) { lines.push('// ============================================================================') - lines.push('// Function Hooks (typed wrappers)') + lines.push('// Named Context Providers') lines.push('// ============================================================================') lines.push('') - for (const fn of regularFunctions) { + for (const [ctxName, ctxMeta] of namedContextEntries) { + const ctxPascal = toPascalCase(ctxName) + const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) + const params = ctxMeta.params || {} + const paramEntries = Object.entries(params) + + // Internal React context type + lines.push(`const ${ctxPascal}ContextInternal = createContext<{`) + for (const fn of ctxFunctions) { + lines.push(` ${fn.name}: ${fn.outputType}`) + } + lines.push(`} | null>(null)`) + lines.push('') + + // Props interface + lines.push(`export interface ${ctxPascal}ContextProps {`) + lines.push(` children: ReactNode`) + for (const [pName, pMeta] of paramEntries) { + const tsType = jsonTypeToTS(pMeta.type) + const optional = pMeta.required ? '' : '?' + lines.push(` ${pName}${optional}: ${tsType}`) + } + lines.push(`}`) + lines.push('') + + // Provider component + lines.push(`export function ${ctxPascal}Context({ children, ...params }: ${ctxPascal}ContextProps) {`) + lines.push(` const mizan = useMizan()`) + lines.push(` const [data, setData] = useState<{`) + for (const fn of ctxFunctions) { + lines.push(` ${fn.name}: ${fn.outputType}`) + } + lines.push(` } | null>(null)`) + lines.push('') + lines.push(` const refetch = useCallback(async () => {`) + lines.push(` await mizan.whenReady`) + lines.push(` const qs = new URLSearchParams()`) + for (const [pName] of paramEntries) { + lines.push(` if (params.${pName} !== undefined) qs.set('${pName}', String(params.${pName}))`) + } + lines.push(` const resp = await mizan.request('GET', \`\${mizan.baseUrl}/ctx/${ctxName}/?\${qs}\`)`) + lines.push(` const result = await resp.json()`) + lines.push(` if (!result.error) setData(result.data)`) + + // Dependency array: mizan + each param + const deps = ['mizan', ...paramEntries.map(([pName]) => `params.${pName}`)] + lines.push(` }, [${deps.join(', ')}])`) + lines.push('') + lines.push(` useEffect(() => { refetch() }, [refetch])`) + lines.push(` useEffect(() => mizan.registerContextProvider('${ctxName}', refetch), [mizan, refetch])`) + lines.push('') + lines.push(` return <${ctxPascal}ContextInternal value={data}>{children}`) + lines.push(`}`) + lines.push('') + + // Individual data hooks + for (const fn of ctxFunctions) { + const hookPascal = pascalCase(fn.camelName) + lines.push(`export function use${hookPascal}(): ${fn.outputType} {`) + lines.push(` const ctx = useContext(${ctxPascal}ContextInternal)`) + lines.push(` if (!ctx) throw new Error('use${hookPascal} must be used within ${ctxPascal}Context')`) + lines.push(` return ctx.${fn.name}`) + lines.push(`}`) + lines.push('') + } + } + } + + // ============================================================================ + // Mutation Hooks (with auto-invalidation) + // ============================================================================ + + if (mutationFunctions.length > 0) { + lines.push('// ============================================================================') + lines.push('// Mutation Hooks (auto-invalidate on success)') + lines.push('// ============================================================================') + lines.push('') + + for (const fn of mutationFunctions) { + const pascal = pascalCase(fn.camelName) + const transport = fn.transport || 'http' + const affectedContexts = getAffectedContexts(fn.affects) + + lines.push(`/** Call ${fn.name}. Auto-invalidates: ${affectedContexts.join(', ')} */`) + lines.push(`export function use${pascal}() {`) + lines.push(` const mizan = useMizan()`) + + if (fn.hasInput) { + lines.push(` return useCallback(async (input: ${fn.inputType}) => {`) + lines.push(` const result = await mizan.call<${fn.inputType}, ${fn.outputType}>('${fn.name}', input, '${transport}')`) + } else { + lines.push(` return useCallback(async () => {`) + lines.push(` const result = await mizan.call('${fn.name}', undefined, '${transport}')`) + } + + // Invalidation + if (affectedContexts.length === 1) { + lines.push(` await mizan.invalidateContext('${affectedContexts[0]}')`) + } else if (affectedContexts.length > 1) { + lines.push(` await Promise.all([`) + for (const ctx of affectedContexts) { + lines.push(` mizan.invalidateContext('${ctx}'),`) + } + lines.push(` ])`) + } + + lines.push(` return result`) + lines.push(` }, [mizan])`) + lines.push(`}`) + lines.push('') + } + } + + // ============================================================================ + // Plain Function Hooks + // ============================================================================ + + if (plainFunctions.length > 0) { + lines.push('// ============================================================================') + lines.push('// Function Hooks') + lines.push('// ============================================================================') + lines.push('') + + for (const fn of plainFunctions) { const pascal = pascalCase(fn.camelName) - // Transport is known at generation time - pass it directly const transport = fn.transport || 'http' if (fn.hasInput) { - lines.push(`/**`) - lines.push(` * Call ${fn.name} server function.`) - lines.push(` * Transport: ${transport}`) - lines.push(` */`) + lines.push(`/** Call ${fn.name}. Transport: ${transport} */`) lines.push(`export function use${pascal}() {`) lines.push(` return useMizanCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`) lines.push(`}`) } else { - lines.push(`/**`) - lines.push(` * Call ${fn.name} server function.`) - lines.push(` * Transport: ${transport}`) - lines.push(` */`) + lines.push(`/** Call ${fn.name}. Transport: ${transport} */`) lines.push(`export function use${pascal}() {`) lines.push(` return useMizanCall('${fn.name}', '${transport}')`) lines.push(`}`) @@ -401,14 +591,14 @@ export function generateMizanProvider(schema, options = {}) { */ export function generateMizanServer(schema) { const functions = schema['x-mizan-functions'] || [] - const contexts = functions.filter(fn => fn.isContext) + const globalContexts = functions.filter(fn => fn.isContext === 'global') - if (contexts.length === 0) { + if (globalContexts.length === 0) { return null } - // Collect type imports for contexts - const typeImports = contexts.map(ctx => ctx.outputType).filter(Boolean) + // Collect type imports for global contexts + const typeImports = globalContexts.map(ctx => ctx.outputType).filter(Boolean) const uniqueTypeImports = [...new Set(typeImports)].sort() const lines = [ @@ -430,55 +620,54 @@ export function generateMizanServer(schema) { lines.push('// Hydration Types') lines.push('// ============================================================================') lines.push('') - lines.push('/** Typed hydration data for SSR */') - lines.push('export interface DjangoHydration {') - for (const ctx of contexts) { + lines.push('/** Typed hydration data for SSR (global contexts only) */') + lines.push('export interface MizanHydrationData {') + for (const ctx of globalContexts) { lines.push(` ${ctx.camelName}?: ${ctx.outputType}`) } lines.push('}') lines.push('') + lines.push('/** @deprecated Use MizanHydrationData instead */') + lines.push('export type DjangoHydration = MizanHydrationData') + lines.push('') - // SSR Hydration Helper + // SSR Hydration Helper — single bundled GET lines.push('// ============================================================================') lines.push('// SSR Hydration Helper') lines.push('// ============================================================================') lines.push('') lines.push('/**') - lines.push(' * Fetch hydration data for SSR.') + lines.push(' * Fetch hydration data for SSR via bundled context endpoint.') lines.push(' *') lines.push(' * Call this in your server component:') - lines.push(' * const hydration = await getDjangoHydration(client)') - lines.push(' * return ...') + lines.push(' * const hydration = await getMizanHydration(client)') + lines.push(' * return ...') lines.push(' */') - lines.push('export async function getDjangoHydration(') + lines.push('export async function getMizanHydration(') lines.push(" client: { request: (method: string, url: string, body?: unknown) => Promise }") - lines.push('): Promise {') - lines.push(' const hydration: DjangoHydration = {}') + lines.push('): Promise {') + lines.push(' const hydration: MizanHydrationData = {}') lines.push('') - lines.push(' const results = await Promise.allSettled([') - for (const ctx of contexts) { - lines.push(` client.request('POST', '/api/mizan/call/', { fn: '${ctx.name}', args: {} }),`) + lines.push(' try {') + lines.push(" const response = await client.request('GET', '/api/mizan/ctx/global/')") + lines.push(' const result = await response.json()') + lines.push(' if (!result.error) {') + for (const ctx of globalContexts) { + lines.push(` if (result.data?.${ctx.name} !== undefined) hydration.${ctx.camelName} = result.data.${ctx.name}`) } - lines.push(' ])') - lines.push('') - - contexts.forEach((ctx, i) => { - lines.push(` if (results[${i}].status === 'fulfilled') {`) - lines.push(` const data = await (results[${i}] as PromiseFulfilledResult).value.json()`) - lines.push(` if (data.error) {`) - lines.push(` console.error('[getDjangoHydration] ${ctx.name} failed:', data.code, data.message)`) - lines.push(` } else {`) - lines.push(` hydration.${ctx.camelName} = data.data`) - lines.push(` }`) - lines.push(` } else {`) - lines.push(` console.error('[getDjangoHydration] ${ctx.name} request failed:', (results[${i}] as PromiseRejectedResult).reason)`) - lines.push(' }') - }) - + lines.push(' } else {') + lines.push(" console.error('[getMizanHydration] Global context fetch failed:', result.code, result.message)") + lines.push(' }') + lines.push(' } catch (e) {') + lines.push(" console.error('[getMizanHydration] Request failed:', e)") + lines.push(' }') lines.push('') lines.push(' return hydration') lines.push('}') lines.push('') + lines.push('/** @deprecated Use getMizanHydration instead */') + lines.push('export const getDjangoHydration = getMizanHydration') + lines.push('') return lines.join('\n') } @@ -658,7 +847,7 @@ export function generateMizanForms(schema) { lines.push('// Form Registry') lines.push('// ============================================================================') lines.push('') - lines.push('export const DJANGO_FORMS = {') + lines.push('export const MIZAN_FORMS = {') for (const [formName, group] of formGroups) { if (!group.schema) continue const pascalName = toPascalCase(formName)