// Block registry — the single definition every PRD-08 projection derives // from (panel, canvas, compiler, assumptions sheet). A `BlockDef` wraps the // existing `Block` contract with pure-data metadata; levelset gains no // UI dependency. There is deliberately no registry singleton: the consuming // app constructs its own instance and passes it to `validateGraph` / // `compileGraph`, keeping levelset side-effect-free. import type { Block } from "./types.ts"; export type BlockFamily = "constraint" | "scorer" | "search" | "collector"; /** Drives toolbox disclosure in the consuming app (UI state, not authz). */ export type BlockTier = "sales" | "estimator" | "engineer"; /** Projects one block parameter onto a Tier-1 panel control. */ export interface PanelBinding { /** Dot-path into the block's input params (e.g. "window.start"). */ paramPath: string; label: string; control: "date" | "slider" | "number" | "select" | "toggle"; options?: Record; } export interface BlockDef { /** Stable id used in graphs ("PeakCap"). Unique per registry. */ code: string; family: BlockFamily; tier: BlockTier; /** The existing contract: schema, apply, doc. */ block: Block; /** Curated default params (Blockly shadow values). Validated against * `block.schema.input` at `register()` time. */ defaultShadow: I; /** e.g. "max {max} concurrent {resource} crews" */ assumptionTemplate?: string; /** Tier-1 panel projection. */ panelBindings?: PanelBinding[]; /** Params reset on template save. */ projectAnchoredPaths?: string[]; } export interface BlockRegistry { /** Throws on duplicate `code` or a `defaultShadow` that fails the * block's input schema. */ register(def: BlockDef): void; get(code: string): BlockDef | undefined; list(filter?: { family?: BlockFamily; tier?: BlockTier }): BlockDef[]; } function resolveDotPath(value: unknown, path: string): unknown { let current = value; for (const segment of path.split(".")) { if (current === null || typeof current !== "object") return undefined; current = (current as Record)[segment]; } return current; } export function createRegistry(defs?: BlockDef[]): BlockRegistry { const byCode = new Map(); const registry: BlockRegistry = { register(def: BlockDef): void { if (byCode.has(def.code)) { throw new Error(`Duplicate block code: ${def.code}`); } const parsed = def.block.schema.input.safeParse(def.defaultShadow); if (!parsed.success) { throw new Error( `Invalid defaultShadow for block "${def.code}": ${parsed.error.message}`, ); } // Dot-paths are consumed app-side (panel generation, template save); // a typo there would otherwise fail silently at first use. Check them // against the parsed shadow so they fail here instead — defaultShadow // is curated to be complete, so every declared path must resolve. const paths = [ ...(def.panelBindings ?? []).map((binding) => binding.paramPath), ...(def.projectAnchoredPaths ?? []), ]; for (const path of paths) { if (resolveDotPath(parsed.data, path) === undefined) { throw new Error( `Block "${def.code}": path "${path}" does not resolve in defaultShadow`, ); } } byCode.set(def.code, def); }, get(code: string): BlockDef | undefined { return byCode.get(code); }, list(filter?: { family?: BlockFamily; tier?: BlockTier }): BlockDef[] { const all = [...byCode.values()]; if (!filter) return all; return all.filter( (def) => (filter.family === undefined || def.family === filter.family) && (filter.tier === undefined || def.tier === filter.tier), ); }, }; for (const def of defs ?? []) registry.register(def); return registry; }