/** * Build the BC (bounded-context) graph the Map renders — one card per app with * its member primitives, plus the inter-BC flow edges. * * Resilient by design: membership comes from the manifest's per-kind arrays * (always present); edges come from the deep `model.edges` when available, else * from the flat `graph.events`. So the Map works against both a full deep * manifest and an older/partial one — never throws on missing fields. */ import type { Manifest } from "./manifest"; export interface BcRow { /** Graph node id (`${kind}:${name}`). */ readonly id: string; readonly kind: string; readonly name: string; readonly public?: boolean; } export interface BcNode { readonly name: string; readonly rows: BcRow[]; } export interface BcEdge { readonly from: string; readonly to: string; readonly label?: string; } export interface BcGraph { readonly bcs: BcNode[]; readonly edges: BcEdge[]; } /** A positioned BC node — what the canvas lays out. */ export interface BcLayoutNode extends BcNode { readonly x: number; readonly y: number; readonly width: number; readonly height: number; } /** A business-rule invariant surfaced from a `validate()` predicate. */ export interface NodeInvariant { readonly rule: string; readonly message?: string; } /** A config module surfaced on an app: its file path and top-level field names. */ export interface NodeConfigModule { readonly file: string; readonly keys: readonly string[]; } /** The full detail for one selected primitive — what NodeCard renders. */ export interface BcNodeDetail { readonly id: string; readonly kind: string; readonly name: string; readonly app?: string; readonly description?: string; readonly public?: boolean; readonly source?: { file: string; line?: number; column?: number }; /** Input/payload schema when the kind carries one (actions, queries…). */ readonly schema?: unknown; readonly emits?: string[]; /** Business rules surfaced from `validate()` predicates (actions, actors). */ readonly invariants?: NodeInvariant[]; /** Environment variable names this app reads (app nodes only). */ readonly env?: readonly string[]; /** Config modules this app exposes (app nodes only). */ readonly config?: readonly NodeConfigModule[]; /** Remaining kind-specific fields (states, schedule, subscribesTo, …). */ readonly extra: Record; } /** Normalize a raw `config` value to `NodeConfigModule[]` (or undefined). */ export function toConfigModules(raw: unknown): NodeConfigModule[] | undefined { if (!Array.isArray(raw)) return undefined; const out: NodeConfigModule[] = []; for (const r of raw) { if (r && typeof r === "object" && typeof (r as { file?: unknown }).file === "string") { const { file, keys } = r as { file: string; keys?: unknown }; out.push({ file, keys: Array.isArray(keys) ? (keys as string[]) : [] }); } } return out.length ? out : undefined; } /** Normalize a raw `env` value to a string array (or undefined). */ export function toEnvVars(raw: unknown): string[] | undefined { if (!Array.isArray(raw)) return undefined; const out = raw.filter((x): x is string => typeof x === "string"); return out.length ? out : undefined; } /** Normalize a raw `invariants` value to `NodeInvariant[]` (or undefined). */ export function toInvariants(raw: unknown): NodeInvariant[] | undefined { if (!Array.isArray(raw)) return undefined; const out: NodeInvariant[] = []; for (const r of raw) { if (r && typeof r === "object" && typeof (r as { rule?: unknown }).rule === "string") { const { rule, message } = r as { rule: string; message?: unknown }; out.push(typeof message === "string" ? { rule, message } : { rule }); } } return out.length ? out : undefined; } /** Per-kind arrays on the manifest → the node kind they map to. */ const MEMBER_KINDS: ReadonlyArray = [ ["actions", "action"], ["queries", "query"], ["events", "event"], ["actors", "actor"], ["workflows", "workflow"], ["projections", "projection"], ]; type Entry = { name?: unknown; app?: unknown; public?: unknown }; type RawGraphEdge = { from?: unknown; to?: unknown; via?: unknown; type?: unknown }; function arr(m: Record, key: string): Entry[] { const v = m[key]; return Array.isArray(v) ? (v as Entry[]) : []; } /** `"action:orders.place"` → `"orders.place"`. */ function nameOfId(id: string): string { const i = id.indexOf(":"); return i === -1 ? id : id.slice(i + 1); } export function buildBcGraph(manifest: Manifest | null | undefined): BcGraph { if (!manifest) return { bcs: [], edges: [] }; const m = manifest as unknown as Record; const appNames = new Set(); for (const a of arr(m, "apps")) if (typeof a.name === "string") appNames.add(a.name); const nameToApp = new Map(); const rowsByApp = new Map(); for (const [key, kind] of MEMBER_KINDS) { for (const e of arr(m, key)) { if (typeof e.name !== "string") continue; const app = typeof e.app === "string" && e.app ? e.app : "(unscoped)"; appNames.add(app); nameToApp.set(e.name, app); const rows = rowsByApp.get(app) ?? []; rows.push({ id: `${kind}:${e.name}`, kind, name: e.name, public: e.public === true }); rowsByApp.set(app, rows); } } const bcs: BcNode[] = [...appNames] .sort() .map((name) => ({ name, rows: rowsByApp.get(name) ?? [] })); const edges = new Map(); const add = (fromApp?: string, toApp?: string, label?: string) => { if (fromApp && toApp && fromApp !== toApp) edges.set(`${fromApp}->${toApp}`, { from: fromApp, to: toApp, label }); }; // Prefer the deep model edges; fall back to the flat event graph. const model = m.model as { edges?: RawGraphEdge[] } | undefined; if (model && Array.isArray(model.edges) && model.edges.length) { for (const e of model.edges) { if (typeof e.from !== "string" || typeof e.to !== "string") continue; add( nameToApp.get(nameOfId(e.from)), nameToApp.get(nameOfId(e.to)), typeof e.type === "string" ? e.type : undefined, ); } } else { const graph = m.graph as { events?: RawGraphEdge[] } | undefined; for (const e of graph?.events ?? []) { if (typeof e.from !== "string" || typeof e.to !== "string") continue; add( nameToApp.get(e.from), nameToApp.get(e.to), typeof e.via === "string" ? e.via : undefined, ); } } return { bcs, edges: [...edges.values()] }; } const KIND_TO_KEY: Record = Object.fromEntries( MEMBER_KINDS.map(([key, kind]) => [kind, key]), ); /** Fields we surface on their own; everything else lands in `extra`. */ const KNOWN_FIELDS = new Set([ "name", "app", "kind", "description", "public", "source", "inputSchema", "schema", "payloadSchema", "emits", "invariants", ]); /** * Resolve a graph-node id (`"action:orders.place"`) to its full detail by * looking the entry up in the manifest's flat per-kind array. Returns null * when the kind/name isn't found — callers render an empty detail panel. */ export function nodeDetail( manifest: Manifest | null | undefined, id: string | null | undefined, ): BcNodeDetail | null { if (!manifest || !id) return null; const sep = id.indexOf(":"); if (sep === -1) return null; const kind = id.slice(0, sep); const name = id.slice(sep + 1); const key = KIND_TO_KEY[kind]; if (!key) return null; const m = manifest as unknown as Record; const entry = arr(m, key).find((e) => e.name === name) as Record | undefined; if (!entry) return null; const extra: Record = {}; for (const [k, v] of Object.entries(entry)) { if (!KNOWN_FIELDS.has(k) && v !== undefined) extra[k] = v; } const emits = Array.isArray(entry.emits) ? (entry.emits as string[]) : undefined; const schema = entry.inputSchema ?? entry.schema ?? entry.payloadSchema; return { id, kind, name, app: typeof entry.app === "string" ? entry.app : undefined, description: typeof entry.description === "string" ? entry.description : undefined, public: entry.public === true, source: entry.source as BcNodeDetail["source"], schema, emits, invariants: toInvariants(entry.invariants), extra, }; } export interface LayoutOptions { readonly columns?: number; readonly width?: number; readonly gapX?: number; readonly gapY?: number; readonly rowHeight?: number; readonly originX?: number; readonly originY?: number; } /** * Pure grid layout for BC cards — deterministic positions so the canvas is * stable across renders and the layout is unit-testable without a DOM. Card * height scales with member count so tall BCs don't overlap their neighbours. */ export function layoutBcGraph(graph: BcGraph, opts: LayoutOptions = {}): BcLayoutNode[] { const columns = Math.max(1, opts.columns ?? 3); const width = opts.width ?? 260; const gapX = opts.gapX ?? 48; const gapY = opts.gapY ?? 48; const rowHeight = opts.rowHeight ?? 20; const originX = opts.originX ?? 0; const originY = opts.originY ?? 0; const headerH = 64; // Row height is driven by the tallest card in each grid row so cards in a // row share a baseline and the next row clears the tallest one. const heightOf = (n: BcNode) => headerH + Math.max(1, n.rows.length) * rowHeight + 16; const out: BcLayoutNode[] = []; let rowTop = originY; for (let i = 0; i < graph.bcs.length; i += columns) { const rowNodes = graph.bcs.slice(i, i + columns); const rowMax = Math.max(...rowNodes.map(heightOf)); rowNodes.forEach((n, col) => { out.push({ ...n, x: originX + col * (width + gapX), y: rowTop, width, height: heightOf(n), }); }); rowTop += rowMax + gapY; } return out; }