Restructure tree by role; rename mizan-runtime → mizan-base

packages/ flattens into:
  backends/   server protocol adapters (mizan-django, mizan-ts)
  frontends/  client kernel + framework adapters (mizan-base, mizan-react, mizan-vue, mizan-svelte)
  workers/    runtime workers (mizan-ssr)
  cores/      shared language-level primitives (empty for now; mizan-python forthcoming)

The frontend kernel (was packages/mizan-runtime, now frontends/mizan-base) is
renamed to reflect its role — it's the shared base that frontend adapters
depend on directly. Reflects the substrate position that per-framework adapters
wrap a single shared kernel; codegen targets the adapter, not the raw kernel.

Path updates landed in: Makefile, two Gitea workflows, Dockerfile.test, four
example/harness config files, .claude/settings.local.json, four docs
(CLAUDE/ISSUES/ROADMAP/AFI_ARCHITECTURE), four codegen templates (stage1 +
react/vue/svelte adapters), and three package.jsons (the mizan-base rename
plus mizan-vue/svelte peerDeps).

Generated files under examples/django-react-site/harness/src/api/ still
reference @mizan/runtime — left as-is; they're regenerated artifacts and
the harness is non-functional pending the React wrapper-layer codegen.

Also folded in a pre-existing fix: the Gitea workflows had
working-directory: react / django pointing at a layout that predates
packages/, never updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 20:55:37 -04:00
parent 6eca514777
commit fe39fcb229
126 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@mizan/ts",
"devDependencies": {
"bun-types": "latest",
},
},
},
"packages": {
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@mizan/ts",
"version": "0.1.0",
"description": "Mizan TypeScript backend adapter — server functions, context bundling, invalidation protocol.",
"type": "module",
"main": "src/index.ts",
"scripts": {
"test": "bun test"
},
"devDependencies": {
"bun-types": "latest"
},
"license": "MIT"
}

44
backends/mizan-ts/src/cache/backend.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
/**
* Cache backends — MemoryCache for testing.
*
* Simple key-value store. No reverse indexes.
*/
export interface CacheBackend {
get(key: string): string | null
set(key: string, value: string): void
delete(key: string): boolean
deleteByPrefix(prefix: string): number
clear(): void
}
export class MemoryCache implements CacheBackend {
private _store = new Map<string, string>()
get(key: string): string | null {
return this._store.get(key) ?? null
}
set(key: string, value: string): void {
this._store.set(key, value)
}
delete(key: string): boolean {
return this._store.delete(key)
}
deleteByPrefix(prefix: string): number {
let count = 0
for (const key of [...this._store.keys()]) {
if (key.startsWith(prefix)) {
this._store.delete(key)
count++
}
}
return count
}
clear(): void {
this._store.clear()
}
}

