Flatten to three packages + extract mizan-runtime

packages/
  mizan-runtime/   Framework-agnostic state engine (~150 lines)
                   Context registry, batched invalidation, fetch primitives
  mizan-django/    Django server adapter (was packages/mizan-rpc/adapters/django/)
                   Codegen moved to mizan-django/generate/
  mizan-react/     React adapter (was packages/mizan-csr/adapters/react/)

Removed premature abstractions: mizan-ast, mizan-schema, mizan-rpc,
mizan-csr, mizan-ssr stub packages. The actual architecture is three
concrete packages, not five abstract layers.

mizan-runtime implements the v1 spec: registerContext with params,
scoped invalidation via microtask batching, server-driven invalidation
from mutation responses, mizanFetch for context bundles, mizanCall for
mutations.

264 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 15:47:17 -04:00
parent b28ee72c67
commit 787f90fd12
141 changed files with 167 additions and 15 deletions

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env node
/**
* mizan Code Generator CLI
*
* Generate TypeScript types, React provider, and hooks from Django schemas.
*
* Usage:
* npx mizan-generate # Run once
* npx mizan-generate --watch # Watch mode
*/
import { promises as fs } from 'fs'
import path from 'path'
import { fetchChannelsSchema, fetchMizanSchema } from './lib/fetch.mjs'
import { generateMizanFiles } from './lib/mizan.mjs'
import { generateChannelsFiles } from './lib/channels.mjs'
import { generateIndex } from './lib/index.mjs'
// Use cwd — the script runs via `npx mizan-generate` from the frontend root
const frontendDir = process.cwd()
/**
* Load configuration from django.config.mjs
*/
async function loadConfig(configPath) {
const fullPath = path.resolve(frontendDir, configPath)
try {
await fs.access(fullPath)
} catch {
throw new Error(`Config file not found: ${fullPath}`)
}
// Convert to file:// URL for Windows compatibility
const fileUrl = new URL(`file://${fullPath.replace(/\\/g, '/')}`)
if (configPath.endsWith('.mjs') || configPath.endsWith('.js')) {
const module = await import(fileUrl)
return module.default
}
if (configPath.endsWith('.ts')) {
try {
const module = await import(fileUrl)
return module.default
} catch {
throw new Error(
`Cannot load TypeScript config directly. Either:\n` +
` 1. Install tsx: npm install -D tsx\n` +
` 2. Use django.config.mjs instead`
)
}
}
throw new Error(`Unsupported config file format: ${configPath}`)
}
/**
* Write generated code to file.
*/
async function writeOutput(filePath, content) {
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(filePath, content, 'utf8')
}
/**
* Run schema generation.
*/
async function generate(config, options = {}) {
const { output } = options
console.log('[mizan] Starting schema generation...')
const outputPath = output || config.output || 'src/api/generated.ts'
let channelsSchema = null
let mizanSchema = null
// Fetch and generate channels if available
try {
console.log('[mizan] Fetching channels schema...')
channelsSchema = await fetchChannelsSchema(config.source, frontendDir)
const channelCount = channelsSchema['x-mizan-channels']?.length || 0
if (channelCount > 0) {
console.log(`[mizan] Found ${channelCount} channels`)
const channelsTypesPath = outputPath.replace(/\.ts$/, '.channels.ts')
const fullChannelsTypesPath = path.resolve(frontendDir, channelsTypesPath)
const channelsHooksPath = outputPath.replace(/\.ts$/, '.channels.hooks.tsx')
const fullChannelsHooksPath = path.resolve(frontendDir, channelsHooksPath)
const channelsSchemaPath = outputPath.replace(/\.ts$/, '.channels.schema.json')
const fullChannelsSchemaPath = path.resolve(frontendDir, channelsSchemaPath)
const { types: channelsTypes, hooks: channelsHooks } = await generateChannelsFiles(channelsSchema)
console.log(`[mizan] Generating -> ${channelsTypesPath}`)
await writeOutput(fullChannelsTypesPath, channelsTypes)
if (channelsHooks) {
console.log(`[mizan] Generating -> ${channelsHooksPath}`)
await writeOutput(fullChannelsHooksPath, channelsHooks)
}
console.log(`[mizan] Generating -> ${channelsSchemaPath}`)
await writeOutput(fullChannelsSchemaPath, JSON.stringify(channelsSchema, null, 2))
} else {
console.log('[mizan] No channels registered, skipping channels generation')
}
} catch (err) {
console.log(`[mizan] Channels schema not available: ${err.message}`)
}
// Fetch and generate mizan files
try {
console.log('[mizan] Fetching mizan schema...')
mizanSchema = await fetchMizanSchema(config.source, frontendDir)
const functionCount = mizanSchema['x-mizan-functions']?.length || 0
if (functionCount > 0) {
console.log(`[mizan] Found ${functionCount} mizan functions`)
const mizanTypesPath = outputPath.replace(/\.ts$/, '.mizan.ts')
const fullMizanTypesPath = path.resolve(frontendDir, mizanTypesPath)
const mizanProviderPath = outputPath.replace(/\.ts$/, '.provider.tsx')
const fullMizanProviderPath = path.resolve(frontendDir, mizanProviderPath)
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)
const mizanSchemaPath = outputPath.replace(/\.ts$/, '.mizan.schema.json')
const fullMizanSchemaPath = path.resolve(frontendDir, mizanSchemaPath)
const hasChannels = (channelsSchema?.['x-mizan-channels']?.length || 0) > 0
const { types: mizanTypes, provider: mizanProvider, server: mizanServer, forms: mizanForms } = await generateMizanFiles(mizanSchema, { hasChannels })
console.log(`[mizan] Generating -> ${mizanTypesPath}`)
await writeOutput(fullMizanTypesPath, mizanTypes)
if (mizanProvider) {
console.log(`[mizan] Generating -> ${mizanProviderPath}`)
await writeOutput(fullMizanProviderPath, mizanProvider)
}
if (mizanServer) {
console.log(`[mizan] Generating -> ${mizanServerPath}`)
await writeOutput(fullMizanServerPath, mizanServer)
}
if (mizanForms) {
console.log(`[mizan] Generating -> ${mizanFormsPath}`)
await writeOutput(fullMizanFormsPath, mizanForms)
}
console.log(`[mizan] Generating -> ${mizanSchemaPath}`)
await writeOutput(fullMizanSchemaPath, JSON.stringify(mizanSchema, null, 2))
} else {
console.log('[mizan] No mizan functions registered, skipping mizan generation')
}
} catch (err) {
console.log(`[mizan] mizan schema not available: ${err.message}`)
}
// Generate consolidated index.ts
const indexPath = path.dirname(outputPath) + '/index.ts'
const fullIndexPath = path.resolve(frontendDir, indexPath)
console.log(`[mizan] Generating -> ${indexPath}`)
const indexContent = generateIndex({
channelsSchema,
mizanSchema,
})
await writeOutput(fullIndexPath, indexContent)
console.log('[mizan] Generation complete!')
}
/**
* Watch for changes and regenerate.
*/
async function watch(config, options) {
const debounce = config.watch?.debounce || 1000
let timeout = null
let running = false
async function runGenerate() {
if (running) {
timeout = setTimeout(runGenerate, debounce)
return
}
running = true
try {
await generate(config, options)
} catch (err) {
console.error('[mizan] Generation failed:', err.message)
} finally {
running = false
}
}
await runGenerate()
console.log('[mizan] Watching for changes (press Ctrl+C to stop)...')
if (config.source.django) {
const { watch: chokidarWatch } = await import('chokidar')
const djangoDir = path.resolve(frontendDir, path.dirname(config.source.django.managePath))
const watcher = chokidarWatch([
path.join(djangoDir, '**/*.py'),
], {
ignored: [
'**/node_modules/**',
'**/__pycache__/**',
'**/migrations/**',
'**/.venv/**',
],
ignoreInitial: true,
})
watcher.on('change', (filePath) => {
console.log(`[mizan] Detected change: ${path.relative(djangoDir, filePath)}`)
if (timeout) clearTimeout(timeout)
timeout = setTimeout(runGenerate, debounce)
})
}
process.on('SIGINT', () => {
console.log('\n[mizan] Stopping watch mode...')
process.exit(0)
})
}
/**
* Main entry point.
*/
async function main() {
const args = process.argv.slice(2)
let configPath = 'django.config.mjs'
let watchMode = false
let output = null
for (let i = 0; i < args.length; i++) {
if (args[i] === '--config' || args[i] === '-c') {
configPath = args[++i]
} else if (args[i] === '--watch' || args[i] === '-w') {
watchMode = true
} else if (args[i] === '--output' || args[i] === '-o') {
output = args[++i]
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
mizan Code Generator - Generate TypeScript from Django schemas
Usage:
npx mizan-generate [options]
Options:
-c, --config <path> Config file path (default: django.config.mjs)
-w, --watch Watch mode - regenerate on changes
-o, --output <path> Output file path (overrides config)
-h, --help Show this help message
`)
process.exit(0)
}
}
const config = await loadConfig(configPath)
const options = { output }
if (watchMode) {
await watch(config, options)
} else {
await generate(config, options)
}
}
main().catch(err => {
console.error('[mizan] Error:', err.message)
process.exit(1)
})