import type { SchemaBase } from './dsl.js'; import type { TableSchema } from './db.js'; // Aggregate declaration: groups multiple tables into one domain concept with // a root table, member tables, cross-member invariants and inter-aggregate // reference rules. This turns "multi-table consistency" from a convention // (hand-written in flows) into a constraint (lintable, codegen-able). // // Members are intentionally minimal: // members: { items: [orderItem] } -> array = 1:N (normal table) // members: { address: orderAddress } -> single = 1:1 (extension table; // designed but not implemented yet) /** Member table(s) keyed by role name. Array = 1:N; non-array = 1:1 extension. */ export type AggregateMember = TableSchema | TableSchema[]; /** A cross-member invariant, checked by generated repository code. */ export interface AggregateInvariant { name: string; /** Expression in the aggregate's field vocabulary (e.g. 'total == sum(items.price * items.qty)'). */ check: string; } export interface DomainAggregate extends SchemaBase { type: 'aggregate'; /** The aggregate root table. */ root: TableSchema; /** Member tables keyed by role name (e.g. 'items', 'address'). */ members: Record; /** Cross-member invariants; optional. */ invariants?: AggregateInvariant[]; /** Inter-aggregate references: only by root ID, keyed by referenced role. */ references?: Record; } export function defineAggregate(options: { root: TableSchema; members?: Record; invariants?: AggregateInvariant[]; references?: Record; description?: string; }): DomainAggregate { const schema: DomainAggregate = { type: 'aggregate', name: options.root.name, description: options.description, root: options.root, members: options.members ?? {}, invariants: options.invariants, references: options.references, }; // Root must have a primary key (aggregate identity). if (options.root.primaryKey === undefined) { throw new Error(`aggregate '${schema.name}': root table '${options.root.name}' must have a primary key`); } const rootPkRefs = Array.isArray(options.root.primaryKey) ? options.root.primaryKey : [options.root.primaryKey]; // Each member must attach to the root. Array members are normal 1:N tables // and must have exactly one FK referencing the root. Non-array (1:1 extension) // members are designed but not implemented yet. for (const [role, member] of Object.entries(schema.members)) { if (Array.isArray(member)) { if (member.length !== 1) { throw new Error( `aggregate '${schema.name}': member '${role}' array must contain exactly one table schema`, ); } const table = member[0]; const fks = Object.values(table.foreignKeys ?? {}).filter((fk) => { const refs = Array.isArray(fk.references) ? fk.references : [fk.references]; return refs.length === rootPkRefs.length && refs.every((r) => rootPkRefs.includes(r)); }); if (fks.length === 0) { throw new Error( `aggregate '${schema.name}': member '${role}' table '${table.name}' has no foreign key referencing root '${options.root.name}' — declare one in the table's foreignKeys`, ); } if (fks.length > 1) { throw new Error( `aggregate '${schema.name}': member '${role}' table '${table.name}' has ${fks.length} foreign keys referencing root '${options.root.name}' — reduce to one FK for minimal aggregate declarations`, ); } } else { throw new Error( `aggregate '${schema.name}': member '${role}' is a non-array table — 1:1 extension tables are designed but not implemented yet; use an array for 1:N members`, ); } } return schema; }