72
backends/mizan-ts/src/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,72 @@
/**
* mizan cache — TypeScript adapter.
*
* Same protocol as Python's mizan.cache. Cross-language conformance
* verified by pin tests. No reverse indexes — scoped purge recomputes
* the key directly, broad purge uses prefix scan.
*/
export { MemoryCache } from './backend'
export type { CacheBackend } from './backend'
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
import type { CacheBackend } from './backend'
import { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
let _cacheInstance: CacheBackend | null = null
export function getCache(): CacheBackend | null {
return _cacheInstance
}
export function setCache(backend: CacheBackend | null): void {
_cacheInstance = backend
}
export function resetCache(): void {
_cacheInstance = null
}
export function cacheGet(
secret: string,
backend: CacheBackend,
context: string,
params: Record<string, any>,
userId?: string,
rev: number = 0,
): string | null {
const key = deriveCacheKey(secret, context, params, userId, rev)
return backend.get(key)
}
export function cachePut(
secret: string,
backend: CacheBackend,
context: string,
params: Record<string, any>,
value: string,
userId?: string,
rev: number = 0,
): void {
const key = deriveCacheKey(secret, context, params, userId, rev)
backend.set(key, value)
}
export function cachePurge(
backend: CacheBackend,
context: string,
params?: Record<string, any> | null,
secret?: string | null,
userId?: string,
rev: number = 0,
): number {
if (params && secret) {
// Scoped purge — recompute key and delete directly
const key = deriveCacheKey(secret, context, params, userId, rev)
return backend.delete(key) ? 1 : 0
} else {
// Broad purge — prefix scan
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
return backend.deleteByPrefix(prefix)
}
}

57
backends/mizan-ts/src/cache/keys.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
/**
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
*
* Protocol-critical: must produce identical output to Python's derive_cache_key.
* Cross-language conformance verified by pin tests.
*
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
*/
import { createHmac } from 'crypto'
const CONTEXT_KEY_PREFIX = 'ctx:'
/**
* JSON.stringify with recursively sorted keys and no whitespace.
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
*/
function stableStringify(obj: any): string {
if (obj === null || obj === undefined) return 'null'
if (typeof obj === 'string') return JSON.stringify(obj)
if (typeof obj === 'number' || typeof obj === 'boolean') return String(obj)
if (Array.isArray(obj)) {
return '[' + obj.map(stableStringify).join(',') + ']'
}
const keys = Object.keys(obj).sort()
const pairs = keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k]))
return '{' + pairs.join(',') + '}'
}
/**
* Derive a deterministic HMAC-SHA256 cache key.
*
* Returns "ctx:{context}:{hmac_hex}".
*/
export function deriveCacheKey(
secret: string,
context: string,
params: Record<string, any>,
userId?: string,
rev: number = 0,
): string {
const sortedParams: Record<string, string> = {}
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
sortedParams[k] = String(v)
}
const keyData: Record<string, any> = { c: context, p: sortedParams, r: rev }
if (userId !== undefined) {
keyData.u = String(userId)
}
const message = stableStringify(keyData)
const hmacHex = createHmac('sha256', secret).update(message).digest('hex')
return `${CONTEXT_KEY_PREFIX}${context}:${hmacHex}`
}
export { CONTEXT_KEY_PREFIX }

View File

@@ -0,0 +1,144 @@
/**
* Mizan @client decorator and function wrapper.
*
* Two registration styles:
*
* 1. Function wrapper (standalone functions):
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
*
* 2. Class decorator (methods):
* class Handlers {
* @client({ context: UserCtx })
* async userProfile(userId: number) { ... }
* }
*/
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
import { register } from './registry'
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
if (ctx instanceof ReactContext) return ctx.name
return ctx
}
function normalizeAffects(
affects: ClientOptions['affects'],
): RegistryEntry['affects'] | undefined {
if (!affects) return undefined
const items = Array.isArray(affects) ? affects : [affects]
return items.map(item => {
if (item instanceof ReactContext) {
return { type: 'context' as const, name: item.name }
}
return { type: 'context' as const, name: item }
})
}
function extractParams(fn: Function): ParamDef[] {
// Extract parameter names from function.toString()
const source = fn.toString()
const match = source.match(/\(([^)]*)\)/)
if (!match || !match[1].trim()) return []
return match[1]
.split(',')
.map(p => p.trim())
.filter(p => p && !p.startsWith('...'))
.map(p => {
// Handle destructured defaults: name = default, name: type
const name = p.split(/[=:]/)[0].trim()
return { name, type: 'any', required: !p.includes('=') }
})
}
function isResponseReturn(result: any): boolean {
return result instanceof Response
}
/**
* Function wrapper — registers a standalone function.
*
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
*/
export function client<T extends (...args: any[]) => Promise<any>>(
options: ClientOptions,
fn: T,
): T
/**
* Class method decorator.
*
* class Handlers {
* @client({ context: UserCtx })
* async userProfile(userId: number) { ... }
* }
*/
export function client(options: ClientOptions): MethodDecorator
export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any {
// Function wrapper form: client(options, fn)
if (fn && typeof fn === 'function') {
const options = optionsOrFn as ClientOptions
const context = resolveContext(options.context)
const affects = normalizeAffects(options.affects)
if (context && affects) {
throw new Error('context and affects are mutually exclusive')
}
const name = fn.name || 'anonymous'
const params = extractParams(fn)
const isView = false // Determined at call time for function wrappers
const entry: RegistryEntry = {
name,
fn: fn as any,
context,
affects,
params,
private: options.private ?? false,
viewPath: isView,
route: options.route,
methods: options.methods,
auth: options.auth,
rev: options.rev,
cache: options.cache,
}
register(entry)
return fn
}
// Decorator form: @client(options)
const options = optionsOrFn as ClientOptions
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value
const context = resolveContext(options.context)
const affects = normalizeAffects(options.affects)
if (context && affects) {
throw new Error('context and affects are mutually exclusive')
}
const params = extractParams(originalMethod)
const entry: RegistryEntry = {
name: propertyKey,
fn: originalMethod,
context,
affects,
params,
private: options.private ?? false,
viewPath: false,
route: options.route,
methods: options.methods,
auth: options.auth,
rev: options.rev,
cache: options.cache,
}
register(entry)
return descriptor
}
}

