/** * The entity registry (#697). * * The manifest describes permissions, events, guards, schedules, attachment * targets, entity relations, searchables and UI contributions. It does not * describe **entities**: `migrations` is a pointer (`journalDir` + * `compatibleFrom`), the tables live in raw SQL the manifest never sees, and * entity *type names* appear only as bare `z.string().min(1)` fragments across * four unrelated, individually optional features. * * Nothing checks those four against each other or against the tables. A typo'd * `parentType` in `entityRelations` parses cleanly and produces an edge that * permission never flows along — the tuple evaluator walks a relation that does * not exist, and a grant that should reach a child silently does not. * * This module gives them something to be checked against. * * ## What it is not * * Migrations do not move in here, and nothing about how tables are created * changes. Whether the model becomes the source that migrations are *derived* * from is #680/#685's question; the registry is a prerequisite either way. */ import { z } from 'zod'; import { type EmittedLifecycle, type LifecycleDef } from './lifecycle.js'; /** * One entity: the table it lives in, its field schema, and its place in the * permission graph. */ export interface EntityDef { /** The physical table. Owned by this module — never another's (rule 4). */ readonly table: string; /** The row shape. Field names are what `key`, `searchables` and events check against. */ readonly fields: z.ZodObject; /** * The parent entity types permission may flow along (design doc §4.2 rule 3). * Checked against the declared entities — a typo is a compile error, where it * used to be a silently dead edge. * * **Plural, and an array even for one.** `entityRelations` is an ALLOWLIST, * not an assertion: the kernel accumulates permitted parents into a *set* per * entity type and `ctx.link` checks membership. `reservation` already hangs * off both `resource` and `member`; `protocol` off both `workorder` and * `employee`. Singular `parent` said "the parent", which is not what the * kernel means and cannot express the real cases. */ readonly parents?: readonly Names[]; /** * The table's identity. Defaults to `['id']`. * * **Declared, because not every table's identity is an `id`.** The `vertical_` * side table keyed by an engine's id — the composition pattern the design * rules prescribe — has no id of its own to have, and inventing one would be * wrong: it would permit two side rows for one work order, which is the very * thing the primary key exists to prevent. Value-keyed tables are the same * shape: a counter per `(kind, year)`, a budget per `(customer, year, month)`. * * **Kept distinct from `key`, because SQL's own distinction is the useful * one.** `primaryKey` is identity; `key` is an additional uniqueness rule. A * table legitimately has both — a composite primary key and a separate * natural key — so reading `key` as the primary key when an entity has no * `id` would conflate two facts to save a field. * * Order is significant and preserved: a composite primary key is also the * index its columns are searched by, left to right. * * **A composite key means the entity cannot be pointed AT.** An `EntityRef` is * one type and one id, so an attachment target, an event subject, a narrowed * permission check and a `parents` edge all need a single column to identify * the row. Those positions accept only single-column-keyed entities, and the * compiler says so — see `PointableName` below. A composite-keyed table is * still a full model member: it gets migrations, a row type and a place in * `model.json`. It is simply not something a grant can narrow to. * * An entity with neither `primaryKey` nor an `id` field is an ERROR, not a * table without a primary key. That silence is what let 15 of one production * vertical's 63 tables emit with no primary key at all while a column-by-column * parity check reported 63/63 (#804). */ readonly primaryKey?: readonly string[]; /** Natural key, if any. Must name fields that exist. */ readonly key?: readonly string[]; /** Fields an erasure must be able to reach (§12). Must name fields that exist. */ readonly erasable?: readonly string[]; /** * Fields that used to be called something else — `{ current: previous }`. * * **The one thing a migration diff cannot derive.** A diff sees a field gone * and a field arrived and cannot tell a rename from a drop-plus-add; guessing * wrong drops the column and the data in it. So this is declared, and it is * the ONLY declaration in the journal that is not derived — everything else, * including the version number, comes from the diff. * * **Deletable after use.** It exists to survive one diff, not forever. Once * the rename has shipped, the old name is gone from the journal and the entry * becomes a no-op that can be removed. A model that accumulates these is * carrying gravestones. */ readonly renamedFrom?: Readonly>; } /** * The entities the platform can point AT — those identified by ONE column. * * An `EntityRef` is a type and a single id. Attachments hang off one, grants * narrow to one, `ctx.link` joins two, an event is about one and names the * output field carrying its id. None of that has a meaning for a table * identified by `(customer_id, year, month)`: there is no one id to carry, and * `entityIdFrom` naming `customer_id` would silently make the event about a * third of a row. * * So a composite `primaryKey` is what makes an entity un-pointable, and that is * DERIVED rather than declared — a `pointable: true` flag would be a second * description of what the key already says, which is how two descriptions come * to disagree. * * **This alias is documentation; the positions inline it.** TypeScript prints an * alias unresolved, so a parameter typed `PointableName` reports * * Argument of type '"budget"' is not assignable to parameter of type * 'PointableName<{ readonly customer: { readonly table: "a"; … } }>' * * — the whole entity map, and not one usable name. Inlined, the same error reads * `Type '"budget"' is not assignable to type '"customer" | "ext" | "site"'`. * Same lesson as #705, verified again here. Every inlined copy has a * `@ts-expect-error` case in `test/model.test.ts`, so a copy that stops biting * turns that directive unused and fails `typecheck`. */ export type PointableName = { readonly [K in keyof T]: T[K] extends { primaryKey: readonly [unknown, unknown, ...unknown[]]; } ? never : K; }[keyof T] & string; /** The field names of one entity, read off its own `fields` schema. */ export type EntityFields = E extends { fields: infer F; } ? F extends z.ZodObject ? keyof z.infer & string : never : never; /** * Declare a module's entities. * * The constraint is self-referential — `parent` is checked against the map's own * keys, and `key`/`erasable` against each entity's own fields — which is what * makes the checks bite per-entity rather than as a union across all of them. * Written the obvious way (an erased supertype) every one of them compiles clean * and enforces nothing; see `test/model.test.ts`, which exists to prove they * still bite. */ export declare function defineEntities & { parents?: readonly ({ readonly [N in keyof T]: T[N] extends { primaryKey: readonly [unknown, unknown, ...unknown[]]; } ? never : N; }[keyof T] & string)[]; primaryKey?: readonly EntityFields[]; key?: readonly EntityFields[]; erasable?: readonly EntityFields[]; renamedFrom?: Readonly, string>>>; }; }>(entities: T): T; /** The declared entity names. */ export type EntityName = keyof T & string; /** * The entity's primary key — declared, or `['id']` if it has an `id` field. * * Resolved in one place because two callers need the same answer and the same * refusal: the DDL emitter, which cannot write a `CREATE TABLE` without it, and * `emitModel`, so the artifact of record carries the fact rather than leaving it * to be re-derived by whoever reads it. * * **It throws rather than returning nothing.** A table with no primary key is * not a shape the model may express: it accepts duplicate rows silently, and a * parity check that compares columns — the natural one to write — reports a * perfect match over it (#804). */ export declare function primaryKeyOf(name: string, entity: EntityDef): readonly string[]; /** * The serialisable form — the artifact of record. * * Everything downstream (migrations, the manifest, the route table, an ER * diagram, a diff classifier) reads THIS, never the TypeScript. That is what * keeps the authoring notation swappable: a different authoring layer is a new * emitter writing the same JSON, and nothing downstream notices. * * Field schemas are rendered with `z.toJSONSchema`, the same conversion the * OpenAPI builder already uses — so there is no second schema language anywhere * in the pipeline. */ export interface EmittedEntity { readonly table: string; readonly fields: Record; /** The permitted parent types, sorted. One shape, always. */ readonly parents?: readonly string[]; /** * Present only when it is not the `['id']` default, and **unsorted** — unlike * `key`, a primary key's column order is part of the fact, so sorting it for a * tidier diff would emit a different index than the one declared. */ readonly primaryKey?: readonly string[]; readonly key?: readonly string[]; readonly erasable?: readonly string[]; } export interface EmittedModel { /** * The version of the declared shape (#976) — an engine passes its * `manifest.version`, which versions the manifest shape independently of the * package version and is bumped only when that shape changes. Optional and * carried verbatim: a module that declares none emits none, so a vertical's * `model.json` is unchanged by the field's existence. */ readonly version?: string; readonly entities: Record; /** * The declared state machines (#844), keyed by entity. Absent when a module * declares none — an empty object would claim "this module has no lifecycles" * where absence honestly says "it has not declared any." */ readonly lifecycles?: Record; } /** * The same shapes as Zod schemas, for re-parsing an emitted `model.json` at a trust * boundary — the deploy manifest carries one (#1214), and the control plane re-parses * rather than trusting the CLI's serialization, exactly as it does the permission * registry. Structural, not semantic: it holds the shape the interfaces above promise * (a `parents` that is a list of names, a transition that names a target state), and * deliberately does NOT re-check coherence — `emitModel`/`emitLifecycles` already * refused an incoherent declaration at emit time, and a reader of stored history must * not start refusing a model an older emitter legitimately produced. */ export declare const emittedState: z.ZodObject<{ on: z.ZodOptional>; allow: z.ZodOptional>; extensible: z.ZodOptional>; terminal: z.ZodOptional>; }, z.core.$strip>; export declare const emittedLifecycle: z.ZodObject<{ field: z.ZodString; initial: z.ZodString; states: z.ZodRecord>; allow: z.ZodOptional>; extensible: z.ZodOptional>; terminal: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>; export declare const emittedEntity: z.ZodObject<{ table: z.ZodString; fields: z.ZodRecord; parents: z.ZodOptional>; primaryKey: z.ZodOptional>; key: z.ZodOptional>; erasable: z.ZodOptional>; }, z.core.$strip>; export declare const emittedModel: z.ZodObject<{ version: z.ZodOptional; entities: z.ZodRecord; parents: z.ZodOptional>; primaryKey: z.ZodOptional>; key: z.ZodOptional>; erasable: z.ZodOptional>; }, z.core.$strip>>; lifecycles: z.ZodOptional>; allow: z.ZodOptional>; extensible: z.ZodOptional>; terminal: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>>>; }, z.core.$strip>; /** * Render the registry to plain JSON. Deterministic: entities and their fields * are emitted in sorted order, so the checked-in artifact diffs cleanly and a * reordered declaration is not a spurious change. */ export declare function emitModel>(entities: T, options?: { readonly lifecycles?: Record; /** * Rendered as the artifact's top-level `version` when supplied (#976). An * engine passes its `manifest.version`, so the checked-in `model.json` is * the field's reader and `lint:model --check` gates a bump the way it gates * a changed table. Omitted when absent — never defaulted — so a module that * declares no version emits no claim about one. */ readonly version?: string; }): EmittedModel; /** * `entityRelations` derived from the `parent` declarations, rather than written * a second time by hand. * * Two descriptions of one fact is how they come to disagree — and the disagreement * here is invisible, because a relation naming an entity that does not exist is a * permission edge that silently never resolves. */ export declare function entityRelationsOf>(entities: T): { entityType: string; parentType: string; }[]; /** * The entity-referencing half of a manifest, narrowed to declared entities. * * Entity-name positions are written `keyof T & string` inline rather than as * `EntityName`. A type ALIAS is printed unresolved in diagnostics — the error * names the alias and inlines the whole entity map — where the inline form lists * the actual names: * * Type '"bkie"' is not assignable to type '"bike" | "customer"'. */ type EntityRefs, M> = { /** * An attachment hangs off ONE entity id, so the target must be pointable. * Inlined rather than aliased, per `PointableName`. */ readonly attachmentTargets?: readonly { readonly entityType: { readonly [K in keyof T]: T[K] extends { primaryKey: readonly [unknown, unknown, ...unknown[]]; } ? never : K; }[keyof T] & string; readonly readPermission: string; readonly writePermission?: string; }[]; /** * A live read announces a change to ONE entity id, so the target must be * pointable for the same reason an attachment target is (#938): the push carries * `(entityType, entityId)` and the client re-reads that entity through the * ordinary operation. A composite key has no single id to send. * Inlined rather than aliased, per `PointableName`. */ readonly liveTargets?: readonly { readonly entityType: { readonly [K in keyof T]: T[K] extends { primaryKey: readonly [unknown, unknown, ...unknown[]]; } ? never : K; }[keyof T] & string; readonly readPermission: string; }[]; /** * `fields` is checked against the NAMED entity's own fields — the only place * in the manifest today where a field name appears at all, and nothing * checked it. */ readonly searchables?: M extends { searchables: infer S; } ? { readonly [I in keyof S]: S[I] extends { entityType: infer N; } ? N extends keyof T & string ? T[N] extends { primaryKey: readonly [unknown, unknown, ...unknown[]]; } ? never : { readonly entityType: N; readonly fields: readonly EntityFields[]; /** `substring` buys inside-the-word matching for a bigger index. Default `prefix`. */ readonly tokenizer?: 'prefix' | 'substring'; } : never : never; } : never; readonly entityViews?: readonly { readonly entityType: keyof T & string; readonly view: string; }[]; /** * The engine registries this module composes, so relation edges naming their * entities can be checked. */ readonly engines?: readonly Record[]; /** * Parent edges involving an entity this module does not own. * * A vertical legitimately declares these: an engine is entity-agnostic, so * only the vertical knows that a work order hangs off a bike, or a protocol * off a work order. **Both sides are checked** against the local entities plus * every entity of every registry in `engines`. * * Local-to-local edges do not belong here — they are DERIVED from the * entities' own `parents`, and declaring one twice is how two descriptions of * a fact come to disagree. * * This replaces the `foreignChildOf` / `foreignChildren` pair, which existed * only because foreign names were uncheckable. They are now, so the split has * nothing left to say. */ readonly relations?: readonly { readonly entityType: ({ readonly [K in keyof T]: T[K] extends { primaryKey: readonly [unknown, unknown, ...unknown[]]; } ? never : K; }[keyof T] & string) | (M extends { engines: readonly (infer R)[]; } ? PointableNamesOf : never); readonly parentType: ({ readonly [K in keyof T]: T[K] extends { primaryKey: readonly [unknown, unknown, ...unknown[]]; } ? never : K; }[keyof T] & string) | (M extends { engines: readonly (infer R)[]; } ? PointableNamesOf : never); }[]; }; /** * The pointable entity names of one composed engine's registry. * * An alias is tolerable HERE, unlike the local side: an engine's names are not * what a diagnostic needs to list — the local union carries those, and this arm * only widens it. Engines declare their registries with `defineEntities` too, so * the tuple survives and the filter bites on their entities as well. */ type PointableNamesOf = R extends Record ? { readonly [K in keyof R]: R[K] extends { primaryKey: readonly [unknown, unknown, ...unknown[]]; } ? never : K; }[keyof R] & string : never; /** * Compose the entity-referencing manifest fragments against the registry. * * `entityRelations` is absent by design: it is DERIVED from the entities' * `parent` declarations (`entityRelationsOf`) rather than written a second time. * * Spread the result into the module's manifest: * * ```ts * export const manifest = moduleManifest.parse({ * id: '@acme/vertical', * …, * ...manifestEntities(entities, { * attachmentTargets: [{ entityType: 'contract', readPermission: 'x:read' }], * searchables: [{ entityType: 'customer', fields: ['name'] }], * }), * }); * ``` * * A typo in any `entityType` is now a compile error naming the declared * entities, and a `searchables` field that the entity does not have is too. */ /** * A searchable as the MANIFEST carries it — the declaration plus the two facts * the kernel cannot build an index without. * * Not authored. `table` and `idColumn` come from the same registry entry whose * fields the declaration is already checked against, so there is no second * statement of where a customer lives to drift from the first (#827). */ export interface EnrichedSearchable { readonly entityType: string; readonly fields: readonly string[]; readonly table: string; readonly idColumn: string; readonly tokenizer?: 'prefix' | 'substring'; } export declare function manifestEntities, const M extends EntityRefs>(entities: T, refs: M): { attachmentTargets: NonNullable | []; liveTargets: NonNullable | []; searchables: EnrichedSearchable[]; entityRelations: { entityType: string; parentType: string; }[]; ui: { entityViews: M['entityViews']; }; }; /** * The row type of a declared entity — what `ctx.sql.query` returns for it. * * `ctx.sql.query` leaves `T` to the vertical, so every handler writes its own * row interface and the schema ends up described three times: the DDL, the * registry, and a hand-written `interface CustomerRow`. This collapses the * third into the second. * * ```ts * export type CustomerRow = EntityRow; * ``` */ export type EntityRow, K extends keyof T> = T[K] extends { fields: infer F; } ? F extends z.ZodObject ? z.infer : never : never; /** Marker prefix on a JSON column's description. Read by the DDL emitter. */ export declare const JSON_COLUMN = "substrat:json:"; /** * A column holding arbitrary JSON. * * Some columns genuinely hold a document — a requirement blob, a set of ids, a * geometry — and modelling their interior would be a second description of * something the vertical parses itself. A production vertical has 19 such fields * across 10 tables, which is what promoted this from "plausible" to real. * * The `because` is required, and that is the point: `z.unknown()` on its own is * still an ERROR to the emitter, so a JSON column can never appear because * somebody could not think of a type. Deliberately opaque and not-yet-modelled * have to be distinguishable, or the first quietly becomes cover for the second. * * Stored as TEXT — SQLite has no JSON type, only functions over TEXT. */ export declare function jsonColumn(because: string): z.ZodType; export {}; //# sourceMappingURL=model.d.ts.map