View File

@@ -0,0 +1,213 @@
/**
* Request dispatch — context GET and mutation POST handlers.
*
* Framework-agnostic. Returns plain objects. The router adapter
* (Express, Hono, etc.) converts to framework-specific responses.
*/
import { getFunction, getContextGroups } from './registry'
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
let _cacheSecret: string | null = null
/** Set the cache secret for origin-side caching. */
export function setCacheSecret(secret: string | null): void {
_cacheSecret = secret
}
export interface MizanResponse {
status: number
body: any
headers: Record<string, string>
}
function sortedStringify(data: any): string {
return JSON.stringify(data, Object.keys(data).sort())
}
/**
* Handle GET /api/mizan/ctx/:contextName/
*
* Bundles all functions in a named context into one response.
*/
export async function handleContextFetch(
contextName: string,
params: Record<string, string>,
): Promise<MizanResponse> {
const groups = getContextGroups()
const fnNames = groups[contextName]
if (!fnNames) {
return {
status: 404,
body: { error: true, code: 'NOT_FOUND', message: `Context '${contextName}' not found` },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
// Resolve effective rev (max across functions) and cache policy (min TTL)
let effectiveRev = 0
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (entry?.rev) effectiveRev = Math.max(effectiveRev, entry.rev)
}
// Origin-side cache lookup
const cacheBackend = getCache()
const cacheSecret = _cacheSecret
if (cacheBackend && cacheSecret) {
try {
const cached = cacheGet(cacheSecret, cacheBackend, contextName, params, undefined, effectiveRev)
if (cached !== null) {
return {
status: 200,
body: JSON.parse(cached),
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
}
}
} catch { /* cache miss on error */ }
}
const results: Record<string, any> = {}
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
// Filter params to only those this function declares
const fnParams: Record<string, any> = {}
for (const p of entry.params) {
if (p.name in params) fnParams[p.name] = params[p.name]
}
try {
const argValues = entry.params.map(p => fnParams[p.name])
const result = await entry.fn(...argValues)
// View path — skip (context GET is for RPC data)
if (result instanceof Response) continue
results[fnName] = result
} catch (e: any) {
return {
status: 500,
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
}
// Resolve effective cache policy for origin-side cache decision
let effectiveCache: number | boolean = true
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
if (entry.cache === false) { effectiveCache = false; break }
if (typeof entry.cache === 'number') {
effectiveCache = effectiveCache === true
? entry.cache
: Math.min(effectiveCache as number, entry.cache)
}
}
// Store in origin-side cache (skip if cache=False)
if (cacheBackend && cacheSecret && effectiveCache !== false) {
try {
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
} catch { /* cache store failure is non-fatal */ }
}
return {
status: 200,
body: results,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}),
},
}
}
/**
* Handle POST /api/mizan/call/
*
* Dispatches to a named function. Returns result + invalidation.
*/
export async function handleMutationCall(
fnName: string,
args: Record<string, any>,
): Promise<MizanResponse> {
const entry = getFunction(fnName)
if (!entry) {
return {
status: 404,
body: { error: true, code: 'NOT_FOUND', message: `Function '${fnName}' not found` },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
// Reject private functions from RPC dispatch
if (entry.private) {
return {
status: 403,
body: { error: true, code: 'FORBIDDEN', message: 'Function is not client-callable' },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
try {
const argValues = entry.params.map(p => args[p.name])
const result = await entry.fn(...argValues)
// View path — return Response directly with invalidation header
if (result instanceof Response) {
const invalidate = resolveInvalidation(entry, args)
if (invalidate) {
result.headers.set('X-Mizan-Invalidate', formatInvalidateHeader(invalidate))
}
result.headers.set('Cache-Control', 'no-store')
return {
status: result.status,
body: result,
headers: Object.fromEntries(result.headers.entries()),
}
}
// RPC path — JSON response with invalidation
const invalidate = resolveInvalidation(entry, args)
const responseData: Record<string, any> = { result }
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
}
if (invalidate) {
responseData.invalidate = invalidate
headers['X-Mizan-Invalidate'] = formatInvalidateHeader(invalidate)
// Purge origin-side cache
const cb = getCache()
if (cb) {
try {
for (const entry of invalidate) {
if (typeof entry === 'string') {
cachePurge(cb, entry)
} else {
cachePurge(cb, entry.context, entry.params, _cacheSecret)
}
}
} catch { /* purge failure is non-fatal */ }
}
}
return { status: 200, body: responseData, headers }
} catch (e: any) {
return {
status: 500,
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
}

View File

@@ -0,0 +1,17 @@
export { ReactContext } from './types'
export type { ClientOptions, EdgeManifest, RegistryEntry } from './types'
export { client } from './decorator'
export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry'
export { handleContextFetch, handleMutationCall } from './dispatch'
export type { MizanResponse } from './dispatch'
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
export { generateManifest } from './manifest'
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
export type { CacheBackend } from './cache'
export { setCacheSecret } from './dispatch'

View File

@@ -0,0 +1,102 @@
/**
* Invalidation protocol — header formatting, auto-scoping.
*
* Matches Django's implementation exactly. Same format. Same rules.
*/
import type { RegistryEntry } from './types'
import { getContextGroups, getContextParamNames, getFunction } from './registry'
type InvalidateEntry = string | { context: string; params: Record<string, any> }
/**
* Resolve invalidation targets with three-tier auto-scoping.
*
* Tier 1: Argument name matching
* Tier 2: Auth inference (Edge-side, not handled here)
* Tier 3: Broad fallback
*/
export function resolveInvalidation(
entry: RegistryEntry,
callArgs: Record<string, any> | null,
): InvalidateEntry[] | null {
if (!entry.affects) return null
const result: InvalidateEntry[] = []
const seen = new Set<string>()
for (const target of entry.affects) {
const targetName = target.name
if (seen.has(targetName)) continue
seen.add(targetName)
// Resolve which context the target belongs to (for param lookup)
const resolved = resolveAffectsTarget(targetName)
const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name
// Tier 1: argument name matching
if (callArgs && ctxForParams) {
const contextParams = getContextParamNames(ctxForParams)
const matched: Record<string, any> = {}
for (const [k, v] of Object.entries(callArgs)) {
if (contextParams.has(k)) matched[k] = v
}
if (Object.keys(matched).length > 0) {
result.push({ context: targetName, params: matched })
continue
}
}
// Tier 3: broad fallback
result.push(targetName)
}
return result.length > 0 ? result : null
}
/**
* Determine whether an affects target is a context name or function name.
*/
function resolveAffectsTarget(name: string): { type: 'context' | 'function'; name: string; context?: string } {
const groups = getContextGroups()
if (name in groups) {
return { type: 'context', name }
}
for (const [ctxName, fnNames] of Object.entries(groups)) {
if (fnNames.includes(name)) {
return { type: 'function', name, context: ctxName }
}
}
return { type: 'context', name }
}
/**
* Format invalidation targets as X-Mizan-Invalidate header value.
*
* Format: comma-separated contexts. Semicolon-separated URL-encoded params.
*/
export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
const parts: string[] = []
for (const entry of invalidate) {
if (typeof entry === 'string') {
parts.push(entry)
} else {
const { context, params } = entry
if (params && Object.keys(params).length > 0) {
const paramStr = Object.entries(params)
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
.map(([k, v]) => `${encodeURIComponent(String(k))}=${encodeURIComponent(String(v))}`)
.join(';')
parts.push(`${context};${paramStr}`)
} else {
parts.push(context)
}
}
}
return parts.join(', ')
}

View File

@@ -0,0 +1,93 @@
/**
* Edge Manifest Generator
*
* Produces the same JSON format as mizan-django. One Edge Worker.
* Two backend languages. Same manifest.
*/
import type { EdgeManifest } from './types'
import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
// Both camelCase and snake_case forms included for cross-language matching.
// Wire format is snake_case (protocol rule); camelCase is the TS-local convention.
const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
const groups = getContextGroups()
const allFunctions = getAllFunctions()
const manifest: EdgeManifest = { version: 1, contexts: {}, mutations: {} }
// Contexts
for (const [ctxName, fnNames] of Object.entries(groups)) {
const paramNames = new Set<string>()
const functions: Array<{ name: string; path: 'rpc' | 'view'; route?: string; methods?: string[] }> = []
const pageRoutes: string[] = []
for (const fnName of fnNames) {
const entry = allFunctions.get(fnName)
if (!entry) continue
for (const p of entry.params) paramNames.add(p.name)
const fnEntry: any = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
if (entry.route) {
fnEntry.route = entry.route
fnEntry.methods = entry.methods || ['GET']
pageRoutes.push(entry.route)
}
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev
if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache
functions.push(fnEntry)
}
const sortedParams = [...paramNames].sort()
const userScoped = [...paramNames].some(p => USER_SCOPED_PARAMS.has(p))
const ctxEntry: EdgeManifest['contexts'][string] = {
functions,
endpoints: [`${baseUrl}/ctx/${ctxName}/`],
params: sortedParams,
user_scoped: userScoped,
render_strategy: userScoped ? 'dynamic_cached' : 'psr',
}
if (pageRoutes.length > 0) {
ctxEntry.page_routes = pageRoutes
}
manifest.contexts[ctxName] = ctxEntry
}
// Mutations
for (const [fnName, entry] of allFunctions) {
if (!entry.affects) continue
const affectedContexts = [...new Set(entry.affects.map(a => a.name))]
// Auto-scoped params
const fnParamNames = new Set(entry.params.map(p => p.name))
const autoScoped: string[] = []
for (const ctxName of affectedContexts) {
const ctxParams = getContextParamNames(ctxName)
for (const p of fnParamNames) {
if (ctxParams.has(p) && !autoScoped.includes(p)) {
autoScoped.push(p)
}
}
}
const mutation: EdgeManifest['mutations'][string] = {
affects: affectedContexts,
}
if (autoScoped.length > 0) mutation.auto_scoped_params = autoScoped.sort()
if (entry.private) mutation.private = true
if (entry.route) {
mutation.route = entry.route
mutation.methods = entry.methods || ['POST']
}
manifest.mutations[fnName] = mutation
}
return manifest
}

View File

@@ -0,0 +1,49 @@
/**
* Mizan Registry — Central registration for server functions.
*/
import type { RegistryEntry } from './types'
const _functions: Map<string, RegistryEntry> = new Map()
export function register(entry: RegistryEntry): void {
if (_functions.has(entry.name) && _functions.get(entry.name)!.fn !== entry.fn) {
throw new Error(`Function '${entry.name}' already registered`)
}
_functions.set(entry.name, entry)
}
export function getFunction(name: string): RegistryEntry | undefined {
return _functions.get(name)
}
export function getAllFunctions(): Map<string, RegistryEntry> {
return new Map(_functions)
}
export function getContextGroups(): Record<string, string[]> {
const groups: Record<string, string[]> = {}
for (const [name, entry] of _functions) {
if (entry.context) {
if (!groups[entry.context]) groups[entry.context] = []
groups[entry.context].push(name)
}
}
return groups
}
export function getContextParamNames(contextName: string): Set<string> {
const params = new Set<string>()
for (const [, entry] of _functions) {
if (entry.context === contextName) {
for (const p of entry.params) {
params.add(p.name)
}
}
}
return params
}
export function clearRegistry(): void {
_functions.clear()
}

View File

@@ -0,0 +1,66 @@
/**
* Mizan TypeScript Adapter — Shared Types
*/
export class ReactContext {
constructor(public readonly name: string) {
if (!name) throw new Error('ReactContext name must be non-empty')
}
}
export type AffectsTarget = ReactContext | string
export interface ClientOptions {
context?: ReactContext | string
affects?: AffectsTarget | AffectsTarget[]
private?: boolean
route?: string
methods?: string[]
auth?: boolean
rev?: number
cache?: number | false
}
export interface ParamDef {
name: string
type: string
required: boolean
}
export interface RegistryEntry {
name: string
fn: (...args: any[]) => Promise<any>
context?: string
affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }>
params: ParamDef[]
private: boolean
viewPath: boolean
route?: string
methods?: string[]
auth?: boolean
rev?: number
cache?: number | false
}
export interface ManifestContext {
functions: Array<{ name: string; path: 'rpc' | 'view' }>
endpoints: string[]
params: string[]
user_scoped: boolean
render_strategy: 'psr' | 'dynamic_cached'
page_routes?: string[]
}
export interface ManifestMutation {
affects: string[]
auto_scoped_params?: string[]
private?: boolean
route?: string
methods?: string[]
}
export interface EdgeManifest {
version: number
contexts: Record<string, ManifestContext>
mutations: Record<string, ManifestMutation>
}

View File

@@ -0,0 +1,425 @@
/**
* Edge Compatibility Tests — mirrors Django's EdgeCompatibilityTests exactly.
*
* These prove that a Cloudflare Worker (Edge) can sit in front of a
* TypeScript backend and behave identically to sitting in front of Django.
*/
import { describe, test, expect, beforeEach } from 'bun:test'
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
const UserCtx = new ReactContext('user')
function setupUserContext() {
const userProfile = client({ context: UserCtx }, async function userProfile(userId: number) {
return { name: `user_${userId}`, email: `user${userId}@test.com` }
})
const userOrders = client({ context: UserCtx }, async function userOrders(userId: number) {
return { count: userId * 10 }
})
const updateProfile = client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
return { name, email: `user${userId}@test.com` }
})
client({ affects: 'userProfile' }, async function updateName(userId: number, name: string) {
return { name, email: `user${userId}@test.com` }
})
}
describe('Edge Compatibility', () => {
beforeEach(() => {
clearRegistry()
setupUserContext()
})
// ── Deterministic JSON ──────────────────────────────────────────────
test('deterministic JSON output', async () => {
const r1 = await handleContextFetch('user', { userId: '5' })
const r2 = await handleContextFetch('user', { userId: '5' })
expect(JSON.stringify(r1.body)).toBe(JSON.stringify(r2.body))
})
test('different params produce different responses', async () => {
const r1 = await handleContextFetch('user', { userId: '5' })
const r2 = await handleContextFetch('user', { userId: '6' })
expect(JSON.stringify(r1.body)).not.toBe(JSON.stringify(r2.body))
expect(r1.body.userProfile.name).toBe('user_5')
expect(r2.body.userProfile.name).toBe('user_6')
})
// ── Cache-Control correctness ───────────────────────────────────────
test('context GET emits no-store', async () => {
const r = await handleContextFetch('user', { userId: '5' })
expect(r.headers['Cache-Control']).toBe('no-store')
})
test('mutation POST not cacheable', async () => {
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
expect(r.headers['Cache-Control']).toBe('no-store')
})
test('error response not cacheable', async () => {
const r = await handleContextFetch('nonexistent', {})
expect(r.status).toBe(404)
expect(r.headers['Cache-Control']).toBe('no-store')
})
// ── X-Mizan-Invalidate header ──────────────────────────────────────
test('mutation response includes invalidation header', async () => {
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
expect(r.headers['X-Mizan-Invalidate']).toBeDefined()
expect(r.headers['X-Mizan-Invalidate']).toContain('user')
})
test('auto-scoped invalidation in header', async () => {
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
expect(r.headers['X-Mizan-Invalidate']).toBe('user;userId=5')
})
test('invalidation header matches JSON body', async () => {
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
const body = r.body
expect(body.invalidate[0].context).toBe('user')
expect(body.invalidate[0].params.userId).toBe(5)
expect(r.headers['X-Mizan-Invalidate']).toContain('user;userId=5')
})
test('function-level invalidation in header', async () => {
const r = await handleMutationCall('updateName', { userId: 7, name: 'X' })
expect(r.headers['X-Mizan-Invalidate']).toContain('userProfile')
})
test('no invalidation header on context GET', async () => {
const r = await handleContextFetch('user', { userId: '5' })
expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
})
// ── Header format edge cases ───────────────────────────────────────
test('special characters in param values are URL-encoded', () => {
const header = formatInvalidateHeader([
{ context: 'search', params: { q: 'a;b' } },
])
expect(header).not.toContain(';b')
expect(header).toContain('a%3Bb')
})
test('spaces in param values are URL-encoded', () => {
const header = formatInvalidateHeader([
{ context: 'search', params: { q: 'hello world' } },
])
expect(header).not.toContain(' ')
expect(header).toContain('hello%20world')
})
test('header round-trip with special chars', () => {
const header = formatInvalidateHeader([
{ context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } },
])
// Parse (what Edge does)
const segments = header.split(';')
const ctx = segments[0]
const params: Record<string, string> = {}
for (const seg of segments.slice(1)) {
const [k, v] = seg.split('=', 2)
params[decodeURIComponent(k)] = decodeURIComponent(v)
}
expect(ctx).toBe('data')
expect(params.name).toBe("O'Brien")
expect(params.tag).toBe('a;b;c')
})
// ── Empty invalidation ─────────────────────────────────────────────
test('no affects = no header, no body key', async () => {
client({ context: new ReactContext('plain') }, async function plainFn() {
return { ok: true }
})
// A context function called via mutation dispatch (shouldn't have invalidation)
// Actually test a function without affects
clearRegistry()
client({}, async function noAffects() { return { ok: true } })
const r = await handleMutationCall('noAffects', {})
expect(r.body.invalidate).toBeUndefined()
expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
})
// ── Private functions ──────────────────────────────────────────────
test('private functions rejected from RPC', async () => {
clearRegistry()
client({ affects: 'subscription', private: true }, async function webhook() {
return { ok: true }
})
const r = await handleMutationCall('webhook', {})
expect(r.status).toBe(403)
})
// ── Unknown function ───────────────────────────────────────────────
test('unknown function returns 404', async () => {
const r = await handleMutationCall('doesNotExist', {})
expect(r.status).toBe(404)
})
test('unknown context returns 404', async () => {
const r = await handleContextFetch('doesNotExist', {})
expect(r.status).toBe(404)
})
})
describe('Manifest', () => {
beforeEach(() => {
clearRegistry()
setupUserContext()
})
test('manifest matches expected structure', () => {
const m = generateManifest()
expect(m.version).toBe(1)
expect(m.contexts.user).toBeDefined()
expect(m.contexts.user.endpoints).toEqual(['/api/mizan/ctx/user/'])
expect(m.contexts.user.params).toContain('userId')
expect(m.contexts.user.user_scoped).toBe(true)
expect(m.contexts.user.render_strategy).toBe('dynamic_cached')
})
test('mutations section includes auto-scoped params', () => {
const m = generateManifest()
expect(m.mutations.updateProfile).toBeDefined()
expect(m.mutations.updateProfile.affects).toEqual(['user'])
expect(m.mutations.updateProfile.auto_scoped_params).toContain('userId')
})
test('PSR strategy for non-user-scoped context', () => {
clearRegistry()
const ProductCtx = new ReactContext('products')
client({ context: ProductCtx }, async function productDetail(productId: number) {
return { id: productId }
})
const m = generateManifest()
expect(m.contexts.products.user_scoped).toBe(false)
expect(m.contexts.products.render_strategy).toBe('psr')
})
test('private mutation in manifest', () => {
clearRegistry()
client(
{ affects: 'subscription', private: true, route: '/webhooks/stripe/', methods: ['POST'] },
async function stripeWebhook() { return new Response('ok') },
)
const m = generateManifest()
expect(m.mutations.stripeWebhook).toBeDefined()
expect(m.mutations.stripeWebhook.private).toBe(true)
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
expect(m.mutations.stripeWebhook.methods).toEqual(['POST'])
})
test('rev appears in manifest', () => {
clearRegistry()
const Ctx = new ReactContext('data')
client({ context: Ctx, rev: 3 }, async function versionedFn(itemId: number) {
return { value: itemId }
})
const m = generateManifest()
const fn = m.contexts.data.functions[0]
expect(fn.rev).toBe(3)
})
test('cache TTL appears in manifest', () => {
clearRegistry()
const Ctx = new ReactContext('trending')
client({ context: Ctx, cache: 60 }, async function trendingFn() {
return { items: [] }
})
const m = generateManifest()
const fn = m.contexts.trending.functions[0]
expect(fn.cache).toBe(60)
})
test('cache=60 still emits no-store on HTTP', async () => {
clearRegistry()
const Ctx = new ReactContext('live')
client({ context: Ctx, cache: 60 }, async function liveFn() {
return { score: 42 }
})
const r = await handleContextFetch('live', {})
expect(r.headers['Cache-Control']).toBe('no-store')
})
test('cache=false sets no-store', async () => {
clearRegistry()
const Ctx = new ReactContext('random')
client({ context: Ctx, cache: false }, async function randomFn() {
return { value: Math.random() }
})
const r = await handleContextFetch('random', {})
expect(r.headers['Cache-Control']).toBe('no-store')
})
})
// ── Cache Conformance Tests ────────────────────────────────────────────
describe('Cache Conformance', () => {
const SECRET = 'test-pin-secret-that-is-32bytes!'
test('deriveCacheKey determinism', () => {
const k1 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
const k2 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
expect(k1).toBe(k2)
expect(k1).toStartWith('ctx:user:')
expect(k1).toHaveLength('ctx:user:'.length + 64)
})
test('deriveCacheKey param order irrelevant', () => {
const k1 = deriveCacheKey(SECRET, 'ctx', { a: '1', b: '2' })
const k2 = deriveCacheKey(SECRET, 'ctx', { b: '2', a: '1' })
expect(k1).toBe(k2)
})
test('deriveCacheKey cross-language pin (matches Python)', () => {
// These exact values are pinned from Python's derive_cache_key output.
// If this test fails, cross-language cache key compatibility is broken.
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
const userScopedKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, '5', 0)
expect(userScopedKey).toBe('ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2')
})
test('MemoryCache get/set/clear', () => {
const cache = new MemoryCache()
expect(cache.get('k1')).toBeNull()
cache.set('k1', '{"data":true}')
expect(cache.get('k1')).toBe('{"data":true}')
cache.clear()
expect(cache.get('k1')).toBeNull()
})
test('scoped purge recomputes key directly', () => {
const cache = new MemoryCache()
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
const count = cachePurge(cache, 'user', { user_id: '5' }, SECRET)
expect(count).toBe(1)
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).not.toBeNull()
})
test('broad purge removes all entries', () => {
const cache = new MemoryCache()
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
const count = cachePurge(cache, 'user')
expect(count).toBe(2)
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).toBeNull()
})
test('handleContextFetch caches response', async () => {
clearRegistry()
const Ctx = new ReactContext('cached')
client({ context: Ctx }, async function cachedFn(itemId: number) {
return { value: itemId }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
const r1 = await handleContextFetch('cached', { itemId: '1' })
expect(r1.status).toBe(200)
expect(r1.headers['X-Mizan-Cache']).toBe('MISS')
const r2 = await handleContextFetch('cached', { itemId: '1' })
expect(r2.status).toBe(200)
expect(r2.headers['X-Mizan-Cache']).toBe('HIT')
expect(r2.body).toEqual(r1.body)
resetCache()
setCacheSecret(null)
})
test('handleMutationCall purges cache', async () => {
clearRegistry()
const Ctx = new ReactContext('product')
client({ context: Ctx }, async function getProduct(productId: number) {
return { id: productId }
})
client({ affects: Ctx }, async function updateProduct(productId: number, name: string) {
return { ok: true }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
// Prime cache
await handleContextFetch('product', { productId: '1' })
// Mutate
await handleMutationCall('updateProduct', { productId: 1, name: 'New' })
// Cache should be purged — next fetch is MISS
const r = await handleContextFetch('product', { productId: '1' })
expect(r.headers['X-Mizan-Cache']).toBe('MISS')
resetCache()
setCacheSecret(null)
})
test('scoped invalidation preserves other entries', async () => {
clearRegistry()
const Ctx = new ReactContext('user')
client({ context: Ctx }, async function userProfile(userId: number) {
return { name: `user_${userId}` }
})
client({ affects: Ctx }, async function editUser(userId: number, name: string) {
return { ok: true }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
// Prime both users
await handleContextFetch('user', { userId: '5' })
await handleContextFetch('user', { userId: '6' })
// Mutate only user 5
await handleMutationCall('editUser', { userId: 5, name: 'New' })
// User 6 should still be cached
const r6 = await handleContextFetch('user', { userId: '6' })
expect(r6.headers['X-Mizan-Cache']).toBe('HIT')
// User 5 should be a miss
const r5 = await handleContextFetch('user', { userId: '5' })
expect(r5.headers['X-Mizan-Cache']).toBe('MISS')
resetCache()
setCacheSecret(null)
})
})

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"types": ["bun-types"]
},
"include": ["src/**/*", "tests/**/*"]
}