From ac3bc0e3ef9c0590db8fecf7f9ee0ce8e7248348 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 17:22:02 +0200 Subject: [PATCH 01/35] add spec and execution plan for contract domain-storage separation Spec captures the ADR 172 restructuring: widen ContractBase with domain structure (roots, models with typed fields and relations), update the SQL emitter, and bridge validateContract() so consumers are unaffected until Phase 2. Plan decomposes the work into four milestones: new contract structure, consumer migration, old field removal, and IR alignment. --- projects/contract-domain-extraction/plan.md | 158 ++++++++ projects/contract-domain-extraction/spec.md | 376 ++++++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 projects/contract-domain-extraction/plan.md create mode 100644 projects/contract-domain-extraction/spec.md diff --git a/projects/contract-domain-extraction/plan.md b/projects/contract-domain-extraction/plan.md new file mode 100644 index 0000000000..ae78f5d898 --- /dev/null +++ b/projects/contract-domain-extraction/plan.md @@ -0,0 +1,158 @@ +# Contract Domain-Storage Separation — Execution Plan + +## Summary + +Restructure the emitted SQL contract to implement ADR 172's domain-storage separation: extract a shared domain-level representation into `ContractBase`, update the SQL emitter to produce the new JSON layout, and bridge `validateContract()` so no consumer code changes until Phase 2. This is the foundational step toward cross-family consumer code (ORM, validation, tooling). Success means the contract carries a self-describing domain level (`roots`, `models` with typed fields and relations) distinct from SQL-specific storage, with all existing consumers continuing to work via a compatibility bridge. + +**Spec:** [projects/contract-domain-extraction/spec.md](spec.md) + +**Linear:** [TML-2172](https://linear.app/prisma-company/issue/TML-2172) under [WS4: MongoDB & Cross-Family Architecture](https://linear.app/prisma-company/project/ws4-mongodb-and-cross-family-architecture-89d4dcdbcd9a) → milestone "P1: Contract extraction" + +## Collaborators + + +| Role | Person/Team | Context | +| ------------ | ----------- | ------------------------------------------------------------------ | +| Maker | Will | Drives execution | +| Collaborator | Alexey | ORM client — Phase 2 migration must coordinate with his workstream | +| Collaborator | Alberto | DSL/authoring — Phase 4 IR alignment benefits his workstream | + + +## Milestones + +### Milestone 1: New contract structure (no consumer changes) + +Delivers the ADR 172 contract JSON structure, widened TypeScript types, and `validateContract()` bridging — all without modifying consumer code (ORM, query builder, authoring surfaces). All existing tests pass. + +**Tasks:** + +#### 1.1 Type foundation + +- **1.1.1** Add domain types to framework contract package: `DomainField` (`{ nullable: boolean; codecId: string }`), `DomainRelation` (`{ to: string; cardinality: string; strategy: 'reference' | 'embed'; on?: { localFields: string[]; targetFields: string[] } }`), `DomainModel` (with `fields`, `relations`, optional `discriminator`/`variants`/`base`, and generic `storage` extension point). Define in `packages/1-framework/1-core/shared/contract/src/`. +- **1.1.2** Widen `ContractBase` to include `roots: Record` and `models: Record`. Existing type parameters unchanged; new fields added alongside existing ones. +- **1.1.3** Widen `SqlContract` to include new domain fields from `ContractBase` alongside existing `mappings`, top-level `relations`, and current `model.fields` shape. The intersection type carries both old and new fields — consumers can read from either. +- **1.1.4** Write type tests verifying: (a) `SqlContract extends ContractBase`, (b) new domain fields are accessible on `SqlContract`, (c) old consumer-facing fields (`mappings`, `relations`, `model.fields.*.column`) remain accessible. + +#### 1.2 Domain validation extraction + +- **1.2.1** Extract `validateContractDomain()` from `packages/2-mongo-family/1-core/src/validate-domain.ts` into `packages/1-framework/1-core/shared/contract/src/validate-domain.ts`. Move the `DomainContractShape`, `DomainModelShape`, and `DomainValidationResult` types alongside it. +- **1.2.2** Port the existing tests from `packages/2-mongo-family/1-core/test/validate-domain.test.ts` to the framework package. Verify all validation rules: root→model references, variant↔base symmetry, relation target existence, discriminator field existence, single-level polymorphism, orphaned model warnings. +- **1.2.3** Update Mongo's `validate-domain.ts` to re-import from the framework package instead of defining its own copy. Verify Mongo tests still pass. + +#### 1.3 Validation bridge (`validateContract`) + +- **1.3.1** Update `normalizeContract()` in `packages/2-sql/1-core/contract/src/validate.ts` to detect and handle both old (current) and new (ADR 172) JSON formats. This enables incremental fixture migration. +- **1.3.2** Update `validateContract()` to call `validateContractDomain()` (from 1.2.1) as a first pass before SQL-specific storage validation. +- **1.3.3** Add bridging logic: derive old consumer-facing fields from the new structure — `mappings` from `model.storage.fields` + `model.storage.table`, top-level `relations` from `model.relations`, `model.fields[f].column` from `model.storage.fields[f].column`. +- **1.3.4** Update `constructContract()` to populate both old and new fields on the returned object. +- **1.3.5** Write tests verifying the bridge: pass ADR 172 JSON to `validateContract()`, assert the returned object has both old fields (identical to current behavior) and new domain fields. +- **1.3.6** Write tests verifying backward compatibility: pass current-format JSON to `validateContract()`, assert the returned object is identical to current behavior (plus new domain fields populated from the old structure). + +#### 1.4 SQL emitter update + +- **1.4.1** Update the SQL emitter hook (`packages/2-sql/3-tooling/emitter/src/index.ts`) to produce ADR 172 JSON: `roots` (derived from models with `storage.table`), `models` with `{ nullable, codecId }` fields, `model.relations` (model-keyed, with `strategy: "reference"` and `on: { localFields, targetFields }`), `model.storage` (with `table` and `fields` field-to-column mappings). Remove top-level `relations` and `mappings` from the emitted JSON. +- **1.4.2** Update the emitter's `validateStructure()` to validate the new JSON shape (e.g., every model has `fields`, `relations`, `storage`; every `model.storage.table` exists in `storage.tables`). +- **1.4.3** Update `generateContractTypes()` to emit `contract.d.ts` with both old and new type fields. The `Contract` type must include `roots`, `models` with domain fields, plus `mappings`, top-level `relations`, and old `model.fields` shape for backward compatibility. +- **1.4.4** Update emitter tests (`packages/2-sql/3-tooling/emitter/test/`) to assert the new JSON structure and new `.d.ts` content. + +#### 1.5 Fixture migration + +- **1.5.1** Update the demo contract: `examples/prisma-next-demo/src/prisma/contract.json` and `contract.d.ts` to the new structure. Regenerate by running the emitter (or update manually if the emitter isn't wired to the demo yet). +- **1.5.2** Update integration test fixtures: `test/integration/test/fixtures/contract.json`, the 12 authoring parity expected contracts under `test/integration/test/authoring/parity/`, and `test/integration/test/fixtures/contract.d.ts`. +- **1.5.3** Update package test fixture JSON files: `sql-lane` (3 files), `contract-ts` (2 files), `relational-core`, `sql-orm-client`, e2e framework, and `eslint`. +- **1.5.4** Update inline test contract objects in `*.test.ts` files. Prioritize by package: (a) `sql-contract` validate/construct tests (~~20 files), (b) `relational-core` operations-registry (~~26 inline contracts), (c) postgres planner/migration tests, (d) ORM client test helpers, (e) integration tests. +- **1.5.5** Update migration fixtures: the ~105 `migration.json` files under `examples/prisma-next-demo/migration-fixtures/`. The `storageHash` is based on the contract IR (not JSON bytes), so the structural JSON change should not alter hashes. With dual-format `normalizeContract()` (task 1.3.1), these can be migrated incrementally. +- **1.5.6** Update the JSON Schema (`packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json`) to reflect the new structure. +- **1.5.7** Run full test suite (`pnpm test:all`) and verify zero failures. Fix any remaining fixture mismatches. + +#### 1.6 Verification + +- **1.6.1** Run `pnpm lint:deps` to verify no layering violations from the domain validation extraction. +- **1.6.2** Run `pnpm typecheck` across all packages. +- **1.6.3** Verify no changes to ORM client source (`packages/3-extensions/sql-orm-client/src/`), query builder source, or contract authoring source — only test fixtures updated. + +### Milestone 2: Migrate consumers to new type fields + +Migrates consumer code to read from the new domain-level TypeScript fields instead of the old SQL-specific ones. Coordinated with Alexey's ORM workstream. The JSON is already in ADR 172 format (Milestone 1). This phase is consumer-by-consumer, not atomic. + +**Tasks:** + +- **2.1** Migrate `sql-orm-client` field↔column resolution: replace `mappings.fieldToColumn[model][field]` reads with `contract.models[model].storage.fields[field].column` in `collection-column-mapping.ts`, `filters.ts`, `collection-contract.ts`, `collection-runtime.ts`, `collection.ts`, `model-accessor.ts`, `aggregate-builder.ts`, `grouped-collection.ts`, `mutation-executor.ts`. +- **2.2** Migrate `sql-orm-client` model→table resolution: replace `mappings.modelToTable[model]` reads with `contract.models[model].storage.table` in `collection-contract.ts`, `filters.ts`, `model-accessor.ts`. +- **2.3** Migrate `sql-orm-client` relations: replace `contract.relations[tableName]` reads with `contract.models[modelName].relations` in `collection-contract.ts`, `mutation-executor.ts`, `model-accessor.ts`. +- **2.4** Migrate `sql-orm-client` field type reads: replace storage-layer codec/nullable reads with `model.fields[f].codecId` and `model.fields[f].nullable`. +- **2.5** Update `sql-orm-client` type-level generics: update `types.ts` conditional types from `TContract['mappings']['fieldToColumn']` to domain-level access patterns. +- **2.6** Update `relational-core` types: update `ExtractTableToModel`/`ExtractColumnToField` in `packages/2-sql/4-lanes/relational-core/src/types.ts` to use new domain fields. +- **2.7** Migrate `paradedb` extension: update BM25 index field column resolution in `packages/3-extensions/paradedb/src/types/index-types.ts`. +- **2.8** Verify: no consumer imports or reads from `mappings`, no consumer reads top-level `relations`. + +### Milestone 3: Remove old type fields + +Removes the backward-compatibility shim from `validateContract()` and old fields from `SqlContract`. Only possible after all consumers are migrated (Milestone 2 complete). + +**Tasks:** + +- **3.1** Remove `mappings` from `SqlContract` type and `validateContract()` derivation logic. +- **3.2** Remove top-level `relations` from `SqlContract` type and `validateContract()` derivation logic. +- **3.3** Remove old model field shape (`{ column: string }` without `nullable`/`codecId`) from the type. +- **3.4** Update `contract.d.ts` emission to reflect the final shape (no old fields). +- **3.5** Remove old-format JSON support from `normalizeContract()` (if dual-format was added in 1.3.1). +- **3.6** Update all remaining test fixtures and type tests to reflect the clean types. +- **3.7** Run full test suite and typecheck. + +### Milestone 4: Contract IR alignment + +Aligns the internal `ContractIR` representation with the emitted contract JSON structure. Reduces impedance mismatch for the DSL authoring layer. Coordinate timing with Alberto. + +**Tasks:** + +- **4.1** Audit current `ContractIR` structure vs the emitted JSON. Document the structural gaps (e.g., IR has top-level `relations` and `mappings`; emitted JSON does not). +- **4.2** Update `ContractIR` to mirror the ADR 172 structure: domain-level `models` with `fields`/`relations`/`storage`, `roots`, no top-level `relations` or `mappings`. +- **4.3** Update the emitter (`emit.ts`) to read from the new IR structure (remove translation logic that was bridging old IR → new JSON). +- **4.4** Update all IR construction sites: PSL interpreter, TypeScript contract builders (`contract-ts`), and any tooling that produces `ContractIR`. +- **4.5** Update IR-level tests and validation. +- **4.6** Run full test suite and typecheck. + +### Close-out + +- **C.1** Verify all acceptance criteria from the spec are met (cross-reference each criterion with its test evidence). +- **C.2** Finalize ADR 172 (mark as "implemented" if applicable) and update the Data Contract subsystem doc to reflect the new structure. +- **C.3** Migrate any long-lived documentation from `projects/contract-domain-extraction/` into `docs/`. +- **C.4** Strip repo-wide references to `projects/contract-domain-extraction/`** (replace with canonical `docs/` links or remove). +- **C.5** Delete `projects/contract-domain-extraction/`. + +## Test Coverage + + +| Acceptance Criterion | Test Type | Task/Milestone | Notes | +| --------------------------------------------------------------------------------------------------------------------- | ------------------ | -------------- | ----------------------------------------------------- | +| SQL emitter produces ADR 172 JSON: `roots`, `models` with `{ nullable, codecId }`, `model.relations`, `model.storage` | Unit + Integration | 1.4.4, 1.5.7 | Emitter tests + integration artifact shape test | +| Demo and test fixture `contract.json` files reflect new structure | Integration | 1.5.1–1.5.7 | All existing tests pass with updated fixtures | +| `ContractBase` has typed `roots`, `models` (with domain fields) | Type test | 1.1.4 | `test-d.ts` assertions | +| `SqlContract extends ContractBase` with SQL storage + retains old fields | Type test | 1.1.4 | `test-d.ts` assertions | +| Emitted `contract.d.ts` includes both old and new field shapes | Unit | 1.4.4 | Emitter generation tests | +| `validateContract()` parses new JSON and returns widened type with old fields | Unit | 1.3.5 | Bridge round-trip tests | +| Shared domain validation runs as part of SQL `validateContract()` | Unit | 1.3.2, 1.2.2 | Domain validation tests ported from mongo | +| ORM client, query builder, authoring surfaces not modified in Phase 1 | Manual/CI | 1.6.3 | Git diff verification — no changes to consumer `src/` | +| All existing tests pass without modification (Phase 1) | CI | 1.5.7, 1.6.2 | Full test suite | +| ORM client reads from domain fields (Phase 2) | Unit + Integration | 2.1–2.4 | ORM client test suite | +| No consumer reads `mappings` or top-level `relations` (Phase 2) | Manual + grep | 2.8 | Code search verification | +| `mappings` removed from `SqlContract` (Phase 3) | Type test + CI | 3.1, 3.7 | Compile-time verification | +| Top-level `relations` removed (Phase 3) | Type test + CI | 3.2, 3.7 | Compile-time verification | +| Old field shape removed (Phase 3) | Type test + CI | 3.3, 3.7 | Compile-time verification | +| `contract.d.ts` reflects final shape (Phase 3) | Unit | 3.4 | Emitter generation tests | +| `ContractIR` mirrors emitted JSON (Phase 4) | Unit + Integration | 4.5 | IR tests | + + +## Open Items + +1. **Dual-format `normalizeContract()`.** Task 1.3.1 adds detection of old vs new JSON format in `normalizeContract()` to enable incremental fixture migration. This adds temporary complexity but significantly reduces risk — fixtures can be migrated across multiple PRs rather than atomically. The old-format path is removed in task 3.5. +2. **Spec open questions (with default assumptions from spec):** + - `model.storage.fields` shape: just `{ column: string }` (minimal). Top-level `storage.tables` is source of truth for column metadata. + - Relation join naming: use ADR 172 naming (`localFields`/`targetFields`), not old naming (`childCols`/`parentCols`). + - `roots` derivation: emitter derives from existing model/table mapping. Explicit authoring-level roots is Phase 4 / DSL concern. + - `strategy` on relations: include `"strategy": "reference"` explicitly on all SQL relations. +3. **Phase 2 coordination with Alexey.** The ORM client migration (tasks 2.1–2.5) touches core ORM internals. This must be sequenced to avoid conflicts with Alexey's active ORM development. The widened types from Phase 1 allow him to migrate incrementally. +4. `**paradedb` extension (`packages/3-extensions/paradedb/`).** Task 2.7 covers BM25 index column resolution. Confirm this extension is actively maintained and whether its owner needs notification. +5. **Unresolved spec open questions** carried forward from spec (see spec § Open Questions for full context). + diff --git a/projects/contract-domain-extraction/spec.md b/projects/contract-domain-extraction/spec.md new file mode 100644 index 0000000000..6497a1a545 --- /dev/null +++ b/projects/contract-domain-extraction/spec.md @@ -0,0 +1,376 @@ +# Summary + +Restructure the emitted contract to implement ADR 172's domain-storage separation, extracting the shared domain-level representation into a framework-level `ContractBase` that both SQL and Mongo families consume. This is the foundational step toward cross-family consumer code (ORM clients, validation, tooling). + +# Description + +Today, everything above `ContractBase` is SQL-specific. `ContractBase` is a thin header (hashes, target, capabilities). `SqlContract` extends it and adds `models`, `relations`, `storage`, and `mappings` — but the model fields shape (`{ column: string }`), the top-level table-keyed relations, and the mappings section are all SQL artifacts, not domain concepts. + +The MongoDB PoC proved that the domain level — `roots`, `models` (with `{ nullable, codecId }` fields, optional `discriminator`/`variants`/`base`), and `model.relations` — is structurally identical across families. Only the storage details diverge: SQL needs field-to-column mappings, table schemas, and indexes; Mongo needs collection names. + +This project widens `ContractBase` to carry the shared domain structure, updates the SQL emitter to produce the ADR 172 JSON structure, extracts common domain validation, and migrates consumers incrementally — all without breaking active development on the ORM client or DSL workstreams. + +**Key insight:** Consumers never read `contract.json` directly — they access the contract through `validateContract()`, which parses the JSON and returns a typed object. This means the emitted JSON structure can change freely in Phase 1 (go straight to ADR 172's target structure), as long as `validateContract()` continues to return a type with all the fields consumers currently rely on. The "additive" constraint applies to the TypeScript types consumers see, not to the JSON itself. + +**Key constraint:** Alexey is actively developing the SQL ORM client, and Alberto is working on the DSL/authoring layer. The TypeScript types returned by `validateContract()` must be widened (add new fields), not contracted (don't remove old fields yet), to avoid blocking either workstream. + +# Before / After + +## contract.json (emitted JSON) + +**Before** (current — SQL-specific structure): + +```json +{ + "models": { + "User": { + "fields": { + "id": { "column": "id" }, + "email": { "column": "email" }, + "name": { "column": "display_name" } + }, + "relations": {}, + "storage": { "table": "user" } + } + }, + "relations": { + "post": { + "user": { + "to": "User", "cardinality": "N:1", + "on": { "childCols": ["id"], "parentCols": ["userId"] } + } + } + }, + "mappings": { + "modelToTable": { "User": "user" }, + "tableToModel": { "user": "User" }, + "fieldToColumn": { "User": { "id": "id", "name": "display_name" } }, + "columnToField": { "user": { "id": "id", "display_name": "name" } } + }, + "storage": { + "tables": { + "user": { + "columns": { + "id": { "nativeType": "character", "codecId": "pg/char@1", "nullable": false }, + "email": { "nativeType": "text", "codecId": "pg/text@1", "nullable": false }, + "display_name": { "nativeType": "text", "codecId": "pg/text@1", "nullable": true } + } + } + } + } +} +``` + +**After** (target — ADR 172 domain-storage separation): + +```json +{ + "roots": { + "users": "User", + "posts": "Post" + }, + "models": { + "User": { + "fields": { + "id": { "nullable": false, "codecId": "pg/char@1" }, + "email": { "nullable": false, "codecId": "pg/text@1" }, + "name": { "nullable": true, "codecId": "pg/text@1" } + }, + "relations": { + "posts": { + "to": "Post", "cardinality": "1:N", "strategy": "reference", + "on": { "localFields": ["id"], "targetFields": ["userId"] } + } + }, + "storage": { + "table": "user", + "fields": { + "id": { "column": "id" }, + "email": { "column": "email" }, + "name": { "column": "display_name" } + } + } + } + }, + "storage": { + "tables": { + "user": { + "columns": { + "id": { "nativeType": "character", "codecId": "pg/char@1", "nullable": false }, + "email": { "nativeType": "text", "codecId": "pg/text@1", "nullable": false }, + "display_name": { "nativeType": "text", "codecId": "pg/text@1", "nullable": true } + } + } + } + } +} +``` + +Key changes in the JSON: +- `roots` is new — declares ORM entry points +- `model.fields` carries `{ nullable, codecId }` instead of `{ column }` +- `model.relations` is model-keyed with `strategy` and `on: { localFields, targetFields }` +- `model.storage.fields` carries the field-to-column mapping (moved from `model.fields`) +- Top-level `relations` and `mappings` are gone — their information now lives on the model + +## ContractBase (framework type) + +**Before** (current — thin header, no domain structure): + +```typescript +interface ContractBase< + TStorageHash extends StorageHashBase = StorageHashBase, + TExecutionHash extends ExecutionHashBase = ExecutionHashBase, + TProfileHash extends ProfileHashBase = ProfileHashBase, +> { + readonly schemaVersion: string; + readonly target: string; + readonly targetFamily: string; + readonly storageHash: TStorageHash; + readonly executionHash?: TExecutionHash; + readonly profileHash?: TProfileHash; + readonly capabilities: Record>; + readonly extensionPacks: Record; + readonly meta: Record; + readonly sources: Record; + readonly execution?: ExecutionSection; +} +``` + +**After** (target — includes domain structure): + +```typescript +interface ContractBase< + TStorageHash extends StorageHashBase = StorageHashBase, + TExecutionHash extends ExecutionHashBase = ExecutionHashBase, + TProfileHash extends ProfileHashBase = ProfileHashBase, +> { + readonly schemaVersion: string; + readonly target: string; + readonly targetFamily: string; + readonly storageHash: TStorageHash; + readonly executionHash?: TExecutionHash; + readonly profileHash?: TProfileHash; + readonly capabilities: Record>; + readonly extensionPacks: Record; + readonly meta: Record; + readonly sources: Record; + readonly execution?: ExecutionSection; + + // Domain structure (new) + readonly roots: Record; + readonly models: Record; + readonly relations: Record; + readonly storage: Record; + readonly discriminator?: { readonly field: string }; + readonly variants?: Record; + readonly base?: string; + }>; +} +``` + +## SqlContract (Phase 1 — widened, not contracted) + +During Phase 1, `SqlContract` carries both old and new fields. Consumers can read from either: + +```typescript +type SqlContract = ContractBase<...> & { + readonly storage: S; + readonly models: M; // has BOTH { nullable, codecId } and { column } on fields + + // Old fields (retained for consumer compatibility during Phase 1-2) + readonly relations: R; // top-level table-keyed relations + readonly mappings: Map; // modelToTable, fieldToColumn, etc. + + // New fields (from ContractBase) + // readonly roots: ... // inherited from ContractBase + // readonly models[m].relations: ... // model-keyed relations (inherited) + // readonly models[m].storage: ... // field-to-column mapping (inherited) +}; +``` + +## validateContract() bridging (Phase 1) + +`validateContract()` parses the new JSON and derives old fields so consumers see no change: + +```typescript +function validateContract(value: unknown): TContract { + const contract = parseNewJsonStructure(value); // reads ADR 172 JSON + validateDomain(contract); // shared framework validation + validateSqlStorage(contract); // SQL-specific validation + + // Bridge: derive old fields from new structure + return { + ...contract, + mappings: deriveMappings(contract), // model.storage → mappings + relations: deriveTopLevelRelations(contract), // model.relations → table-keyed relations + } as TContract; +} + +function deriveMappings(contract): SqlMappings { + const modelToTable: Record = {}; + const fieldToColumn: Record> = {}; + for (const [modelName, model] of Object.entries(contract.models)) { + modelToTable[modelName] = model.storage.table; + fieldToColumn[modelName] = {}; + for (const [fieldName, field] of Object.entries(model.storage.fields)) { + fieldToColumn[modelName][fieldName] = field.column; + } + } + // ... derive reverse mappings + return { modelToTable, tableToModel, fieldToColumn, columnToField }; +} +``` + +After Phase 3, the bridging logic and old fields are removed. + +# Requirements + +## Functional Requirements + +### Phase 1: New contract structure (no consumer changes) + +This phase changes the emitted JSON to match ADR 172's target structure, widens the TypeScript types, and updates `validateContract()` to parse the new JSON while continuing to return the old consumer-facing type. The ORM client, query builder, and contract authoring surfaces are untouched. + +**Emitted JSON (can change freely):** + +1. **Update the SQL emitter to produce ADR 172's JSON structure.** The emitter produces `contract.json` matching the target layout: `roots`, `models` with `{ nullable, codecId }` fields, `model.relations` (model-keyed, with `strategy` and `on: { localFields, targetFields }`), `model.storage` (with `table` and field-to-column mappings). The old top-level `relations`, `mappings`, and `model.fields: { column }` shape can be removed from the JSON or retained — consumers don't read the JSON directly. + +2. **Update demo and test contract fixtures.** The demo app's `contract.json`, and contract fixtures embedded in tests across multiple packages (e.g., inline contract objects, fixture files, test helpers that construct contracts), all encode the current JSON structure. These must be audited and updated to match the new structure. This is likely the most labour-intensive part of Phase 1. + +**Types (widen, don't contract):** + +3. **Widen `ContractBase` to include domain structure.** Add `roots`, typed `models` (with `fields: Record`, `relations`, optional `discriminator`/`variants`/`base`), and a generic storage extension point. `ContractBase` constrains family contracts via `extends ContractBase`, not `ContractBase` — storage details appear at multiple attachment points (model.storage, top-level storage, relation join details). + +4. **Widen `SqlContract` to include new fields alongside old.** `SqlContract extends ContractBase` and adds SQL-specific storage. During this phase, `SqlContract` carries *both* the new domain fields (from `ContractBase`) and the old SQL-specific ones (`mappings`, top-level `relations`, `model.fields` with `{ column }` shape). This is what makes the transition non-breaking — existing consumers continue reading the old fields on the TypeScript type. + +5. **Update `contract.d.ts` emission.** The emitted type file produces a `Contract` type that includes both old and new fields. + +**Validation (bridges JSON → consumer types):** + +6. **Update `validateContract()` to parse the new JSON and return the widened type.** `validateContract()` reads the new JSON structure and populates *both* the new domain fields and the old consumer-facing fields (e.g., deriving `mappings` from `model.storage`, deriving top-level `relations` from `model.relations`). Consumers see no change in the returned object. + +7. **Extract shared domain validation.** Move the family-agnostic validation logic from `packages/2-mongo-family/1-core/src/validate-domain.ts` into the framework layer (`packages/1-framework/`). This covers: roots → model references, variant ↔ base bidirectional consistency, relation target existence, discriminator field existence, single-level polymorphism enforcement, orphaned model detection. SQL's `validateContract()` calls this as a first pass before SQL-specific storage validation. + +### Phase 2: Migrate consumers to new type fields + +The JSON is already in the target structure (Phase 1). `validateContract()` derives old fields from the new structure. This phase migrates consumers to read from the new TypeScript fields instead of the old ones. + +1. **Migrate ORM client to read from domain fields.** The ORM client switches from reading `mappings.fieldToColumn` / `mappings.modelToTable` to reading `model.storage.fields` / `model.storage.table`, and from reading field types via the storage layer to reading `model.fields[f].codecId` and `model.fields[f].nullable`. It switches from the top-level `relations` to `model.relations`. This must be coordinated with Alexey. +2. **Migrate query builder and runtime.** The SQL query builder, relational core, and runtime shift to reading domain-level field metadata where appropriate. Runtime codec resolution uses `model.fields[f].codecId`. + +### Phase 3: Remove old type fields + +The JSON already lacks the old fields (removed in Phase 1). This phase removes the backwards-compatibility shim from `validateContract()` and the old fields from `SqlContract`. + +1. **Remove `mappings` from `SqlContract` and `validateContract()`.** Once no consumer reads `modelToTable`, `tableToModel`, `fieldToColumn`, or `columnToField`, remove the type fields and the derivation logic in `validateContract()`. +2. **Remove old model field shape.** Remove `{ column }` from `model.fields` type — consumers now read `{ nullable, codecId }`. The field-to-column mapping lives in `model.storage.fields`. +3. **Remove top-level `relations` from `SqlContract`.** Once all consumers read `model.relations`, the top-level table-keyed `relations` type field and its derivation logic can be removed. + +### Phase 4: Contract IR alignment (follow-up) + +1. **Align `ContractIR` with the new contract JSON structure.** Update the internal representation used during emission so it more closely mirrors the emitted JSON. This reduces impedance mismatch and makes it easier for the DSL layer to target the IR. Coordinate timing with Alberto. + +## Non-Functional Requirements + +- **Zero breakage during Phase 1.** All existing tests, the demo app, and downstream consumers must continue working without modification when Phase 1 lands. `validateContract()` bridges the new JSON structure to the old consumer-facing type. +- **Incremental migration.** Phase 2 changes should be deployable consumer-by-consumer, not as a single atomic switch. +- **Type safety throughout.** The widened `ContractBase` must provide typed access to domain fields. Consumers switching from old fields to new ones should get equivalent or better type inference. + +## Non-goals + +- **Mongo emitter.** This project updates the SQL emitter. A Mongo emitter is a separate project. +- **Value objects section.** Designing the contract representation for value objects is out of scope. The domain structure carries `models` only. +- **Change streams / subscriptions.** Runtime lifecycle changes are not in scope. +- **PSL/DSL authoring changes.** The authoring surface adapts to the new IR (Phase 4) but designing new authoring syntax is out of scope. + +# Acceptance Criteria + +### Phase 1: New contract structure + +**Emitted JSON:** + +- [ ] The SQL emitter produces `contract.json` matching ADR 172's structure: `roots`, `models` with `{ nullable, codecId }` fields, `model.relations` (model-keyed), `model.storage` +- [ ] Demo and test fixture `contract.json` files reflect the new structure + +**Types:** + +- [ ] `ContractBase` has typed `roots`, `models` (with `fields: Record`, `relations`, optional `discriminator`/`variants`/`base`), declared in the framework core package +- [ ] `SqlContract extends ContractBase` with SQL-specific storage and retains old consumer-facing fields (`mappings`, top-level `relations`, `model.fields` with `{ column }`) +- [ ] Emitted `contract.d.ts` includes both old and new field shapes + +**Validation:** + +- [ ] `validateContract()` parses the new JSON structure and returns the widened type, populating old fields (e.g., `mappings`) from new structure (e.g., `model.storage`) +- [ ] Shared domain validation (roots, variants, relations, discriminators, orphans) runs as part of SQL `validateContract()` + +**No consumer changes:** + +- [ ] The ORM client, query builder, and contract authoring surfaces are not modified +- [ ] All existing tests pass without modification + +### Phase 2: Migrate consumers + +- [ ] The ORM client reads field types from `model.fields[f].codecId` and `model.fields[f].nullable`, not from the storage layer +- [ ] The ORM client reads field-to-column mappings from `model.storage.fields`, not from `mappings` +- [ ] The ORM client reads relations from `model.relations`, not from the top-level `relations` block +- [ ] No consumer imports or reads from the `mappings` section +- [ ] No consumer reads relations from the top-level `relations` block + +### Phase 3: Remove old type fields + +- [ ] `mappings` is removed from `SqlContract` and the `validateContract()` derivation logic +- [ ] Top-level `relations` type field is removed from `SqlContract` and `validateContract()` +- [ ] Old model field shape (`{ column: string }` without `nullable`/`codecId`) is removed from the type +- [ ] `contract.d.ts` emission reflects the final shape (no old fields) + +### Phase 4: IR alignment + +- [ ] `ContractIR` mirrors the emitted contract JSON structure (domain/storage separation, model-level relations, `roots`) + +# Other Considerations + +## Security + +No security implications — this is an internal structural refactor of build artifacts and types. + +## Cost + +No cost implications — no new infrastructure, no runtime performance changes. + +## Observability + +No observability changes needed. The contract structure is a build-time artifact. + +## Coordination + +- **Alexey (ORM client):** Phase 2 requires migrating the ORM client to read from new type fields. Phase 1 adds new fields alongside old ones on the TypeScript type, so Alexey can switch call sites incrementally at his pace. No changes required from him until Phase 2. +- **Alberto (DSL/authoring):** Phase 4 updates the Contract IR he targets. This should be coordinated but is not a synchronous dependency — the emitter can produce the new contract JSON from the old IR during Phases 1–3. Phase 4 aligns the IR for his benefit. +- **Demo app and test fixtures:** `contract.json` files are updated in Phase 1 to the new structure. Since `validateContract()` bridges to the old type, everything continues to work. + +# References + +- [ADR 172 — Contract domain-storage separation](../../docs/architecture%20docs/adrs/ADR%20172%20-%20Contract%20domain-storage%20separation.md) — the target contract structure +- [ADR 174 — Aggregate roots and relation strategies](../../docs/architecture%20docs/adrs/ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md) — `roots` section design +- [10. MongoDB Family](../../docs/architecture%20docs/subsystems/10.%20MongoDB%20Family.md) — design principles, contract examples +- [cross-cutting-learnings.md](../../docs/planning/mongo-target/cross-cutting-learnings.md) — domain model design principles +- [contract-symmetry.md](../../docs/planning/mongo-target/1-design-docs/contract-symmetry.md) — Mongo/SQL convergence analysis +- Current `ContractBase`: `packages/1-framework/1-core/shared/contract/src/types.ts` +- Current `SqlContract`: `packages/2-sql/1-core/contract/src/types.ts` +- Current SQL `validateContract()`: `packages/2-sql/1-core/contract/src/validate.ts` +- Extractable domain validation: `packages/2-mongo-family/1-core/src/validate-domain.ts` +- Current emitted contract: `examples/prisma-next-demo/src/prisma/contract.json` + +# Open Questions + +1. `**model.storage.fields` shape for SQL.** ADR 172 shows `"fields": { "id": { "column": "id" } }`. Should `model.storage.fields` carry any additional info beyond the column name (e.g., the nativeType, to avoid a second lookup into the top-level storage section)? **Default assumption:** Keep it minimal — just `{ column: string }`. The top-level `storage.tables` section is the source of truth for column metadata. +2. **Relation join details in `model.relations`.** The current top-level relations use `childCols`/`parentCols`. ADR 172 uses `on: { localFields, targetFields }`. Should the new `model.relations` use the ADR 172 naming (`localFields`/`targetFields`) or keep the existing naming for continuity during migration? **Default assumption:** Use the ADR 172 naming. The old top-level block coexists during Phase 2, so consumers can migrate at their own pace. +3. **Where does `roots` come from during emission?** Currently, every model with a `storage.table` is implicitly a root. Should the emitter derive `roots` automatically (every model → a root entry with pluralized name), or should the authoring surface declare them? **Default assumption:** The emitter derives `roots` from the existing model/table mapping for now. Explicit authoring-level `roots` is a DSL concern for Phase 4 / Alberto's workstream. +4. `**model.relations` with `strategy`.** The new relations include `"strategy": "reference" | "embed"`. For SQL, all relations are `"reference"` (no embedding). Should the SQL emitter include `"strategy": "reference"` on every relation, or omit it since it's the only option? **Default assumption:** Include it explicitly — the domain structure should be self-describing, and consumers shouldn't need to know "SQL means reference." + From 8d9f6bad185bc04bf34633431f0bdad6e2a2b258 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 17:24:37 +0200 Subject: [PATCH 02/35] add skill to prevent heredoc escaping in commit messages The Shell tool does not reliably parse heredocs inside $(cat ...) for git commit -m. Use single-quoted strings with embedded newlines instead. --- .../skills/multiline-commit-messages/SKILL.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .agents/skills/multiline-commit-messages/SKILL.md diff --git a/.agents/skills/multiline-commit-messages/SKILL.md b/.agents/skills/multiline-commit-messages/SKILL.md new file mode 100644 index 0000000000..af6a0cf402 --- /dev/null +++ b/.agents/skills/multiline-commit-messages/SKILL.md @@ -0,0 +1,38 @@ +--- +name: multiline-commit-messages +description: >- + Use single-quoted strings for multiline git commit messages in the Shell tool. + Prevents heredoc escaping failures that produce garbled commit messages. +--- + +# Multiline commit messages + +The Shell tool sends commands as a single string. Heredoc syntax (`<<'EOF'`) inside `$(cat ...)` is fragile and fails silently — the literal `$(cat <<'EOF' ...` ends up as the commit message instead of the intended text. + +## Rule + +**Never** use `$(cat <<'EOF' ...)` or `$(cat < Date: Tue, 31 Mar 2026 17:34:38 +0200 Subject: [PATCH 03/35] add typechecked type design for contract domain-storage separation Complete type design covering all phases: new framework-level domain types, widened ContractBase and SqlContract, Phase 1 emitted shape, MongoContract alignment proof, and Phase 3 final types. Includes 25+ type-level assertions that verify structural compatibility with the existing codebase (tsc passes clean). --- .../contract-domain-extraction/tsconfig.json | 24 + .../contract-domain-extraction/type-design.ts | 562 ++++++++++++++++++ 2 files changed, 586 insertions(+) create mode 100644 projects/contract-domain-extraction/tsconfig.json create mode 100644 projects/contract-domain-extraction/type-design.ts diff --git a/projects/contract-domain-extraction/tsconfig.json b/projects/contract-domain-extraction/tsconfig.json new file mode 100644 index 0000000000..667316ceda --- /dev/null +++ b/projects/contract-domain-extraction/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "preserve", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "noEmit": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "skipLibCheck": true, + "paths": { + "@prisma-next/contract/types": [ + "../../packages/1-framework/1-core/shared/contract/src/types.ts" + ], + "@prisma-next/sql-contract/types": ["../../packages/2-sql/1-core/contract/src/types.ts"], + "@prisma-next/mongo-core": ["../../packages/2-mongo-family/1-core/src/exports/index.ts"], + "@prisma-next/operations": [ + "../../packages/1-framework/1-core/shared/operations/src/index.ts" + ] + } + }, + "include": ["*.ts"] +} diff --git a/projects/contract-domain-extraction/type-design.ts b/projects/contract-domain-extraction/type-design.ts new file mode 100644 index 0000000000..9c30812e81 --- /dev/null +++ b/projects/contract-domain-extraction/type-design.ts @@ -0,0 +1,562 @@ +/** + * Contract Domain-Storage Separation — Complete Type Design + * + * This file defines the target type system for ADR 172 domain-storage separation. + * It imports existing types and defines the new/changed types alongside them, + * with type-level assertions proving structural compatibility. + * + * Run: node_modules/.bin/tsc --project projects/contract-domain-extraction/tsconfig.json + * + * Structure: + * §1 New framework-level domain types + * §2 Widened ContractBase (Phase 1) + * §3 New SQL model types (Phase 1) + * §4 Widened SqlContract (Phase 1) + * §5 Phase 1 emitted contract.d.ts shape (example) + * §6 validateContract() bridging design + * §7 MongoContract alignment proof + * §8 Phase 3 final types + */ + +// --- Imports from existing codebase --- + +import type { + ExecutionHashBase, + ExecutionSection, + ContractBase as ExistingContractBase, + ProfileHashBase, + Source, + StorageHashBase, +} from '@prisma-next/contract/types'; +import type { + MongoContract, + MongoEmbedRelation, + MongoModelDefinition, + MongoModelField, + MongoReferenceRelation, + MongoRelation, +} from '@prisma-next/mongo-core'; +import type { + SqlContract as ExistingSqlContract, + SqlMappings, + SqlStorage, +} from '@prisma-next/sql-contract/types'; + +// ============================================================================ +// §1 New framework-level domain types +// ============================================================================ +// +// Package: @prisma-next/contract (packages/1-framework/1-core/shared/contract/src/) + +export type DomainField = { + readonly nullable: boolean; + readonly codecId: string; +}; + +export type DomainRelationOn = { + readonly localFields: readonly string[]; + readonly targetFields: readonly string[]; +}; + +export type DomainRelation = { + readonly to: string; + readonly cardinality: '1:1' | '1:N' | 'N:1'; + readonly strategy: 'reference' | 'embed'; + readonly on?: DomainRelationOn; +}; + +export type DomainDiscriminator = { + readonly field: string; +}; + +export type DomainModel = { + readonly fields: Record; + readonly relations: Record; + readonly storage: Record; + readonly discriminator?: DomainDiscriminator; + readonly variants?: Record; + readonly base?: string; +}; + +// ============================================================================ +// §2 Widened ContractBase (Phase 1) +// ============================================================================ +// +// ContractBase gains `roots` and `models`. Existing fields unchanged. + +export interface ContractBase< + TStorageHash extends StorageHashBase = StorageHashBase, + TExecutionHash extends ExecutionHashBase = ExecutionHashBase, + TProfileHash extends ProfileHashBase = ProfileHashBase, +> { + // EXISTING (unchanged) + readonly schemaVersion: string; + readonly target: string; + readonly targetFamily: string; + readonly storageHash: TStorageHash; + readonly executionHash?: TExecutionHash | undefined; + readonly profileHash?: TProfileHash | undefined; + readonly capabilities: Record>; + readonly extensionPacks: Record; + readonly meta: Record; + readonly sources: Record; + readonly execution?: ExecutionSection; + + // NEW + readonly roots: Record; + readonly models: Record; +} + +// Proof: widened ContractBase is a superset of the existing one +type _AssertWidenedExtendsExisting = ContractBase extends ExistingContractBase ? true : never; +const _assertWidenedExtendsExisting: _AssertWidenedExtendsExisting = true; + +// ============================================================================ +// §3 New SQL model types (Phase 1) +// ============================================================================ +// +// Package: @prisma-next/sql-contract (packages/2-sql/1-core/contract/src/) + +export type SqlModelFieldStorage = { + readonly column: string; +}; + +export type SqlModelStorage = { + readonly table: string; + readonly fields: Record; +}; + +export type SqlRelation = { + readonly to: string; + readonly cardinality: '1:1' | '1:N' | 'N:1'; + readonly strategy: 'reference'; + readonly on: DomainRelationOn; +}; + +/** + * Phase 1: model field with BOTH old (column) and new (nullable, codecId) properties. + * validateContract() populates `column` from model.storage.fields[fieldName].column. + */ +export type SqlModelFieldPhase1 = { + readonly column: string; // PHASE 3: REMOVED + readonly nullable: boolean; + readonly codecId: string; +}; + +export type SqlModelDefinitionPhase1 = { + readonly fields: Record; + readonly relations: Record; + readonly storage: SqlModelStorage; + readonly discriminator?: DomainDiscriminator; + readonly variants?: Record; + readonly base?: string; +}; + +// Proof: SqlModelDefinitionPhase1 satisfies DomainModel +type _AssertSqlModelSatisfiesDomain = SqlModelDefinitionPhase1 extends DomainModel ? true : never; +const _assertSqlModelSatisfiesDomain: _AssertSqlModelSatisfiesDomain = true; + +// Proof: SqlRelation satisfies DomainRelation +type _AssertSqlRelationSatisfiesDomain = SqlRelation extends DomainRelation ? true : never; +const _assertSqlRelationSatisfiesDomain: _AssertSqlRelationSatisfiesDomain = true; + +// Proof: SqlModelFieldPhase1 satisfies DomainField +type _AssertSqlFieldSatisfiesDomain = SqlModelFieldPhase1 extends DomainField ? true : never; +const _assertSqlFieldSatisfiesDomain: _AssertSqlFieldSatisfiesDomain = true; + +// ============================================================================ +// §4 Widened SqlContract (Phase 1) +// ============================================================================ +// +// SqlContract = ContractBase & { storage, models, relations, mappings } +// The intersection with ContractBase brings in roots + models (domain level). +// The & { models: M } intersection narrows models with SQL-specific shape. + +export type SqlContract< + S extends SqlStorage = SqlStorage, + M extends Record = Record, + R extends Record = Record, + Map extends SqlMappings = SqlMappings, + TStorageHash extends StorageHashBase = StorageHashBase, + TExecutionHash extends ExecutionHashBase = ExecutionHashBase, + TProfileHash extends ProfileHashBase = ProfileHashBase, +> = ContractBase & { + readonly targetFamily: string; + readonly storage: S; + readonly models: M; + readonly relations: R; // PHASE 3: REMOVED + readonly mappings: Map; // PHASE 3: REMOVED + readonly execution?: ExecutionSection; +}; + +// Proof: SqlContract extends ContractBase +type _AssertSqlExtendsBase = SqlContract extends ContractBase ? true : never; +const _assertSqlExtendsBase: _AssertSqlExtendsBase = true; + +// Proof: SqlContract extends ExistingSqlContract (backward compatible) +type _AssertNewSqlExtendsOldSql = SqlContract extends ExistingSqlContract ? true : never; +const _assertNewSqlExtendsOldSql: _AssertNewSqlExtendsOldSql = true; + +// ============================================================================ +// §5 Phase 1 emitted contract.d.ts shape (example) +// ============================================================================ +// +// What the emitter produces. Both old and new fields on each model. + +type ExampleModels = { + readonly User: { + readonly fields: { + readonly id: { + readonly column: 'id'; + readonly nullable: false; + readonly codecId: 'pg/int4@1'; + }; + readonly email: { + readonly column: 'email'; + readonly nullable: false; + readonly codecId: 'pg/text@1'; + }; + readonly name: { + readonly column: 'display_name'; + readonly nullable: true; + readonly codecId: 'pg/text@1'; + }; + }; + readonly relations: { + readonly posts: { + readonly to: 'Post'; + readonly cardinality: '1:N'; + readonly strategy: 'reference'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['userId']; + }; + }; + }; + readonly storage: { + readonly table: 'user'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly email: { readonly column: 'email' }; + readonly name: { readonly column: 'display_name' }; + }; + }; + }; + readonly Post: { + readonly fields: { + readonly id: { + readonly column: 'id'; + readonly nullable: false; + readonly codecId: 'pg/int4@1'; + }; + readonly title: { + readonly column: 'title'; + readonly nullable: false; + readonly codecId: 'pg/text@1'; + }; + readonly userId: { + readonly column: 'user_id'; + readonly nullable: false; + readonly codecId: 'pg/int4@1'; + }; + }; + readonly relations: { + readonly user: { + readonly to: 'User'; + readonly cardinality: 'N:1'; + readonly strategy: 'reference'; + readonly on: { + readonly localFields: readonly ['userId']; + readonly targetFields: readonly ['id']; + }; + }; + }; + readonly storage: { + readonly table: 'post'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly title: { readonly column: 'title' }; + readonly userId: { readonly column: 'user_id' }; + }; + }; + }; +}; + +type ExampleRelations = { + readonly user: { + readonly posts: { + readonly to: 'Post'; + readonly cardinality: '1:N'; + readonly on: { + readonly childCols: readonly ['user_id']; + readonly parentCols: readonly ['id']; + }; + }; + }; + readonly post: { + readonly user: { + readonly to: 'User'; + readonly cardinality: 'N:1'; + readonly on: { + readonly childCols: readonly ['user_id']; + readonly parentCols: readonly ['id']; + }; + }; + }; +}; + +type ExampleMappings = { + readonly modelToTable: { readonly User: 'user'; readonly Post: 'post' }; + readonly tableToModel: { readonly user: 'User'; readonly post: 'Post' }; + readonly fieldToColumn: { + readonly User: { readonly id: 'id'; readonly email: 'email'; readonly name: 'display_name' }; + readonly Post: { readonly id: 'id'; readonly title: 'title'; readonly userId: 'user_id' }; + }; + readonly columnToField: { + readonly user: { readonly id: 'id'; readonly email: 'email'; readonly display_name: 'name' }; + readonly post: { readonly id: 'id'; readonly title: 'title'; readonly user_id: 'userId' }; + }; +}; + +type ExampleStorageHash = StorageHashBase<'sha256:abc123'>; +type ExampleExecutionHash = ExecutionHashBase<'sha256:def456'>; +type ExampleProfileHash = ProfileHashBase<'sha256:ghi789'>; + +type ExampleContract = SqlContract< + SqlStorage, + ExampleModels, + ExampleRelations, + ExampleMappings, + ExampleStorageHash, + ExampleExecutionHash, + ExampleProfileHash +>; + +// Proof: example contract extends ContractBase +type _AssertExampleExtendsBase = ExampleContract extends ContractBase ? true : never; +const _assertExampleExtendsBase: _AssertExampleExtendsBase = true; + +// Proof: example contract extends SqlContract (default params) +type _AssertExampleExtendsSql = ExampleContract extends SqlContract ? true : never; +const _assertExampleExtendsSql: _AssertExampleExtendsSql = true; + +// Proof: new roots field is accessible +type _VerifyRoots = ExampleContract['roots']; +const _verifyRoots: _VerifyRoots = { users: 'User' }; + +// Proof: both old and new field properties are accessible +type _VerifyFieldColumn = ExampleContract['models']['User']['fields']['name']['column']; +const _verifyFieldColumn: _VerifyFieldColumn = 'display_name'; + +type _VerifyFieldNullable = ExampleContract['models']['User']['fields']['name']['nullable']; +const _verifyFieldNullable: _VerifyFieldNullable = true; + +type _VerifyFieldCodecId = ExampleContract['models']['User']['fields']['name']['codecId']; +const _verifyFieldCodecId: _VerifyFieldCodecId = 'pg/text@1'; + +// Proof: model.relations accessible +type _VerifyModelRelTo = ExampleContract['models']['User']['relations']['posts']['to']; +const _verifyModelRelTo: _VerifyModelRelTo = 'Post'; + +// Proof: model.storage accessible +type _VerifyModelStorageTable = ExampleContract['models']['User']['storage']['table']; +const _verifyModelStorageTable: _VerifyModelStorageTable = 'user'; + +// Proof: old mappings still accessible +type _VerifyMapping = ExampleContract['mappings']['modelToTable']['User']; +const _verifyMapping: _VerifyMapping = 'user'; + +// Proof: old top-level relations still accessible +type _VerifyTopRel = ExampleContract['relations']['user']['posts']['to']; +const _verifyTopRel: _VerifyTopRel = 'Post'; + +// ============================================================================ +// §6 validateContract() bridging design +// ============================================================================ +// +// normalizeContract() detects JSON format by inspecting the first model's first +// field: if it has 'nullable' and 'codecId', it's new format; if it has 'column' +// without 'nullable', it's old format. +// +// Old format normalization: +// 1. Build model.storage.fields from model.fields[f].column +// 2. Populate model.fields with { nullable, codecId } from storage.tables +// 3. Build model.relations from top-level relations +// 4. Derive roots from models (each model with storage.table → root entry) +// +// New format: pass through as-is. +// +// After normalization, both formats yield the same internal structure. +// +// Bridge (new → old, for consumer compatibility): +// - mappings: derived from model.storage.table + model.storage.fields +// - top-level relations: derived from model.relations (rekey by table name, +// convert localFields/targetFields → childCols/parentCols) +// - model.fields[f].column: derived from model.storage.fields[f].column +// +// validateContractDomain() runs on the normalized structure (shared validation). +// validateContractLogic() + validateStorageSemantics() run after (SQL-specific). + +// ============================================================================ +// §7 MongoContract alignment proof +// ============================================================================ +// +// MongoContract's domain types are structurally compatible with ContractBase's +// domain types. This is the key property that enables cross-family consumers. + +// MongoModelField ≡ DomainField +type _AssertMongoFieldIsDomainField = MongoModelField extends DomainField ? true : never; +const _assertMongoFieldIsDomainField: _AssertMongoFieldIsDomainField = true; + +type _AssertDomainFieldIsMongoField = DomainField extends MongoModelField ? true : never; +const _assertDomainFieldIsMongoField: _AssertDomainFieldIsMongoField = true; + +// MongoReferenceRelation extends DomainRelation +type _AssertMongoRefRelExtendsBase = MongoReferenceRelation extends DomainRelation ? true : never; +const _assertMongoRefRelExtendsBase: _AssertMongoRefRelExtendsBase = true; + +// MongoEmbedRelation extends DomainRelation +type _AssertMongoEmbedRelExtendsBase = MongoEmbedRelation extends DomainRelation ? true : never; +const _assertMongoEmbedRelExtendsBase: _AssertMongoEmbedRelExtendsBase = true; + +// MongoRelation (union) extends DomainRelation +type _AssertMongoRelExtendsBase = MongoRelation extends DomainRelation ? true : never; +const _assertMongoRelExtendsBase: _AssertMongoRelExtendsBase = true; + +// MongoModelDefinition extends DomainModel +type _AssertMongoModelExtendsDomain = MongoModelDefinition extends DomainModel ? true : never; +const _assertMongoModelExtendsDomain: _AssertMongoModelExtendsDomain = true; + +// MongoContract's domain shape is compatible with ContractBase (structural) +// MongoContract doesn't formally extend ContractBase yet (lacks schemaVersion, +// storageHash, etc.) — that's a separate follow-up. What matters: domain fields match. +type _AssertMongoRootsCompatible = MongoContract['roots'] extends ContractBase['roots'] + ? true + : never; +const _assertMongoRootsCompatible: _AssertMongoRootsCompatible = true; + +type _AssertMongoModelsCompatible = MongoContract['models'] extends ContractBase['models'] + ? true + : never; +const _assertMongoModelsCompatible: _AssertMongoModelsCompatible = true; + +// DomainContractShape (used by validateContractDomain) is a subset of ContractBase +type DomainContractShape = { + readonly roots: Record; + readonly models: Record; +}; + +type _AssertContractBaseSatisfiesDomainShape = ContractBase extends DomainContractShape + ? true + : never; +const _assertContractBaseSatisfiesDomainShape: _AssertContractBaseSatisfiesDomainShape = true; + +// ============================================================================ +// §8 Phase 3 final types (old fields removed) +// ============================================================================ + +type SqlModelFieldFinal = { + readonly nullable: boolean; + readonly codecId: string; +}; + +type SqlModelDefinitionFinal = { + readonly fields: Record; + readonly relations: Record; + readonly storage: SqlModelStorage; + readonly discriminator?: DomainDiscriminator; + readonly variants?: Record; + readonly base?: string; +}; + +// Phase 3: SqlContract drops R and Map type parameters +type SqlContractFinal< + S extends SqlStorage = SqlStorage, + M extends Record = Record, + TStorageHash extends StorageHashBase = StorageHashBase, + TExecutionHash extends ExecutionHashBase = ExecutionHashBase, + TProfileHash extends ProfileHashBase = ProfileHashBase, +> = ContractBase & { + readonly targetFamily: string; + readonly storage: S; + readonly models: M; + readonly execution?: ExecutionSection; +}; + +// Proof: final types still extend ContractBase +type _AssertFinalExtendsBase = SqlContractFinal extends ContractBase ? true : never; +const _assertFinalExtendsBase: _AssertFinalExtendsBase = true; + +// Proof: final model satisfies DomainModel +type _AssertFinalModelSatisfiesDomain = SqlModelDefinitionFinal extends DomainModel ? true : never; +const _assertFinalModelSatisfiesDomain: _AssertFinalModelSatisfiesDomain = true; + +// Proof: final field satisfies DomainField +type _AssertFinalFieldSatisfiesDomain = SqlModelFieldFinal extends DomainField ? true : never; +const _assertFinalFieldSatisfiesDomain: _AssertFinalFieldSatisfiesDomain = true; + +// Phase 3 example: clean contract without old fields +type ExampleContractPhase3 = SqlContractFinal< + SqlStorage, + { + readonly User: { + readonly fields: { + readonly id: { readonly nullable: false; readonly codecId: 'pg/int4@1' }; + readonly email: { readonly nullable: false; readonly codecId: 'pg/text@1' }; + readonly name: { readonly nullable: true; readonly codecId: 'pg/text@1' }; + }; + readonly relations: { + readonly posts: { + readonly to: 'Post'; + readonly cardinality: '1:N'; + readonly strategy: 'reference'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['userId']; + }; + }; + }; + readonly storage: { + readonly table: 'user'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly email: { readonly column: 'email' }; + readonly name: { readonly column: 'display_name' }; + }; + }; + }; + }, + ExampleStorageHash, + ExampleExecutionHash, + ExampleProfileHash +>; + +// Proof: column mapping via model.storage.fields (not mappings) +type _VerifyPhase3Column = + ExampleContractPhase3['models']['User']['storage']['fields']['name']['column']; +const _verifyPhase3Column: _VerifyPhase3Column = 'display_name'; + +// Proof: domain fields directly accessible +type _VerifyPhase3Nullable = ExampleContractPhase3['models']['User']['fields']['name']['nullable']; +const _verifyPhase3Nullable: _VerifyPhase3Nullable = true; + +// ============================================================================ +// Summary: type parameter evolution +// ============================================================================ +// +// Phase 1 (widened, backward compatible): +// ContractBase +// + roots: Record +// + models: Record +// +// SqlContract +// = ContractBase<...> & { storage: S, models: M, relations: R, mappings: Map } +// models[Model].fields[f] has { column, nullable, codecId } +// +// Phase 3 (final, breaking): +// ContractBase unchanged +// +// SqlContract +// = ContractBase<...> & { storage: S, models: M } +// R and Map type parameters dropped +// models[Model].fields[f] has { nullable, codecId } (no column) From 72660b2df6fa50aa4719570d237397fa9aa9f9b8 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 17:40:09 +0200 Subject: [PATCH 04/35] add models intersection validation to type design Prove that ContractBase.models (Record) and SqlContract models (literal M) intersect correctly: domain-level properties are accessible, SQL-specific literal types are preserved, and SqlContract is assignable to ContractBase for cross-family consumers. --- .../contract-domain-extraction/type-design.ts | 84 +++++++++++++++---- 1 file changed, 70 insertions(+), 14 deletions(-) diff --git a/projects/contract-domain-extraction/type-design.ts b/projects/contract-domain-extraction/type-design.ts index 9c30812e81..0c0254eeec 100644 --- a/projects/contract-domain-extraction/type-design.ts +++ b/projects/contract-domain-extraction/type-design.ts @@ -340,33 +340,89 @@ const _assertExampleExtendsBase: _AssertExampleExtendsBase = true; type _AssertExampleExtendsSql = ExampleContract extends SqlContract ? true : never; const _assertExampleExtendsSql: _AssertExampleExtendsSql = true; -// Proof: new roots field is accessible -type _VerifyRoots = ExampleContract['roots']; -const _verifyRoots: _VerifyRoots = { users: 'User' }; +// ============================================================================ +// §5a Key structural validation: `models` intersection +// ============================================================================ +// +// The critical question: when ContractBase declares +// models: Record +// and SqlContract intersects with +// & { models: M } +// does TypeScript resolve the intersection so that BOTH domain-level +// properties (from ContractBase) and SQL-specific literal types (from M) +// are accessible on the same model key? +// +// Answer: yes. The intersection creates models: Record & M. +// For a known key like 'User', TypeScript intersects the index signature's +// value type (DomainModel) with the explicit key's type from M. +// Since M's model types extend DomainModel, the intersection is just M's type +// (the narrower type), with all literal type information preserved. +// +// The assertions below prove this property concretely. -// Proof: both old and new field properties are accessible -type _VerifyFieldColumn = ExampleContract['models']['User']['fields']['name']['column']; -const _verifyFieldColumn: _VerifyFieldColumn = 'display_name'; +// First, verify the raw intersection type for models +type ExampleModelsIntersection = ExampleContract['models']; -type _VerifyFieldNullable = ExampleContract['models']['User']['fields']['name']['nullable']; +// Known key access: TypeScript resolves 'User' from M's literal type, +// not from the index signature. Literal types are preserved. +type ExampleUserModel = ExampleModelsIntersection['User']; + +// Domain-level properties (from ContractBase's DomainModel) are accessible +// through the intersection — AND retain their literal types from M: +type _VerifyFieldNullable = ExampleUserModel['fields']['name']['nullable']; const _verifyFieldNullable: _VerifyFieldNullable = true; +// ^? true (literal), not boolean (widened) -type _VerifyFieldCodecId = ExampleContract['models']['User']['fields']['name']['codecId']; +type _VerifyFieldCodecId = ExampleUserModel['fields']['name']['codecId']; const _verifyFieldCodecId: _VerifyFieldCodecId = 'pg/text@1'; +// ^? 'pg/text@1' (literal), not string (widened) + +// SQL-specific properties (from M, NOT on DomainModel) are also accessible +// through the same intersection: +type _VerifyFieldColumn = ExampleUserModel['fields']['name']['column']; +const _verifyFieldColumn: _VerifyFieldColumn = 'display_name'; +// ^? 'display_name' (literal) — this property comes from M, not DomainModel -// Proof: model.relations accessible -type _VerifyModelRelTo = ExampleContract['models']['User']['relations']['posts']['to']; +// model.relations: domain-level relation properties with literal types from M +type _VerifyModelRelTo = ExampleUserModel['relations']['posts']['to']; const _verifyModelRelTo: _VerifyModelRelTo = 'Post'; -// Proof: model.storage accessible -type _VerifyModelStorageTable = ExampleContract['models']['User']['storage']['table']; +type _VerifyModelRelStrategy = ExampleUserModel['relations']['posts']['strategy']; +const _verifyModelRelStrategy: _VerifyModelRelStrategy = 'reference'; + +// model.storage: SQL-specific storage bridge with literal types from M +type _VerifyModelStorageTable = ExampleUserModel['storage']['table']; const _verifyModelStorageTable: _VerifyModelStorageTable = 'user'; -// Proof: old mappings still accessible +type _VerifyModelStorageFieldCol = ExampleUserModel['storage']['fields']['name']['column']; +const _verifyModelStorageFieldCol: _VerifyModelStorageFieldCol = 'display_name'; + +// The model satisfies DomainModel (can be passed to domain-level consumers) +type _AssertUserModelIsDomainModel = ExampleUserModel extends DomainModel ? true : never; +const _assertUserModelIsDomainModel: _AssertUserModelIsDomainModel = true; + +// roots: new field from ContractBase, accessible through the intersection +type _VerifyRoots = ExampleContract['roots']; +const _verifyRoots: _VerifyRoots = { users: 'User' }; + +// A function that accepts ContractBase can receive the SqlContract +// and read domain-level models from it: +function _domainConsumer(contract: ContractBase): string[] { + return Object.entries(contract.models).map(([name, model]) => { + const fieldNames = Object.keys(model.fields); + const relationNames = Object.keys(model.relations); + return `${name}: ${fieldNames.length} fields, ${relationNames.length} relations`; + }); +} +// This compiles: SqlContract is assignable to ContractBase +function _sqlConsumer(contract: ExampleContract): string[] { + return _domainConsumer(contract); +} + +// Old fields (PHASE 3: REMOVED) still accessible during Phase 1 type _VerifyMapping = ExampleContract['mappings']['modelToTable']['User']; const _verifyMapping: _VerifyMapping = 'user'; -// Proof: old top-level relations still accessible type _VerifyTopRel = ExampleContract['relations']['user']['posts']['to']; const _verifyTopRel: _VerifyTopRel = 'Post'; From 976d08c5e79162dba049edd90cf972229ca5d31c Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 18:18:07 +0200 Subject: [PATCH 05/35] add domain types and widen ContractBase with roots+models Introduce DomainField, DomainRelation, DomainModel in the framework contract package and widen ContractBase to carry roots and models. Add SqlModelStorage, SqlRelation SQL-specific domain types. Type tests verify SqlContract extends ContractBase, domain fields are accessible, and old consumer fields remain unchanged. --- .../shared/contract/src/domain-types.ts | 29 ++++++ .../shared/contract/src/exports/types.ts | 7 ++ .../1-core/shared/contract/src/types.ts | 3 + .../shared/contract/test/domain-types.test.ts | 71 ++++++++++++++ .../1-core/contract/src/exports/types.ts | 3 + packages/2-sql/1-core/contract/src/types.ts | 17 ++++ .../1-core/contract/test/domain-types.test.ts | 94 +++++++++++++++++++ 7 files changed, 224 insertions(+) create mode 100644 packages/1-framework/1-core/shared/contract/src/domain-types.ts create mode 100644 packages/1-framework/1-core/shared/contract/test/domain-types.test.ts create mode 100644 packages/2-sql/1-core/contract/test/domain-types.test.ts diff --git a/packages/1-framework/1-core/shared/contract/src/domain-types.ts b/packages/1-framework/1-core/shared/contract/src/domain-types.ts new file mode 100644 index 0000000000..dc5525df74 --- /dev/null +++ b/packages/1-framework/1-core/shared/contract/src/domain-types.ts @@ -0,0 +1,29 @@ +export type DomainField = { + readonly nullable: boolean; + readonly codecId: string; +}; + +export type DomainRelationOn = { + readonly localFields: readonly string[]; + readonly targetFields: readonly string[]; +}; + +export type DomainRelation = { + readonly to: string; + readonly cardinality: '1:1' | '1:N' | 'N:1'; + readonly strategy: 'reference' | 'embed'; + readonly on?: DomainRelationOn; +}; + +export type DomainDiscriminator = { + readonly field: string; +}; + +export type DomainModel = { + readonly fields: Record; + readonly relations: Record; + readonly storage: Record; + readonly discriminator?: DomainDiscriminator; + readonly variants?: Record; + readonly base?: string; +}; diff --git a/packages/1-framework/1-core/shared/contract/src/exports/types.ts b/packages/1-framework/1-core/shared/contract/src/exports/types.ts index 69f0ebd198..368cc95a5b 100644 --- a/packages/1-framework/1-core/shared/contract/src/exports/types.ts +++ b/packages/1-framework/1-core/shared/contract/src/exports/types.ts @@ -1,3 +1,10 @@ +export type { + DomainDiscriminator, + DomainField, + DomainModel, + DomainRelation, + DomainRelationOn, +} from '../domain-types'; export type { $, Brand, diff --git a/packages/1-framework/1-core/shared/contract/src/types.ts b/packages/1-framework/1-core/shared/contract/src/types.ts index 28569c3508..8e1ee13bbb 100644 --- a/packages/1-framework/1-core/shared/contract/src/types.ts +++ b/packages/1-framework/1-core/shared/contract/src/types.ts @@ -1,4 +1,5 @@ import type { OperationRegistry } from '@prisma-next/operations'; +import type { DomainModel } from './domain-types'; import type { ContractIR } from './ir'; /** @@ -71,6 +72,8 @@ export interface ContractBase< readonly meta: Record; readonly sources: Record; readonly execution?: ExecutionSection; + readonly roots: Record; + readonly models: Record; } export interface FieldType { diff --git a/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts b/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts new file mode 100644 index 0000000000..a7c6de6259 --- /dev/null +++ b/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import type { DomainField, DomainModel, DomainRelation } from '../src/domain-types'; +import type { ContractBase } from '../src/types'; + +describe('domain types', () => { + it('ContractBase includes roots and models', () => { + type Roots = ContractBase['roots']; + type Models = ContractBase['models']; + + const roots: Roots = { users: 'User' }; + const models: Models = { + User: { + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: {}, + storage: { table: 'user' }, + }, + }; + + expect(roots).toEqual({ users: 'User' }); + expect(models.User.fields.id.nullable).toBe(false); + }); + + it('DomainField carries nullable and codecId', () => { + const field: DomainField = { nullable: true, codecId: 'pg/text@1' }; + expect(field.nullable).toBe(true); + expect(field.codecId).toBe('pg/text@1'); + }); + + it('DomainRelation supports reference strategy with on clause', () => { + const relation: DomainRelation = { + to: 'Post', + cardinality: '1:N', + strategy: 'reference', + on: { localFields: ['id'], targetFields: ['userId'] }, + }; + expect(relation.to).toBe('Post'); + expect(relation.strategy).toBe('reference'); + }); + + it('DomainRelation supports embed strategy without on clause', () => { + const relation: DomainRelation = { + to: 'Address', + cardinality: '1:1', + strategy: 'embed', + }; + expect(relation.strategy).toBe('embed'); + expect(relation.on).toBeUndefined(); + }); + + it('DomainModel supports polymorphism fields', () => { + const model: DomainModel = { + fields: { type: { nullable: false, codecId: 'pg/text@1' } }, + relations: {}, + storage: {}, + discriminator: { field: 'type' }, + variants: { Special: { value: 'special' } }, + }; + expect(model.discriminator?.field).toBe('type'); + expect(model.variants).toBeDefined(); + }); + + it('DomainModel supports base for variant models', () => { + const model: DomainModel = { + fields: {}, + relations: {}, + storage: {}, + base: 'Parent', + }; + expect(model.base).toBe('Parent'); + }); +}); diff --git a/packages/2-sql/1-core/contract/src/exports/types.ts b/packages/2-sql/1-core/contract/src/exports/types.ts index 0ed1d1496e..a47dc293c8 100644 --- a/packages/2-sql/1-core/contract/src/exports/types.ts +++ b/packages/2-sql/1-core/contract/src/exports/types.ts @@ -22,7 +22,10 @@ export type { ResolveOperationTypes, SqlContract, SqlMappings, + SqlModelFieldStorage, + SqlModelStorage, SqlQueryOperationTypes, + SqlRelation, SqlStorage, StorageColumn, StorageTable, diff --git a/packages/2-sql/1-core/contract/src/types.ts b/packages/2-sql/1-core/contract/src/types.ts index d02a7d8fd9..19240945bc 100644 --- a/packages/2-sql/1-core/contract/src/types.ts +++ b/packages/2-sql/1-core/contract/src/types.ts @@ -1,6 +1,7 @@ import type { ColumnDefault, ContractBase, + DomainRelationOn, ExecutionHashBase, ExecutionSection, ProfileHashBase, @@ -133,6 +134,22 @@ export type ModelDefinition = { readonly relations: Record; }; +export type SqlModelFieldStorage = { + readonly column: string; +}; + +export type SqlModelStorage = { + readonly table: string; + readonly fields: Record; +}; + +export type SqlRelation = { + readonly to: string; + readonly cardinality: '1:1' | '1:N' | 'N:1'; + readonly strategy: 'reference'; + readonly on: DomainRelationOn; +}; + export type SqlMappings = { readonly modelToTable?: Record; readonly tableToModel?: Record; diff --git a/packages/2-sql/1-core/contract/test/domain-types.test.ts b/packages/2-sql/1-core/contract/test/domain-types.test.ts new file mode 100644 index 0000000000..162bd27d3e --- /dev/null +++ b/packages/2-sql/1-core/contract/test/domain-types.test.ts @@ -0,0 +1,94 @@ +import type { ContractBase, DomainRelation } from '@prisma-next/contract/types'; +import { describe, expect, it } from 'vitest'; +import type { SqlContract, SqlMappings, SqlRelation, SqlStorage } from '../src/types'; + +type AssertExtends = T extends U ? true : never; + +describe('domain type compatibility', () => { + describe('SqlContract extends ContractBase', () => { + it('type-level assertion', () => { + const _proof: AssertExtends = true; + expect(_proof).toBe(true); + }); + }); + + describe('domain fields accessible on SqlContract models', () => { + it('DomainModel fields are accessible via index signature', () => { + type ModelFromContract = SqlContract['models'][string]; + type FieldsFromModel = ModelFromContract['fields']; + + const fields: FieldsFromModel = { + id: { nullable: false, codecId: 'pg/int4@1' }, + }; + expect(fields.id.nullable).toBe(false); + expect(fields.id.codecId).toBe('pg/int4@1'); + }); + }); + + describe('old consumer-facing fields remain accessible', () => { + it('mappings accessible on SqlContract', () => { + type Mappings = SqlContract['mappings']; + const mappings: Mappings = { + modelToTable: { User: 'user' }, + tableToModel: { user: 'User' }, + }; + expect(mappings.modelToTable).toBeDefined(); + }); + + it('top-level relations accessible on SqlContract', () => { + type Relations = SqlContract['relations']; + const relations: Relations = {}; + expect(relations).toBeDefined(); + }); + }); + + describe('roots accessible on SqlContract via ContractBase', () => { + it('roots field exists on SqlContract', () => { + type Roots = SqlContract['roots']; + const roots: Roots = { users: 'User' }; + expect(roots.users).toBe('User'); + }); + }); + + describe('SqlRelation extends DomainRelation', () => { + it('type-level assertion', () => { + const _proof: AssertExtends = true; + expect(_proof).toBe(true); + }); + }); + + describe('concrete typed contract preserves literal types', () => { + it('literal types flow through the intersection', () => { + type ExampleModels = { + readonly User: { + readonly fields: { + readonly name: { + readonly column: 'display_name'; + readonly nullable: true; + readonly codecId: 'pg/text@1'; + }; + }; + readonly relations: {}; + readonly storage: { readonly table: 'user' }; + }; + }; + + type ExampleContract = SqlContract< + SqlStorage, + ExampleModels, + Record, + SqlMappings + >; + + type NameField = ExampleContract['models']['User']['fields']['name']; + + const _nullable: NameField['nullable'] = true; + const _codecId: NameField['codecId'] = 'pg/text@1'; + const _column: NameField['column'] = 'display_name'; + + expect(_nullable).toBe(true); + expect(_codecId).toBe('pg/text@1'); + expect(_column).toBe('display_name'); + }); + }); +}); From 284adff406f06544c5257bcc2926c197ee947279 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 18:20:04 +0200 Subject: [PATCH 06/35] extract domain validation from mongo-core to framework contract Move validateContractDomain and its types to @prisma-next/contract so the SQL family can share the same domain validation logic. Mongo re-exports from the new location; tests ported to framework. --- .../1-core/shared/contract/package.json | 1 + .../contract/src/exports/validate-domain.ts | 6 + .../shared/contract/src/validate-domain.ts | 172 ++++++++ .../contract/test/validate-domain.test.ts | 381 ++++++++++++++++++ .../1-core/shared/contract/tsdown.config.ts | 7 +- .../1-core/src/validate-domain.ts | 180 +-------- 6 files changed, 572 insertions(+), 175 deletions(-) create mode 100644 packages/1-framework/1-core/shared/contract/src/exports/validate-domain.ts create mode 100644 packages/1-framework/1-core/shared/contract/src/validate-domain.ts create mode 100644 packages/1-framework/1-core/shared/contract/test/validate-domain.test.ts diff --git a/packages/1-framework/1-core/shared/contract/package.json b/packages/1-framework/1-core/shared/contract/package.json index 5db4a896b5..9b13330040 100644 --- a/packages/1-framework/1-core/shared/contract/package.json +++ b/packages/1-framework/1-core/shared/contract/package.json @@ -37,6 +37,7 @@ "./framework-components": "./dist/framework-components.mjs", "./ir": "./dist/ir.mjs", "./types": "./dist/types.mjs", + "./validate-domain": "./dist/validate-domain.mjs", "./package.json": "./package.json" }, "repository": { diff --git a/packages/1-framework/1-core/shared/contract/src/exports/validate-domain.ts b/packages/1-framework/1-core/shared/contract/src/exports/validate-domain.ts new file mode 100644 index 0000000000..4ca99b7824 --- /dev/null +++ b/packages/1-framework/1-core/shared/contract/src/exports/validate-domain.ts @@ -0,0 +1,6 @@ +export { + type DomainContractShape, + type DomainModelShape, + type DomainValidationResult, + validateContractDomain, +} from '../validate-domain'; diff --git a/packages/1-framework/1-core/shared/contract/src/validate-domain.ts b/packages/1-framework/1-core/shared/contract/src/validate-domain.ts new file mode 100644 index 0000000000..84f64b338d --- /dev/null +++ b/packages/1-framework/1-core/shared/contract/src/validate-domain.ts @@ -0,0 +1,172 @@ +export interface DomainModelShape { + readonly fields: Record; + readonly relations: Record; + readonly discriminator?: { readonly field: string }; + readonly variants?: Record; + readonly base?: string; +} + +export interface DomainContractShape { + readonly roots: Record; + readonly models: Record; +} + +export interface DomainValidationResult { + readonly warnings: string[]; +} + +export function validateContractDomain(contract: DomainContractShape): DomainValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + const modelNames = new Set(Object.keys(contract.models)); + + validateRoots(contract, modelNames, errors); + validateVariantsAndBases(contract, modelNames, errors); + validateRelationTargets(contract, modelNames, errors); + validateDiscriminators(contract, errors); + detectOrphanedModels(contract, modelNames, warnings); + + if (errors.length > 0) { + throw new Error(`Contract domain validation failed:\n- ${errors.join('\n- ')}`); + } + + return { warnings }; +} + +function validateRoots( + contract: DomainContractShape, + modelNames: Set, + errors: string[], +): void { + const seenValues = new Set(); + for (const [rootKey, modelName] of Object.entries(contract.roots)) { + if (seenValues.has(modelName)) { + errors.push(`Duplicate root value: "${modelName}" is mapped by multiple root keys`); + } + seenValues.add(modelName); + + if (!modelNames.has(modelName)) { + errors.push( + `Root "${rootKey}" references model "${modelName}" which does not exist in models`, + ); + } + } +} + +function validateVariantsAndBases( + contract: DomainContractShape, + modelNames: Set, + errors: string[], +): void { + for (const [modelName, model] of Object.entries(contract.models)) { + if (model.variants) { + for (const variantName of Object.keys(model.variants)) { + if (!modelNames.has(variantName)) { + errors.push( + `Model "${modelName}" lists variant "${variantName}" which does not exist in models`, + ); + continue; + } + const variantModel = contract.models[variantName]; + if (!variantModel) continue; + if (variantModel.base !== modelName) { + errors.push( + `Variant "${variantName}" has base "${variantModel.base ?? '(none)'}" but expected "${modelName}"`, + ); + } + } + } + + if (model.base) { + if (!modelNames.has(model.base)) { + errors.push(`Model "${modelName}" has base "${model.base}" which does not exist in models`); + continue; + } + const baseModel = contract.models[model.base]; + if (!baseModel) continue; + if (!baseModel.variants || !(modelName in baseModel.variants)) { + errors.push( + `Model "${modelName}" has base "${model.base}" which does not list it as a variant`, + ); + } + } + } +} + +function validateRelationTargets( + contract: DomainContractShape, + modelNames: Set, + errors: string[], +): void { + for (const [modelName, model] of Object.entries(contract.models)) { + for (const [relName, relation] of Object.entries(model.relations)) { + if (!modelNames.has(relation.to)) { + errors.push( + `Relation "${relName}" on model "${modelName}" targets "${relation.to}" which does not exist in models`, + ); + } + } + } +} + +function validateDiscriminators(contract: DomainContractShape, errors: string[]): void { + for (const [modelName, model] of Object.entries(contract.models)) { + if (model.discriminator) { + if (!model.variants || Object.keys(model.variants).length === 0) { + errors.push(`Model "${modelName}" has discriminator but no variants`); + } + if (!(model.discriminator.field in model.fields)) { + errors.push( + `Discriminator field "${model.discriminator.field}" is not a field on model "${modelName}"`, + ); + } + } + + if (model.variants && Object.keys(model.variants).length > 0 && !model.discriminator) { + errors.push(`Model "${modelName}" has variants but no discriminator`); + } + + if (model.base) { + if (model.discriminator) { + errors.push(`Model "${modelName}" has base and must not have discriminator`); + } + if (model.variants && Object.keys(model.variants).length > 0) { + errors.push(`Model "${modelName}" has base and must not have variants`); + } + } + } +} + +function detectOrphanedModels( + contract: DomainContractShape, + modelNames: Set, + warnings: string[], +): void { + const referenced = new Set(); + + for (const modelName of Object.values(contract.roots)) { + referenced.add(modelName); + } + + for (const model of Object.values(contract.models)) { + for (const relation of Object.values(model.relations)) { + referenced.add(relation.to); + } + if (model.variants) { + for (const variantName of Object.keys(model.variants)) { + referenced.add(variantName); + } + } + if (model.base) { + referenced.add(model.base); + } + } + + for (const modelName of modelNames) { + if (!referenced.has(modelName)) { + warnings.push( + `Orphaned model: "${modelName}" is not referenced by any root, relation, or variant`, + ); + } + } +} diff --git a/packages/1-framework/1-core/shared/contract/test/validate-domain.test.ts b/packages/1-framework/1-core/shared/contract/test/validate-domain.test.ts new file mode 100644 index 0000000000..6ac56b331b --- /dev/null +++ b/packages/1-framework/1-core/shared/contract/test/validate-domain.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it } from 'vitest'; +import { validateContractDomain } from '../src/validate-domain'; + +function makeMinimalModel(overrides: Record = {}) { + return { + fields: {}, + relations: {}, + ...overrides, + }; +} + +function makeValidContract(overrides: Record = {}) { + return { + roots: { items: 'Item' }, + models: { + Item: makeMinimalModel({ + fields: { _id: { codecId: 'mongo/objectId@1', nullable: false } }, + }), + }, + ...overrides, + }; +} + +describe('validateContractDomain()', () => { + describe('root validation', () => { + it('accepts valid roots', () => { + expect(() => validateContractDomain(makeValidContract())).not.toThrow(); + }); + + it('rejects duplicate root values', () => { + const contract = makeValidContract({ + roots: { items: 'Item', things: 'Item' }, + }); + expect(() => validateContractDomain(contract)).toThrow(/duplicate root.*Item/i); + }); + + it('rejects root referencing non-existent model', () => { + const contract = makeValidContract({ + roots: { items: 'Item', ghosts: 'Ghost' }, + }); + expect(() => validateContractDomain(contract)).toThrow(/root.*ghosts.*Ghost.*not exist/i); + }); + }); + + describe('variant-base bidirectional consistency', () => { + it('accepts consistent variant-base relationships', () => { + const contract = makeValidContract({ + roots: { items: 'Item' }, + models: { + Item: makeMinimalModel({ + fields: { type: { codecId: 'mongo/string@1', nullable: false } }, + discriminator: { field: 'type' }, + variants: { SpecialItem: { value: 'special' } }, + }), + SpecialItem: makeMinimalModel({ base: 'Item' }), + }, + }); + expect(() => validateContractDomain(contract)).not.toThrow(); + }); + + it('rejects variant referencing non-existent model', () => { + const contract = makeValidContract({ + models: { + Item: makeMinimalModel({ + fields: { type: { codecId: 'mongo/string@1', nullable: false } }, + discriminator: { field: 'type' }, + variants: { Ghost: { value: 'ghost' } }, + }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow(/variant.*Ghost.*not exist/i); + }); + + it('rejects variant whose base does not match the declaring model', () => { + const contract = makeValidContract({ + roots: { items: 'Item' }, + models: { + Item: makeMinimalModel({ + fields: { type: { codecId: 'mongo/string@1', nullable: false } }, + discriminator: { field: 'type' }, + variants: { Child: { value: 'child' } }, + }), + Other: makeMinimalModel({ + fields: { type: { codecId: 'mongo/string@1', nullable: false } }, + discriminator: { field: 'type' }, + variants: {}, + }), + Child: makeMinimalModel({ base: 'Other' }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow( + /variant.*Child.*base.*Other.*expected.*Item/i, + ); + }); + + it('rejects model with base that does not list it as a variant', () => { + const contract = makeValidContract({ + roots: { items: 'Item' }, + models: { + Item: makeMinimalModel({ + fields: { type: { codecId: 'mongo/string@1', nullable: false } }, + discriminator: { field: 'type' }, + variants: {}, + }), + Orphan: makeMinimalModel({ base: 'Item' }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow( + /model.*Orphan.*base.*Item.*not list.*variant/i, + ); + }); + + it('rejects model with base referencing non-existent model', () => { + const contract = makeValidContract({ + models: { + Item: makeMinimalModel({ base: 'Ghost' }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow(/base.*Ghost.*not exist/i); + }); + }); + + describe('relation target validation', () => { + it('accepts relations with valid targets', () => { + const contract = makeValidContract({ + roots: { items: 'Item' }, + models: { + Item: makeMinimalModel({ + relations: { + owner: { + to: 'User', + cardinality: 'N:1', + strategy: 'reference', + on: { localFields: ['ownerId'], targetFields: ['_id'] }, + }, + }, + }), + User: makeMinimalModel(), + }, + }); + expect(() => validateContractDomain(contract)).not.toThrow(); + }); + + it('rejects relation targeting non-existent model', () => { + const contract = makeValidContract({ + models: { + Item: makeMinimalModel({ + relations: { + owner: { + to: 'Ghost', + cardinality: 'N:1', + strategy: 'reference', + on: { localFields: ['ownerId'], targetFields: ['_id'] }, + }, + }, + }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow( + /relation.*owner.*Item.*target.*Ghost.*not exist/i, + ); + }); + }); + + describe('discriminator invariants', () => { + it('rejects model with discriminator but no variants', () => { + const contract = makeValidContract({ + models: { + Item: makeMinimalModel({ + fields: { type: { codecId: 'mongo/string@1', nullable: false } }, + discriminator: { field: 'type' }, + }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow( + /model.*Item.*discriminator.*no variants/i, + ); + }); + + it('rejects model with discriminator field not in fields', () => { + const contract = makeValidContract({ + models: { + Item: makeMinimalModel({ + fields: { _id: { codecId: 'mongo/objectId@1', nullable: false } }, + discriminator: { field: 'kind' }, + variants: { Special: { value: 'special' } }, + }), + Special: makeMinimalModel({ base: 'Item' }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow( + /discriminator.*kind.*not.*field.*Item/i, + ); + }); + + it('rejects model with base that also has discriminator', () => { + const contract = makeValidContract({ + roots: { items: 'Item' }, + models: { + Item: makeMinimalModel({ + fields: { type: { codecId: 'mongo/string@1', nullable: false } }, + discriminator: { field: 'type' }, + variants: { Child: { value: 'child' } }, + }), + Child: makeMinimalModel({ + base: 'Item', + discriminator: { field: 'type' }, + }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow( + /model.*Child.*base.*must not.*discriminator/i, + ); + }); + + it('rejects model with variants but no discriminator', () => { + const contract = makeValidContract({ + models: { + Item: makeMinimalModel({ + fields: { type: { codecId: 'mongo/string@1', nullable: false } }, + variants: { Special: { value: 'special' } }, + }), + Special: makeMinimalModel({ base: 'Item' }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow( + /model.*Item.*variants.*no discriminator/i, + ); + }); + + it('rejects model with base that also has variants', () => { + const contract = makeValidContract({ + roots: { items: 'Item' }, + models: { + Item: makeMinimalModel({ + fields: { type: { codecId: 'mongo/string@1', nullable: false } }, + discriminator: { field: 'type' }, + variants: { Child: { value: 'child' } }, + }), + Child: makeMinimalModel({ + base: 'Item', + variants: { Grandchild: { value: 'grandchild' } }, + }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow( + /model.*Child.*base.*must not.*variants/i, + ); + }); + }); + + describe('orphaned model warnings', () => { + it('returns warnings for orphaned models', () => { + const contract = makeValidContract({ + roots: { items: 'Item' }, + models: { + Item: makeMinimalModel(), + Orphan: makeMinimalModel(), + }, + }); + const result = validateContractDomain(contract); + expect(result.warnings).toContainEqual(expect.stringMatching(/orphan.*Orphan/i)); + }); + + it('does not warn for models referenced by relations', () => { + const contract = makeValidContract({ + roots: { items: 'Item' }, + models: { + Item: makeMinimalModel({ + relations: { + tag: { + to: 'Tag', + cardinality: '1:1', + strategy: 'embed', + field: 'tag', + }, + }, + }), + Tag: makeMinimalModel(), + }, + }); + const result = validateContractDomain(contract); + expect(result.warnings).toHaveLength(0); + }); + + it('does not warn for models listed as variants', () => { + const contract = makeValidContract({ + roots: { items: 'Item' }, + models: { + Item: makeMinimalModel({ + fields: { type: { codecId: 'mongo/string@1', nullable: false } }, + discriminator: { field: 'type' }, + variants: { Special: { value: 'special' } }, + }), + Special: makeMinimalModel({ base: 'Item' }), + }, + }); + const result = validateContractDomain(contract); + expect(result.warnings).toHaveLength(0); + }); + }); + + describe('happy path', () => { + it('validates a complex contract with polymorphism and relations', () => { + const contract = { + roots: { tasks: 'Task', users: 'User' }, + models: { + Task: makeMinimalModel({ + fields: { + _id: { codecId: 'mongo/objectId@1', nullable: false }, + title: { codecId: 'mongo/string@1', nullable: false }, + type: { codecId: 'mongo/string@1', nullable: false }, + assigneeId: { codecId: 'mongo/objectId@1', nullable: false }, + }, + relations: { + assignee: { + to: 'User', + cardinality: 'N:1', + strategy: 'reference', + on: { localFields: ['assigneeId'], targetFields: ['_id'] }, + }, + comments: { + to: 'Comment', + cardinality: '1:N', + strategy: 'embed', + field: 'comments', + }, + }, + discriminator: { field: 'type' }, + variants: { + Bug: { value: 'bug' }, + Feature: { value: 'feature' }, + }, + }), + Bug: makeMinimalModel({ + fields: { severity: { codecId: 'mongo/string@1', nullable: false } }, + base: 'Task', + }), + Feature: makeMinimalModel({ + fields: { + priority: { codecId: 'mongo/string@1', nullable: false }, + targetRelease: { codecId: 'mongo/string@1', nullable: false }, + }, + base: 'Task', + }), + User: makeMinimalModel({ + fields: { + _id: { codecId: 'mongo/objectId@1', nullable: false }, + name: { codecId: 'mongo/string@1', nullable: false }, + email: { codecId: 'mongo/string@1', nullable: false }, + }, + relations: { + addresses: { + to: 'Address', + cardinality: '1:N', + strategy: 'embed', + field: 'addresses', + }, + }, + }), + Address: makeMinimalModel({ + fields: { + street: { codecId: 'mongo/string@1', nullable: false }, + city: { codecId: 'mongo/string@1', nullable: false }, + zip: { codecId: 'mongo/string@1', nullable: false }, + }, + }), + Comment: makeMinimalModel({ + fields: { + _id: { codecId: 'mongo/objectId@1', nullable: false }, + text: { codecId: 'mongo/string@1', nullable: false }, + createdAt: { codecId: 'mongo/date@1', nullable: false }, + }, + }), + }, + }; + const result = validateContractDomain(contract); + expect(result.warnings).toHaveLength(0); + }); + }); +}); diff --git a/packages/1-framework/1-core/shared/contract/tsdown.config.ts b/packages/1-framework/1-core/shared/contract/tsdown.config.ts index 47aaa44d98..6b4a0d9444 100644 --- a/packages/1-framework/1-core/shared/contract/tsdown.config.ts +++ b/packages/1-framework/1-core/shared/contract/tsdown.config.ts @@ -1,5 +1,10 @@ import { defineConfig } from '@prisma-next/tsdown'; export default defineConfig({ - entry: ['src/exports/types.ts', 'src/exports/ir.ts', 'src/exports/framework-components.ts'], + entry: [ + 'src/exports/types.ts', + 'src/exports/ir.ts', + 'src/exports/framework-components.ts', + 'src/exports/validate-domain.ts', + ], }); diff --git a/packages/2-mongo-family/1-core/src/validate-domain.ts b/packages/2-mongo-family/1-core/src/validate-domain.ts index 2cc21ed028..e43d5c7db3 100644 --- a/packages/2-mongo-family/1-core/src/validate-domain.ts +++ b/packages/2-mongo-family/1-core/src/validate-domain.ts @@ -1,174 +1,6 @@ -export interface DomainModelShape { - readonly fields: Record; - readonly relations: Record; - readonly discriminator?: { readonly field: string }; - readonly variants?: Record; - readonly base?: string; -} - -export interface DomainContractShape { - readonly roots: Record; - readonly models: Record; -} - -export interface DomainValidationResult { - readonly warnings: string[]; -} - -export function validateContractDomain(contract: DomainContractShape): DomainValidationResult { - const errors: string[] = []; - const warnings: string[] = []; - const modelNames = new Set(Object.keys(contract.models)); - - validateRoots(contract, modelNames, errors); - validateVariantsAndBases(contract, modelNames, errors); - validateRelationTargets(contract, modelNames, errors); - validateDiscriminators(contract, errors); - detectOrphanedModels(contract, modelNames, warnings); - - if (errors.length > 0) { - throw new Error(`Contract domain validation failed:\n- ${errors.join('\n- ')}`); - } - - return { warnings }; -} - -function validateRoots( - contract: DomainContractShape, - modelNames: Set, - errors: string[], -): void { - const seenValues = new Set(); - for (const [rootKey, modelName] of Object.entries(contract.roots)) { - if (seenValues.has(modelName)) { - errors.push(`Duplicate root value: "${modelName}" is mapped by multiple root keys`); - } - seenValues.add(modelName); - - if (!modelNames.has(modelName)) { - errors.push( - `Root "${rootKey}" references model "${modelName}" which does not exist in models`, - ); - } - } -} - -function validateVariantsAndBases( - contract: DomainContractShape, - modelNames: Set, - errors: string[], -): void { - for (const [modelName, model] of Object.entries(contract.models)) { - if (model.variants) { - for (const variantName of Object.keys(model.variants)) { - if (!modelNames.has(variantName)) { - errors.push( - `Model "${modelName}" lists variant "${variantName}" which does not exist in models`, - ); - continue; - } - const variantModel = contract.models[variantName]; - if (!variantModel) continue; - if (variantModel.base !== modelName) { - errors.push( - `Variant "${variantName}" has base "${variantModel.base ?? '(none)'}" but expected "${modelName}"`, - ); - } - } - } - - if (model.base) { - if (!modelNames.has(model.base)) { - errors.push(`Model "${modelName}" has base "${model.base}" which does not exist in models`); - continue; - } - const baseModel = contract.models[model.base]; - if (!baseModel) continue; - if (!baseModel.variants || !(modelName in baseModel.variants)) { - errors.push( - `Model "${modelName}" has base "${model.base}" which does not list it as a variant`, - ); - } - } - } -} - -function validateRelationTargets( - contract: DomainContractShape, - modelNames: Set, - errors: string[], -): void { - for (const [modelName, model] of Object.entries(contract.models)) { - for (const [relName, relation] of Object.entries(model.relations)) { - if (!modelNames.has(relation.to)) { - errors.push( - `Relation "${relName}" on model "${modelName}" targets "${relation.to}" which does not exist in models`, - ); - } - } - } -} - -function validateDiscriminators(contract: DomainContractShape, errors: string[]): void { - for (const [modelName, model] of Object.entries(contract.models)) { - if (model.discriminator) { - if (!model.variants || Object.keys(model.variants).length === 0) { - errors.push(`Model "${modelName}" has discriminator but no variants`); - } - if (!(model.discriminator.field in model.fields)) { - errors.push( - `Discriminator field "${model.discriminator.field}" is not a field on model "${modelName}"`, - ); - } - } - - if (model.variants && Object.keys(model.variants).length > 0 && !model.discriminator) { - errors.push(`Model "${modelName}" has variants but no discriminator`); - } - - // Single-level polymorphism only: a variant (model with `base`) cannot itself - // declare discriminator/variants. Multi-level polymorphism is out of scope per ADR 2. - if (model.base) { - if (model.discriminator) { - errors.push(`Model "${modelName}" has base and must not have discriminator`); - } - if (model.variants && Object.keys(model.variants).length > 0) { - errors.push(`Model "${modelName}" has base and must not have variants`); - } - } - } -} - -function detectOrphanedModels( - contract: DomainContractShape, - modelNames: Set, - warnings: string[], -): void { - const referenced = new Set(); - - for (const modelName of Object.values(contract.roots)) { - referenced.add(modelName); - } - - for (const model of Object.values(contract.models)) { - for (const relation of Object.values(model.relations)) { - referenced.add(relation.to); - } - if (model.variants) { - for (const variantName of Object.keys(model.variants)) { - referenced.add(variantName); - } - } - if (model.base) { - referenced.add(model.base); - } - } - - for (const modelName of modelNames) { - if (!referenced.has(modelName)) { - warnings.push( - `Orphaned model: "${modelName}" is not referenced by any root, relation, or variant`, - ); - } - } -} +export { + type DomainContractShape, + type DomainModelShape, + type DomainValidationResult, + validateContractDomain, +} from '@prisma-next/contract/validate-domain'; From e8ded02fdd794db85649ae1b0699ef5292547805 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 18:26:18 +0200 Subject: [PATCH 07/35] add dual-format normalizeContract bridge and domain validation normalizeContract() now detects old vs new (ADR 172) contract JSON format and enriches with the missing fields from the other format. Old-format inputs gain roots, domain fields (nullable, codecId), model.storage.fields and model.relations. New-format inputs gain model.fields[f].column and top-level relations. Integrate validateContractDomain after structural validation. Update arktype schemas to accept the widened model and contract shape. --- .../2-sql/1-core/contract/src/construct.ts | 5 +- .../2-sql/1-core/contract/src/validate.ts | 382 +++++++++++++++--- .../2-sql/1-core/contract/src/validators.ts | 21 +- .../1-core/contract/test/validate.test.ts | 224 ++++++++++ 4 files changed, 568 insertions(+), 64 deletions(-) diff --git a/packages/2-sql/1-core/contract/src/construct.ts b/packages/2-sql/1-core/contract/src/construct.ts index 4054cbf2c3..69b8869f54 100644 --- a/packages/2-sql/1-core/contract/src/construct.ts +++ b/packages/2-sql/1-core/contract/src/construct.ts @@ -169,9 +169,12 @@ export function constructContract>( const defaultMappings = computeDefaultMappings(input.models as Record); const mappings = mergeMappings(defaultMappings, existingMappings); + const stripped = stripGenerated(input); + const contractWithMappings = { - ...stripGenerated(input), + ...stripped, mappings, + roots: (stripped as Record)['roots'] ?? {}, }; return contractWithMappings as TContract; diff --git a/packages/2-sql/1-core/contract/src/validate.ts b/packages/2-sql/1-core/contract/src/validate.ts index ee10c60610..5ee6a803af 100644 --- a/packages/2-sql/1-core/contract/src/validate.ts +++ b/packages/2-sql/1-core/contract/src/validate.ts @@ -1,5 +1,6 @@ import type { ColumnDefaultLiteralInputValue } from '@prisma-next/contract/types'; import { isTaggedBigInt, isTaggedRaw } from '@prisma-next/contract/types'; +import { validateContractDomain } from '@prisma-next/contract/validate-domain'; import { constructContract } from './construct'; import type { SqlContract, SqlStorage, StorageColumn, StorageTable } from './types'; import { applyFkDefaults } from './types'; @@ -172,81 +173,338 @@ export function decodeContractDefaults>(contra } as T; } -export function normalizeContract(contract: unknown): SqlContract { - if (typeof contract !== 'object' || contract === null) { - return contract as SqlContract; +function normalizeStorage(contractObj: Record): Record { + const normalizedStorage = contractObj['storage']; + if (!normalizedStorage || typeof normalizedStorage !== 'object') + return normalizedStorage as Record; + + const storage = normalizedStorage as Record; + const tables = storage['tables'] as Record | undefined; + if (!tables) return storage; + + const normalizedTables: Record = {}; + for (const [tableName, table] of Object.entries(tables)) { + const tableObj = table as Record; + const columns = tableObj['columns'] as Record | undefined; + + if (columns) { + const normalizedColumns: Record = {}; + for (const [columnName, column] of Object.entries(columns)) { + const columnObj = column as Record; + normalizedColumns[columnName] = { + ...columnObj, + nullable: columnObj['nullable'] ?? false, + }; + } + + const rawForeignKeys = (tableObj['foreignKeys'] ?? []) as Array>; + const normalizedForeignKeys = rawForeignKeys.map((fk) => ({ + ...fk, + ...applyFkDefaults({ + constraint: typeof fk['constraint'] === 'boolean' ? fk['constraint'] : undefined, + index: typeof fk['index'] === 'boolean' ? fk['index'] : undefined, + }), + })); + + normalizedTables[tableName] = { + ...tableObj, + columns: normalizedColumns, + uniques: tableObj['uniques'] ?? [], + indexes: tableObj['indexes'] ?? [], + foreignKeys: normalizedForeignKeys, + }; + } else { + normalizedTables[tableName] = tableObj; + } } - const contractObj = contract as Record; + return { ...storage, tables: normalizedTables }; +} - let normalizedStorage = contractObj['storage']; - if (normalizedStorage && typeof normalizedStorage === 'object' && normalizedStorage !== null) { - const storage = normalizedStorage as Record; - const tables = storage['tables'] as Record | undefined; - - if (tables) { - const normalizedTables: Record = {}; - for (const [tableName, table] of Object.entries(tables)) { - const tableObj = table as Record; - const columns = tableObj['columns'] as Record | undefined; - - if (columns) { - const normalizedColumns: Record = {}; - for (const [columnName, column] of Object.entries(columns)) { - const columnObj = column as Record; - normalizedColumns[columnName] = { - ...columnObj, - nullable: columnObj['nullable'] ?? false, - }; - } - - // Normalize foreign keys: add constraint/index defaults if missing - const rawForeignKeys = (tableObj['foreignKeys'] ?? []) as Array>; - const normalizedForeignKeys = rawForeignKeys.map((fk) => ({ - ...fk, - ...applyFkDefaults({ - constraint: typeof fk['constraint'] === 'boolean' ? fk['constraint'] : undefined, - index: typeof fk['index'] === 'boolean' ? fk['index'] : undefined, - }), - })); - - normalizedTables[tableName] = { - ...tableObj, - columns: normalizedColumns, - uniques: tableObj['uniques'] ?? [], - indexes: tableObj['indexes'] ?? [], - foreignKeys: normalizedForeignKeys, - }; +type RawModel = Record; +type RawField = Record; +type RawRelation = Record; +type RawStorageObj = { tables: Record> }; + +function detectFormat(models: Record): 'old' | 'new' { + for (const model of Object.values(models)) { + const fields = model['fields'] as Record | undefined; + if (!fields) continue; + for (const field of Object.values(fields)) { + if ('column' in field) return 'old'; + if ('codecId' in field) return 'new'; + } + } + return 'old'; +} + +function buildColumnToFieldMap(fields: Record): Record { + const map: Record = {}; + for (const [fieldName, field] of Object.entries(fields)) { + const col = field['column'] as string | undefined; + if (col) map[col] = fieldName; + } + return map; +} + +function enrichOldFormatModels( + models: Record, + storageObj: RawStorageObj, + topRelations: Record>, +): { enrichedModels: Record; roots: Record } { + const roots: Record = {}; + const tableToModel: Record = {}; + + for (const [modelName, model] of Object.entries(models)) { + const modelStorage = model['storage'] as Record | undefined; + const tableName = modelStorage?.['table'] as string | undefined; + if (tableName) { + roots[modelName] = modelName; + tableToModel[tableName] = modelName; + } + } + + const enrichedModels: Record = {}; + + for (const [modelName, model] of Object.entries(models)) { + const fields = (model['fields'] ?? {}) as Record; + const modelStorage = model['storage'] as Record | undefined; + const tableName = modelStorage?.['table'] as string | undefined; + const storageTable = tableName + ? (storageObj.tables[tableName] as Record | undefined) + : undefined; + const storageColumns = (storageTable?.['columns'] ?? {}) as Record< + string, + Record + >; + + const enrichedFields: Record = {}; + const modelStorageFields: Record = {}; + + for (const [fieldName, field] of Object.entries(fields)) { + const colName = field['column'] as string; + const storageCol = storageColumns[colName]; + enrichedFields[fieldName] = { + ...field, + nullable: storageCol?.['nullable'] ?? false, + codecId: storageCol?.['codecId'] ?? '', + }; + modelStorageFields[fieldName] = { column: colName }; + } + + const enrichedStorage = { + ...(modelStorage ?? {}), + fields: modelStorageFields, + }; + + enrichedModels[modelName] = { + ...model, + fields: enrichedFields, + storage: enrichedStorage, + relations: model['relations'] ?? {}, + }; + } + + for (const [tableName, tableRels] of Object.entries(topRelations)) { + const modelName = tableToModel[tableName]; + if (!modelName) continue; + const existingModel = enrichedModels[modelName]; + if (!existingModel) continue; + + const existingRels = (existingModel['relations'] ?? {}) as Record; + const targetColumnToField: Record> = {}; + + const modelRelations: Record = { ...existingRels }; + for (const [relName, rel] of Object.entries(tableRels)) { + const on = rel['on'] as { childCols?: string[]; parentCols?: string[] } | undefined; + const parentCols = on?.['parentCols'] ?? []; + const childCols = on?.['childCols'] ?? []; + + const toModel = rel['to'] as string; + const sourceFields = (existingModel['fields'] ?? {}) as Record; + const sourceColToField = buildColumnToFieldMap(sourceFields); + + if (!targetColumnToField[toModel]) { + const targetModelObj = enrichedModels[toModel]; + if (targetModelObj) { + targetColumnToField[toModel] = buildColumnToFieldMap( + (targetModelObj['fields'] ?? {}) as Record, + ); } else { - normalizedTables[tableName] = tableObj; + targetColumnToField[toModel] = {}; } } + const targetColToField = targetColumnToField[toModel] ?? {}; - normalizedStorage = { - ...storage, - tables: normalizedTables, + const localFields = parentCols.map((c: string) => sourceColToField[c] ?? c); + const targetFields = childCols.map((c: string) => targetColToField[c] ?? c); + + modelRelations[relName] = { + to: toModel, + cardinality: rel['cardinality'], + strategy: 'reference', + on: { localFields, targetFields }, }; } + + enrichedModels[modelName] = { + ...existingModel, + relations: modelRelations, + }; + } + + return { enrichedModels, roots }; +} + +function enrichNewFormatModels(models: Record): { + enrichedModels: Record; + topRelations: Record>; +} { + const enrichedModels: Record = {}; + const topRelations: Record> = {}; + const modelToTable: Record = {}; + + for (const [modelName, model] of Object.entries(models)) { + const modelStorage = model['storage'] as Record | undefined; + const tableName = modelStorage?.['table'] as string | undefined; + if (tableName) modelToTable[modelName] = tableName; } - let normalizedModels = contractObj['models']; - if (normalizedModels && typeof normalizedModels === 'object' && normalizedModels !== null) { - const models = normalizedModels as Record; - const normalizedModelsObj: Record = {}; - for (const [modelName, model] of Object.entries(models)) { - const modelObj = model as Record; - normalizedModelsObj[modelName] = { - ...modelObj, - relations: modelObj['relations'] ?? {}, + for (const [modelName, model] of Object.entries(models)) { + const fields = (model['fields'] ?? {}) as Record; + const modelStorage = model['storage'] as Record | undefined; + const storageFields = (modelStorage?.['fields'] ?? {}) as Record< + string, + Record + >; + + const enrichedFields: Record = {}; + for (const [fieldName, field] of Object.entries(fields)) { + const sfEntry = storageFields[fieldName]; + const column = sfEntry?.['column'] as string | undefined; + enrichedFields[fieldName] = column ? { ...field, column } : { ...field }; + } + + enrichedModels[modelName] = { + ...model, + fields: enrichedFields, + relations: model['relations'] ?? {}, + }; + + const modelRels = (model['relations'] ?? {}) as Record; + const tableName = modelToTable[modelName]; + if (!tableName) continue; + + for (const [relName, rel] of Object.entries(modelRels)) { + const on = rel['on'] as { localFields?: string[]; targetFields?: string[] } | undefined; + if (!on) continue; + const toModel = rel['to'] as string; + const toTable = modelToTable[toModel]; + if (!toTable) continue; + + const sourceFields = enrichedFields; + const targetModelObj = models[toModel]; + const targetFields = (targetModelObj?.['fields'] ?? {}) as Record; + const targetStorageObj = targetModelObj?.['storage'] as Record | undefined; + const targetStorageFields = (targetStorageObj?.['fields'] ?? {}) as Record< + string, + Record + >; + + const parentCols = (on.localFields ?? []).map((f: string) => { + const sf = storageFields[f]; + return ( + (sf?.['column'] as string | undefined) ?? + (sourceFields[f]?.['column'] as string | undefined) ?? + f + ); + }); + + const childCols = (on.targetFields ?? []).map((f: string) => { + const tsf = targetStorageFields[f]; + return ( + (tsf?.['column'] as string | undefined) ?? + (targetFields[f]?.['column'] as string | undefined) ?? + f + ); + }); + + if (!topRelations[tableName]) topRelations[tableName] = {}; + topRelations[tableName][relName] = { + to: toModel, + cardinality: rel['cardinality'], + on: { parentCols, childCols }, }; } - normalizedModels = normalizedModelsObj; + } + + return { enrichedModels, topRelations }; +} + +export function normalizeContract(contract: unknown): SqlContract { + if (typeof contract !== 'object' || contract === null) { + return contract as SqlContract; + } + + const contractObj = contract as Record; + const normalizedStorage = normalizeStorage(contractObj); + + const rawModels = contractObj['models']; + if (!rawModels || typeof rawModels !== 'object' || rawModels === null) { + return { + ...contractObj, + roots: contractObj['roots'] ?? {}, + models: rawModels ?? {}, + relations: contractObj['relations'] ?? {}, + storage: normalizedStorage, + extensionPacks: contractObj['extensionPacks'] ?? {}, + capabilities: contractObj['capabilities'] ?? {}, + meta: contractObj['meta'] ?? {}, + sources: contractObj['sources'] ?? {}, + } as SqlContract; + } + + const modelsObj = rawModels as Record; + const format = detectFormat(modelsObj); + + let normalizedModels: Record; + let roots: Record; + let topRelations: Record>; + + if (format === 'new') { + const result = enrichNewFormatModels(modelsObj); + normalizedModels = result.enrichedModels; + topRelations = { + ...((contractObj['relations'] ?? {}) as Record>), + ...result.topRelations, + }; + roots = (contractObj['roots'] as Record) ?? {}; + } else { + const rawStorageObj = + normalizedStorage && typeof normalizedStorage === 'object' + ? (normalizedStorage as Record) + : {}; + const storageObj = { + tables: ((rawStorageObj as Record)['tables'] ?? {}) as Record< + string, + Record + >, + }; + const existingRelations = (contractObj['relations'] ?? {}) as Record< + string, + Record + >; + const result = enrichOldFormatModels(modelsObj, storageObj, existingRelations); + normalizedModels = result.enrichedModels; + roots = result.roots; + topRelations = existingRelations; } return { ...contractObj, + roots, models: normalizedModels, - relations: contractObj['relations'] ?? {}, + relations: topRelations, storage: normalizedStorage, extensionPacks: contractObj['extensionPacks'] ?? {}, capabilities: contractObj['capabilities'] ?? {}, @@ -259,7 +517,17 @@ export function validateContract>( value: unknown, ): TContract { const normalized = normalizeContract(value); + const structurallyValid = validateSqlContract>(normalized); + + validateContractDomain({ + roots: structurallyValid.roots as Record, + models: structurallyValid.models as Record< + string, + { fields: Record; relations: Record } + >, + }); + validateContractLogic(structurallyValid); const semanticErrors = validateStorageSemantics(structurallyValid.storage); diff --git a/packages/2-sql/1-core/contract/src/validators.ts b/packages/2-sql/1-core/contract/src/validators.ts index d5845cb656..704435f1e4 100644 --- a/packages/2-sql/1-core/contract/src/validators.ts +++ b/packages/2-sql/1-core/contract/src/validators.ts @@ -3,8 +3,6 @@ import type { ForeignKey, ForeignKeyReferences, ModelDefinition, - ModelField, - ModelStorage, PrimaryKey, ReferentialAction, SqlContract, @@ -135,18 +133,28 @@ const StorageSchema = type({ 'types?': type({ '[string]': StorageTypeInstanceSchema }), }); -const ModelFieldSchema = type.declare().type({ +const ModelFieldSchema = type({ + 'column?': 'string', + 'nullable?': 'boolean', + 'codecId?': 'string', +}); + +const ModelStorageFieldSchema = type({ column: 'string', }); -const ModelStorageSchema = type.declare().type({ +const ModelStorageSchema = type({ table: 'string', + 'fields?': type({ '[string]': ModelStorageFieldSchema }), }); -const ModelSchema = type.declare().type({ +const ModelSchema = type({ storage: ModelStorageSchema, fields: type({ '[string]': ModelFieldSchema }), relations: type({ '[string]': 'unknown' }), + 'discriminator?': 'unknown', + 'variants?': 'unknown', + 'base?': 'string', }); const MappingsSchema = type({ @@ -178,6 +186,7 @@ const SqlContractSchema = type({ 'meta?': ContractMetaSchema, 'sources?': 'Record', 'relations?': type({ '[string]': 'unknown' }), + 'roots?': 'Record', 'mappings?': MappingsSchema, models: type({ '[string]': ModelSchema }), storage: StorageSchema, @@ -220,7 +229,7 @@ export function validateModel(value: unknown): ModelDefinition { const messages = result.map((p: { message: string }) => p.message).join('; '); throw new Error(`Model validation failed: ${messages}`); } - return result; + return result as ModelDefinition; } /** diff --git a/packages/2-sql/1-core/contract/test/validate.test.ts b/packages/2-sql/1-core/contract/test/validate.test.ts index 0e27d50a47..f7f3ef0172 100644 --- a/packages/2-sql/1-core/contract/test/validate.test.ts +++ b/packages/2-sql/1-core/contract/test/validate.test.ts @@ -766,4 +766,228 @@ describe('validateContract', () => { /NOT NULL but has a literal null default/, ); }); + + describe('dual-format bridge', () => { + const oldFormatContract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:bridge-test', + models: { + User: { + storage: { table: 'user' }, + fields: { + id: { column: 'id' }, + email: { column: 'email' }, + }, + relations: {}, + }, + Post: { + storage: { table: 'post' }, + fields: { + id: { column: 'id' }, + userId: { column: 'user_id' }, + }, + relations: {}, + }, + }, + relations: { + post: { + author: { + cardinality: 'N:1', + on: { parentCols: ['user_id'], childCols: ['id'] }, + to: 'User', + }, + }, + user: { + posts: { + cardinality: '1:N', + on: { parentCols: ['id'], childCols: ['user_id'] }, + to: 'Post', + }, + }, + }, + storage: { + tables: { + user: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + email: { nativeType: 'text', codecId: 'pg/text@1', nullable: true }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + post: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + user_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [ + { + columns: ['user_id'], + references: { table: 'user', columns: ['id'] }, + constraint: true, + index: true, + }, + ], + }, + }, + }, + }; + + it('old format enriches with domain fields (nullable, codecId, roots, storage.fields, relations)', () => { + const result = validateContract>(oldFormatContract); + + expect(result.roots).toEqual({ User: 'User', Post: 'Post' }); + + const userModel = result.models.User as Record; + const userFields = userModel['fields'] as Record>; + expect(userFields['email']['nullable']).toBe(true); + expect(userFields['email']['codecId']).toBe('pg/text@1'); + expect(userFields['email']['column']).toBe('email'); + + const userStorage = userModel['storage'] as Record; + const userStorageFields = userStorage['fields'] as Record>; + expect(userStorageFields['id']).toEqual({ column: 'id' }); + expect(userStorageFields['email']).toEqual({ column: 'email' }); + + const userRels = userModel['relations'] as Record>; + expect(userRels['posts']).toEqual({ + to: 'Post', + cardinality: '1:N', + strategy: 'reference', + on: { localFields: ['id'], targetFields: ['userId'] }, + }); + }); + + it('old format preserves original mappings and top-level relations', () => { + const result = validateContract>(oldFormatContract); + + expect(result.mappings.modelToTable).toEqual({ User: 'user', Post: 'post' }); + expect(result.mappings.tableToModel).toEqual({ user: 'User', post: 'Post' }); + + const relations = result.relations as Record>; + expect(relations['post']['author']).toBeDefined(); + expect(relations['user']['posts']).toBeDefined(); + }); + + const newFormatContract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:bridge-test-new', + roots: { User: 'User', Post: 'Post' }, + models: { + User: { + storage: { + table: 'user', + fields: { + id: { column: 'id' }, + email: { column: 'email' }, + }, + }, + fields: { + id: { nullable: false, codecId: 'pg/int4@1' }, + email: { nullable: true, codecId: 'pg/text@1' }, + }, + relations: { + posts: { + to: 'Post', + cardinality: '1:N', + strategy: 'reference', + on: { localFields: ['id'], targetFields: ['userId'] }, + }, + }, + }, + Post: { + storage: { + table: 'post', + fields: { + id: { column: 'id' }, + userId: { column: 'user_id' }, + }, + }, + fields: { + id: { nullable: false, codecId: 'pg/int4@1' }, + userId: { nullable: false, codecId: 'pg/int4@1' }, + }, + relations: { + author: { + to: 'User', + cardinality: 'N:1', + strategy: 'reference', + on: { localFields: ['userId'], targetFields: ['id'] }, + }, + }, + }, + }, + storage: { + tables: { + user: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + email: { nativeType: 'text', codecId: 'pg/text@1', nullable: true }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + post: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + user_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [ + { + columns: ['user_id'], + references: { table: 'user', columns: ['id'] }, + constraint: true, + index: true, + }, + ], + }, + }, + }, + }; + + it('new format derives old fields (column on model fields, top-level relations)', () => { + const result = validateContract>(newFormatContract); + + const postModel = result.models.Post as Record; + const postFields = postModel['fields'] as Record>; + expect(postFields['userId']['column']).toBe('user_id'); + expect(postFields['id']['column']).toBe('id'); + + const relations = result.relations as Record>; + const postRels = relations['post'] as Record>; + expect(postRels['author']['to']).toBe('User'); + expect(postRels['author']['cardinality']).toBe('N:1'); + expect(postRels['author']['on']).toEqual({ + parentCols: ['user_id'], + childCols: ['id'], + }); + }); + + it('new format preserves roots and domain-level model fields', () => { + const result = validateContract>(newFormatContract); + + expect(result.roots).toEqual({ User: 'User', Post: 'Post' }); + + const userModel = result.models.User as Record; + const userFields = userModel['fields'] as Record>; + expect(userFields['email']['nullable']).toBe(true); + expect(userFields['email']['codecId']).toBe('pg/text@1'); + + expect(result.mappings.modelToTable).toEqual({ User: 'user', Post: 'post' }); + }); + }); }); From 9dc0f841224780245d0d0bff3bcc67048d05cc59 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 18:35:10 +0200 Subject: [PATCH 08/35] update emitter to produce ADR 172 contract structure ContractIR gains optional roots field. emit() includes roots in the canonical contract and makes relations optional. The SQL emitter hook now generates model fields with { column, nullable, codecId } literal types, emits model.storage.fields and model.relations in the domain format, and adds roots to the ContractBase type. --- .../control-plane/src/emission/emit.ts | 10 ++- .../1-core/shared/contract/src/ir.ts | 4 +- packages/2-sql/3-tooling/emitter/src/index.ts | 81 +++++++++++-------- .../emitter-hook.generation.advanced.test.ts | 53 +++++++----- .../emitter-hook.generation.basic.test.ts | 42 ++++++---- .../emitter-hook.parameterized-types.test.ts | 58 ++++++++----- 6 files changed, 154 insertions(+), 94 deletions(-) diff --git a/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts b/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts index 8d6f895ce9..c3dd0a4304 100644 --- a/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts +++ b/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts @@ -17,7 +17,8 @@ const CanonicalContractSchema = type({ targetFamily: 'string', target: 'string', models: type({ '[string]': 'unknown' }), - relations: type({ '[string]': 'unknown' }), + 'relations?': type({ '[string]': 'unknown' }), + 'roots?': 'Record', storage: type({ '[string]': 'unknown' }), 'execution?': type({ '[string]': 'unknown' }), extensionPacks: type({ '[string]': 'unknown' }), @@ -58,8 +59,8 @@ function validateCoreStructure(ir: ContractIR): void { if (!ir.storage || typeof ir.storage !== 'object') { throw new Error('ContractIR must have storage'); } - if (!ir.relations || typeof ir.relations !== 'object') { - throw new Error('ContractIR must have relations'); + if (ir.relations !== undefined && typeof ir.relations !== 'object') { + throw new Error('ContractIR relations must be an object when provided'); } if (!ir.extensionPacks || typeof ir.extensionPacks !== 'object') { throw new Error('ContractIR must have extensionPacks'); @@ -103,8 +104,9 @@ export async function emit( schemaVersion: ir.schemaVersion, targetFamily: ir.targetFamily, target: ir.target, + ...ifDefined('roots', ir.roots), models: ir.models, - relations: ir.relations, + ...ifDefined('relations', ir.relations), storage: ir.storage, ...ifDefined('execution', ir.execution), extensionPacks: ir.extensionPacks, diff --git a/packages/1-framework/1-core/shared/contract/src/ir.ts b/packages/1-framework/1-core/shared/contract/src/ir.ts index a7ac8196c0..199b14c77e 100644 --- a/packages/1-framework/1-core/shared/contract/src/ir.ts +++ b/packages/1-framework/1-core/shared/contract/src/ir.ts @@ -24,6 +24,7 @@ export interface ContractIR< readonly schemaVersion: string; readonly targetFamily: string; readonly target: string; + readonly roots?: Record; readonly models: TModels; readonly relations: TRelations; readonly storage: TStorage; @@ -110,17 +111,18 @@ export function contractIR< readonly meta: Record; readonly sources: Record; }; + roots?: Record; storage: TStorage; models: TModels; relations: TRelations; execution?: TExecution; }): ContractIR { - // ContractIR doesn't include storageHash/executionHash or profileHash (those are computed by emitter) return { schemaVersion: opts.header.schemaVersion, target: opts.header.target, targetFamily: opts.header.targetFamily, ...opts.meta, + ...ifDefined('roots', opts.roots), storage: opts.storage, models: opts.models, relations: opts.relations, diff --git a/packages/2-sql/3-tooling/emitter/src/index.ts b/packages/2-sql/3-tooling/emitter/src/index.ts index ecb8e4da53..818c381049 100644 --- a/packages/2-sql/3-tooling/emitter/src/index.ts +++ b/packages/2-sql/3-tooling/emitter/src/index.ts @@ -297,6 +297,7 @@ export const sqlTargetFamilyHook = { const modelsType = this.generateModelsType(models, storage, parameterizedRenderers); const relationsType = this.generateRelationsType(ir.relations); const mappingsType = this.generateMappingsType(models, storage); + const rootsType = this.generateRootsType(ir.roots); const executionHashType = hashes.executionHash ? `ExecutionHashBase<'${hashes.executionHash}'>` @@ -348,6 +349,7 @@ export const sqlTargetFamilyHook = { ProfileHash > & { readonly target: ${this.serializeValue(ir.target)}; + readonly roots: ${rootsType}; readonly capabilities: ${this.serializeValue(ir.capabilities)}; readonly extensionPacks: ${this.serializeValue(ir.extensionPacks)}; readonly execution: ${this.serializeValue(ir.execution)}; @@ -361,6 +363,16 @@ export const sqlTargetFamilyHook = { `; }, + generateRootsType(roots: Record | undefined): string { + if (!roots || Object.keys(roots).length === 0) { + return 'Record'; + } + const entries = Object.entries(roots) + .map(([key, value]) => `readonly ${key}: '${value}'`) + .join('; '); + return `{ ${entries} }`; + }, + generateStorageType(storage: SqlStorage): string { const tables: string[] = []; for (const [tableName, table] of Object.entries(storage.tables)) { @@ -522,59 +534,58 @@ export const sqlTargetFamilyHook = { return 'Record'; } - const renderCtx: TypeRenderContext = { codecTypesName: 'CodecTypes' }; - const modelTypes: string[] = []; for (const [modelName, model] of Object.entries(models)) { const fields: string[] = []; + const storageFieldParts: string[] = []; const tableName = model.storage.table; const table = storage.tables[tableName]; - if (table) { - for (const [fieldName, field] of Object.entries(model.fields)) { - const column = table.columns[field.column]; - if (!column) { - fields.push(`readonly ${fieldName}: { readonly column: '${field.column}' }`); - continue; - } + for (const [fieldName, field] of Object.entries(model.fields)) { + const colName = field.column; + const column = table?.columns[colName]; + const nullable = column?.nullable ?? false; + const codecId = column?.codecId ?? ''; + fields.push( + `readonly ${fieldName}: { readonly column: '${colName}'; readonly nullable: ${nullable}; readonly codecId: '${codecId}' }`, + ); + storageFieldParts.push(`readonly ${fieldName}: { readonly column: '${colName}' }`); + } - const jsType = this.generateColumnType( - column, - storage, - parameterizedRenderers, - renderCtx, + const relations: string[] = []; + const modelRels = model.relations as Record; + for (const [relName, rel] of Object.entries(modelRels)) { + if (typeof rel !== 'object' || rel === null) continue; + const relObj = rel as Record; + const relParts: string[] = []; + if (relObj['to']) relParts.push(`readonly to: '${relObj['to']}'`); + if (relObj['cardinality']) + relParts.push(`readonly cardinality: '${relObj['cardinality']}'`); + if (relObj['strategy']) relParts.push(`readonly strategy: '${relObj['strategy']}'`); + const on = relObj['on'] as { localFields?: string[]; targetFields?: string[] } | undefined; + if (on?.localFields && on.targetFields) { + const localFields = on.localFields.map((f) => `'${f}'`).join(', '); + const targetFields = on.targetFields.map((f) => `'${f}'`).join(', '); + relParts.push( + `readonly on: { readonly localFields: readonly [${localFields}]; readonly targetFields: readonly [${targetFields}] }`, ); - fields.push(`readonly ${fieldName}: ${jsType}`); } - } else { - for (const [fieldName, field] of Object.entries(model.fields)) { - fields.push(`readonly ${fieldName}: { readonly column: '${field.column}' }`); + if (relParts.length > 0) { + relations.push(`readonly ${relName}: { ${relParts.join('; ')} }`); } } - const relations: string[] = []; - for (const [relName, rel] of Object.entries(model.relations)) { - if (typeof rel === 'object' && rel !== null && 'on' in rel) { - const on = rel.on as { parentCols?: string[]; childCols?: string[] }; - if (on.parentCols && on.childCols) { - const parentCols = on.parentCols.map((c) => `'${c}'`).join(', '); - const childCols = on.childCols.map((c) => `'${c}'`).join(', '); - relations.push( - `readonly ${relName}: { readonly on: { readonly parentCols: readonly [${parentCols}]; readonly childCols: readonly [${childCols}] } }`, - ); - } - } + const storageParts = [`readonly table: '${tableName}'`]; + if (storageFieldParts.length > 0) { + storageParts.push(`readonly fields: { ${storageFieldParts.join('; ')} }`); } const modelParts: string[] = [ - `storage: { readonly table: '${tableName}' }`, + `storage: { ${storageParts.join('; ')} }`, `fields: { ${fields.join('; ')} }`, + `relations: { ${relations.join('; ')} }`, ]; - if (relations.length > 0) { - modelParts.push(`relations: { ${relations.join('; ')} }`); - } - modelTypes.push(`readonly ${modelName}: { ${modelParts.join('; ')} }`); } diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts index 043cbec102..228bcbc02e 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts @@ -46,9 +46,12 @@ describe('sql-target-family-hook', () => { }, relations: { posts: { + to: 'Post', + cardinality: '1:N', + strategy: 'reference', on: { - parentCols: ['id'], - childCols: ['userId'], + localFields: ['id'], + targetFields: ['userId'], }, }, }, @@ -90,7 +93,7 @@ describe('sql-target-family-hook', () => { const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); expect(types).toContain('relations: {'); expect(types).toContain( - "readonly posts: { readonly on: { readonly parentCols: readonly ['id']; readonly childCols: readonly ['userId'] } }", + "readonly posts: { readonly to: 'Post'; readonly cardinality: '1:N'; readonly strategy: 'reference'; readonly on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['userId'] } }", ); }); @@ -354,15 +357,21 @@ describe('sql-target-family-hook', () => { }, relations: { posts: { + to: 'Post', + cardinality: '1:N', + strategy: 'reference', on: { - parentCols: ['id'], - childCols: ['userId'], + localFields: ['id'], + targetFields: ['userId'], }, }, comments: { + to: 'Comment', + cardinality: '1:N', + strategy: 'reference', on: { - parentCols: ['id'], - childCols: ['authorId'], + localFields: ['id'], + targetFields: ['authorId'], }, }, }, @@ -403,11 +412,12 @@ describe('sql-target-family-hook', () => { const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); expect(types).toContain('export type Relations'); - // Relations type is table-based, not model-based - // The test data doesn't include ir.relations, so Relations will be Record - // But we can verify that relations are embedded in the Models type - expect(types).toContain('readonly posts: { readonly on:'); - expect(types).toContain('readonly comments: { readonly on:'); + expect(types).toContain( + "readonly posts: { readonly to: 'Post'; readonly cardinality: '1:N'; readonly strategy: 'reference'; readonly on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['userId'] } }", + ); + expect(types).toContain( + "readonly comments: { readonly to: 'Comment'; readonly cardinality: '1:N'; readonly strategy: 'reference'; readonly on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['authorId'] } }", + ); }); it('generates relations type as empty when no relations', () => { @@ -561,10 +571,8 @@ describe('sql-target-family-hook', () => { storage: { table: 'user' }, fields: {}, relations: { - // Missing 'on' - invalidRel1: { to: 'Post' }, - // Missing 'parentCols' - invalidRel2: { on: { childCols: ['id'] } }, + partialRel: { to: 'Post' }, + invalidRel: { on: { childCols: ['id'] } }, }, }, }, @@ -581,9 +589,8 @@ describe('sql-target-family-hook', () => { }); const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - // Should run without error and not generate relations output for invalid ones - expect(types).not.toContain('invalidRel1'); - expect(types).not.toContain('invalidRel2'); + expect(types).toContain("readonly partialRel: { readonly to: 'Post' }"); + expect(types).not.toContain('invalidRel'); }); it('generates mappings type with models that have no fields', () => { @@ -761,7 +768,9 @@ describe('sql-target-family-hook', () => { parameterizedRenderers, }); - expect(types).toContain("CodecTypes['pg/vector@1']['output'] & { length: 1536 }"); + expect(types).toContain( + "readonly vector: { readonly column: 'vector'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + ); }); it('renders column type using typeRef with parameterized renderer', () => { @@ -815,6 +824,8 @@ describe('sql-target-family-hook', () => { parameterizedRenderers, }); - expect(types).toContain("CodecTypes['pg/vector@1']['output'] & { length: 1536 }"); + expect(types).toContain( + "readonly vector: { readonly column: 'vector'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + ); }); }); diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts index 7ad0a832f0..f151a6dc0a 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts @@ -65,6 +65,7 @@ describe('sql-target-family-hook', () => { const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); expect(types).toContain('export type Contract'); expect(types).toContain('CodecTypes'); + expect(types).toContain('readonly roots:'); }); describe('Contract and TypeMaps shape', () => { @@ -584,8 +585,12 @@ describe('sql-target-family-hook', () => { }); const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - expect(types).toContain("readonly name: CodecTypes['pg/text@1']['output'] | null"); - expect(types).toContain("readonly email: CodecTypes['pg/text@1']['output']"); + expect(types).toContain( + "readonly name: { readonly column: 'name'; readonly nullable: true; readonly codecId: 'pg/text@1' }", + ); + expect(types).toContain( + "readonly email: { readonly column: 'email'; readonly nullable: false; readonly codecId: 'pg/text@1' }", + ); }); it('generates contract types with model field missing column reference', () => { @@ -617,7 +622,9 @@ describe('sql-target-family-hook', () => { }); const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - expect(types).toContain("readonly email: { readonly column: 'nonexistent' }"); + expect(types).toContain( + "readonly email: { readonly column: 'nonexistent'; readonly nullable: false; readonly codecId: '' }", + ); }); it('generates contract types with model referencing missing table', () => { @@ -647,7 +654,9 @@ describe('sql-target-family-hook', () => { }); const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - expect(types).toContain("readonly id: { readonly column: 'id' }"); + expect(types).toContain( + "readonly id: { readonly column: 'id'; readonly nullable: false; readonly codecId: '' }", + ); }); it('generates contract types with undefined models', () => { @@ -704,9 +713,12 @@ describe('sql-target-family-hook', () => { }); const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - // When nullable is undefined, it should default to false (not nullable) - expect(types).toContain("readonly id: CodecTypes['pg/int4@1']['output']"); - expect(types).not.toContain("readonly id: CodecTypes['pg/int4@1']['output'] | null"); + expect(types).toContain( + "readonly id: { readonly column: 'id'; readonly nullable: false; readonly codecId: 'pg/int4@1' }", + ); + expect(types).not.toContain( + "readonly id: { readonly column: 'id'; readonly nullable: true; readonly codecId: 'pg/int4@1' }", + ); }); it('renders parameterized type when column has typeParams and renderer exists', () => { @@ -754,10 +766,12 @@ describe('sql-target-family-hook', () => { parameterizedRenderers, }); - // The parameterized renderer should be used for the vector column - expect(types).toContain('readonly vector: Vector<1536>'); - // The scalar codec should still use CodecTypes lookup - expect(types).toContain("readonly id: CodecTypes['pg/int4@1']['output']"); + expect(types).toContain( + "readonly vector: { readonly column: 'vector'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + ); + expect(types).toContain( + "readonly id: { readonly column: 'id'; readonly nullable: false; readonly codecId: 'pg/int4@1' }", + ); }); it('falls back to CodecTypes when column has typeParams but no renderer', () => { @@ -790,11 +804,11 @@ describe('sql-target-family-hook', () => { }, }); - // No parameterized renderers provided const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - // Should fall back to CodecTypes lookup since no renderer exists - expect(types).toContain("readonly vector: CodecTypes['pg/vector@1']['output']"); + expect(types).toContain( + "readonly vector: { readonly column: 'vector'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + ); expect(types).not.toContain('Vector<1536>'); }); diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.parameterized-types.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.parameterized-types.test.ts index 2106c67d3e..2351a67da7 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.parameterized-types.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.parameterized-types.test.ts @@ -68,8 +68,9 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - // Should use the parameterized renderer for the vector column - expect(types).toContain('readonly embedding: Vector<1536>'); + expect(types).toContain( + "readonly embedding: { readonly column: 'embedding'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + ); }); it('falls back to CodecTypes[codecId].output for columns without typeParams', () => { @@ -110,8 +111,9 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - // int4 column should use the standard codec types lookup - expect(types).toContain("readonly id: CodecTypes['pg/int4@1']['output']"); + expect(types).toContain( + "readonly id: { readonly column: 'id'; readonly nullable: false; readonly codecId: 'pg/int4@1' }", + ); }); it('emits nullable parameterized type with | null suffix', () => { @@ -157,7 +159,9 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain('readonly embedding: Vector<1536> | null'); + expect(types).toContain( + "readonly embedding: { readonly column: 'embedding'; readonly nullable: true; readonly codecId: 'pg/vector@1' }", + ); }); it('uses custom renderer logic for complex type generation', () => { @@ -207,7 +211,9 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain('readonly value: Decimal<10, 2>'); + expect(types).toContain( + "readonly value: { readonly column: 'value'; readonly nullable: false; readonly codecId: 'pg/decimal@1' }", + ); }); }); @@ -262,7 +268,9 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain("readonly role: 'USER' | 'ADMIN' | 'MODERATOR'"); + expect(types).toContain( + "readonly role: { readonly column: 'role'; readonly nullable: false; readonly codecId: 'pg/enum@1' }", + ); }); }); @@ -317,7 +325,9 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain('readonly embedding: Vector<1536>'); + expect(types).toContain( + "readonly embedding: { readonly column: 'embedding'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + ); }); }); @@ -386,10 +396,15 @@ describe('sql-target-family-hook parameterized type emission', () => { // Output should be identical expect(types1).toBe(types2); - // Model fields should follow the model.fields order (embedding1, embedding2, embedding3) - expect(types1).toContain('readonly embedding1: Vector<1536>'); - expect(types1).toContain('readonly embedding2: Vector<384>'); - expect(types1).toContain('readonly embedding3: Vector<768>'); + expect(types1).toContain( + "readonly embedding1: { readonly column: 'embedding1'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + ); + expect(types1).toContain( + "readonly embedding2: { readonly column: 'embedding2'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + ); + expect(types1).toContain( + "readonly embedding3: { readonly column: 'embedding3'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + ); }); }); @@ -432,8 +447,9 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - // Should fall back to standard codec types lookup - expect(types).toContain("readonly value: CodecTypes['custom/type@1']['output']"); + expect(types).toContain( + "readonly value: { readonly column: 'value'; readonly nullable: false; readonly codecId: 'custom/type@1' }", + ); }); it('handles typeRef pointing to non-existent storage.types entry gracefully', () => { @@ -480,8 +496,9 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - // Should fall back to standard codec types lookup when typeRef doesn't resolve - expect(types).toContain("readonly value: CodecTypes['pg/vector@1']['output']"); + expect(types).toContain( + "readonly value: { readonly column: 'value'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + ); }); it('handles empty typeParams object by falling back to standard lookup', () => { @@ -527,8 +544,9 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - // Empty typeParams means "no params" - fall back to standard codec lookup - expect(types).toContain("readonly value: CodecTypes['pg/vector@1']['output']"); + expect(types).toContain( + "readonly value: { readonly column: 'value'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + ); }); it('works without options parameter (backwards compatibility)', () => { @@ -560,7 +578,9 @@ describe('sql-target-family-hook parameterized type emission', () => { // Call without options (4th parameter) const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - expect(types).toContain("readonly id: CodecTypes['pg/int4@1']['output']"); + expect(types).toContain( + "readonly id: { readonly column: 'id'; readonly nullable: false; readonly codecId: 'pg/int4@1' }", + ); }); }); From 02da4ea7629fc8eda2e8fcda47bdbb9b7208016f Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 18:58:14 +0200 Subject: [PATCH 09/35] update emitter output fixtures and canonicalization for ADR 172 Include roots in canonicalized contract JSON output. Update parity expected contracts, demo contract/types, JSON schema, and round-trip tests to reflect the enriched ADR 172 format (roots, model.storage.fields, model.fields.codecId, domain-format model.relations). Relations are now optional in the framework emitter since document contracts may not have top-level relations. --- .../prisma-next-demo/src/prisma/contract.d.ts | 98 ++++++++++++++++--- .../prisma-next-demo/src/prisma/contract.json | 84 ++++++++++++++-- .../src/emission/canonicalization.ts | 12 ++- .../3-tooling/emitter/test/emitter.test.ts | 21 +--- .../schemas/data-contract-sql-v1.json | 80 ++++++++++++--- .../core-surface/expected.contract.json | 71 ++++++++++++-- .../default-cuid-2/expected.contract.json | 14 ++- .../expected.contract.json | 14 ++- .../default-nanoid-16/expected.contract.json | 14 ++- .../default-nanoid/expected.contract.json | 14 ++- .../expected.contract.json | 14 ++- .../default-ulid/expected.contract.json | 14 ++- .../default-uuid-v4/expected.contract.json | 14 ++- .../default-uuid-v7/expected.contract.json | 14 ++- .../map-attributes/expected.contract.json | 37 ++++++- .../expected.contract.json | 18 +++- .../expected.contract.json | 49 +++++++++- 17 files changed, 487 insertions(+), 95 deletions(-) diff --git a/examples/prisma-next-demo/src/prisma/contract.d.ts b/examples/prisma-next-demo/src/prisma/contract.d.ts index 6df3613f88..a4d128329d 100644 --- a/examples/prisma-next-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo/src/prisma/contract.d.ts @@ -142,22 +142,97 @@ type ContractBase = SqlContract< }, { readonly Post: { - storage: { readonly table: 'post' }; + storage: { + readonly table: 'post'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly title: { readonly column: 'title' }; + readonly userId: { readonly column: 'userId' }; + readonly createdAt: { readonly column: 'createdAt' }; + readonly embedding: { readonly column: 'embedding' }; + }; + }; fields: { - readonly id: Char<36>; - readonly title: CodecTypes['pg/text@1']['output']; - readonly userId: CodecTypes['pg/text@1']['output']; - readonly createdAt: CodecTypes['pg/timestamptz@1']['output']; - readonly embedding: Vector<1536> | null; + readonly id: { + readonly column: 'id'; + readonly nullable: false; + readonly codecId: 'sql/char@1'; + }; + readonly title: { + readonly column: 'title'; + readonly nullable: false; + readonly codecId: 'pg/text@1'; + }; + readonly userId: { + readonly column: 'userId'; + readonly nullable: false; + readonly codecId: 'pg/text@1'; + }; + readonly createdAt: { + readonly column: 'createdAt'; + readonly nullable: false; + readonly codecId: 'pg/timestamptz@1'; + }; + readonly embedding: { + readonly column: 'embedding'; + readonly nullable: true; + readonly codecId: 'pg/vector@1'; + }; + }; + relations: { + readonly user: { + readonly to: 'User'; + readonly cardinality: 'N:1'; + readonly strategy: 'reference'; + readonly on: { + readonly localFields: readonly ['userId']; + readonly targetFields: readonly ['id']; + }; + }; }; }; readonly User: { - storage: { readonly table: 'user' }; + storage: { + readonly table: 'user'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly email: { readonly column: 'email' }; + readonly createdAt: { readonly column: 'createdAt' }; + readonly kind: { readonly column: 'kind' }; + }; + }; fields: { - readonly id: Char<36>; - readonly email: CodecTypes['pg/text@1']['output']; - readonly createdAt: CodecTypes['pg/timestamptz@1']['output']; - readonly kind: 'admin' | 'user'; + readonly id: { + readonly column: 'id'; + readonly nullable: false; + readonly codecId: 'sql/char@1'; + }; + readonly email: { + readonly column: 'email'; + readonly nullable: false; + readonly codecId: 'pg/text@1'; + }; + readonly createdAt: { + readonly column: 'createdAt'; + readonly nullable: false; + readonly codecId: 'pg/timestamptz@1'; + }; + readonly kind: { + readonly column: 'kind'; + readonly nullable: false; + readonly codecId: 'pg/enum@1'; + }; + }; + relations: { + readonly posts: { + readonly to: 'Post'; + readonly cardinality: '1:N'; + readonly strategy: 'reference'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['userId']; + }; + }; }; }; }, @@ -222,6 +297,7 @@ type ContractBase = SqlContract< ProfileHash > & { readonly target: 'postgres'; + readonly roots: { readonly Post: 'Post'; readonly User: 'User' }; readonly capabilities: { readonly postgres: { readonly jsonAgg: true; diff --git a/examples/prisma-next-demo/src/prisma/contract.json b/examples/prisma-next-demo/src/prisma/contract.json index 67b4bce153..a6db78c6e2 100644 --- a/examples/prisma-next-demo/src/prisma/contract.json +++ b/examples/prisma-next-demo/src/prisma/contract.json @@ -4,48 +4,121 @@ "target": "postgres", "storageHash": "sha256:43f728c37e9b8f369b2b8acefa387906afd4555646a08528254eceee247342d7", "executionHash": "sha256:630618d96f7674c186a027d1295bfc5d688c4168c5a023a1aea01553820387dc", - "profileHash": "sha256:ea5c6635c0c0bd71badced0f3ee8ba912cf72dc836ae165cd533dc8f68cbfc9f", + "profileHash": "sha256:83d66b1cce776c9ec9e6d168086e5bd1030ccf461823b9eef39cf49f1833c6dd", + "roots": { + "Post": "Post", + "User": "User" + }, "models": { "Post": { "fields": { "createdAt": { + "codecId": "pg/timestamptz@1", "column": "createdAt" }, "embedding": { - "column": "embedding" + "codecId": "pg/vector@1", + "column": "embedding", + "nullable": true }, "id": { + "codecId": "sql/char@1", "column": "id" }, "title": { + "codecId": "pg/text@1", "column": "title" }, "userId": { + "codecId": "pg/text@1", "column": "userId" } }, - "relations": {}, + "relations": { + "user": { + "cardinality": "N:1", + "on": { + "localFields": [ + "userId" + ], + "targetFields": [ + "id" + ] + }, + "strategy": "reference", + "to": "User" + } + }, "storage": { + "fields": { + "createdAt": { + "column": "createdAt" + }, + "embedding": { + "column": "embedding" + }, + "id": { + "column": "id" + }, + "title": { + "column": "title" + }, + "userId": { + "column": "userId" + } + }, "table": "post" } }, "User": { "fields": { "createdAt": { + "codecId": "pg/timestamptz@1", "column": "createdAt" }, "email": { + "codecId": "pg/text@1", "column": "email" }, "id": { + "codecId": "sql/char@1", "column": "id" }, "kind": { + "codecId": "pg/enum@1", "column": "kind" } }, - "relations": {}, + "relations": { + "posts": { + "cardinality": "1:N", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "userId" + ] + }, + "strategy": "reference", + "to": "Post" + } + }, "storage": { + "fields": { + "createdAt": { + "column": "createdAt" + }, + "email": { + "column": "email" + }, + "id": { + "column": "id" + }, + "kind": { + "column": "kind" + } + }, "table": "user" } } @@ -232,8 +305,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": { diff --git a/packages/1-framework/1-core/migration/control-plane/src/emission/canonicalization.ts b/packages/1-framework/1-core/migration/control-plane/src/emission/canonicalization.ts index 3de34491f7..b0ea21e581 100644 --- a/packages/1-framework/1-core/migration/control-plane/src/emission/canonicalization.ts +++ b/packages/1-framework/1-core/migration/control-plane/src/emission/canonicalization.ts @@ -9,8 +9,9 @@ type NormalizedContract = { storageHash?: string; executionHash?: string; profileHash?: string; + roots?: Record; models: Record; - relations: Record; + relations?: Record; storage: Record; execution?: Record; extensionPacks: Record; @@ -22,8 +23,9 @@ export type CanonicalContractInput = { schemaVersion: string; targetFamily: string; target: string; + roots?: Record; models: Record; - relations: Record; + relations?: Record; storage: Record; execution?: Record; extensionPacks: Record; @@ -42,6 +44,7 @@ const TOP_LEVEL_ORDER = [ 'storageHash', 'executionHash', 'profileHash', + 'roots', 'models', 'relations', 'storage', @@ -105,6 +108,7 @@ function omitDefaults(obj: unknown, path: readonly string[]): unknown { const isRequiredModels = isArrayEqual(currentPath, ['models']); const isRequiredTables = isArrayEqual(currentPath, ['storage', 'tables']); const isRequiredRelations = isArrayEqual(currentPath, ['relations']); + const isRequiredRoots = isArrayEqual(currentPath, ['roots']); const isRequiredExtensionPacks = isArrayEqual(currentPath, ['extensionPacks']); const isRequiredCapabilities = isArrayEqual(currentPath, ['capabilities']); const isRequiredMeta = isArrayEqual(currentPath, ['meta']); @@ -150,6 +154,7 @@ function omitDefaults(obj: unknown, path: readonly string[]): unknown { !isRequiredModels && !isRequiredTables && !isRequiredRelations && + !isRequiredRoots && !isRequiredExtensionPacks && !isRequiredCapabilities && !isRequiredMeta && @@ -275,8 +280,9 @@ export function canonicalizeContract(ir: CanonicalContractInput): string { schemaVersion: ir.schemaVersion, targetFamily: ir.targetFamily, target: ir.target, + ...ifDefined('roots', ir.roots), models: ir.models, - relations: ir.relations, + ...ifDefined('relations', ir.relations), storage: ir.storage, ...ifDefined('execution', ir.execution), extensionPacks: ir.extensionPacks, diff --git a/packages/1-framework/3-tooling/emitter/test/emitter.test.ts b/packages/1-framework/3-tooling/emitter/test/emitter.test.ts index 0421a961e0..04d391186e 100644 --- a/packages/1-framework/3-tooling/emitter/test/emitter.test.ts +++ b/packages/1-framework/3-tooling/emitter/test/emitter.test.ts @@ -428,23 +428,6 @@ describe('emitter', () => { await expect(emit(ir, options, mockSqlHook)).rejects.toThrow('ContractIR must have storage'); }); - it('throws error when relations is missing', async () => { - const ir = createContractIR({ - relations: undefined as unknown as Record, - }) as ContractIR; - - const operationRegistry = createOperationRegistry(); - const options: EmitOptions = { - outputDir: '', - operationRegistry, - codecTypeImports: [], - operationTypeImports: [], - extensionIds: [], - }; - - await expect(emit(ir, options, mockSqlHook)).rejects.toThrow('ContractIR must have relations'); - }); - it('throws error when relations is not an object', async () => { const ir = createContractIR({ relations: 'not-an-object' as unknown as Record, @@ -459,7 +442,9 @@ describe('emitter', () => { extensionIds: [], }; - await expect(emit(ir, options, mockSqlHook)).rejects.toThrow('ContractIR must have relations'); + await expect(emit(ir, options, mockSqlHook)).rejects.toThrow( + 'ContractIR relations must be an object when provided', + ); }); it('throws error when extension packs are missing', async () => { diff --git a/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json b/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json index bbf11b921a..c55d6cc5bb 100644 --- a/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json +++ b/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json @@ -66,6 +66,13 @@ "$ref": "#/$defs/Source" } }, + "roots": { + "type": "object", + "description": "Mapping of root model names to their identifiers", + "additionalProperties": { + "type": "string" + } + }, "models": { "type": "object", "description": "Model definitions mapping application-level models to storage tables", @@ -115,7 +122,15 @@ "required": ["mutations"] } }, - "required": ["schemaVersion", "target", "targetFamily", "storageHash", "models", "storage"], + "required": [ + "schemaVersion", + "target", + "targetFamily", + "storageHash", + "models", + "storage", + "roots" + ], "$defs": { "StorageTable": { "type": "object", @@ -470,6 +485,13 @@ "table": { "type": "string", "description": "Table name in storage.tables" + }, + "fields": { + "type": "object", + "description": "Per-field storage mappings", + "additionalProperties": { + "$ref": "#/$defs/ModelStorageField" + } } }, "required": ["table"] @@ -499,14 +521,33 @@ "column": { "type": "string", "description": "Column name in the model's backing table" + }, + "codecId": { + "type": "string", + "description": "Codec identifier for the field (derived from storage column)" + }, + "nullable": { + "type": "boolean", + "description": "Whether the field allows NULL values (derived from storage column)" } }, "required": ["column"] }, - "ModelRelation": { + "ModelStorageField": { "type": "object", - "description": "Model relation definition", + "description": "Per-field storage mapping", "additionalProperties": false, + "properties": { + "column": { + "type": "string", + "description": "Column name in the model's backing table" + } + }, + "required": ["column"] + }, + "ModelRelation": { + "type": "object", + "description": "Model relation definition (domain format with localFields/targetFields, or storage format with parentCols/childCols)", "properties": { "to": { "type": "string", @@ -517,30 +558,39 @@ "enum": ["1:1", "1:N", "N:1", "N:M"], "description": "Relation cardinality" }, + "strategy": { + "type": "string", + "enum": ["reference", "embed"], + "description": "Relation strategy" + }, "on": { "type": "object", "description": "Relation field mappings", - "additionalProperties": false, "properties": { "parentCols": { "type": "array", - "description": "Parent table columns", - "items": { - "type": "string" - } + "description": "Parent table columns (storage format)", + "items": { "type": "string" } }, "childCols": { "type": "array", - "description": "Child table columns", - "items": { - "type": "string" - } + "description": "Child table columns (storage format)", + "items": { "type": "string" } + }, + "localFields": { + "type": "array", + "description": "Local model fields (domain format)", + "items": { "type": "string" } + }, + "targetFields": { + "type": "array", + "description": "Target model fields (domain format)", + "items": { "type": "string" } } - }, - "required": ["parentCols", "childCols"] + } } }, - "required": ["to", "cardinality", "on"] + "required": ["to", "cardinality"] } } } diff --git a/test/integration/test/authoring/parity/core-surface/expected.contract.json b/test/integration/test/authoring/parity/core-surface/expected.contract.json index e754980cfa..accf2be9fd 100644 --- a/test/integration/test/authoring/parity/core-surface/expected.contract.json +++ b/test/integration/test/authoring/parity/core-surface/expected.contract.json @@ -3,51 +3,111 @@ "targetFamily": "sql", "target": "postgres", "storageHash": "sha256:ba5cf6cccf8cd906b25e0a8d075875aff7492d815c738166c0146ac4b2535a28", - "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "roots": { + "Post": "Post", + "User": "User" + }, "models": { "Post": { "fields": { "id": { + "codecId": "pg/int4@1", "column": "id" }, "rating": { - "column": "rating" + "codecId": "pg/float8@1", + "column": "rating", + "nullable": true }, "title": { + "codecId": "pg/text@1", "column": "title" }, "userId": { + "codecId": "pg/int4@1", "column": "userId" } }, - "relations": {}, + "relations": { + "author": { + "cardinality": "N:1", + "on": { + "localFields": ["userId"], + "targetFields": ["id"] + }, + "strategy": "reference", + "to": "User" + } + }, "storage": { + "fields": { + "id": { + "column": "id" + }, + "rating": { + "column": "rating" + }, + "title": { + "column": "title" + }, + "userId": { + "column": "userId" + } + }, "table": "post" } }, "User": { "fields": { "createdAt": { + "codecId": "pg/timestamptz@1", "column": "createdAt" }, "email": { + "codecId": "pg/text@1", "column": "email" }, "id": { + "codecId": "pg/int4@1", "column": "id" }, "isActive": { + "codecId": "pg/bool@1", "column": "isActive" }, "profile": { - "column": "profile" + "codecId": "pg/jsonb@1", + "column": "profile", + "nullable": true }, "role": { + "codecId": "pg/enum@1", "column": "role" } }, "relations": {}, "storage": { + "fields": { + "createdAt": { + "column": "createdAt" + }, + "email": { + "column": "email" + }, + "id": { + "column": "id" + }, + "isActive": { + "column": "isActive" + }, + "profile": { + "column": "profile" + }, + "role": { + "column": "role" + } + }, "table": "user" } } @@ -194,8 +254,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json b/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json index c281bd38ee..95402833f9 100644 --- a/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json +++ b/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json @@ -4,16 +4,25 @@ "target": "postgres", "storageHash": "sha256:ab25b558e03744f8878f72857aef610808c4f3f05e2827e68bcd872b826a428b", "executionHash": "sha256:9c330b02774fd7b35ab7463eea7551d8e147b97fa8dac27aed080a630be6bd0c", - "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { + "codecId": "sql/char@1", "column": "id" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } @@ -65,8 +74,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json b/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json index 8dab7997f8..a0576112a5 100644 --- a/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json +++ b/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json @@ -3,16 +3,25 @@ "targetFamily": "sql", "target": "postgres", "storageHash": "sha256:439aa8d13a4e7326d69ac80a5f65e0f5cfc876e392af997d5b052eadda9580d4", - "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { + "codecId": "pg/text@1", "column": "id" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } @@ -49,8 +58,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json b/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json index c360c3c7e4..df3ead0af2 100644 --- a/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json +++ b/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json @@ -4,16 +4,25 @@ "target": "postgres", "storageHash": "sha256:4e890b7aba7b44bb761ef4a35d741cce748ce247f480bc514648c7914786f0d9", "executionHash": "sha256:123e615289bb93b88765b0522740cac06d7a22bf3a9e4968fe92e01c1ca0b3a5", - "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { + "codecId": "sql/char@1", "column": "id" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } @@ -68,8 +77,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-nanoid/expected.contract.json b/test/integration/test/authoring/parity/default-nanoid/expected.contract.json index 1abf097a3b..8777b9d8cc 100644 --- a/test/integration/test/authoring/parity/default-nanoid/expected.contract.json +++ b/test/integration/test/authoring/parity/default-nanoid/expected.contract.json @@ -4,16 +4,25 @@ "target": "postgres", "storageHash": "sha256:f9fa02b90981ca52bb1be98bd793d8fc3aa515b8d40269c22d64b2e476021e08", "executionHash": "sha256:2643d48dc917fa4ac9680d8404819ca290f0539dabcada463926743f4c526f65", - "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { + "codecId": "sql/char@1", "column": "id" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } @@ -65,8 +74,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json b/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json index 2d43bf3387..62611abfe5 100644 --- a/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json +++ b/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json @@ -4,16 +4,25 @@ "target": "postgres", "storageHash": "sha256:9e27b90da3eb44a199ce86ba13e2082b0d2cdc502803c95138a32b3afb88c0ec", "executionHash": "sha256:c3b03395de492379b9ba6d6f890debc53739629234ba98a8dbf7dc8c4301a910", - "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { + "codecId": "pg/text@1", "column": "id" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } @@ -62,8 +71,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": { diff --git a/test/integration/test/authoring/parity/default-ulid/expected.contract.json b/test/integration/test/authoring/parity/default-ulid/expected.contract.json index 0b5fdc90eb..dc8ed78a56 100644 --- a/test/integration/test/authoring/parity/default-ulid/expected.contract.json +++ b/test/integration/test/authoring/parity/default-ulid/expected.contract.json @@ -4,16 +4,25 @@ "target": "postgres", "storageHash": "sha256:f23c5ccabb210de6564c7084708ecc900ad3e7769cc9585baad6561edfa20f66", "executionHash": "sha256:f2232a980cff0854e38fdedd0842347ffc96d92a8aaaab4da1749d4575598adc", - "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { + "codecId": "sql/char@1", "column": "id" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } @@ -65,8 +74,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json b/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json index 37f22741f4..27202af2a7 100644 --- a/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json +++ b/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json @@ -4,16 +4,25 @@ "target": "postgres", "storageHash": "sha256:acb9845800b87237a5d37f2fa3739899bfd13d9dc2d7ce0ea47865bce2cc666d", "executionHash": "sha256:57c307c24cd2dc8eb5e7cf53beb4767f19728c92925835a8ca24b3f11a5cfc95", - "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { + "codecId": "sql/char@1", "column": "id" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } @@ -65,8 +74,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json b/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json index bebe9998ab..440c1c15c3 100644 --- a/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json +++ b/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json @@ -4,16 +4,25 @@ "target": "postgres", "storageHash": "sha256:acb9845800b87237a5d37f2fa3739899bfd13d9dc2d7ce0ea47865bce2cc666d", "executionHash": "sha256:287547440de2f13dbe48f300b47dda09631a7e9b551728f6cb99ef5217aa2fdc", - "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { + "codecId": "sql/char@1", "column": "id" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } @@ -65,8 +74,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/map-attributes/expected.contract.json b/test/integration/test/authoring/parity/map-attributes/expected.contract.json index 36ea3982b0..90687f2f7f 100644 --- a/test/integration/test/authoring/parity/map-attributes/expected.contract.json +++ b/test/integration/test/authoring/parity/map-attributes/expected.contract.json @@ -3,30 +3,60 @@ "targetFamily": "sql", "target": "postgres", "storageHash": "sha256:6a38af603d36ab9862c3e73197e094c00b696e55a0928d0003a7b5e9201017bc", - "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "roots": { + "Member": "Member", + "Team": "Team" + }, "models": { "Member": { "fields": { "id": { + "codecId": "pg/int4@1", "column": "member_id" }, "teamId": { + "codecId": "pg/int4@1", "column": "team_ref" } }, - "relations": {}, + "relations": { + "team": { + "cardinality": "N:1", + "on": { + "localFields": ["teamId"], + "targetFields": ["id"] + }, + "strategy": "reference", + "to": "Team" + } + }, "storage": { + "fields": { + "id": { + "column": "member_id" + }, + "teamId": { + "column": "team_ref" + } + }, "table": "team_member" } }, "Team": { "fields": { "id": { + "codecId": "pg/int4@1", "column": "team_id" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "team_id" + } + }, "table": "org_team" } } @@ -109,8 +139,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json b/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json index c0856c8a2a..2611852c50 100644 --- a/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json +++ b/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json @@ -3,19 +3,32 @@ "targetFamily": "sql", "target": "postgres", "storageHash": "sha256:a7aadce021465acff4f105ee787388a4896d8161afe863d3c90b3320b06b0010", - "profileHash": "sha256:ea5c6635c0c0bd71badced0f3ee8ba912cf72dc836ae165cd533dc8f68cbfc9f", + "profileHash": "sha256:83d66b1cce776c9ec9e6d168086e5bd1030ccf461823b9eef39cf49f1833c6dd", + "roots": { + "Document": "Document" + }, "models": { "Document": { "fields": { "embedding": { + "codecId": "pg/vector@1", "column": "embedding" }, "id": { + "codecId": "pg/int4@1", "column": "id" } }, "relations": {}, "storage": { + "fields": { + "embedding": { + "column": "embedding" + }, + "id": { + "column": "id" + } + }, "table": "document" } } @@ -69,8 +82,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": { diff --git a/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json b/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json index b1177d40cc..f23504240a 100644 --- a/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json +++ b/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json @@ -3,30 +3,70 @@ "targetFamily": "sql", "target": "postgres", "storageHash": "sha256:7bc2d92b8bd871bb95a1030e0befcb591b9641949fed2263293e9111ca1b90b3", - "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "roots": { + "Post": "Post", + "User": "User" + }, "models": { "Post": { "fields": { "id": { + "codecId": "pg/int4@1", "column": "id" }, "userId": { + "codecId": "pg/int4@1", "column": "userId" } }, - "relations": {}, + "relations": { + "user": { + "cardinality": "N:1", + "on": { + "localFields": ["userId"], + "targetFields": ["id"] + }, + "strategy": "reference", + "to": "User" + } + }, "storage": { + "fields": { + "id": { + "column": "id" + }, + "userId": { + "column": "userId" + } + }, "table": "post" } }, "User": { "fields": { "id": { + "codecId": "pg/int4@1", "column": "id" } }, - "relations": {}, + "relations": { + "posts": { + "cardinality": "1:N", + "on": { + "localFields": ["id"], + "targetFields": ["userId"] + }, + "strategy": "reference", + "to": "Post" + } + }, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } @@ -110,8 +150,7 @@ "returning": true }, "sql": { - "enums": true, - "returning": true + "enums": true } }, "extensionPacks": {}, From 17167805891b798c4724256a4a449df1a824f65f Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 19:30:18 +0200 Subject: [PATCH 10/35] keep models off ContractBase to avoid index signature leakage Adding models: Record to ContractBase created an intersection with SqlContract's M parameter, propagating an index signature that broke noPropertyAccessFromIndexSignature and caused 267+ type errors in consumers. Instead, only roots lives on ContractBase; domain model conformance is enforced at runtime by validateContractDomain. Also restores generateModelsType to emit JS codec types for model.fields (query builder compat) while keeping domain info in model.storage.fields. --- .../1-core/shared/contract/src/types.ts | 2 -- .../shared/contract/test/domain-types.test.ts | 12 ++++--- .../2-authoring/contract-ts/src/contract.ts | 3 +- packages/2-sql/3-tooling/emitter/src/index.ts | 34 ++++++++++++++----- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/1-framework/1-core/shared/contract/src/types.ts b/packages/1-framework/1-core/shared/contract/src/types.ts index 8e1ee13bbb..fb5ffa5c83 100644 --- a/packages/1-framework/1-core/shared/contract/src/types.ts +++ b/packages/1-framework/1-core/shared/contract/src/types.ts @@ -1,5 +1,4 @@ import type { OperationRegistry } from '@prisma-next/operations'; -import type { DomainModel } from './domain-types'; import type { ContractIR } from './ir'; /** @@ -73,7 +72,6 @@ export interface ContractBase< readonly sources: Record; readonly execution?: ExecutionSection; readonly roots: Record; - readonly models: Record; } export interface FieldType { diff --git a/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts b/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts index a7c6de6259..0f8af7f2cf 100644 --- a/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts +++ b/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts @@ -3,12 +3,15 @@ import type { DomainField, DomainModel, DomainRelation } from '../src/domain-typ import type { ContractBase } from '../src/types'; describe('domain types', () => { - it('ContractBase includes roots and models', () => { + it('ContractBase includes roots', () => { type Roots = ContractBase['roots']; - type Models = ContractBase['models']; const roots: Roots = { users: 'User' }; - const models: Models = { + expect(roots).toEqual({ users: 'User' }); + }); + + it('DomainModel can represent SQL models', () => { + const models: Record = { User: { fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, relations: {}, @@ -16,8 +19,7 @@ describe('domain types', () => { }, }; - expect(roots).toEqual({ users: 'User' }); - expect(models.User.fields.id.nullable).toBe(false); + expect(models['User']?.fields['id']?.nullable).toBe(false); }); it('DomainField carries nullable and codecId', () => { diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract.ts b/packages/2-sql/2-authoring/contract-ts/src/contract.ts index 8e9fb53a5e..d64c493576 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract.ts @@ -281,7 +281,8 @@ function validateContractLogic(structurallyValidatedContract: SqlContract'; } + const renderCtx: TypeRenderContext = { codecTypesName: 'CodecTypes' }; + const modelTypes: string[] = []; for (const [modelName, model] of Object.entries(models)) { const fields: string[] = []; @@ -541,15 +543,29 @@ export const sqlTargetFamilyHook = { const tableName = model.storage.table; const table = storage.tables[tableName]; - for (const [fieldName, field] of Object.entries(model.fields)) { - const colName = field.column; - const column = table?.columns[colName]; - const nullable = column?.nullable ?? false; - const codecId = column?.codecId ?? ''; - fields.push( - `readonly ${fieldName}: { readonly column: '${colName}'; readonly nullable: ${nullable}; readonly codecId: '${codecId}' }`, - ); - storageFieldParts.push(`readonly ${fieldName}: { readonly column: '${colName}' }`); + if (table) { + for (const [fieldName, field] of Object.entries(model.fields)) { + const column = table.columns[field.column]; + if (!column) { + fields.push(`readonly ${fieldName}: { readonly column: '${field.column}' }`); + storageFieldParts.push(`readonly ${fieldName}: { readonly column: '${field.column}' }`); + continue; + } + + const jsType = this.generateColumnType( + column, + storage, + parameterizedRenderers, + renderCtx, + ); + fields.push(`readonly ${fieldName}: ${jsType}`); + storageFieldParts.push(`readonly ${fieldName}: { readonly column: '${field.column}' }`); + } + } else { + for (const [fieldName, field] of Object.entries(model.fields)) { + fields.push(`readonly ${fieldName}: { readonly column: '${field.column}' }`); + storageFieldParts.push(`readonly ${fieldName}: { readonly column: '${field.column}' }`); + } } const relations: string[] = []; From 7d128846e97b7ee6ce19d8c4da5a40e721f333b8 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 19:30:30 +0200 Subject: [PATCH 11/35] add roots field to test contracts and update emitter test assertions ContractBase now requires roots: Record. All test contracts that construct SqlContract objects inline need roots: {}. Also regenerates parity fixtures and demo contract to reflect the corrected emitter output (JS codec types in model.fields, domain info only in model.storage.fields). --- .../prisma-next-demo/src/prisma/contract.d.ts | 54 ++++--------------- .../test/contract.normalization.test.ts | 3 +- .../test/contract.structure.test.ts | 5 +- .../emitter-hook.generation.advanced.test.ts | 4 +- .../emitter-hook.generation.basic.test.ts | 36 ++++--------- .../emitter-hook.parameterized-types.test.ts | 52 +++++------------- .../family/test/contract-to-schema-ir.test.ts | 1 + .../family/test/schema-verify.basic.test.ts | 2 + .../test/schema.types.test-d.ts | 2 + .../test/json-schema-validation.test.ts | 1 + .../test/mutation-default-generators.test.ts | 1 + .../test/parameterized-types.test.ts | 1 + .../2-sql/5-runtime/test/sql-context.test.ts | 1 + .../5-runtime/test/sql-family-adapter.test.ts | 1 + .../2-sql/5-runtime/test/sql-runtime.test.ts | 1 + packages/2-sql/5-runtime/test/utils.ts | 1 + .../postgres/test/postgres.test.ts | 1 + .../migrations/fixtures/runner-fixtures.ts | 1 + .../test/migrations/planner.behavior.test.ts | 1 + .../test/migrations/planner.case1.test.ts | 5 ++ .../planner.contract-to-schema-ir.test.ts | 2 + .../test/migrations/planner.fk-config.test.ts | 1 + .../planner.reconciliation-unit.test.ts | 1 + ...planner.reconciliation.integration.test.ts | 3 ++ .../migrations/planner.reconciliation.test.ts | 1 + .../planner.referential-actions.test.ts | 1 + .../planner.semantic-satisfaction.test.ts | 1 + .../planner.storage-types.integration.test.ts | 1 + .../migrations/planner.storage-types.test.ts | 3 ++ ...ma-verify.after-runner.integration.test.ts | 2 + .../6-adapters/postgres/test/test-utils.ts | 1 + 31 files changed, 74 insertions(+), 117 deletions(-) diff --git a/examples/prisma-next-demo/src/prisma/contract.d.ts b/examples/prisma-next-demo/src/prisma/contract.d.ts index a4d128329d..16e57581d0 100644 --- a/examples/prisma-next-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo/src/prisma/contract.d.ts @@ -153,31 +153,11 @@ type ContractBase = SqlContract< }; }; fields: { - readonly id: { - readonly column: 'id'; - readonly nullable: false; - readonly codecId: 'sql/char@1'; - }; - readonly title: { - readonly column: 'title'; - readonly nullable: false; - readonly codecId: 'pg/text@1'; - }; - readonly userId: { - readonly column: 'userId'; - readonly nullable: false; - readonly codecId: 'pg/text@1'; - }; - readonly createdAt: { - readonly column: 'createdAt'; - readonly nullable: false; - readonly codecId: 'pg/timestamptz@1'; - }; - readonly embedding: { - readonly column: 'embedding'; - readonly nullable: true; - readonly codecId: 'pg/vector@1'; - }; + readonly id: Char<36>; + readonly title: CodecTypes['pg/text@1']['output']; + readonly userId: CodecTypes['pg/text@1']['output']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['output']; + readonly embedding: Vector<1536> | null; }; relations: { readonly user: { @@ -202,26 +182,10 @@ type ContractBase = SqlContract< }; }; fields: { - readonly id: { - readonly column: 'id'; - readonly nullable: false; - readonly codecId: 'sql/char@1'; - }; - readonly email: { - readonly column: 'email'; - readonly nullable: false; - readonly codecId: 'pg/text@1'; - }; - readonly createdAt: { - readonly column: 'createdAt'; - readonly nullable: false; - readonly codecId: 'pg/timestamptz@1'; - }; - readonly kind: { - readonly column: 'kind'; - readonly nullable: false; - readonly codecId: 'pg/enum@1'; - }; + readonly id: Char<36>; + readonly email: CodecTypes['pg/text@1']['output']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['output']; + readonly kind: 'admin' | 'user'; }; relations: { readonly posts: { diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract.normalization.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract.normalization.test.ts index cd7793591e..1edbba7965 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract.normalization.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract.normalization.test.ts @@ -819,8 +819,7 @@ describe('validateContract normalization', () => { // biome-ignore lint/suspicious/noExplicitAny: testing invalid input } as any; const normalized = normalizeContract(contractInput); - // Normalization should pass through null models unchanged - expect(normalized.models).toBeNull(); + expect(normalized.models).toEqual({}); }); }); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract.structure.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract.structure.test.ts index be9e8c6be1..5faab66354 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract.structure.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract.structure.test.ts @@ -62,10 +62,11 @@ describe('validateContract structure validation', () => { expect(() => validateContract>(invalid)).toThrow(/storage/); }); - it('throws on missing models', () => { + it('defaults missing models to empty object', () => { // biome-ignore lint/suspicious/noExplicitAny: testing invalid input const invalid = { ...validContractInput, models: undefined } as any; - expect(() => validateContract>(invalid)).toThrow(/models/); + const result = validateContract>(invalid); + expect(result.models).toEqual({}); }); it('throws on invalid column type', () => { diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts index 228bcbc02e..3b03a5645d 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts @@ -769,7 +769,7 @@ describe('sql-target-family-hook', () => { }); expect(types).toContain( - "readonly vector: { readonly column: 'vector'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + "readonly vector: CodecTypes['pg/vector@1']['output'] & { length: 1536 }", ); }); @@ -825,7 +825,7 @@ describe('sql-target-family-hook', () => { }); expect(types).toContain( - "readonly vector: { readonly column: 'vector'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", + "readonly vector: CodecTypes['pg/vector@1']['output'] & { length: 1536 }", ); }); }); diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts index f151a6dc0a..e8bb9b4992 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.basic.test.ts @@ -585,12 +585,8 @@ describe('sql-target-family-hook', () => { }); const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - expect(types).toContain( - "readonly name: { readonly column: 'name'; readonly nullable: true; readonly codecId: 'pg/text@1' }", - ); - expect(types).toContain( - "readonly email: { readonly column: 'email'; readonly nullable: false; readonly codecId: 'pg/text@1' }", - ); + expect(types).toContain("readonly name: CodecTypes['pg/text@1']['output'] | null"); + expect(types).toContain("readonly email: CodecTypes['pg/text@1']['output']"); }); it('generates contract types with model field missing column reference', () => { @@ -622,9 +618,7 @@ describe('sql-target-family-hook', () => { }); const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - expect(types).toContain( - "readonly email: { readonly column: 'nonexistent'; readonly nullable: false; readonly codecId: '' }", - ); + expect(types).toContain("readonly email: { readonly column: 'nonexistent' }"); }); it('generates contract types with model referencing missing table', () => { @@ -654,9 +648,7 @@ describe('sql-target-family-hook', () => { }); const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - expect(types).toContain( - "readonly id: { readonly column: 'id'; readonly nullable: false; readonly codecId: '' }", - ); + expect(types).toContain("readonly id: { readonly column: 'id' }"); }); it('generates contract types with undefined models', () => { @@ -713,12 +705,8 @@ describe('sql-target-family-hook', () => { }); const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - expect(types).toContain( - "readonly id: { readonly column: 'id'; readonly nullable: false; readonly codecId: 'pg/int4@1' }", - ); - expect(types).not.toContain( - "readonly id: { readonly column: 'id'; readonly nullable: true; readonly codecId: 'pg/int4@1' }", - ); + expect(types).toContain("readonly id: CodecTypes['pg/int4@1']['output']"); + expect(types).not.toContain("readonly id: CodecTypes['pg/int4@1']['output'] | null"); }); it('renders parameterized type when column has typeParams and renderer exists', () => { @@ -766,12 +754,8 @@ describe('sql-target-family-hook', () => { parameterizedRenderers, }); - expect(types).toContain( - "readonly vector: { readonly column: 'vector'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", - ); - expect(types).toContain( - "readonly id: { readonly column: 'id'; readonly nullable: false; readonly codecId: 'pg/int4@1' }", - ); + expect(types).toContain('readonly vector: Vector<1536>'); + expect(types).toContain("readonly id: CodecTypes['pg/int4@1']['output']"); }); it('falls back to CodecTypes when column has typeParams but no renderer', () => { @@ -806,9 +790,7 @@ describe('sql-target-family-hook', () => { const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - expect(types).toContain( - "readonly vector: { readonly column: 'vector'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", - ); + expect(types).toContain("readonly vector: CodecTypes['pg/vector@1']['output']"); expect(types).not.toContain('Vector<1536>'); }); diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.parameterized-types.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.parameterized-types.test.ts index 2351a67da7..ecb84557a2 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.parameterized-types.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.parameterized-types.test.ts @@ -68,9 +68,7 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain( - "readonly embedding: { readonly column: 'embedding'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", - ); + expect(types).toContain('readonly embedding: Vector<1536>'); }); it('falls back to CodecTypes[codecId].output for columns without typeParams', () => { @@ -111,9 +109,7 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain( - "readonly id: { readonly column: 'id'; readonly nullable: false; readonly codecId: 'pg/int4@1' }", - ); + expect(types).toContain("readonly id: CodecTypes['pg/int4@1']['output']"); }); it('emits nullable parameterized type with | null suffix', () => { @@ -159,9 +155,7 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain( - "readonly embedding: { readonly column: 'embedding'; readonly nullable: true; readonly codecId: 'pg/vector@1' }", - ); + expect(types).toContain('readonly embedding: Vector<1536> | null'); }); it('uses custom renderer logic for complex type generation', () => { @@ -211,9 +205,7 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain( - "readonly value: { readonly column: 'value'; readonly nullable: false; readonly codecId: 'pg/decimal@1' }", - ); + expect(types).toContain('readonly value: Decimal<10, 2>'); }); }); @@ -268,9 +260,7 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain( - "readonly role: { readonly column: 'role'; readonly nullable: false; readonly codecId: 'pg/enum@1' }", - ); + expect(types).toContain("readonly role: 'USER' | 'ADMIN' | 'MODERATOR'"); }); }); @@ -325,9 +315,7 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain( - "readonly embedding: { readonly column: 'embedding'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", - ); + expect(types).toContain('readonly embedding: Vector<1536>'); }); }); @@ -396,15 +384,9 @@ describe('sql-target-family-hook parameterized type emission', () => { // Output should be identical expect(types1).toBe(types2); - expect(types1).toContain( - "readonly embedding1: { readonly column: 'embedding1'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", - ); - expect(types1).toContain( - "readonly embedding2: { readonly column: 'embedding2'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", - ); - expect(types1).toContain( - "readonly embedding3: { readonly column: 'embedding3'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", - ); + expect(types1).toContain('readonly embedding1: Vector<1536>'); + expect(types1).toContain('readonly embedding2: Vector<384>'); + expect(types1).toContain('readonly embedding3: Vector<768>'); }); }); @@ -447,9 +429,7 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain( - "readonly value: { readonly column: 'value'; readonly nullable: false; readonly codecId: 'custom/type@1' }", - ); + expect(types).toContain("readonly value: CodecTypes['custom/type@1']['output']"); }); it('handles typeRef pointing to non-existent storage.types entry gracefully', () => { @@ -496,9 +476,7 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain( - "readonly value: { readonly column: 'value'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", - ); + expect(types).toContain("readonly value: CodecTypes['pg/vector@1']['output']"); }); it('handles empty typeParams object by falling back to standard lookup', () => { @@ -544,9 +522,7 @@ describe('sql-target-family-hook parameterized type emission', () => { parameterizedRenderers, }); - expect(types).toContain( - "readonly value: { readonly column: 'value'; readonly nullable: false; readonly codecId: 'pg/vector@1' }", - ); + expect(types).toContain("readonly value: CodecTypes['pg/vector@1']['output']"); }); it('works without options parameter (backwards compatibility)', () => { @@ -578,9 +554,7 @@ describe('sql-target-family-hook parameterized type emission', () => { // Call without options (4th parameter) const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); - expect(types).toContain( - "readonly id: { readonly column: 'id'; readonly nullable: false; readonly codecId: 'pg/int4@1' }", - ); + expect(types).toContain("readonly id: CodecTypes['pg/int4@1']['output']"); }); }); diff --git a/packages/2-sql/3-tooling/family/test/contract-to-schema-ir.test.ts b/packages/2-sql/3-tooling/family/test/contract-to-schema-ir.test.ts index 5fb1362e61..45c7989495 100644 --- a/packages/2-sql/3-tooling/family/test/contract-to-schema-ir.test.ts +++ b/packages/2-sql/3-tooling/family/test/contract-to-schema-ir.test.ts @@ -36,6 +36,7 @@ function wrap(storage: SqlStorage): SqlContract { storage, models: {}, relations: {}, + roots: {}, mappings: {}, capabilities: {}, extensionPacks: {}, diff --git a/packages/2-sql/3-tooling/family/test/schema-verify.basic.test.ts b/packages/2-sql/3-tooling/family/test/schema-verify.basic.test.ts index 09a1a24868..dae53192de 100644 --- a/packages/2-sql/3-tooling/family/test/schema-verify.basic.test.ts +++ b/packages/2-sql/3-tooling/family/test/schema-verify.basic.test.ts @@ -66,6 +66,7 @@ describe('verifySqlSchema - basic', () => { }, models: {}, relations: {}, + roots: {}, mappings: {}, capabilities: {}, extensionPacks: {}, @@ -124,6 +125,7 @@ describe('verifySqlSchema - basic', () => { }, models: {}, relations: {}, + roots: {}, mappings: {}, capabilities: {}, extensionPacks: {}, diff --git a/packages/2-sql/4-lanes/relational-core/test/schema.types.test-d.ts b/packages/2-sql/4-lanes/relational-core/test/schema.types.test-d.ts index db10919b52..9a5f7cb396 100644 --- a/packages/2-sql/4-lanes/relational-core/test/schema.types.test-d.ts +++ b/packages/2-sql/4-lanes/relational-core/test/schema.types.test-d.ts @@ -149,6 +149,7 @@ const contractWithTypes: ContractWithTypes = { }, }, models: {}, + roots: {}, relations: {}, mappings: {}, extensionPacks: {}, @@ -291,6 +292,7 @@ test('schema.types is generic record when contract does not specify types', () = }, }, models: {}, + roots: {}, relations: {}, mappings: {}, extensionPacks: {}, diff --git a/packages/2-sql/5-runtime/test/json-schema-validation.test.ts b/packages/2-sql/5-runtime/test/json-schema-validation.test.ts index 3ba34d5062..2f7b73201f 100644 --- a/packages/2-sql/5-runtime/test/json-schema-validation.test.ts +++ b/packages/2-sql/5-runtime/test/json-schema-validation.test.ts @@ -135,6 +135,7 @@ function createJsonSchemaContract( storageHash: coreHash('sha256:test'), models: {}, relations: {}, + roots: {}, storage: { tables: { user: { diff --git a/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts b/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts index c0c2317dc7..e475ab6f5c 100644 --- a/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts +++ b/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts @@ -20,6 +20,7 @@ const testContract: SqlContract = { storageHash: coreHash('sha256:test'), models: {}, relations: {}, + roots: {}, storage: { tables: { user: { diff --git a/packages/2-sql/5-runtime/test/parameterized-types.test.ts b/packages/2-sql/5-runtime/test/parameterized-types.test.ts index 9ba9ee8a49..c2c42b112f 100644 --- a/packages/2-sql/5-runtime/test/parameterized-types.test.ts +++ b/packages/2-sql/5-runtime/test/parameterized-types.test.ts @@ -37,6 +37,7 @@ function createParamTypesTestContract( storageHash: coreHash('sha256:test'), models: {}, relations: {}, + roots: {}, storage: { tables: { test: { diff --git a/packages/2-sql/5-runtime/test/sql-context.test.ts b/packages/2-sql/5-runtime/test/sql-context.test.ts index cdd9ab5597..23260fe3a7 100644 --- a/packages/2-sql/5-runtime/test/sql-context.test.ts +++ b/packages/2-sql/5-runtime/test/sql-context.test.ts @@ -22,6 +22,7 @@ const testContract: SqlContract = { storageHash: coreHash('sha256:test'), models: {}, relations: {}, + roots: {}, storage: { tables: {} }, extensionPacks: {}, capabilities: {}, diff --git a/packages/2-sql/5-runtime/test/sql-family-adapter.test.ts b/packages/2-sql/5-runtime/test/sql-family-adapter.test.ts index e2a5fe1fde..aaf6555a96 100644 --- a/packages/2-sql/5-runtime/test/sql-family-adapter.test.ts +++ b/packages/2-sql/5-runtime/test/sql-family-adapter.test.ts @@ -12,6 +12,7 @@ const testContract: SqlContract = { storageHash: coreHash('sha256:test-hash'), models: {}, relations: {}, + roots: {}, storage: { tables: {} }, extensionPacks: {}, capabilities: {}, diff --git a/packages/2-sql/5-runtime/test/sql-runtime.test.ts b/packages/2-sql/5-runtime/test/sql-runtime.test.ts index 247051636b..6661760904 100644 --- a/packages/2-sql/5-runtime/test/sql-runtime.test.ts +++ b/packages/2-sql/5-runtime/test/sql-runtime.test.ts @@ -29,6 +29,7 @@ const testContract: SqlContract = { storageHash: coreHash('sha256:test'), models: {}, relations: {}, + roots: {}, storage: { tables: {} }, extensionPacks: {}, capabilities: {}, diff --git a/packages/2-sql/5-runtime/test/utils.ts b/packages/2-sql/5-runtime/test/utils.ts index 02d382481f..e902f50bcc 100644 --- a/packages/2-sql/5-runtime/test/utils.ts +++ b/packages/2-sql/5-runtime/test/utils.ts @@ -280,6 +280,7 @@ export function createTestContract( storage: rest.storage ?? { tables: {} }, models: rest.models ?? {}, relations: rest.relations ?? {}, + roots: rest.roots ?? {}, mappings: rest.mappings ?? {}, capabilities: rest.capabilities ?? {}, extensionPacks: rest.extensionPacks ?? {}, diff --git a/packages/3-extensions/postgres/test/postgres.test.ts b/packages/3-extensions/postgres/test/postgres.test.ts index 743ae440fd..7cbaa13d19 100644 --- a/packages/3-extensions/postgres/test/postgres.test.ts +++ b/packages/3-extensions/postgres/test/postgres.test.ts @@ -72,6 +72,7 @@ const contract: SqlContract = { target: 'postgres', storageHash: 'sha256:test' as never, models: {}, + roots: {}, relations: {}, storage: { tables: {} }, extensionPacks: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/fixtures/runner-fixtures.ts b/packages/3-targets/3-targets/postgres/test/migrations/fixtures/runner-fixtures.ts index 2627181771..35cd11b65b 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/fixtures/runner-fixtures.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/fixtures/runner-fixtures.ts @@ -32,6 +32,7 @@ export const contract: SqlContract = { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.behavior.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.behavior.test.ts index 84578f800c..701231f938 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.behavior.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.behavior.test.ts @@ -515,6 +515,7 @@ function createTestContract(overrides?: Partial>): SqlCo }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.case1.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.case1.test.ts index 00c23fb2d0..aaead58e00 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.case1.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.case1.test.ts @@ -71,6 +71,7 @@ function createTestContract(overrides?: Partial>): SqlCo }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, @@ -251,6 +252,7 @@ describe('PostgresMigrationPlanner - when database is empty', () => { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, @@ -310,6 +312,7 @@ describe('PostgresMigrationPlanner - when database is empty', () => { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, @@ -524,6 +527,7 @@ describe('PostgresMigrationPlanner - composite unique constraint DDL', () => { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, @@ -589,6 +593,7 @@ describe('PostgresMigrationPlanner - column defaults', () => { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.contract-to-schema-ir.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.contract-to-schema-ir.test.ts index 6f1d793f4f..4c15c4d76b 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.contract-to-schema-ir.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.contract-to-schema-ir.test.ts @@ -60,6 +60,7 @@ function createTestContract( storageHash: coreHash('sha256:test'), profileHash: profileHash('sha256:profile'), storage, + roots: {}, models: {}, relations: {}, mappings: {}, @@ -784,6 +785,7 @@ function createDemoContract( storageHash: coreHash('sha256:demo'), profileHash: profileHash('sha256:demo-profile'), storage, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.fk-config.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.fk-config.test.ts index a9dd8ced4d..491c25246b 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.fk-config.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.fk-config.test.ts @@ -48,6 +48,7 @@ function createFkTestContract(fkConfig: { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation-unit.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation-unit.test.ts index ac35b3490d..d471e5cf02 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation-unit.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation-unit.test.ts @@ -56,6 +56,7 @@ function emptyContract(): SqlContract { storageHash: coreHash('sha256:test'), profileHash: profileHash('sha256:test'), storage: { tables: {} }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation.integration.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation.integration.test.ts index abcb2c12a9..d1b183ade1 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation.integration.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation.integration.test.ts @@ -32,6 +32,7 @@ function makeContract( storageHash: coreHash(`sha256:reconciliation-integ-${hashSuffix}`), profileHash: profileHash(`sha256:reconciliation-integ-${hashSuffix}`), storage: { tables }, + roots: {}, models: {}, relations: {}, mappings: {}, @@ -1002,6 +1003,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, @@ -1283,6 +1285,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation.test.ts index 3950316cbb..31cb68eb14 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.reconciliation.test.ts @@ -175,6 +175,7 @@ function createContract( storageHash: coreHash('sha256:reconciliation-contract'), profileHash: profileHash('sha256:reconciliation-profile'), storage: { tables }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.referential-actions.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.referential-actions.test.ts index 9d5c6a62ac..1e06af21ba 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.referential-actions.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.referential-actions.test.ts @@ -52,6 +52,7 @@ function createRefActionContract( }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.semantic-satisfaction.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.semantic-satisfaction.test.ts index 6ca8eafd8d..cb99d5b24d 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.semantic-satisfaction.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.semantic-satisfaction.test.ts @@ -237,6 +237,7 @@ function createTestContract(overrides?: Partial>): SqlCo storage: { tables: {}, }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.storage-types.integration.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.storage-types.integration.test.ts index e782c1f7a5..65507f5208 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.storage-types.integration.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.storage-types.integration.test.ts @@ -43,6 +43,7 @@ const contractWithEnum: SqlContract = { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.storage-types.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.storage-types.test.ts index 65e755b4e1..3bb11d47f6 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.storage-types.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.storage-types.test.ts @@ -80,6 +80,7 @@ describe('PostgresMigrationPlanner - storage types', () => { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, @@ -150,6 +151,7 @@ describe('PostgresMigrationPlanner - storage types', () => { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, @@ -244,6 +246,7 @@ describe('PostgresMigrationPlanner - storage types', () => { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/schema-verify.after-runner.integration.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/schema-verify.after-runner.integration.test.ts index 40b4101069..aca0a12475 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/schema-verify.after-runner.integration.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/schema-verify.after-runner.integration.test.ts @@ -137,6 +137,7 @@ describe.sequential('Schema verification after runner - integration', () => { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, @@ -199,6 +200,7 @@ describe.sequential('Schema verification after runner - integration', () => { }, }, }, + roots: {}, models: {}, relations: {}, mappings: {}, diff --git a/packages/3-targets/6-adapters/postgres/test/test-utils.ts b/packages/3-targets/6-adapters/postgres/test/test-utils.ts index 5130043f0d..bf35828e10 100644 --- a/packages/3-targets/6-adapters/postgres/test/test-utils.ts +++ b/packages/3-targets/6-adapters/postgres/test/test-utils.ts @@ -27,6 +27,7 @@ export function createTestContract(storage: Partial = {}): SqlContra ...storage, } as SqlStorage, models: {}, + roots: {}, relations: {}, mappings: {}, capabilities: {}, From c98c83c470f874474d13814794c6844b129bd5af Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 19:48:07 +0200 Subject: [PATCH 12/35] restore models on ContractBase via generic TModels parameter Use a generic TModels parameter (defaulting to Record) to satisfy the ADR 172 acceptance criterion without reintroducing index-signature leakage from noPropertyAccessFromIndexSignature. SqlContract passes its M through to ContractBase, removing the duplicate models property from the intersection. --- .../1-framework/1-core/shared/contract/src/types.ts | 3 +++ .../1-core/shared/contract/test/domain-types.test.ts | 10 ++++------ packages/2-sql/1-core/contract/src/types.ts | 3 +-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/1-framework/1-core/shared/contract/src/types.ts b/packages/1-framework/1-core/shared/contract/src/types.ts index fb5ffa5c83..3efe21d8b5 100644 --- a/packages/1-framework/1-core/shared/contract/src/types.ts +++ b/packages/1-framework/1-core/shared/contract/src/types.ts @@ -1,4 +1,5 @@ import type { OperationRegistry } from '@prisma-next/operations'; +import type { DomainModel } from './domain-types'; import type { ContractIR } from './ir'; /** @@ -59,6 +60,7 @@ export interface ContractBase< TStorageHash extends StorageHashBase = StorageHashBase, TExecutionHash extends ExecutionHashBase = ExecutionHashBase, TProfileHash extends ProfileHashBase = ProfileHashBase, + TModels extends Record = Record, > { readonly schemaVersion: string; readonly target: string; @@ -72,6 +74,7 @@ export interface ContractBase< readonly sources: Record; readonly execution?: ExecutionSection; readonly roots: Record; + readonly models: TModels; } export interface FieldType { diff --git a/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts b/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts index 0f8af7f2cf..d0b9571123 100644 --- a/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts +++ b/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts @@ -3,15 +3,12 @@ import type { DomainField, DomainModel, DomainRelation } from '../src/domain-typ import type { ContractBase } from '../src/types'; describe('domain types', () => { - it('ContractBase includes roots', () => { + it('ContractBase includes roots and models', () => { type Roots = ContractBase['roots']; + type Models = ContractBase['models']; const roots: Roots = { users: 'User' }; - expect(roots).toEqual({ users: 'User' }); - }); - - it('DomainModel can represent SQL models', () => { - const models: Record = { + const models: Models = { User: { fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, relations: {}, @@ -19,6 +16,7 @@ describe('domain types', () => { }, }; + expect(roots).toEqual({ users: 'User' }); expect(models['User']?.fields['id']?.nullable).toBe(false); }); diff --git a/packages/2-sql/1-core/contract/src/types.ts b/packages/2-sql/1-core/contract/src/types.ts index 19240945bc..a5e5f488dd 100644 --- a/packages/2-sql/1-core/contract/src/types.ts +++ b/packages/2-sql/1-core/contract/src/types.ts @@ -227,10 +227,9 @@ export type SqlContract< TStorageHash extends StorageHashBase = StorageHashBase, TExecutionHash extends ExecutionHashBase = ExecutionHashBase, TProfileHash extends ProfileHashBase = ProfileHashBase, -> = ContractBase & { +> = ContractBase & { readonly targetFamily: string; readonly storage: S; - readonly models: M; readonly relations: R; readonly mappings: Map; readonly execution?: ExecutionSection; From c633804505ea61961384e2c0199a2030e7c4261f Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 18:27:30 +0200 Subject: [PATCH 13/35] refine design questions: SQL JSON relevance, M:N gap, strategy naming - Q16: clarify that union field types apply directly to SQL JSON/JSONB columns, not just hypothetically - Q17: note that the SQL contract also has no M:N concept today - Q18: new question on whether 'strategy: embed/reference' violates the facts-not-instructions design principle (naming, not concept) --- .../1-design-docs/design-questions.md | 285 ++++++++++++++++++ projects/contract-domain-extraction/plan.md | 41 ++- projects/contract-domain-extraction/spec.md | 20 ++ 3 files changed, 344 insertions(+), 2 deletions(-) diff --git a/docs/planning/mongo-target/1-design-docs/design-questions.md b/docs/planning/mongo-target/1-design-docs/design-questions.md index b7406166f3..d3d4c9bb24 100644 --- a/docs/planning/mongo-target/1-design-docs/design-questions.md +++ b/docs/planning/mongo-target/1-design-docs/design-questions.md @@ -318,3 +318,288 @@ Sub-questions: - **Runner integration**: The migration runner needs to execute Mongo update commands, not SQL. Does the runner use the same adapter interface the ORM uses, or does it need its own execution path? For the PoC: Out of scope for the Mongo workstream, but the april-milestone doc already notes the cross-workstream connection: "If the data invariant model from workstream 1 works well, it may become the foundation for document schema evolution." The Mongo workstream should validate that the invariant model's assumptions hold for a schemaless database — the main risk is that "postcondition = query" becomes expensive when you have to sample documents rather than inspect DDL. + +--- + +## 15. Polymorphic associations + +ADR 173 covers polymorphic *models* (a model that has specializations via `discriminator`/`variants`/`base`). Polymorphic *associations* are a different concept: a relation that can point to one of several different model types, distinguished by a type discriminator on the relation itself. + +Classic example: a `Comment` that can belong to either a `Post` or a `Video`: + +``` +Comment → commentable → Post | Video +``` + +This is not a polymorphic model — `Comment` is always a `Comment`. It's the *relation target* that varies. + +**The question**: How does the contract express a relation that can target one of N models? + +### SQL representations + +| Pattern | How it works | Trade-offs | +|---|---|---| +| **Type + ID pair** (Rails-style) | `commentable_type: string` + `commentable_id: int` | Widely used. No FK constraint possible — the database can't enforce referential integrity across multiple tables. | +| **Multiple nullable FKs** | `post_id: int?` + `video_id: int?` with a check constraint that exactly one is non-null | FK constraints work. Gets unwieldy with many targets. | +| **Join table per target** | `comment_posts(comment_id, post_id)` + `comment_videos(comment_id, video_id)` | Clean relational design. More tables, more joins. | + +### MongoDB representations + +| Pattern | How it works | Trade-offs | +|---|---|---| +| **DBRef-like** | `{ ref: ObjectId, refType: "Post" }` | Idiomatic. No database enforcement. | +| **Convention field** | `commentableId: ObjectId` + `commentableType: "Post" | "Video"` | Same as SQL type+ID pair. | + +### Contract considerations + +The current relation shape is: + +```json +{ + "to": "Post", "cardinality": "1:N", "strategy": "reference", + "on": { "localFields": ["authorId"], "targetFields": ["id"] } +} +``` + +A polymorphic association would need something like: + +```json +{ + "commentable": { + "cardinality": "N:1", + "strategy": "reference", + "polymorphic": true, + "discriminator": "commentableType", + "targets": { + "Post": { "on": { "localFields": ["commentableId"], "targetFields": ["id"] } }, + "Video": { "on": { "localFields": ["commentableId"], "targetFields": ["id"] } } + } + } +} +``` + +Sub-questions: +- **Is `polymorphic` a relation-level property, or does this decompose into multiple relations?** An alternative representation: the contract declares separate `commentablePost` and `commentableVideo` relations, and the ORM provides a union accessor `commentable` that dispatches based on `commentableType`. This avoids adding polymorphism to the relation model — the complexity lives in the ORM layer. +- **How does this interact with `model.relations` vs top-level relations?** If relations are on the model (per ADR 172), the polymorphic association lives on `Comment.relations`. +- **Type inference**: The ORM's return type for `comment.commentable` must be `Post | Video`. The discriminator field `commentableType` must narrow the type — same pattern as model polymorphism but on a relation. +- **Referential integrity**: SQL can't enforce FK constraints on type+ID polymorphic associations. Should the contract express this limitation, or is it an implementation detail? + +### Relationship to ADR 173 + +ADR 173's polymorphic models and polymorphic associations are orthogonal concepts — you can have one without the other. But they share the pattern of "discriminated union resolved by a type field." It's worth considering whether a unified mechanism serves both, or whether the concepts are different enough to warrant separate representations. + +Not yet designed. Not blocking for April — but should be designed before the contract shape stabilises. + +--- + +## 16. Union field types (mixed-type fields) + +A MongoDB document field can hold values of different BSON types — a field `score` might be an `Int32` in some documents and a `String` in others. This is common in untyped or evolving collections. + +**The question**: How does the contract represent a field that can hold one of several types? + +### The current model + +Today, a field has a single `codecId`: + +```json +{ "nullable": false, "codecId": "mongo/int32@1" } +``` + +This assumes every value in the field is the same type. For SQL, this is always true (columns are typed). For MongoDB, it's an aspiration — many real-world collections have mixed-type fields, either by design or from schema evolution. + +### Where this matters + +- **Introspection**: Introspecting an existing MongoDB collection will encounter mixed-type fields. The introspector needs to either pick one type and warn, or represent the union. +- **Schema evolution**: Migrating a field from `string` to `int` means documents will contain both types until migration completes. The contract should be able to describe this transitional state. +- **Intentional unions**: Some schemas intentionally use mixed types — a `metadata` field that can be a string or an object, a `value` field that can be a number or a string. +- **TypeScript type inference**: If a field can be `int | string`, the generated TypeScript type should be `number | string`. The codec layer needs to handle multiple possible BSON types for the same field. + +### Options + +**A. Array of codec IDs** + +```json +{ "nullable": false, "codecId": ["mongo/int32@1", "mongo/string@1"] } +``` + +Simple, but changes `codecId` from `string` to `string | string[]` everywhere it's consumed. Every consumer must handle the array case. + +**B. Dedicated union codec** + +```json +{ "nullable": false, "codecId": "mongo/union@1", "codecParams": { "types": ["int32", "string"] } } +``` + +Keeps `codecId` as a single string. The union codec is itself parameterised. More complex codec implementation, but the contract field shape doesn't change. + +**C. Discriminated field union (inline polymorphism)** + +```json +{ + "score": { + "nullable": false, + "union": [ + { "codecId": "mongo/int32@1" }, + { "codecId": "mongo/string@1" } + ] + } +} +``` + +Extends the field shape with a `union` property. Most explicit, but changes the field schema. + +### Relationship to polymorphic models + +Model-level polymorphism (ADR 173) and field-level unions are different granularities of the same idea: "this location can hold one of several shapes." Model polymorphism uses a discriminator field to distinguish shapes; field unions typically don't have a discriminator (the type is inspected at runtime). This is analogous to TypeScript's discriminated unions vs. plain unions. + +### SQL relevance + +This applies directly to SQL via `JSON`/`JSONB` columns. A JSON column can hold arbitrary structures, and the content may have mixed types at any level. If the contract represents typed JSON column content (which is a natural extension of the value objects work), the same union question applies. This is not a hypothetical future concern — SQL JSON columns are widely used today, and typed access to their contents is a common request. + +Not yet designed. Not blocking for April — the PoC contracts all have single-type fields. But this is a prerequisite for MongoDB introspection, for accurately modeling real-world Mongo collections, and for typed SQL JSON column access. + +--- + +## 17. Many-to-many relationships + +Many-to-many (M:N) relationships are common in both SQL and MongoDB, but they're modeled very differently. + +**The question**: How does the contract represent M:N relationships, and how does the ORM surface them? + +### SQL representations + +In SQL, M:N requires a join table: + +``` +User ←→ user_roles ←→ Role +``` + +The join table (`user_roles`) has foreign keys to both sides. Two 1:N relations compose into one logical M:N. Prisma ORM calls these "implicit many-to-many" (the join table is managed for you) or "explicit many-to-many" (the join table is a model you manage). + +| Pattern | How it works | Trade-offs | +|---|---|---| +| **Implicit join table** | ORM manages the join table transparently. User sees `user.roles` and `role.users`. | Clean API. Join table has no payload — can't add attributes like `assignedAt` to the relationship. | +| **Explicit join model** | `UserRole` is a model with `userId`, `roleId`, and optional payload fields. Two 1:N relations. | Flexible — join model can carry data. But the user manages three models instead of a logical two. | + +### MongoDB representations + +MongoDB has more options because documents can contain arrays: + +| Pattern | How it works | Trade-offs | +|---|---|---| +| **Array of references** | `User.roleIds: [ObjectId]` — each user stores an array of role IDs | Simple. No join collection. But getting all users for a role requires scanning all users. Bidirectional traversal is expensive unless both sides store arrays. | +| **Embedded array** | `User.roles: [{ name, permissions }]` — each user embeds the full role data | No joins needed. But denormalised — updating a role means updating every user. | +| **Join collection** | A `user_roles` collection, same as SQL | Normalised. Needs `$lookup` or multi-query. Less idiomatic for Mongo. | + +### Contract considerations + +The current relation model has `cardinality: "1:N" | "N:1"`. Adding `"M:N"` is a new cardinality, and it needs to be paired with storage details: + +**Option A: M:N as a contract primitive** + +```json +{ + "roles": { + "to": "Role", + "cardinality": "M:N", + "strategy": "reference", + "via": "user_roles" + } +} +``` + +The contract expresses the logical M:N. The `via` property names the join table/collection. The ORM manages traversal. + +**Option B: M:N decomposes into two 1:N relations** + +```json +"UserRole": { + "fields": { "userId": { ... }, "roleId": { ... } }, + "relations": { + "user": { "to": "User", "cardinality": "N:1", ... }, + "role": { "to": "Role", "cardinality": "N:1", ... } + } +} +``` + +No new cardinality. The user explicitly models the join entity. The ORM can provide a convenience accessor (`user.roles`) that traverses `user → userRoles → role`, but the contract only knows about 1:N relations. This is Prisma ORM's "explicit many-to-many" approach. + +**Option C: Array-of-references (Mongo-specific)** + +For MongoDB, an array of ObjectIds in the parent document is a common M:N pattern without a join collection: + +```json +{ + "roleIds": { "nullable": false, "codecId": "mongo/array@1" }, + "roles": { + "to": "Role", + "cardinality": "M:N", + "strategy": "reference", + "on": { "localFields": ["roleIds"], "targetFields": ["id"] } + } +} +``` + +The relation is backed by an array field on the model, not a join table. This is a Mongo-specific storage detail — the domain relation is still M:N, but the persistence mechanism is different from SQL's join table. + +### Sub-questions + +- **Implicit vs explicit**: Should the contract support implicit M:N (managed join table) or only explicit (user models the join entity)? Implicit is more ergonomic but hides structure; explicit is more honest but more verbose. +- **Array-of-references storage**: For Mongo, should M:N via arrays-of-references be a first-class storage strategy, or is it expressed differently? +- **Join entity payload**: If the relationship itself carries data (e.g., `assignedAt` on a user-role assignment), implicit M:N can't express this. Does the contract need to support both implicit (no payload) and explicit (with payload)? +- **Bidirectional traversal**: In the array-of-references pattern, only one side stores the array. Traversal from the other side requires a query. Should the contract express both directions, and how does the ORM handle the asymmetry? + +Note: the SQL contract currently has no way to record M:N relationships either. Join tables exist in `storage.tables` and the foreign keys are declared, but there's no contract-level concept that says "these two models have an M:N relationship via this join table." The relation model only knows `1:N` / `N:1`. This is a gap for both families, not just Mongo. + +Not yet designed. M:N was explicitly deferred for the SQL ORM. Now that we're modeling contract relations across families, it's worth designing — particularly because MongoDB's array-of-references pattern doesn't decompose naturally into two 1:N relations the way SQL's join table does. + +--- + +## 18. Relation `strategy` naming: fact or instruction? + +One of the contract's design principles is that it **describes facts, not instructions** (see [10. MongoDB Family](../../../architecture%20docs/subsystems/10.%20MongoDB%20Family.md)). The current relation design uses `"strategy": "reference" | "embed"` to describe how a relation is persisted. + +**The question**: Does the word `strategy` violate the facts-not-instructions principle? + +### The tension + +Compare how other storage facts are stated: + +- `"storage": { "table": "users" }` — fact: this model's data lives in the `users` table +- `"storage": { "collection": "users" }` — fact: this model's data lives in the `users` collection +- `"nullable": false` — fact: this field does not accept null values + +Now: `"strategy": "embed"` — this reads as an instruction ("use the embedding strategy") rather than a fact ("this relation's data is physically co-located in the parent document"). + +### Is it actually a fact? + +Yes — `strategy: "embed"` describes the physical arrangement of data. Address data is nested inside User documents. That's a structural fact about the database, not something the ORM chooses at query time. + +The problem is the *name*, not the *concept*. "Strategy" implies a choice being made, not a reality being described. The contract should read as "this is how the data is arranged" rather than "this is how you should arrange the data." + +### Alternative names + +| Name | Reads as | Example | +|---|---|---| +| `strategy` | "use this strategy" (instruction) | `"strategy": "embed"` | +| `persistence` | "persisted this way" (fact) | `"persistence": "embedded"` | +| `storage` | "stored this way" (fact) | `"storage": "embedded"` — conflicts with `model.storage` | +| `placement` | "placed here" (fact) | `"placement": "embedded"` | + +Or: drop the explicit property entirely. The fact that Address has `storage: {}` (no table/collection) already implies its data is embedded somewhere. The relation's `on` field describes the join details for references; its absence could signal embedding. + +### Derivability + +Could `strategy` be derived rather than stated? + +- If the target model has a `storage.collection`/`storage.table` → it's a reference (the target has its own storage location) +- If the target model has `storage: {}` → it's embedded (no independent storage) + +This would make embedding a consequence of the target model's storage declaration, not a property on the relation. But it breaks if the same model is embedded in one relation and referenced in another (which we haven't designed but could arise). + +### Recommendation + +If `strategy` remains, consider renaming to something that reads as descriptive rather than prescriptive. `persistence` is the strongest candidate — it clearly describes the physical arrangement without implying a runtime decision. + +This is a naming question, not a structural one — the concept itself (distinguishing embedded from referenced relations) is sound. Worth resolving before the contract shape stabilises. diff --git a/projects/contract-domain-extraction/plan.md b/projects/contract-domain-extraction/plan.md index ae78f5d898..670ad888ca 100644 --- a/projects/contract-domain-extraction/plan.md +++ b/projects/contract-domain-extraction/plan.md @@ -97,8 +97,9 @@ Removes the backward-compatibility shim from `validateContract()` and old fields - **3.3** Remove old model field shape (`{ column: string }` without `nullable`/`codecId`) from the type. - **3.4** Update `contract.d.ts` emission to reflect the final shape (no old fields). - **3.5** Remove old-format JSON support from `normalizeContract()` (if dual-format was added in 1.3.1). -- **3.6** Update all remaining test fixtures and type tests to reflect the clean types. -- **3.7** Run full test suite and typecheck. +- **3.6** Remove the generic `TModels` parameter from `ContractBase`. Once consumers read from domain-level fields and `SqlContract` no longer carries query-builder-specific model types via `M`, simplify `ContractBase` back to a concrete `models: Record`. The generic was introduced to avoid `noPropertyAccessFromIndexSignature` index-signature leakage while `SqlContract`'s `M` still overrides the base `models` type. +- **3.7** Update all remaining test fixtures and type tests to reflect the clean types. +- **3.8** Run full test suite and typecheck. ### Milestone 4: Contract IR alignment @@ -113,6 +114,40 @@ Aligns the internal `ContractIR` representation with the emitted contract JSON s - **4.5** Update IR-level tests and validation. - **4.6** Run full test suite and typecheck. +### Milestone 5: Emitter generalization + +Refactors the `TargetFamilyHook` interface so the framework `emit()` generates domain-level `.d.ts` content and the family hook provides only storage-specific type blocks. Today `sqlTargetFamilyHook.generateContractTypes()` owns the entire `.d.ts` — ~60–70% of which (roots, model domain fields, model relations, imports, hashes, codec types, the template skeleton) is family-agnostic. This means any new family emitter would duplicate all of it. After this milestone, a new family hook only needs to provide storage-specific type generation. + +Independent of Milestone 4 (IR alignment) — can be done before or after. + +**Tasks:** + +#### 5.1 Design the narrower hook interface + +- **5.1.1** Audit `sqlTargetFamilyHook` methods and classify each as domain-level (framework) or storage-level (family). Document the split in a short design note. +- **5.1.2** Design the new `TargetFamilyHook` interface: remove `generateContractTypes()`, add `generateStorageType(storage)`, `generateModelStorageType(model, storage)`, and any other family-specific type generation callbacks needed. Keep `validateTypes()` and `validateStructure()` on the hook. + +#### 5.2 Extract domain-level type generation to the framework + +- **5.2.1** Move `generateRootsType()` to the framework emitter. +- **5.2.2** Move model domain field type generation (`generateColumnType()`, the codec → TypeScript type logic, parameterized renderer dispatch) to the framework emitter. +- **5.2.3** Move model relation type generation (ADR 172 `to`/`cardinality`/`strategy`/`on` serialization) to the framework emitter. +- **5.2.4** Move import deduplication, hash type aliases, codec/operation type intersections, `DefaultLiteralValue`, `TypeMaps`, and the `.d.ts` template skeleton to the framework emitter. +- **5.2.5** The framework emitter calls the hook's storage-specific methods to fill in the storage sections, then assembles the complete `.d.ts`. + +#### 5.3 Update SQL hook to the narrower interface + +- **5.3.1** Implement `generateStorageType(storage)` on the SQL hook (extract from current `generateStorageType` — already a separate method, just needs to conform to the new interface). +- **5.3.2** Implement `generateModelStorageType(model, storage)` on the SQL hook (field-to-column mapping type generation, extracted from `generateModelsType`). +- **5.3.3** Remove `generateContractTypes()`, `generateModelsType()`, `generateRootsType()`, `generateRelationsType()`, `generateMappingsType()` from the SQL hook (now framework-owned or obsolete after M3). +- **5.3.4** Update `serializeValue()` / `serializeObjectKey()` — decide whether these are shared utilities (framework) or hook-specific. Likely framework. + +#### 5.4 Regression verification + +- **5.4.1** Verify generated `contract.d.ts` is byte-identical (modulo formatting) before and after the refactor, using the demo contract and all 12 parity fixtures. +- **5.4.2** Run full test suite and typecheck. +- **5.4.3** Update emitter hook tests to test the new interface methods individually. + ### Close-out - **C.1** Verify all acceptance criteria from the spec are met (cross-reference each criterion with its test evidence). @@ -142,6 +177,8 @@ Aligns the internal `ContractIR` representation with the emitted contract JSON s | Old field shape removed (Phase 3) | Type test + CI | 3.3, 3.7 | Compile-time verification | | `contract.d.ts` reflects final shape (Phase 3) | Unit | 3.4 | Emitter generation tests | | `ContractIR` mirrors emitted JSON (Phase 4) | Unit + Integration | 4.5 | IR tests | +| `TargetFamilyHook` no longer owns domain-level type generation (Phase 5) | Unit + Regression | 5.4.1–5.4.3 | Byte-identical `.d.ts` output; updated hook unit tests | +| New family hook only needs storage-specific methods (Phase 5) | Interface test | 5.1.2, 5.3 | Hook interface conformance | ## Open Items diff --git a/projects/contract-domain-extraction/spec.md b/projects/contract-domain-extraction/spec.md index 6497a1a545..6987073238 100644 --- a/projects/contract-domain-extraction/spec.md +++ b/projects/contract-domain-extraction/spec.md @@ -277,6 +277,19 @@ The JSON already lacks the old fields (removed in Phase 1). This phase removes t 1. **Align `ContractIR` with the new contract JSON structure.** Update the internal representation used during emission so it more closely mirrors the emitted JSON. This reduces impedance mismatch and makes it easier for the DSL layer to target the IR. Coordinate timing with Alberto. +### Phase 5: Emitter generalization + +With ADR 172's domain-storage separation, most of the `.d.ts` generation logic in `sqlTargetFamilyHook.generateContractTypes()` is now family-agnostic: roots, model domain fields (`nullable`, `codecId` → TypeScript types), model relations, import deduplication, hash type aliases, codec/operation type intersections, the `.d.ts` skeleton. Only the storage-level type generation (tables, columns, PKs, FKs, indexes, named type instances) and backward-compat types (`mappings`, old top-level `relations`) are genuinely SQL-specific. + +This phase refactors the `TargetFamilyHook` interface so the framework `emit()` generates domain-level `.d.ts` content and the family hook provides only storage-specific type blocks. This eliminates the need for each family to duplicate ~60–70% of the type generation logic when implementing a new family emitter (e.g., Mongo). + +1. **Refactor `TargetFamilyHook` interface.** Replace the monolithic `generateContractTypes()` method with a narrower interface. The framework generates domain-level sections (roots type, model domain fields, model relations, imports, hashes, codec types, `.d.ts` skeleton). The hook provides: `generateStorageType(storage)`, `generateModelStorageType(model)`, and any family-specific type blocks. +2. **Move domain-level type generation to the framework emitter.** Extract `generateRootsType()`, model field type generation (`generateColumnType()`), model relation type generation, import deduplication, hash aliases, and the `.d.ts` template from the SQL hook into the framework's `emit()`. +3. **Update SQL hook to implement the narrower interface.** The SQL hook retains `generateStorageType()` (tables/columns/PKs/FKs/indexes), `generateStorageTypesType()` (named type instances), and validation methods. It no longer owns the `.d.ts` skeleton or domain-level type generation. +4. **Verify emitter output is identical.** The generated `contract.d.ts` must be byte-identical before and after the refactor (modulo formatting). Use the demo contract and parity fixtures as regression tests. + +This phase is independent of Phase 4 (IR alignment) and can be done before or after it. + ## Non-Functional Requirements - **Zero breakage during Phase 1.** All existing tests, the demo app, and downstream consumers must continue working without modification when Phase 1 lands. `validateContract()` bridges the new JSON structure to the old consumer-facing type. @@ -334,6 +347,13 @@ The JSON already lacks the old fields (removed in Phase 1). This phase removes t - [ ] `ContractIR` mirrors the emitted contract JSON structure (domain/storage separation, model-level relations, `roots`) +### Phase 5: Emitter generalization + +- [ ] `TargetFamilyHook` no longer has a monolithic `generateContractTypes()` — domain-level type generation lives in the framework `emit()` +- [ ] The SQL hook provides only storage-specific type generation (`generateStorageType`, `generateModelStorageType`) and family-specific validation +- [ ] Generated `contract.d.ts` output is identical before and after the refactor (regression-tested against demo and parity fixtures) +- [ ] A new family emitter (e.g., Mongo) would not need to duplicate domain-level type generation logic + # Other Considerations ## Security From c1b9bc78d3b346c68897e998db332ebad357cdc9 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 19:01:28 +0200 Subject: [PATCH 14/35] ADR 177: ownership replaces relation strategy Introduce `owner` on models as a domain-level fact for aggregate membership, replacing `strategy: "reference" | "embed"` on relations. Relations become plain graph edges; the parent's `storage.relations` maps owned relations to physical locations. This resolves the tension that `strategy` read as an instruction rather than a fact, and that embedding was declared on the wrong object (the relation edge instead of the owned model itself). --- ... - Ownership replaces relation strategy.md | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md diff --git a/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md b/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md new file mode 100644 index 0000000000..1e20e4579c --- /dev/null +++ b/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md @@ -0,0 +1,236 @@ +# ADR 177 — Ownership replaces relation strategy + +## At a glance + +A User model with a referenced relation (Post) and an owned model (Address). Address declares `"owner": "User"` — a domain fact. The relation from User to Address is a plain graph edge with no storage annotation. The parent's `storage.relations` maps the relation to a physical location. + +**Mongo contract:** + +```json +{ + "roots": { + "users": "User", + "posts": "Post" + }, + "models": { + "User": { + "fields": { + "id": { "nullable": false, "codecId": "mongo/objectId@1" }, + "email": { "nullable": false, "codecId": "mongo/string@1" } + }, + "relations": { + "posts": { + "to": "Post", "cardinality": "1:N", + "on": { "localFields": ["id"], "targetFields": ["authorId"] } + }, + "addresses": { "to": "Address", "cardinality": "1:N" } + }, + "storage": { + "collection": "users", + "relations": { + "addresses": { "field": "addresses" } + } + } + }, + "Post": { + "fields": { + "id": { "nullable": false, "codecId": "mongo/objectId@1" }, + "title": { "nullable": false, "codecId": "mongo/string@1" }, + "authorId": { "nullable": false, "codecId": "mongo/objectId@1" } + }, + "relations": { + "author": { + "to": "User", "cardinality": "N:1", + "on": { "localFields": ["authorId"], "targetFields": ["id"] } + } + }, + "storage": { "collection": "posts" } + }, + "Address": { + "owner": "User", + "fields": { + "street": { "nullable": false, "codecId": "mongo/string@1" }, + "city": { "nullable": false, "codecId": "mongo/string@1" } + }, + "relations": {}, + "storage": {} + } + } +} +``` + +**SQL contract (same domain, different storage):** + +```json +{ + "roots": { + "users": "User", + "posts": "Post" + }, + "models": { + "User": { + "fields": { + "id": { "nullable": false, "codecId": "pg/int4@1" }, + "email": { "nullable": false, "codecId": "pg/text@1" } + }, + "relations": { + "posts": { + "to": "Post", "cardinality": "1:N", + "on": { "localFields": ["id"], "targetFields": ["authorId"] } + }, + "addresses": { "to": "Address", "cardinality": "1:N" } + }, + "storage": { + "table": "users", + "fields": { + "id": { "column": "id" }, + "email": { "column": "email" } + }, + "relations": { + "addresses": { "column": "address_data" } + } + } + }, + "Address": { + "owner": "User", + "fields": { + "street": { "nullable": false, "codecId": "pg/text@1" }, + "city": { "nullable": false, "codecId": "pg/text@1" } + }, + "relations": {}, + "storage": {} + } + } +} +``` + +Three things to notice: + +1. **`owner: "User"` is on the model, not the relation.** Address declares where it belongs — a domain fact about the model itself. This mirrors how `base` declares polymorphic specialization. +2. **Relations are plain graph edges.** `{ "to": "Address", "cardinality": "1:N" }` — no `strategy`, no storage annotation. The relation describes the graph structure, nothing more. +3. **`storage.relations` maps owned relations to physical locations.** In Mongo, Address data lives in the `addresses` field of User's document. In SQL, it lives in the `address_data` JSONB column on the `users` table. This parallels how `storage.fields` maps scalar fields to columns. + +## Context + +[ADR 174](ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md) introduced `"strategy": "reference" | "embed"` on relations to distinguish between cross-collection references and embedded sub-documents. This solved the problem of expressing where related data physically lives, but the design had several issues that became apparent during further modelling: + +- **`strategy` reads as an instruction, not a fact.** One of the contract's design principles is that it describes facts about the data, not instructions for the ORM. "Use the embedding strategy" is prescriptive; "Address is part of User" is descriptive. + +- **Embedding was on the wrong object.** "Address is a component of User" is a fact about Address, not about the edge from User to Address. The relation from User to Address is just a graph edge — the fact that Address lives inside User's storage is a property of Address itself. + +- **The relation mixed domain and storage concerns.** `strategy: "embed"` is really saying two things at once: a domain fact (Address belongs to User) and a storage fact (Address data is co-located with User's data). These belong in different places per the domain-storage separation principle ([ADR 172](ADR%20172%20-%20Contract%20domain-storage%20separation.md)). + +- **The physical storage location was missing.** `strategy: "embed"` said "this relation is embedded" but didn't say *where* — which field in the parent document holds the embedded data, or which JSONB column in the SQL table. This information was left as an open question. + +## Problem + +How should the contract express that a model's data lives inside another model's storage, in a way that: + +1. States a domain fact (component membership) separately from storage details (physical location) +2. Is self-describing on the model, not just on the relation +3. Tells the ORM where to find the data in the parent's storage + +## Decision + +### `owner` on the model declares component membership + +An owned model declares its owner with `"owner": "ModelName"`: + +```json +"Address": { + "owner": "User", + "fields": { ... }, + "storage": {} +} +``` + +This is a domain-level fact: "Address is a component of User." It means: + +- Address has no independent storage — its `storage` block is empty. +- Address data is co-located with its owner's storage. In Mongo, this means an embedded document. In SQL, this means a JSONB column or denormalized columns on the owner's table. +- Address is not an ORM entry point — it doesn't appear in `roots`. +- Address's lifecycle is bound to User's. Deleting a User deletes its Addresses. + +The pattern mirrors `base` for polymorphism: just as `Bug` says `"base": "Task"` to declare it specializes Task, `Address` says `"owner": "User"` to declare it belongs to User. + +### `strategy` is removed from relations + +Relations become plain graph edges describing connections between models: + +```json +"relations": { + "posts": { + "to": "Post", "cardinality": "1:N", + "on": { "localFields": ["id"], "targetFields": ["authorId"] } + }, + "addresses": { "to": "Address", "cardinality": "1:N" } +} +``` + +A relation to a model that has `owner` (Address) carries no `on` block — there's no foreign key join for co-located data. A relation to an independent model (Post) carries `on` with join details, as before. The distinction between "owned" and "referenced" is derivable from the target model's `owner` property, but the domain-level relation doesn't need to state it. + +### `storage.relations` maps owned relations to physical locations + +The parent's storage section gains a `relations` block that maps relation names to physical locations, parallel to how `storage.fields` maps field names: + +```json +"storage": { + "collection": "users", + "fields": { + "id": { "field": "_id" }, + "email": { "field": "email" } + }, + "relations": { + "addresses": { "field": "addresses" } + } +} +``` + +In Mongo, `"field": "addresses"` means the `addresses` array in the document. In SQL, `"column": "address_data"` means the JSONB column on the table. + +This solves the open question from ADR 174 about where embedded data physically lives — it's in the parent's storage mapping, alongside the field mappings. + +### Three ways a model declares its place in the graph + +| Declaration | Meaning | Example | +|---|---|---| +| Present in `roots` | "I am an ORM entry point" | `"roots": { "users": "User" }` | +| `owner` | "I belong to this model's aggregate" | `"owner": "User"` | +| `base` | "I specialize this model" | `"base": "Task"` | + +All three are domain facts, stated on the model itself, self-describing. + +## Consequences + +### Benefits + +- **Domain-storage separation is clean.** `owner` is a domain fact. `storage.relations` is a storage mapping. They're in different sections, serving different consumers. +- **Relations are simple.** Graph edges with `to`, `cardinality`, and optional `on`. No storage annotations on domain-level relations. +- **Self-describing models.** Looking at Address, you immediately see `"owner": "User"` — you don't need to search other models' relations for a `strategy: "embed"` annotation to understand where Address belongs. +- **The contract states facts, not instructions.** "Address is owned by User" describes a relationship. "Use the embedding strategy" prescribes behavior. +- **Storage location is explicit.** `storage.relations` tells the ORM exactly which field/column holds the embedded data, solving the open question from ADR 174. + +### Costs + +- **A model can only have one owner.** This is intentional — it aligns with the principle that each model has one canonical storage location. If Address is owned by User, its data lives in User's storage, period. The same Address *type* could theoretically be reused (as a value object), but that's a separate concept not yet designed. +- **Supersedes ADR 174's relation strategy design.** Existing contract examples, design docs, and the Mongo PoC implementation use `strategy: "reference" | "embed"`. These need updating. + +### What this changes from ADR 174 + +| Aspect | ADR 174 | This ADR | +|---|---|---| +| Where embedding is declared | On the relation: `"strategy": "embed"` | On the model: `"owner": "User"` | +| Physical location of embedded data | Open question | `storage.relations` on the parent | +| Relation shape | `{ to, cardinality, strategy, on }` | `{ to, cardinality, on? }` — no `strategy` | +| `"strategy": "reference"` | Explicit on reference relations | Absent — references are the default (relation has `on`) | + +### Open questions + +- **Nested ownership.** Can an owned model itself own other models? (e.g., User → Order → LineItem, where Order is `owner: "User"` and LineItem is `owner: "Order"`). The design supports this — ownership is just a property declaration — but the storage implications (nested embedding) need validation. +- **Value objects vs owned entities.** An owned Address with an `_id` is an entity. An owned Address without identity is a value object. The contract doesn't yet distinguish these. See the open question in [ADR 174](ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md). + +## Related + +- [ADR 172 — Contract domain-storage separation](ADR%20172%20-%20Contract%20domain-storage%20separation.md) — domain/storage principle this ADR builds on +- [ADR 174 — Aggregate roots and relation strategies](ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md) — superseded relation strategy design +- [design-questions.md § Q18](../../planning/mongo-target/1-design-docs/design-questions.md) — the discussion that led to this decision From c08f70f96af1541a249f29e39021916143b9599b Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 19:01:36 +0200 Subject: [PATCH 15/35] update docs to use owner/storage.relations instead of strategy Propagate ADR 177 across all architecture docs, design docs, glossary, reference material, and project specs. Replaces strategy: "embed"/"reference" with the owner model property and storage.relations mapping. --- .../skills/write-architecture-docs/SKILL.md | 6 +- ...72 - Contract domain-storage separation.md | 8 ++- ...morphism via discriminator and variants.md | 4 +- ...Aggregate roots and relation strategies.md | 3 + .../subsystems/1. Data Contract.md | 2 +- .../subsystems/10. MongoDB Family.md | 39 ++++++----- docs/glossary.md | 9 ++- .../1-design-docs/contract-symmetry.md | 7 +- .../1-design-docs/design-questions.md | 69 +++++-------------- .../1-design-docs/mongo-poc-plan.md | 4 +- .../mongo-target/cross-cutting-learnings.md | 6 +- .../mongodb-feature-support-priorities.md | 2 +- docs/reference/mongodb-user-journey.md | 2 +- docs/reference/mongodb-user-promise.md | 4 +- projects/contract-domain-extraction/spec.md | 5 +- 15 files changed, 73 insertions(+), 97 deletions(-) diff --git a/.agents/skills/write-architecture-docs/SKILL.md b/.agents/skills/write-architecture-docs/SKILL.md index 3492a52921..6e0d554dbe 100644 --- a/.agents/skills/write-architecture-docs/SKILL.md +++ b/.agents/skills/write-architecture-docs/SKILL.md @@ -27,7 +27,7 @@ Architecture docs in this repo serve two audiences: team members working on the **Write for a developer without prior context.** Imagine someone joining the team and reading this doc as their first exposure to this part of the system. -- Explain *why* before *what*. Before introducing a concept like `strategy: "embed"`, explain the problem it solves: "In SQL, related data lives in separate tables and is joined at query time. In MongoDB, the idiomatic pattern is to store related data inside the parent document." +- Explain *why* before *what*. Before introducing a concept like model ownership, explain the problem it solves: "In SQL, related data lives in separate tables and is joined at query time. In MongoDB, the idiomatic pattern is to store related data inside the parent document." - Let ideas breathe. Don't compress three concepts into one sentence. If a sentence requires the reader to already understand three things to parse it, break it apart. - Use concrete examples — code snippets, JSON fragments, "a developer writing X gets Y under the hood." Abstract descriptions are hard to pin understanding to. @@ -46,8 +46,8 @@ Bad: "MongoDB is a database family in Prisma Next. The contract, ORM, execution **Inline summaries with ADR links.** When referencing an ADR, summarize the key idea in the text and link the ADR for depth. The doc should be understandable without following any links. -Good: "Embedding is a property of the *relation*, not the model. A relation with `"strategy": "embed"` means the related model lives nested inside the parent's document. See [ADR 174](...)." -Bad: "See [ADR 174](...) for how embedding works." +Good: "An owned model declares `owner: \"User\"` — a domain fact about aggregate membership. Its data lives within the owner's storage. See [ADR 177](...)." +Bad: "See [ADR 177](...) for how embedding works." **References section.** Organize by durability: 1. Architecture decisions (ADRs) — first diff --git a/docs/architecture docs/adrs/ADR 172 - Contract domain-storage separation.md b/docs/architecture docs/adrs/ADR 172 - Contract domain-storage separation.md index 592603fd9e..1bbc2a423a 100644 --- a/docs/architecture docs/adrs/ADR 172 - Contract domain-storage separation.md +++ b/docs/architecture docs/adrs/ADR 172 - Contract domain-storage separation.md @@ -130,7 +130,7 @@ Each model's domain section should give a reader a complete picture of the field The contract has three levels, each serving a different consumer: -1. **Domain level** (`roots`, `model.fields`, `model.relations`, `model.discriminator`/`variants`) — what the application models. Family-agnostic structure. Consumed by the ORM for type inference, by agents for understanding the data model, by any tool that doesn't need to know about storage. +1. **Domain level** (`roots`, `model.fields`, `model.relations`, `model.owner`, `model.discriminator`/`variants`) — what the application models. Family-agnostic structure. Consumed by the ORM for type inference, by agents for understanding the data model, by any tool that doesn't need to know about storage. 2. **Model storage bridge** (`model.storage`) — how domain fields connect to persistence. Sits on the model to preserve co-location. SQL carries field-to-column mappings because field names and column names can differ; Mongo carries only the collection name. `model.storage.fields` is available to Mongo should field name remapping ever be needed (e.g., `createdAt` → `_created_at`), but typically Mongo doesn't need it. 3. **Top-level storage** (`storage`) — the database schema itself. SQL: every table, every column with its native type, nullability constraint, default, plus indexes and foreign keys. Mongo: collection metadata (indexes, validators). Consumed by migration tooling, schema introspection, and DDL generation. @@ -158,7 +158,8 @@ For Mongo, the redundancy is much smaller. There's no column indirection, so `mo ### Other domain-level properties -- **`model.relations`** — connections to other models with cardinality and strategy (see [ADR 174](ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md)). +- **`model.relations`** — connections to other models with cardinality and optional join details (see [ADR 174](ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md)). +- **`model.owner`** — declares aggregate membership: an owned model's data is co-located with its owner's storage (see [ADR 177](ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md)). - **`model.discriminator`** + **`model.variants`** — optional polymorphism declaration (see [ADR 173](ADR%20173%20-%20Polymorphism%20via%20discriminator%20and%20variants.md)). **Note — relations placement is a change from the current contract.** The current SQL emitter produces a top-level `relations` block as a sibling of `models`, keyed by model name (e.g., `contract.relations.user.posts`). The SQL ORM client consumes relations from this top-level block. This ADR moves relations onto each model (`model.relations`) because a model's relationships are part of its domain description — a reader should be able to understand a model completely without consulting a separate section. The current top-level placement was not a deliberate design choice; it diverged from the test fixtures (which use the nested form) during emitter development. The SQL emitter and ORM client will need to be updated to match. @@ -167,7 +168,7 @@ For Mongo, the redundancy is much smaller. There's no column indirection, so `mo ### Benefits -- **Shared contract base is viable.** The domain level (`roots`, `models` with `fields`/`discriminator`/`variants`, `relations`) is structurally identical between families. A `ContractBase` type can capture this, with `model.storage` as a generic/family-specific extension point. +- **Shared contract base is viable.** The domain level (`roots`, `models` with `fields`/`discriminator`/`variants`/`owner`, `relations`) is structurally identical between families. A `ContractBase` type can capture this, with `model.storage` as a generic/family-specific extension point. - **Consumer libraries can be family-agnostic** for domain-level operations (listing models, traversing relations, finding aggregate roots). - **Each family controls its own storage representation** without compromising the other. - **The storage divergence is narrower.** Moving `codecId` to the domain level means Mongo's `model.storage` is just a collection name. The remaining divergence (SQL's field-to-column mappings) reflects a genuine structural difference. @@ -194,6 +195,7 @@ For Mongo, the redundancy is much smaller. There's no column indirection, so `mo - [ADR 173 — Polymorphism via discriminator and variants](ADR%20173%20-%20Polymorphism%20via%20discriminator%20and%20variants.md) - [ADR 174 — Aggregate roots and relation strategies](ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md) +- [ADR 177 — Ownership replaces relation strategy](ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md) — `owner` on models replaces `strategy` on relations - [Data Contract subsystem doc](../subsystems/1.%20Data%20Contract.md) — contract structure and semantics - [MongoDB Family subsystem doc](../subsystems/10.%20MongoDB%20Family.md) — Mongo contract, ORM, and execution pipeline diff --git a/docs/architecture docs/adrs/ADR 173 - Polymorphism via discriminator and variants.md b/docs/architecture docs/adrs/ADR 173 - Polymorphism via discriminator and variants.md index 5bbc6d9690..263bea1b6e 100644 --- a/docs/architecture docs/adrs/ADR 173 - Polymorphism via discriminator and variants.md +++ b/docs/architecture docs/adrs/ADR 173 - Polymorphism via discriminator and variants.md @@ -21,7 +21,7 @@ A polymorphic Task model with Bug and Feature variants. Task declares which fiel "Feature": { "value": "feature" } }, "relations": { - "assignee": { "to": "User", "cardinality": "N:1", "strategy": "reference" } + "assignee": { "to": "User", "cardinality": "N:1", "on": { "localFields": ["assigneeId"], "targetFields": ["id"] } } }, "storage": { "table": "tasks", @@ -157,7 +157,7 @@ All persistence-level polymorphism reduces to "multiple shapes in the same stora A model can be simultaneously: - Polymorphic (has `discriminator` + `variants`) AND an aggregate root (appears in `roots`) -- A variant AND embedded (parent has `"strategy": "embed"`) +- A variant AND owned (has `"owner": "ParentModel"`) - Polymorphic AND embedded These are independent properties. This composability is why we rejected labeled strategies — they create a false choice between roles that are actually orthogonal. diff --git a/docs/architecture docs/adrs/ADR 174 - Aggregate roots and relation strategies.md b/docs/architecture docs/adrs/ADR 174 - Aggregate roots and relation strategies.md index 33448da870..6e978efa1f 100644 --- a/docs/architecture docs/adrs/ADR 174 - Aggregate roots and relation strategies.md +++ b/docs/architecture docs/adrs/ADR 174 - Aggregate roots and relation strategies.md @@ -1,5 +1,7 @@ # ADR 174 — Aggregate roots and relation strategies +> **Partial supersession:** The **relation strategy** design (`"strategy": "reference" | "embed"`) in this ADR has been superseded by [ADR 177 — Ownership replaces relation strategy](ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md). Embedding is now expressed via `"owner": "ParentModel"` on the owned model, with `storage.relations` on the parent mapping the relation to its physical location. The **aggregate roots** design (`roots` section) remains unchanged. + ## At a glance A User model as an aggregate root with a referenced relation (Post) and an embedded relation (Address). Post is also an aggregate root. Address is not — it only exists inside User documents. @@ -180,6 +182,7 @@ This design means: - [ADR 172 — Contract domain-storage separation](ADR%20172%20-%20Contract%20domain-storage%20separation.md) — why `model.storage` stays on the model - [ADR 173 — Polymorphism via discriminator and variants](ADR%20173%20-%20Polymorphism%20via%20discriminator%20and%20variants.md) — why strategy labels are problematic +- [ADR 177 — Ownership replaces relation strategy](ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md) — supersedes the relation `strategy` design; `owner` on models replaces `strategy` on relations - [design-questions.md § DQ #1](../../planning/mongo-target/1-design-docs/design-questions.md) — embedded documents resolution - [cross-cutting-learnings.md § learning #1](../../planning/mongo-target/cross-cutting-learnings.md) — explicit aggregate roots - [cross-cutting-learnings.md § learning #5](../../planning/mongo-target/cross-cutting-learnings.md) — models are entities, not just data descriptions diff --git a/docs/architecture docs/subsystems/1. Data Contract.md b/docs/architecture docs/subsystems/1. Data Contract.md index 2de9f54ca7..c750d4c212 100644 --- a/docs/architecture docs/subsystems/1. Data Contract.md +++ b/docs/architecture docs/subsystems/1. Data Contract.md @@ -167,7 +167,7 @@ The contract's domain-level structure generalizes across database families. The - **`roots`** — explicit aggregate roots mapping ORM accessor names to models (e.g., `"users": "User"`). Models not in `roots` are accessed through relations or variant relationships. See [ADR 174](../adrs/ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md). - **`model.fields`** — domain-level field descriptors as `Record`. Both `nullable` and `codecId` are domain concepts. "Family-agnostic" describes the structure, not the values — codec ID values differ across families but the structure is identical. See [ADR 172](../adrs/ADR%20172%20-%20Contract%20domain-storage%20separation.md). - **Polymorphism** — `discriminator` + `variants` on the base model, `base` on each variant. Persistence strategy (STI vs MTI) is emergent from storage mappings. Uses specialization/generalization terminology. See [ADR 173](../adrs/ADR%20173%20-%20Polymorphism%20via%20discriminator%20and%20variants.md). -- **Embedded types** — embedding is a relation property (`"strategy": "embed"`), not a model property. Embedded models in Mongo and typed JSON columns in SQL are the same contract-level problem. See [ADR 174](../adrs/ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md). +- **Model ownership** — an owned model declares `"owner": "ParentModel"`, a domain fact about aggregate membership. Its data is co-located with the owner's storage (embedded document in Mongo, JSONB column in SQL). The parent's `storage.relations` maps the relation to its physical location. Relations to owned models are plain graph edges. See [ADR 177](../adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md). - **Entity vs value object** — models are entities (have identity and lifecycle). Value objects (no identity, defined by their properties) are a separate concept that belongs in a dedicated contract section. ### Core entities vs pack extensions diff --git a/docs/architecture docs/subsystems/10. MongoDB Family.md b/docs/architecture docs/subsystems/10. MongoDB Family.md index ca91713500..a394d96016 100644 --- a/docs/architecture docs/subsystems/10. MongoDB Family.md +++ b/docs/architecture docs/subsystems/10. MongoDB Family.md @@ -28,7 +28,7 @@ A Mongo contract describes the same things as a SQL contract — models, fields, }, "relations": { "posts": { - "to": "Post", "cardinality": "1:N", "strategy": "reference", + "to": "Post", "cardinality": "1:N", "on": { "localFields": ["_id"], "targetFields": ["authorId"] } } }, @@ -42,17 +42,20 @@ A Mongo contract describes the same things as a SQL contract — models, fields, }, "relations": { "author": { - "to": "User", "cardinality": "N:1", "strategy": "reference", + "to": "User", "cardinality": "N:1", "on": { "localFields": ["authorId"], "targetFields": ["_id"] } }, - "comments": { - "to": "Comment", "cardinality": "1:N", "strategy": "embed", - "field": "comments" - } + "comments": { "to": "Comment", "cardinality": "1:N" } }, - "storage": { "collection": "posts" } + "storage": { + "collection": "posts", + "relations": { + "comments": { "field": "comments" } + } + } }, "Comment": { + "owner": "Post", "fields": { "_id": { "nullable": false, "codecId": "mongo/objectId@1" }, "text": { "nullable": false, "codecId": "mongo/string@1" }, @@ -68,7 +71,7 @@ A Mongo contract describes the same things as a SQL contract — models, fields, A few things to notice before reading further: - **`roots`** declares which models are ORM entry points — `db.users` and `db.posts`. Comment is not in `roots` because it's only reachable through Post. -- **`strategy: "reference"`** on the `posts` relation means Post lives in its own collection. **`strategy: "embed"`** on `comments` means Comment documents live nested inside Post documents. +- **`owner: "Post"`** on the Comment model means Comment belongs to Post's aggregate. Its data lives inside Post documents. The relation from Post to Comment (`{ "to": "Comment", "cardinality": "1:N" }`) is a plain graph edge — no storage annotation. The physical location is in Post's `storage.relations`: `"comments": { "field": "comments" }`. - **Comment has `storage: {}`** — empty, because it doesn't own a collection. Its data lives within Post's storage. Note that Comment is still an entity with its own `_id` — it has identity even when embedded. Value objects (data without identity, like an address or a money amount) are a [separate concept not yet represented in the contract](#open-questions). - **Field structure is identical to SQL.** Each field is `{ nullable, codecId }`. A Mongo field says `"mongo/string@1"` where SQL would say `"pg/text@1"`, but the shape is the same. - **Storage is much thinner than SQL.** SQL needs field-to-column mappings; Mongo just needs the collection name, because document fields match domain fields directly. @@ -89,7 +92,7 @@ These principles shape all contract and ORM design decisions — not just for Mo **5. Each model has one canonical storage location.** A model (entity) belongs to exactly one aggregate. It either owns its own collection/table (aggregate root) or it lives inside another model's storage (embedded entity). This is a consistency boundary: if a model could live in two places, two consumers could independently mutate it, breaking invariant guarantees. When you need data from one aggregate root available inside another for read performance, that's denormalization — the embedded copy is a snapshot (a value object), not the model itself. The canonical model lives in one place. -**6. Embedding is a property of the relation, not the model.** The parent's relation declaration decides whether the related model is stored by reference or by embedding. The embedded model itself doesn't know where it lives — its `storage` block is empty. This means the same model can be embedded in different parents, and the storage strategy can change without modifying the model. +**6. Ownership is a domain fact stated on the model.** An owned model declares `"owner": "ParentModel"` — a domain fact about aggregate membership. This mirrors how `base` declares polymorphic specialization. Relations to owned models are plain graph edges (no storage annotations); the parent's `storage.relations` maps them to physical locations. See [ADR 177](../adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md). ## How PN accommodates MongoDB @@ -99,18 +102,19 @@ MongoDB's data model stresses every layer of the PN stack in ways that SQL doesn In SQL, related data lives in separate tables and is joined at query time. In MongoDB, the idiomatic pattern is to store related data *inside* the parent document — a Post document contains an array of Comment subdocuments. This means the ORM needs to know the difference between "this relation points to another collection" and "this relation is stored inside the parent document." -The contract captures this with a `strategy` property on each relation: +The contract captures this with the `owner` property on the owned model: -- `"strategy": "reference"` — the related model lives in its own collection. Resolved at query time via `$lookup` (MongoDB's join equivalent) or by running a second query and stitching the results together. -- `"strategy": "embed"` — the related model lives nested inside the parent's document. No join needed; the data comes back in the same query. +- An owned model declares `"owner": "ParentModel"` — a domain-level fact stating that the model belongs to the parent's aggregate. Its `storage` block is empty (`{}`), meaning "I don't own a collection; my data lives within my owner's storage." +- The parent's `storage.relations` maps the relation to its physical storage location: `"comments": { "field": "comments" }` for Mongo, `"comments": { "column": "comments_data" }` for SQL JSONB. +- Relations to owned models are plain graph edges: `{ "to": "Comment", "cardinality": "1:N" }` — no storage annotation on the relation itself. -Importantly, embedding is a property of the *relation*, not the model. The same `Comment` model could be embedded inside `Post` documents in one contract and stored in its own collection in another. The `Comment` model itself doesn't know where it's stored — its `storage` block is empty (`{}`), meaning "I don't own a collection; my data lives wherever a parent embeds me." See [ADR 174](../adrs/ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md). +A referenced relation (to a model with its own collection) carries `on` join details: `{ "to": "Post", "cardinality": "1:N", "on": { "localFields": ["_id"], "targetFields": ["authorId"] } }`. The absence of `owner` on the target model — plus the presence of `on` — distinguishes references from owned relations. See [ADR 177](../adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md). -This also introduces the concept of **aggregate roots**: models that own their own collection and can be queried directly. In SQL, every model has its own table, so this distinction is invisible. In MongoDB, some models are embedded and can only be reached through a parent. The contract makes this explicit with a top-level `roots` section that maps ORM accessor names (like `db.users`) to model names (like `User`). If a model isn't in `roots`, it's not an independent entry point. +This also introduces the concept of **aggregate roots**: models that own their own collection and can be queried directly. In SQL, every model has its own table, so this distinction is invisible. In MongoDB, some models are owned by a parent and can only be reached through it. The contract makes this explicit with a top-level `roots` section that maps ORM accessor names (like `db.users`) to model names (like `User`). If a model isn't in `roots`, it's not an independent entry point. See [ADR 174](../adrs/ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md). -Each model has exactly one canonical storage location — either its own collection (aggregate root) or inside a parent's document (embedded). A model can't be both. When developers need data from one aggregate root available inside another for read performance (the MongoDB "extended reference" pattern), the embedded copy is a denormalized snapshot — a value object — not the model itself. The canonical Post lives in the `posts` collection; a `{ title, slug }` summary embedded in a User document is a different thing, even though it shares some fields. The contract does not yet have a representation for value objects — see [Open questions](#open-questions). See also [design principle #5](#design-principles). +Each model has exactly one canonical storage location — either its own collection (aggregate root) or inside a parent's document (owned). A model can't be both. When developers need data from one aggregate root available inside another for read performance (the MongoDB "extended reference" pattern), the embedded copy is a denormalized snapshot — a value object — not the model itself. The canonical Post lives in the `posts` collection; a `{ title, slug }` summary embedded in a User document is a different thing, even though it shares some fields. The contract does not yet have a representation for value objects — see [Open questions](#open-questions). See also [design principle #5](#design-principles). -In the ORM, embedded relations auto-project into the parent row — no separate query is issued. Referenced relations use `$lookup` pipeline stages or multi-query stitching, similar to how SQL handles joins. +In the ORM, owned relations auto-project into the parent row — no separate query is issued. Referenced relations use `$lookup` pipeline stages or multi-query stitching, similar to how SQL handles joins. ### No schema enforcement @@ -276,8 +280,9 @@ These are design refinements, not architectural risks. The full analysis is in t - [ADR 172 — Contract domain-storage separation](../adrs/ADR%20172%20-%20Contract%20domain-storage%20separation.md) — the domain/storage split that enables a shared `ContractBase` - [ADR 173 — Polymorphism via discriminator and variants](../adrs/ADR%20173%20-%20Polymorphism%20via%20discriminator%20and%20variants.md) — how the contract represents polymorphism -- [ADR 174 — Aggregate roots and relation strategies](../adrs/ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md) — explicit roots, embed vs. reference +- [ADR 174 — Aggregate roots and relation strategies](../adrs/ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md) — explicit roots, aggregate root declaration - [ADR 175 — Shared ORM Collection interface](../adrs/ADR%20175%20-%20Shared%20ORM%20Collection%20interface.md) — fluent chaining as the shared ORM API +- [ADR 177 — Ownership replaces relation strategy](../adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md) — `owner` on models replaces `strategy` on relations **Reference material:** diff --git a/docs/glossary.md b/docs/glossary.md index 388bf1a14a..baab85087a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -83,14 +83,13 @@ Value objects are a future concept in the contract; currently they're represente > **Status:** Not yet implemented in the contract. Tracked as an open question. -### Relation Strategy +### Owner -How a relation between two models is persisted. Each relation in the contract declares one of two strategies: +A domain-level property on a model declaring aggregate membership. If Address says `"owner": "User"`, it means Address is a component of User's aggregate — its data is co-located within User's storage (embedded document in MongoDB, JSONB column in SQL). Owned models don't appear in `roots` and have empty `storage` blocks. The parent's `storage.relations` maps the relation to its physical location. See [ADR 177](architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md). -- **`reference`** — the related model lives in its own storage unit (table or collection). Resolved at query time via JOIN (SQL) or `$lookup` / application-level stitching (MongoDB). -- **`embed`** — the related model is nested inside the parent's document (MongoDB) or JSON column (SQL). No join needed; the data comes back in the same read. +### Relation -Embedding is a property of the *relation*, not the model. The same model can be embedded in one parent and referenced from another. +A connection between two models in the contract. Relations are plain graph edges: they declare `to` (target model), `cardinality` (`1:N`, `N:1`), and optionally `on` (join details for referenced relations). Relations carry no storage annotations — whether the target model is co-located or independent is determined by whether it has an `owner` property. ### Discriminator diff --git a/docs/planning/mongo-target/1-design-docs/contract-symmetry.md b/docs/planning/mongo-target/1-design-docs/contract-symmetry.md index 9ddab71359..9f6cba0132 100644 --- a/docs/planning/mongo-target/1-design-docs/contract-symmetry.md +++ b/docs/planning/mongo-target/1-design-docs/contract-symmetry.md @@ -27,7 +27,8 @@ These elements are identical between SQL and Mongo: | **`codecId`** | Field type identifier on `model.fields[f].codecId`. The concept is family-agnostic; available IDs depend on framework composition. | | **`nullable`** | Domain-level field metadata (`model.fields[f].nullable`). Non-optional boolean. | | **`discriminator` + `variants` + `base`** | Polymorphism declaration. Base model lists specializations (`variants`); each variant names its generalization (`base`). Bidirectional, same structure in both families. | -| **`model.relations`** | Connections to other models with cardinality and strategy. | +| **`model.relations`** | Connections to other models with cardinality and optional join details. | +| **`model.owner`** | Declares aggregate membership — owned model's data is co-located with the owner's storage. See [ADR 177](../../../architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md). | | **Variant models as siblings** | Base models, variants, and embedded models all appear as top-level `models` entries. | | **TypeMaps phantom key** | `ContractWithTypeMaps` / `MongoContractWithTypeMaps` | | **Codec abstractions** | Registry interface is family-agnostic; codecs themselves are family-specific. | @@ -70,6 +71,6 @@ The domain model's four building blocks map to contract structure: | **Aggregate root** | Entry in `roots`, model with `storage` containing table/collection | | **Entity** | Entry in `models` with `fields` and `relations` | | **Value object** | Dedicated contract section (not yet designed) | -| **Reference** | Relation with `"strategy": "reference"` | -| **Embedding** | Relation with `"strategy": "embed"` | +| **Owned model** | Model with `"owner": "ParentModel"` — co-located storage | +| **Reference** | Relation with `on` join details to an independent model | | **Polymorphism** | `discriminator` + `variants` on base model; `base` on each variant (specialization/generalization) | diff --git a/docs/planning/mongo-target/1-design-docs/design-questions.md b/docs/planning/mongo-target/1-design-docs/design-questions.md index d3d4c9bb24..f38bf1483b 100644 --- a/docs/planning/mongo-target/1-design-docs/design-questions.md +++ b/docs/planning/mongo-target/1-design-docs/design-questions.md @@ -10,13 +10,15 @@ See also: [MongoDB primitives reference](../../../reference/mongodb-primitives-r ## 1. Embedded documents *(resolved — cross-family concern)* -**Answer**: Embedding is a relation property. The parent model's relation declares `"strategy": "embed"` (vs `"reference"` for cross-collection/cross-table). The embedded model appears as a sibling in `models` with its own `fields` and `storage` block (field mappings but no table/collection name). The embedded model doesn't know where it's embedded — that's on the parent's relation. +**Answer**: Embedding is expressed via model ownership. An owned model declares `"owner": "ParentModel"` — a domain-level fact stating that the model belongs to the parent's aggregate. The owned model appears as a sibling in `models` with its own `fields` block. Its `storage` block is empty (`{}`), because it doesn't own a storage unit. The parent's `storage.relations` section maps the relation to its physical storage location (e.g., `"addresses": { "field": "addresses" }` for Mongo, `"addresses": { "column": "address_data" }` for SQL JSONB). + +Relations to owned models are plain graph edges: `{ "to": "Address", "cardinality": "1:N" }` — no `strategy`, no storage annotation. The `owner` property on the target model distinguishes owned relations from referenced ones. This is a cross-family concern: SQL typed JSON/JSONB columns are the same contract-level problem (structured data nested in a parent entity). Both families need type-safe dot-notation queries, TypeScript type generation, and reusability across models. The difference is convention (Mongo: embedding is idiomatic; SQL: JSON columns are an escape hatch), not capability. Value objects (Address, GeoPoint) without identity are a separate concept — they belong in a dedicated value objects section, not `models`. See [cross-cutting-learnings.md](../cross-cutting-learnings.md) for the entity vs value object distinction. -**Still open**: relation storage details for embedding. A relation with `"strategy": "embed"` needs to know which field in the parent document holds the embedded data. The exact shape isn't designed yet. +See [ADR 177 — Ownership replaces relation strategy](../../../architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md) for the full rationale. --- @@ -224,7 +226,8 @@ For the PoC: Out of scope. The architecture constraints are: - **`roots`** — maps ORM accessor names to model names - **`models`** — all entities with `fields` (records of `{ nullable, codecId }`), optional `discriminator` + `variants`, and `relations` - **`model.storage`** — family-specific extension point (SQL: field → column; Mongo: field → codec) -- **`relations`** — with cardinality and strategy (`"reference"` or `"embed"`) +- **`relations`** — with cardinality and optional join details (`on`) +- **`owner`** — model-level declaration of aggregate membership (see [ADR 177](../../../architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md)) - **value objects** — named field structures without identity (not yet designed) This is not a mechanical extraction from either contract — it's a new abstraction rooted in domain modeling concepts: @@ -234,8 +237,8 @@ This is not a mechanical extraction from either contract — it's a new abstract | **Aggregate root** | Entry in `roots`, model with storage containing table/collection | | **Entity** | Entry in `models` | | **Value object** | Dedicated contract section (not yet designed) | -| **Reference** | Relation with `"strategy": "reference"` | -| **Embedding** | Relation with `"strategy": "embed"` | +| **Owned model** | Model with `"owner": "ParentModel"` — co-located storage | +| **Reference** | Relation with `on` join details to an independent model | | **Polymorphism** | `discriminator` + `variants` on any model | See [cross-cutting-learnings.md](../cross-cutting-learnings.md) for the full design principles, examples, and remaining open questions. @@ -356,7 +359,7 @@ The current relation shape is: ```json { - "to": "Post", "cardinality": "1:N", "strategy": "reference", + "to": "Post", "cardinality": "1:N", "on": { "localFields": ["authorId"], "targetFields": ["id"] } } ``` @@ -367,7 +370,6 @@ A polymorphic association would need something like: { "commentable": { "cardinality": "N:1", - "strategy": "reference", "polymorphic": true, "discriminator": "commentableType", "targets": { @@ -503,7 +505,6 @@ The current relation model has `cardinality: "1:N" | "N:1"`. Adding `"M:N"` is a "roles": { "to": "Role", "cardinality": "M:N", - "strategy": "reference", "via": "user_roles" } } @@ -535,7 +536,6 @@ For MongoDB, an array of ObjectIds in the parent document is a common M:N patter "roles": { "to": "Role", "cardinality": "M:N", - "strategy": "reference", "on": { "localFields": ["roleIds"], "targetFields": ["id"] } } } @@ -556,50 +556,15 @@ Not yet designed. M:N was explicitly deferred for the SQL ORM. Now that we're mo --- -## 18. Relation `strategy` naming: fact or instruction? - -One of the contract's design principles is that it **describes facts, not instructions** (see [10. MongoDB Family](../../../architecture%20docs/subsystems/10.%20MongoDB%20Family.md)). The current relation design uses `"strategy": "reference" | "embed"` to describe how a relation is persisted. - -**The question**: Does the word `strategy` violate the facts-not-instructions principle? - -### The tension - -Compare how other storage facts are stated: - -- `"storage": { "table": "users" }` — fact: this model's data lives in the `users` table -- `"storage": { "collection": "users" }` — fact: this model's data lives in the `users` collection -- `"nullable": false` — fact: this field does not accept null values - -Now: `"strategy": "embed"` — this reads as an instruction ("use the embedding strategy") rather than a fact ("this relation's data is physically co-located in the parent document"). - -### Is it actually a fact? - -Yes — `strategy: "embed"` describes the physical arrangement of data. Address data is nested inside User documents. That's a structural fact about the database, not something the ORM chooses at query time. - -The problem is the *name*, not the *concept*. "Strategy" implies a choice being made, not a reality being described. The contract should read as "this is how the data is arranged" rather than "this is how you should arrange the data." - -### Alternative names - -| Name | Reads as | Example | -|---|---|---| -| `strategy` | "use this strategy" (instruction) | `"strategy": "embed"` | -| `persistence` | "persisted this way" (fact) | `"persistence": "embedded"` | -| `storage` | "stored this way" (fact) | `"storage": "embedded"` — conflicts with `model.storage` | -| `placement` | "placed here" (fact) | `"placement": "embedded"` | - -Or: drop the explicit property entirely. The fact that Address has `storage: {}` (no table/collection) already implies its data is embedded somewhere. The relation's `on` field describes the join details for references; its absence could signal embedding. - -### Derivability - -Could `strategy` be derived rather than stated? - -- If the target model has a `storage.collection`/`storage.table` → it's a reference (the target has its own storage location) -- If the target model has `storage: {}` → it's embedded (no independent storage) +## 18. Relation `strategy` naming: fact or instruction? *(resolved)* -This would make embedding a consequence of the target model's storage declaration, not a property on the relation. But it breaks if the same model is embedded in one relation and referenced in another (which we haven't designed but could arise). +**Answer**: `strategy` has been replaced entirely by model-level `owner`. The core insight was that "Address is a component of User" is a domain fact about the model, not a property of the relation edge. Putting `strategy: "embed"` on the relation mixed domain and storage concerns and read as an instruction rather than a fact. -### Recommendation +The resolution: +- **`owner: "ModelName"`** on the owned model (domain fact): "Address belongs to User's aggregate" +- **`storage.relations`** on the parent model (storage mapping): maps the relation to a physical location (`"field": "addresses"` in Mongo, `"column": "address_data"` in SQL) +- **Relations become plain graph edges**: `{ "to": "Address", "cardinality": "1:N" }` — no `strategy` -If `strategy` remains, consider renaming to something that reads as descriptive rather than prescriptive. `persistence` is the strongest candidate — it clearly describes the physical arrangement without implying a runtime decision. +The pattern mirrors `base` for polymorphism: just as a variant says `"base": "Task"`, an owned model says `"owner": "User"`. -This is a naming question, not a structural one — the concept itself (distinguishing embedded from referenced relations) is sound. Worth resolving before the contract shape stabilises. +See [ADR 177 — Ownership replaces relation strategy](../../../architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md) for the full rationale and examples. diff --git a/docs/planning/mongo-target/1-design-docs/mongo-poc-plan.md b/docs/planning/mongo-target/1-design-docs/mongo-poc-plan.md index ae65525f8f..c6b7a2cefb 100644 --- a/docs/planning/mongo-target/1-design-docs/mongo-poc-plan.md +++ b/docs/planning/mongo-target/1-design-docs/mongo-poc-plan.md @@ -100,7 +100,7 @@ The PoC has achieved its goal: **validating that the Prisma Next architecture ca **Polymorphism works end-to-end (Phase 3).** `discriminator` + `variants` + `base` in the contract, polymorphic return types as discriminated unions with literal narrowing, and STI enforcement in Mongo storage validation — all validated with type-level and integration tests. -**Embedded documents work without loading (Phase 3).** Relations with `"strategy": "embed"` auto-project into the parent row. No `include` needed, no separate query, correct type inference. +**Embedded documents work without loading (Phase 3).** Owned models auto-project into the parent row. No `include` needed, no separate query, correct type inference. ### What remains — open design questions @@ -139,7 +139,7 @@ The [design questions](design-questions.md) document has the full analysis and [ ### Resolved - **[#10 — Shared contract surface](design-questions.md#10-shared-contract-surface-what-goes-in-contractbase)**: **Resolved** via [ADR 172](../../../architecture%20docs/adrs/ADR%20172%20-%20Contract%20domain-storage%20separation.md). The domain level (`roots`, `models`, `relations`) is the shared surface. Divergence is scoped to `model.storage`. -- **[#1 — Embedded documents](design-questions.md#1-embedded-documents-relation-field-or-distinct-concept)**: **Resolved** via [ADR 174](../../../architecture%20docs/adrs/ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md). Embedding is a relation property (`"strategy": "embed"`). Remaining detail: relation storage specifics for embedding. +- **[#1 — Embedded documents](design-questions.md#1-embedded-documents-relation-field-or-distinct-concept)**: **Resolved** via [ADR 177](../../../architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md). Embedding is expressed via `owner` on the owned model. Physical location mapped in parent's `storage.relations`. - **[#6 — Polymorphism](design-questions.md#6-polymorphism-and-discriminated-unions-validate-in-april)**: **Resolved** via [ADR 173](../../../architecture%20docs/adrs/ADR%20173%20-%20Polymorphism%20via%20discriminator%20and%20variants.md). `discriminator` + `variants` on base models, `base` on variants (bidirectional navigation), emergent persistence strategy. Uses specialization/generalization terminology. Remaining: polymorphic associations. **Validated in Phase 3** — discriminator narrowing, polymorphic return types, and STI constraint all proven. - **[#3 — ExecutionPlan generalization](design-questions.md#3-execution-plan-generalization)**: **Resolved.** Each family gets its own plan type, plugin interface, and runtime. See [mongo-execution-components.md](mongo-execution-components.md). - **[#7 — Relation loading](design-questions.md#7-relation-loading-application-level-joining-vs-lookup)**: **Resolved in Phase 3.** Referenced relations use `$lookup` aggregation pipeline stages with `$unwind` for to-one cardinalities. Embedded relations are auto-projected — they're always present in the document, so no loading is needed. The `include` interface is shared across families; the resolution strategy differs (SQL: lateral joins / correlated subqueries; Mongo: `$lookup`). diff --git a/docs/planning/mongo-target/cross-cutting-learnings.md b/docs/planning/mongo-target/cross-cutting-learnings.md index 21971938d4..a822c1a24c 100644 --- a/docs/planning/mongo-target/cross-cutting-learnings.md +++ b/docs/planning/mongo-target/cross-cutting-learnings.md @@ -89,7 +89,7 @@ The co-location of table name with field-to-column mappings in SQL is intentiona Embedded documents in Mongo and typed JSON columns in SQL are the same contract-level problem: structured data nested within a parent entity. Both need type-safe dot-notation queries, TypeScript type generation, and reusability across models. -**Embedding is a relation property, not a model property.** The parent model's relation declares the embedding strategy (`"strategy": "embed"`); the embedded model doesn't know where it's embedded. An embedded model has its own `fields` and `storage` (field-to-codec mappings) but no table/collection name — it doesn't own a storage unit. +**Embedding is expressed via model ownership.** An owned model declares `"owner": "ParentModel"` — a domain-level fact about aggregate membership. The parent's `storage.relations` maps the relation to its physical location. Relations to owned models are plain graph edges with no storage annotations. See [ADR 177](../../architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md). An owned model has its own `fields` but no table/collection name — it doesn't own a storage unit. The entity vs value object distinction matters: an embedded **entity** (e.g., a Post with `_id` embedded in User) has identity and lifecycle. An embedded **value object** (e.g., Address) has no identity — it belongs in a dedicated value objects section, not `models`. @@ -211,9 +211,9 @@ The Mongo ORM introduces `InferFullRow` (scalar fields + embedded relation field This should be resolved when extracting the shared contract base type. -### Relation storage details +### Relation storage details *(resolved)* -Relations with `"strategy": "reference"` need family-specific join details (SQL: foreign key columns; Mongo: which field holds the ObjectId). Relations with `"strategy": "embed"` need to know which field in the parent document holds the embedded data. +Reference relations carry `on: { localFields, targetFields }` for join details. Owned relations use `storage.relations` on the parent model to map relations to physical locations (e.g., `"addresses": { "field": "addresses" }` in Mongo, `"addresses": { "column": "address_data" }` in SQL). See [ADR 177](../../architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md). ### `nullable` and `codecId` location *(resolved)* diff --git a/docs/reference/mongodb-feature-support-priorities.md b/docs/reference/mongodb-feature-support-priorities.md index 189aa9a823..6f4389cd32 100644 --- a/docs/reference/mongodb-feature-support-priorities.md +++ b/docs/reference/mongodb-feature-support-priorities.md @@ -9,7 +9,7 @@ A prioritized inventory of MongoDB features and their support status in Prisma O | -------------------------------- | ------------------------------------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Inheritance and Polymorphism** | Polymorphic documents in a single collection, base models with specialized sub-models | Unsupported | PN addresses this with `discriminator`/`variants`/`base` in the contract ([ADR 173](../architecture%20docs/adrs/ADR%20173%20-%20Polymorphism%20via%20discriminator%20and%20variants.md)) | | **Performance Standards** | Query caching, connection pooling, benchmarking | Unknown | Prisma ORM has built-in connection pooling and Prisma Accelerate integration | -| **Representing Relationships** | 1:1, 1:N, N:1, N:M with embedding and referencing | Partial | `@relation` with reference IDs supported, but not created during introspection. PN addresses with `strategy: "reference" \| "embed"` ([ADR 174](../architecture%20docs/adrs/ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md)) | +| **Representing Relationships** | 1:1, 1:N, N:1, N:M with embedding and referencing | Partial | `@relation` with reference IDs supported, but not created during introspection. PN addresses embedding via `owner` on owned models and `storage.relations` on parents ([ADR 177](../architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md)) | ## Medium priority diff --git a/docs/reference/mongodb-user-journey.md b/docs/reference/mongodb-user-journey.md index 1033763212..3c74f0f15d 100644 --- a/docs/reference/mongodb-user-journey.md +++ b/docs/reference/mongodb-user-journey.md @@ -39,7 +39,7 @@ The friction points map directly to PN's design priorities: | Friction point | PN's response | |---|---| | Polymorphic fields typed as `Json` | `discriminator` + `variants` in the contract ([ADR 173](../architecture%20docs/adrs/ADR%20173%20-%20Polymorphism%20via%20discriminator%20and%20variants.md)) | -| Manual relationship definition | Contract declares relations with `strategy: "reference" \| "embed"` ([ADR 174](../architecture%20docs/adrs/ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md)) | +| Manual relationship definition | Contract declares relations and model ownership (`owner` for embedded, `on` for referenced). See [ADR 177](../architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md) | | No data migration support | Data invariant model for schema evolution ([ADR 176](../architecture%20docs/adrs/ADR%20176%20-%20Data%20migrations%20as%20invariant-guarded%20transitions.md)) | | Advanced features require raw queries | Aggregation pipeline DSL as a typed escape hatch (planned) | | Schema introspection friction | Improved introspection with convention-based normalization (planned) | diff --git a/docs/reference/mongodb-user-promise.md b/docs/reference/mongodb-user-promise.md index b094b64d9d..25a669d4bb 100644 --- a/docs/reference/mongodb-user-promise.md +++ b/docs/reference/mongodb-user-promise.md @@ -69,7 +69,7 @@ model Comment { The contract captures: - **Models and fields**: User has `name: String`, `email: String`, etc. - **Relations and their cardinality**: User → Post is 1:N, User → Address is 1:1 -- **Storage strategy**: Address is embedded (lives inside the User document), Post is referenced (lives in its own collection). This is a Mongo-specific concern that the contract makes explicit. +- **Model ownership**: Address is owned by User (lives inside the User document), Post is independent (lives in its own collection). Embedding is a cross-family concern that the contract makes explicit via the `owner` property. - **Field types**: mapped to BSON types via codecs, with TypeScript types derived automatically The distinction between embedded and referenced is a **data modeling decision** that the user makes explicitly. PN doesn't hide it — it surfaces it as a first-class choice, because it affects query semantics, atomicity, and performance. @@ -275,7 +275,7 @@ The aspiration is that the same plugin pipeline that works for SQL also works fo Clarity about what's out of scope is as important as the promises: -- **Portability between SQL and Mongo.** The shared ORM interface means the *patterns* are consistent, but a SQL contract and a Mongo contract are not interchangeable. You can't swap your Postgres database for MongoDB by changing a config line. The domain model transfers; the storage strategy and query capabilities do not. +- **Portability between SQL and Mongo.** The shared ORM interface means the *patterns* are consistent, but a SQL contract and a Mongo contract are not interchangeable. You can't swap your Postgres database for MongoDB by changing a config line. The domain model transfers; the storage details and query capabilities do not. - **Full MongoDB feature coverage.** PN covers the common CRUD and relation patterns. Advanced features (sharding configuration, capped collections, GridFS, time-series collections) are out of scope for the ORM client. Users who need these use the raw driver through PN's escape hatch. - **Hiding that it's MongoDB.** PN is mongo-native, not mongo-agnostic. Embedded documents, `ObjectId`, array operations, and aggregation pipelines are all concepts the user will encounter. PN makes them type-safe and ergonomic, not invisible. - **Field-level encryption management.** MongoDB's CSFLE and Queryable Encryption are driver-level concerns. PN can pass encryption configuration through to the MongoDB driver, but it doesn't implement encryption itself. This is a future adapter-level capability, not an ORM concern. See [design question #13](../planning/mongo-target/1-design-docs/design-questions.md#13-client-side-field-level-encryption-csfle-and-queryable-encryption). diff --git a/projects/contract-domain-extraction/spec.md b/projects/contract-domain-extraction/spec.md index 6987073238..387ee54796 100644 --- a/projects/contract-domain-extraction/spec.md +++ b/projects/contract-domain-extraction/spec.md @@ -78,7 +78,7 @@ This project widens `ContractBase` to carry the shared domain structure, updates }, "relations": { "posts": { - "to": "Post", "cardinality": "1:N", "strategy": "reference", + "to": "Post", "cardinality": "1:N", "on": { "localFields": ["id"], "targetFields": ["userId"] } } }, @@ -354,6 +354,7 @@ This phase is independent of Phase 4 (IR alignment) and can be done before or af - [ ] Generated `contract.d.ts` output is identical before and after the refactor (regression-tested against demo and parity fixtures) - [ ] A new family emitter (e.g., Mongo) would not need to duplicate domain-level type generation logic + # Other Considerations ## Security @@ -392,5 +393,5 @@ No observability changes needed. The contract structure is a build-time artifact 1. `**model.storage.fields` shape for SQL.** ADR 172 shows `"fields": { "id": { "column": "id" } }`. Should `model.storage.fields` carry any additional info beyond the column name (e.g., the nativeType, to avoid a second lookup into the top-level storage section)? **Default assumption:** Keep it minimal — just `{ column: string }`. The top-level `storage.tables` section is the source of truth for column metadata. 2. **Relation join details in `model.relations`.** The current top-level relations use `childCols`/`parentCols`. ADR 172 uses `on: { localFields, targetFields }`. Should the new `model.relations` use the ADR 172 naming (`localFields`/`targetFields`) or keep the existing naming for continuity during migration? **Default assumption:** Use the ADR 172 naming. The old top-level block coexists during Phase 2, so consumers can migrate at their own pace. 3. **Where does `roots` come from during emission?** Currently, every model with a `storage.table` is implicitly a root. Should the emitter derive `roots` automatically (every model → a root entry with pluralized name), or should the authoring surface declare them? **Default assumption:** The emitter derives `roots` from the existing model/table mapping for now. Explicit authoring-level `roots` is a DSL concern for Phase 4 / Alberto's workstream. -4. `**model.relations` with `strategy`.** The new relations include `"strategy": "reference" | "embed"`. For SQL, all relations are `"reference"` (no embedding). Should the SQL emitter include `"strategy": "reference"` on every relation, or omit it since it's the only option? **Default assumption:** Include it explicitly — the domain structure should be self-describing, and consumers shouldn't need to know "SQL means reference." +4. `**model.relations` shape.** Per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), relations are plain graph edges: `{ to, cardinality, on? }` — no `strategy`. Owned models declare `"owner": "ParentModel"` on the model itself. For SQL in Phase 1, all relations are references (with `on` join details) and no models have `owner`. Ownership becomes relevant when SQL introduces JSONB-column embedding. From a3d1424c6b95c923fe6d936506d37fbbf47b2041 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 19:11:30 +0200 Subject: [PATCH 16/35] add design question Q19: self-referential models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analyzes three cases — self-referential reference (works), recursive embedding (logically sound, needs validation), and mixed root/embedded (correctly prevented by one-canonical-storage-location principle). --- .../1-design-docs/design-questions.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/docs/planning/mongo-target/1-design-docs/design-questions.md b/docs/planning/mongo-target/1-design-docs/design-questions.md index f38bf1483b..272cd7c9fd 100644 --- a/docs/planning/mongo-target/1-design-docs/design-questions.md +++ b/docs/planning/mongo-target/1-design-docs/design-questions.md @@ -568,3 +568,99 @@ The resolution: The pattern mirrors `base` for polymorphism: just as a variant says `"base": "Task"`, an owned model says `"owner": "User"`. See [ADR 177 — Ownership replaces relation strategy](../../../architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md) for the full rationale and examples. + +--- + +## 19. Self-referential models + +A model that has a relation to itself — a tree, a hierarchy, a threaded comment chain. Three cases arise, each testing the contract design differently. + +### Case 1: Self-referential reference + +An Employee whose manager is also an Employee. This is a plain FK to self: + +```json +"Employee": { + "fields": { + "id": { "nullable": false, "codecId": "pg/int4@1" }, + "name": { "nullable": false, "codecId": "pg/text@1" }, + "managerId": { "nullable": true, "codecId": "pg/int4@1" } + }, + "relations": { + "manager": { + "to": "Employee", "cardinality": "N:1", + "on": { "localFields": ["managerId"], "targetFields": ["id"] } + }, + "directReports": { + "to": "Employee", "cardinality": "1:N", + "on": { "localFields": ["id"], "targetFields": ["managerId"] } + } + }, + "storage": { "table": "employees", "fields": { ... } } +} +``` + +No issues. Both sides of the relation point to Employee. The model is an aggregate root with its own table/collection. Works identically in SQL and Mongo. + +### Case 2: Self-referential embedding (recursive nesting) + +Threaded comments in Mongo, where replies are embedded subdocuments inside their parent comment, recursively: + +```json +{ "_id": ..., "text": "Top-level", "replies": [ + { "_id": ..., "text": "Reply", "replies": [ + { "_id": ..., "text": "Nested reply", "replies": [] } + ] } +] } +``` + +Can Comment have `owner: "Comment"`? No — that's circular. If Comment is owned by Comment, it has no independent storage, but the root of the chain needs to live somewhere. There's no anchor. + +The way this works: Comment is owned by the *parent entity* (e.g., Post), and the self-referential `replies` relation is just a graph edge that happens to point to the same model type: + +```json +"Post": { + "fields": { "_id": { ... }, "title": { ... } }, + "relations": { + "comments": { "to": "Comment", "cardinality": "1:N" } + }, + "storage": { + "collection": "posts", + "relations": { "comments": { "field": "comments" } } + } +}, +"Comment": { + "owner": "Post", + "fields": { "_id": { ... }, "text": { ... } }, + "relations": { + "replies": { "to": "Comment", "cardinality": "1:N" } + }, + "storage": { + "relations": { "replies": { "field": "replies" } } + } +} +``` + +The model appears once in the contract, but the physical structure is arbitrarily deep. Each Comment subdocument has its own `replies` field, and each reply is also a Comment with the same structure. The ORM would need to detect the cycle in the model graph to handle type projections and stop infinite recursion. + +Note that the owned Comment has a non-empty `storage` block — it needs `storage.relations` to describe where *its* owned children go within its own subdocument. This extends the pattern from ADR 177 where owned models had `"storage": {}`. + +**Status**: Logically sound but unvalidated. Needs implementation to confirm the ORM can untangle recursive self-referential embedding. Practical contracts will almost certainly use references (Case 1) for tree structures. + +### Case 3: Mixed root/embedded for the same model + +A Category tree where top-level categories own their collection but subcategories are embedded inside their parent. + +This violates design principle #5 (one canonical storage location). A Category can't be both an aggregate root with `storage.collection: "categories"` AND an embedded model with `owner: "Category"`. The contract correctly prevents this — `owner` is mutually exclusive with being an aggregate root. + +Resolutions: +- **All categories are roots** with a `parentId` reference (Case 1). Most common in practice. +- **Two models**: `Category` (root, has collection) and `Subcategory` (owned by Category, embedded). Honest about the structural difference, but forces a modeling decision about hierarchy depth. + +### Summary + +| Case | Pattern | Works? | Notes | +|---|---|---|---| +| Self-referential reference | FK to self | Yes | Trivial. Employee → manager. | +| Self-referential embedding | Owned model with relation to self | In theory | Needs a non-self owner as anchor. Unvalidated in ORM. | +| Mixed root/embedded | Same model as both root and owned | No | Correctly prevented by design principle #5. | From 76d5fc1f7874cd816e5e764d7a9e839895b328f3 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 19:11:51 +0200 Subject: [PATCH 17/35] resolve nested ownership open question in ADR 177 Nested ownership chains work structurally. Self-referential ownership is correctly rejected as circular. References Q19 for the full analysis. --- .../adrs/ADR 177 - Ownership replaces relation strategy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md b/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md index 1e20e4579c..68b20fa377 100644 --- a/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md +++ b/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md @@ -226,7 +226,7 @@ All three are domain facts, stated on the model itself, self-describing. ### Open questions -- **Nested ownership.** Can an owned model itself own other models? (e.g., User → Order → LineItem, where Order is `owner: "User"` and LineItem is `owner: "Order"`). The design supports this — ownership is just a property declaration — but the storage implications (nested embedding) need validation. +- ~~**Nested ownership.**~~ **Resolved.** An owned model can itself own other models (e.g., User → Order → LineItem, where Order is `owner: "User"` and LineItem is `owner: "Order"`). Each owned model in the chain uses `storage.relations` to map where its children go within its subdocument. Self-referential ownership (Comment owns Comment) is correctly rejected as circular — the anchor must be a non-self owner. See [design-questions.md § Q19](../../planning/mongo-target/1-design-docs/design-questions.md#19-self-referential-models). - **Value objects vs owned entities.** An owned Address with an `_id` is an entity. An owned Address without identity is a value object. The contract doesn't yet distinguish these. See the open question in [ADR 174](ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md). ## Related From b1f6ac6e6b68ed5ea49a31135870deec782c293f Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 20:19:49 +0200 Subject: [PATCH 18/35] align spec and plan with ADR 177: owner replaces relation strategy Remove strategy from DomainRelation type, add owner to DomainModel, update domain validation requirements to include ownership rules, resolve the model.relations shape open question, and add ADR 177 to references. --- projects/contract-domain-extraction/plan.md | 10 +++++----- projects/contract-domain-extraction/spec.md | 13 +++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/projects/contract-domain-extraction/plan.md b/projects/contract-domain-extraction/plan.md index 670ad888ca..ca7bea271e 100644 --- a/projects/contract-domain-extraction/plan.md +++ b/projects/contract-domain-extraction/plan.md @@ -28,7 +28,7 @@ Delivers the ADR 172 contract JSON structure, widened TypeScript types, and `val #### 1.1 Type foundation -- **1.1.1** Add domain types to framework contract package: `DomainField` (`{ nullable: boolean; codecId: string }`), `DomainRelation` (`{ to: string; cardinality: string; strategy: 'reference' | 'embed'; on?: { localFields: string[]; targetFields: string[] } }`), `DomainModel` (with `fields`, `relations`, optional `discriminator`/`variants`/`base`, and generic `storage` extension point). Define in `packages/1-framework/1-core/shared/contract/src/`. +- **1.1.1** Add domain types to framework contract package: `DomainField` (`{ nullable: boolean; codecId: string }`), `DomainRelation` (`{ to: string; cardinality: string; on?: { localFields: string[]; targetFields: string[] } }` — no `strategy`, per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md)), `DomainModel` (with `fields`, `relations`, optional `discriminator`/`variants`/`base`/`owner`, and generic `storage` extension point). Define in `packages/1-framework/1-core/shared/contract/src/`. - **1.1.2** Widen `ContractBase` to include `roots: Record` and `models: Record`. Existing type parameters unchanged; new fields added alongside existing ones. - **1.1.3** Widen `SqlContract` to include new domain fields from `ContractBase` alongside existing `mappings`, top-level `relations`, and current `model.fields` shape. The intersection type carries both old and new fields — consumers can read from either. - **1.1.4** Write type tests verifying: (a) `SqlContract extends ContractBase`, (b) new domain fields are accessible on `SqlContract`, (c) old consumer-facing fields (`mappings`, `relations`, `model.fields.*.column`) remain accessible. @@ -36,7 +36,7 @@ Delivers the ADR 172 contract JSON structure, widened TypeScript types, and `val #### 1.2 Domain validation extraction - **1.2.1** Extract `validateContractDomain()` from `packages/2-mongo-family/1-core/src/validate-domain.ts` into `packages/1-framework/1-core/shared/contract/src/validate-domain.ts`. Move the `DomainContractShape`, `DomainModelShape`, and `DomainValidationResult` types alongside it. -- **1.2.2** Port the existing tests from `packages/2-mongo-family/1-core/test/validate-domain.test.ts` to the framework package. Verify all validation rules: root→model references, variant↔base symmetry, relation target existence, discriminator field existence, single-level polymorphism, orphaned model warnings. +- **1.2.2** Port the existing tests from `packages/2-mongo-family/1-core/test/validate-domain.test.ts` to the framework package. Verify all validation rules: root→model references, variant↔base symmetry, relation target existence, discriminator field existence, single-level polymorphism, ownership validation (owner references valid model, no self-ownership, owned models not in roots), orphaned model warnings. - **1.2.3** Update Mongo's `validate-domain.ts` to re-import from the framework package instead of defining its own copy. Verify Mongo tests still pass. #### 1.3 Validation bridge (`validateContract`) @@ -50,7 +50,7 @@ Delivers the ADR 172 contract JSON structure, widened TypeScript types, and `val #### 1.4 SQL emitter update -- **1.4.1** Update the SQL emitter hook (`packages/2-sql/3-tooling/emitter/src/index.ts`) to produce ADR 172 JSON: `roots` (derived from models with `storage.table`), `models` with `{ nullable, codecId }` fields, `model.relations` (model-keyed, with `strategy: "reference"` and `on: { localFields, targetFields }`), `model.storage` (with `table` and `fields` field-to-column mappings). Remove top-level `relations` and `mappings` from the emitted JSON. +- **1.4.1** Update the SQL emitter hook (`packages/2-sql/3-tooling/emitter/src/index.ts`) to produce ADR 172 JSON: `roots` (derived from models with `storage.table`), `models` with `{ nullable, codecId }` fields, `model.relations` (model-keyed, with `on: { localFields, targetFields }` — no `strategy`, per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md)), `model.storage` (with `table` and `fields` field-to-column mappings). Remove top-level `relations` and `mappings` from the emitted JSON. - **1.4.2** Update the emitter's `validateStructure()` to validate the new JSON shape (e.g., every model has `fields`, `relations`, `storage`; every `model.storage.table` exists in `storage.tables`). - **1.4.3** Update `generateContractTypes()` to emit `contract.d.ts` with both old and new type fields. The `Contract` type must include `roots`, `models` with domain fields, plus `mappings`, top-level `relations`, and old `model.fields` shape for backward compatibility. - **1.4.4** Update emitter tests (`packages/2-sql/3-tooling/emitter/test/`) to assert the new JSON structure and new `.d.ts` content. @@ -131,7 +131,7 @@ Independent of Milestone 4 (IR alignment) — can be done before or after. - **5.2.1** Move `generateRootsType()` to the framework emitter. - **5.2.2** Move model domain field type generation (`generateColumnType()`, the codec → TypeScript type logic, parameterized renderer dispatch) to the framework emitter. -- **5.2.3** Move model relation type generation (ADR 172 `to`/`cardinality`/`strategy`/`on` serialization) to the framework emitter. +- **5.2.3** Move model relation type generation (ADR 172 `to`/`cardinality`/`on` serialization) to the framework emitter. - **5.2.4** Move import deduplication, hash type aliases, codec/operation type intersections, `DefaultLiteralValue`, `TypeMaps`, and the `.d.ts` template skeleton to the framework emitter. - **5.2.5** The framework emitter calls the hook's storage-specific methods to fill in the storage sections, then assembles the complete `.d.ts`. @@ -188,7 +188,7 @@ Independent of Milestone 4 (IR alignment) — can be done before or after. - `model.storage.fields` shape: just `{ column: string }` (minimal). Top-level `storage.tables` is source of truth for column metadata. - Relation join naming: use ADR 172 naming (`localFields`/`targetFields`), not old naming (`childCols`/`parentCols`). - `roots` derivation: emitter derives from existing model/table mapping. Explicit authoring-level roots is Phase 4 / DSL concern. - - `strategy` on relations: include `"strategy": "reference"` explicitly on all SQL relations. + - ~~`strategy` on relations~~: **Resolved.** Per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), relations are plain graph edges — no `strategy` field. Owned models declare `"owner"` on the model itself. 3. **Phase 2 coordination with Alexey.** The ORM client migration (tasks 2.1–2.5) touches core ORM internals. This must be sequenced to avoid conflicts with Alexey's active ORM development. The widened types from Phase 1 allow him to migrate incrementally. 4. `**paradedb` extension (`packages/3-extensions/paradedb/`).** Task 2.7 covers BM25 index column resolution. Confirm this extension is actively maintained and whether its owner needs notification. 5. **Unresolved spec open questions** carried forward from spec (see spec § Open Questions for full context). diff --git a/projects/contract-domain-extraction/spec.md b/projects/contract-domain-extraction/spec.md index 387ee54796..f03051cd4a 100644 --- a/projects/contract-domain-extraction/spec.md +++ b/projects/contract-domain-extraction/spec.md @@ -109,7 +109,7 @@ This project widens `ContractBase` to carry the shared domain structure, updates Key changes in the JSON: - `roots` is new — declares ORM entry points - `model.fields` carries `{ nullable, codecId }` instead of `{ column }` -- `model.relations` is model-keyed with `strategy` and `on: { localFields, targetFields }` +- `model.relations` is model-keyed with `on: { localFields, targetFields }` (no `strategy` — per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), relations are plain graph edges) - `model.storage.fields` carries the field-to-column mapping (moved from `model.fields`) - Top-level `relations` and `mappings` are gone — their information now lives on the model @@ -164,13 +164,13 @@ interface ContractBase< readonly relations: Record; readonly storage: Record; readonly discriminator?: { readonly field: string }; readonly variants?: Record; readonly base?: string; + readonly owner?: string; }>; } ``` @@ -240,13 +240,13 @@ This phase changes the emitted JSON to match ADR 172's target structure, widens **Emitted JSON (can change freely):** -1. **Update the SQL emitter to produce ADR 172's JSON structure.** The emitter produces `contract.json` matching the target layout: `roots`, `models` with `{ nullable, codecId }` fields, `model.relations` (model-keyed, with `strategy` and `on: { localFields, targetFields }`), `model.storage` (with `table` and field-to-column mappings). The old top-level `relations`, `mappings`, and `model.fields: { column }` shape can be removed from the JSON or retained — consumers don't read the JSON directly. +1. **Update the SQL emitter to produce ADR 172's JSON structure.** The emitter produces `contract.json` matching the target layout: `roots`, `models` with `{ nullable, codecId }` fields, `model.relations` (model-keyed, with `on: { localFields, targetFields }`), `model.storage` (with `table` and field-to-column mappings). Per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), relations are plain graph edges — no `strategy` field. The old top-level `relations`, `mappings`, and `model.fields: { column }` shape can be removed from the JSON or retained — consumers don't read the JSON directly. 2. **Update demo and test contract fixtures.** The demo app's `contract.json`, and contract fixtures embedded in tests across multiple packages (e.g., inline contract objects, fixture files, test helpers that construct contracts), all encode the current JSON structure. These must be audited and updated to match the new structure. This is likely the most labour-intensive part of Phase 1. **Types (widen, don't contract):** -3. **Widen `ContractBase` to include domain structure.** Add `roots`, typed `models` (with `fields: Record`, `relations`, optional `discriminator`/`variants`/`base`), and a generic storage extension point. `ContractBase` constrains family contracts via `extends ContractBase`, not `ContractBase` — storage details appear at multiple attachment points (model.storage, top-level storage, relation join details). +3. **Widen `ContractBase` to include domain structure.** Add `roots`, typed `models` (with `fields: Record`, `relations`, optional `discriminator`/`variants`/`base`/`owner`), and a generic storage extension point. Per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), relations are plain graph edges (`{ to, cardinality, on? }`) and component membership is declared on the model via `owner`. `ContractBase` constrains family contracts via `extends ContractBase`, not `ContractBase` — storage details appear at multiple attachment points (model.storage, top-level storage, relation join details). 4. **Widen `SqlContract` to include new fields alongside old.** `SqlContract extends ContractBase` and adds SQL-specific storage. During this phase, `SqlContract` carries *both* the new domain fields (from `ContractBase`) and the old SQL-specific ones (`mappings`, top-level `relations`, `model.fields` with `{ column }` shape). This is what makes the transition non-breaking — existing consumers continue reading the old fields on the TypeScript type. @@ -256,7 +256,7 @@ This phase changes the emitted JSON to match ADR 172's target structure, widens 6. **Update `validateContract()` to parse the new JSON and return the widened type.** `validateContract()` reads the new JSON structure and populates *both* the new domain fields and the old consumer-facing fields (e.g., deriving `mappings` from `model.storage`, deriving top-level `relations` from `model.relations`). Consumers see no change in the returned object. -7. **Extract shared domain validation.** Move the family-agnostic validation logic from `packages/2-mongo-family/1-core/src/validate-domain.ts` into the framework layer (`packages/1-framework/`). This covers: roots → model references, variant ↔ base bidirectional consistency, relation target existence, discriminator field existence, single-level polymorphism enforcement, orphaned model detection. SQL's `validateContract()` calls this as a first pass before SQL-specific storage validation. +7. **Extract shared domain validation.** Move the family-agnostic validation logic from `packages/2-mongo-family/1-core/src/validate-domain.ts` into the framework layer (`packages/1-framework/`). This covers: roots → model references, variant ↔ base bidirectional consistency, relation target existence, discriminator field existence, single-level polymorphism enforcement, ownership validation (owner references valid model, owned models not in roots, no self-ownership), orphaned model detection. SQL's `validateContract()` calls this as a first pass before SQL-specific storage validation. ### Phase 2: Migrate consumers to new type fields @@ -379,6 +379,7 @@ No observability changes needed. The contract structure is a build-time artifact - [ADR 172 — Contract domain-storage separation](../../docs/architecture%20docs/adrs/ADR%20172%20-%20Contract%20domain-storage%20separation.md) — the target contract structure - [ADR 174 — Aggregate roots and relation strategies](../../docs/architecture%20docs/adrs/ADR%20174%20-%20Aggregate%20roots%20and%20relation%20strategies.md) — `roots` section design +- [ADR 177 — Ownership replaces relation strategy](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md) — `owner` on models, no `strategy` on relations - [10. MongoDB Family](../../docs/architecture%20docs/subsystems/10.%20MongoDB%20Family.md) — design principles, contract examples - [cross-cutting-learnings.md](../../docs/planning/mongo-target/cross-cutting-learnings.md) — domain model design principles - [contract-symmetry.md](../../docs/planning/mongo-target/1-design-docs/contract-symmetry.md) — Mongo/SQL convergence analysis @@ -393,5 +394,5 @@ No observability changes needed. The contract structure is a build-time artifact 1. `**model.storage.fields` shape for SQL.** ADR 172 shows `"fields": { "id": { "column": "id" } }`. Should `model.storage.fields` carry any additional info beyond the column name (e.g., the nativeType, to avoid a second lookup into the top-level storage section)? **Default assumption:** Keep it minimal — just `{ column: string }`. The top-level `storage.tables` section is the source of truth for column metadata. 2. **Relation join details in `model.relations`.** The current top-level relations use `childCols`/`parentCols`. ADR 172 uses `on: { localFields, targetFields }`. Should the new `model.relations` use the ADR 172 naming (`localFields`/`targetFields`) or keep the existing naming for continuity during migration? **Default assumption:** Use the ADR 172 naming. The old top-level block coexists during Phase 2, so consumers can migrate at their own pace. 3. **Where does `roots` come from during emission?** Currently, every model with a `storage.table` is implicitly a root. Should the emitter derive `roots` automatically (every model → a root entry with pluralized name), or should the authoring surface declare them? **Default assumption:** The emitter derives `roots` from the existing model/table mapping for now. Explicit authoring-level `roots` is a DSL concern for Phase 4 / Alberto's workstream. -4. `**model.relations` shape.** Per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), relations are plain graph edges: `{ to, cardinality, on? }` — no `strategy`. Owned models declare `"owner": "ParentModel"` on the model itself. For SQL in Phase 1, all relations are references (with `on` join details) and no models have `owner`. Ownership becomes relevant when SQL introduces JSONB-column embedding. +4. ~~`**model.relations` shape.~~** **Resolved.** Per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), relations are plain graph edges: `{ to, cardinality, on? }` — no `strategy`. Owned models declare `"owner": "ParentModel"` on the model itself. For SQL in Phase 1, all relations are references (with `on` join details) and no models have `owner`. Ownership becomes relevant when SQL introduces JSONB-column embedding. From f08141efdf63768468dd437f20276fefa733633e Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 20:24:46 +0200 Subject: [PATCH 19/35] resolve all open questions in contract-domain-extraction spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q1: model.storage.fields is { column: string } only — minimal bridge. Q2: use localFields/targetFields naming from ADR 172. Q3: emitter derives roots in Phase 1; IR supplies them in Phase 4. --- projects/contract-domain-extraction/spec.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/projects/contract-domain-extraction/spec.md b/projects/contract-domain-extraction/spec.md index f03051cd4a..6b686e59d2 100644 --- a/projects/contract-domain-extraction/spec.md +++ b/projects/contract-domain-extraction/spec.md @@ -389,10 +389,14 @@ No observability changes needed. The contract structure is a build-time artifact - Extractable domain validation: `packages/2-mongo-family/1-core/src/validate-domain.ts` - Current emitted contract: `examples/prisma-next-demo/src/prisma/contract.json` -# Open Questions +# Resolved Questions -1. `**model.storage.fields` shape for SQL.** ADR 172 shows `"fields": { "id": { "column": "id" } }`. Should `model.storage.fields` carry any additional info beyond the column name (e.g., the nativeType, to avoid a second lookup into the top-level storage section)? **Default assumption:** Keep it minimal — just `{ column: string }`. The top-level `storage.tables` section is the source of truth for column metadata. -2. **Relation join details in `model.relations`.** The current top-level relations use `childCols`/`parentCols`. ADR 172 uses `on: { localFields, targetFields }`. Should the new `model.relations` use the ADR 172 naming (`localFields`/`targetFields`) or keep the existing naming for continuity during migration? **Default assumption:** Use the ADR 172 naming. The old top-level block coexists during Phase 2, so consumers can migrate at their own pace. -3. **Where does `roots` come from during emission?** Currently, every model with a `storage.table` is implicitly a root. Should the emitter derive `roots` automatically (every model → a root entry with pluralized name), or should the authoring surface declare them? **Default assumption:** The emitter derives `roots` from the existing model/table mapping for now. Explicit authoring-level `roots` is a DSL concern for Phase 4 / Alberto's workstream. -4. ~~`**model.relations` shape.~~** **Resolved.** Per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), relations are plain graph edges: `{ to, cardinality, on? }` — no `strategy`. Owned models declare `"owner": "ParentModel"` on the model itself. For SQL in Phase 1, all relations are references (with `on` join details) and no models have `owner`. Ownership becomes relevant when SQL introduces JSONB-column embedding. + +1. ~~`**model.storage.fields` shape for SQL.**~~ **Resolved: `{ column: string }` only.** The model storage bridge answers one question: "which column does this field map to?" Column metadata (nativeType, nullable, default, constraints) lives in `storage.tables` — the single source of truth for database schema. The ORM needs the column name for query building and gets the field's type from `model.fields[f].codecId`. No consumer needs nativeType at the `model.storage` level. + +2. ~~**Relation join details naming.**~~ **Resolved: use `localFields`/`targetFields`.** `localFields`/`targetFields` is clearer than `childCols`/`parentCols` — "local" = on this model, "target" = on the related model. The old naming is confusing because "child"/"parent" depends on which direction you're looking. These are field names now (not column names), so `Fields` is correct. Since `model.relations` is a new API surface, consumers migrate from the old top-level block at their own pace during Phase 2. + +3. ~~**Where does `roots` come from during emission?**~~ **Resolved: emitter derives for now; IR supplies in Phase 4.** In Phase 1, the emitter derives `roots` from models with `storage.table` (every model → a root entry with pluralized accessor name). This is a temporary shim — the emitter should be a serializer, not a decision-maker. In Phase 4 (IR alignment), `roots` becomes a first-class field in ContractIR, supplied by the authoring surface. The emitter just serializes what the IR says. The derivation hack is removed. + +4. ~~`**model.relations` shape.**~~ **Resolved.** Per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), relations are plain graph edges: `{ to, cardinality, on? }` — no `strategy`. Owned models declare `"owner": "ParentModel"` on the model itself. For SQL in Phase 1, all relations are references (with `on` join details) and no models have `owner`. Ownership becomes relevant when SQL introduces JSONB-column embedding. From 3cd8f85b5bdb72f7013c84b9ddac1105bfae5e1a Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 20:27:37 +0200 Subject: [PATCH 20/35] mark all spec open questions as resolved in plan --- projects/contract-domain-extraction/plan.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/projects/contract-domain-extraction/plan.md b/projects/contract-domain-extraction/plan.md index ca7bea271e..cbd32bf3ab 100644 --- a/projects/contract-domain-extraction/plan.md +++ b/projects/contract-domain-extraction/plan.md @@ -184,12 +184,11 @@ Independent of Milestone 4 (IR alignment) — can be done before or after. ## Open Items 1. **Dual-format `normalizeContract()`.** Task 1.3.1 adds detection of old vs new JSON format in `normalizeContract()` to enable incremental fixture migration. This adds temporary complexity but significantly reduces risk — fixtures can be migrated across multiple PRs rather than atomically. The old-format path is removed in task 3.5. -2. **Spec open questions (with default assumptions from spec):** - - `model.storage.fields` shape: just `{ column: string }` (minimal). Top-level `storage.tables` is source of truth for column metadata. - - Relation join naming: use ADR 172 naming (`localFields`/`targetFields`), not old naming (`childCols`/`parentCols`). - - `roots` derivation: emitter derives from existing model/table mapping. Explicit authoring-level roots is Phase 4 / DSL concern. - - ~~`strategy` on relations~~: **Resolved.** Per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), relations are plain graph edges — no `strategy` field. Owned models declare `"owner"` on the model itself. +2. ~~**Spec open questions.**~~ **All resolved** (see spec § Open Questions): + - `model.storage.fields` shape: `{ column: string }` only. Top-level `storage.tables` is the single source of truth for column metadata. + - Relation join naming: `localFields`/`targetFields` (not `childCols`/`parentCols`). + - `roots` derivation: emitter derives for now; IR supplies in Phase 4. + - `model.relations` shape: per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), plain graph edges — no `strategy`. Owned models declare `"owner"` on the model itself. 3. **Phase 2 coordination with Alexey.** The ORM client migration (tasks 2.1–2.5) touches core ORM internals. This must be sequenced to avoid conflicts with Alexey's active ORM development. The widened types from Phase 1 allow him to migrate incrementally. 4. `**paradedb` extension (`packages/3-extensions/paradedb/`).** Task 2.7 covers BM25 index column resolution. Confirm this extension is actively maintained and whether its owner needs notification. -5. **Unresolved spec open questions** carried forward from spec (see spec § Open Questions for full context). From 5e022c5f9ef80bdd30133d6a026a823d2cf5c765 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 20:34:10 +0200 Subject: [PATCH 21/35] strip column from emitted model.fields for ADR 172 compliance Emitted contract.json now has pure domain fields ({ codecId } or { codecId, nullable: true }) with column exclusively in model.storage.fields. The toDomainFields helper in emit.ts runs after normalizeContract enrichment and only activates when fields have been enriched (have codecId). --- .../prisma-next-demo/src/prisma/contract.json | 25 +++------- .../control-plane/src/emission/emit.ts | 49 ++++++++++++++++++- .../core-surface/expected.contract.json | 26 +++------- .../default-cuid-2/expected.contract.json | 3 +- .../expected.contract.json | 3 +- .../default-nanoid-16/expected.contract.json | 3 +- .../default-nanoid/expected.contract.json | 3 +- .../expected.contract.json | 3 +- .../default-ulid/expected.contract.json | 3 +- .../default-uuid-v4/expected.contract.json | 3 +- .../default-uuid-v7/expected.contract.json | 3 +- .../map-attributes/expected.contract.json | 9 ++-- .../expected.contract.json | 6 +-- .../expected.contract.json | 9 ++-- 14 files changed, 80 insertions(+), 68 deletions(-) diff --git a/examples/prisma-next-demo/src/prisma/contract.json b/examples/prisma-next-demo/src/prisma/contract.json index a6db78c6e2..ef1ebba019 100644 --- a/examples/prisma-next-demo/src/prisma/contract.json +++ b/examples/prisma-next-demo/src/prisma/contract.json @@ -13,25 +13,20 @@ "Post": { "fields": { "createdAt": { - "codecId": "pg/timestamptz@1", - "column": "createdAt" + "codecId": "pg/timestamptz@1" }, "embedding": { "codecId": "pg/vector@1", - "column": "embedding", "nullable": true }, "id": { - "codecId": "sql/char@1", - "column": "id" + "codecId": "sql/char@1" }, "title": { - "codecId": "pg/text@1", - "column": "title" + "codecId": "pg/text@1" }, "userId": { - "codecId": "pg/text@1", - "column": "userId" + "codecId": "pg/text@1" } }, "relations": { @@ -73,20 +68,16 @@ "User": { "fields": { "createdAt": { - "codecId": "pg/timestamptz@1", - "column": "createdAt" + "codecId": "pg/timestamptz@1" }, "email": { - "codecId": "pg/text@1", - "column": "email" + "codecId": "pg/text@1" }, "id": { - "codecId": "sql/char@1", - "column": "id" + "codecId": "sql/char@1" }, "kind": { - "codecId": "pg/enum@1", - "column": "kind" + "codecId": "pg/enum@1" } }, "relations": { diff --git a/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts b/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts index c3dd0a4304..78f336f6f1 100644 --- a/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts +++ b/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts @@ -7,6 +7,53 @@ import { canonicalizeContract } from './canonicalization'; import { computeExecutionHash, computeProfileHash, computeStorageHash } from './hashing'; import type { EmitOptions, EmitResult } from './types'; +function toDomainFields(models: Record): Record { + const result: Record = {}; + for (const [modelName, modelUnknown] of Object.entries(models)) { + const model = modelUnknown as Record; + const fields = model['fields'] as Record> | undefined; + if (!fields) { + result[modelName] = model; + continue; + } + + const hasEnrichedFields = Object.values(fields).some((f) => f['codecId'] !== undefined); + if (!hasEnrichedFields) { + result[modelName] = model; + continue; + } + + const storage = (model['storage'] ?? {}) as Record; + const existingStorageFields = (storage['fields'] ?? {}) as Record< + string, + Record + >; + const mergedStorageFields: Record> = { + ...existingStorageFields, + }; + + const cleanedFields: Record> = {}; + for (const [fieldName, field] of Object.entries(fields)) { + const { column, ...domainOnly } = field; + if (domainOnly['nullable'] === undefined) { + domainOnly['nullable'] = false; + } + cleanedFields[fieldName] = domainOnly; + + if (column !== undefined && !mergedStorageFields[fieldName]) { + mergedStorageFields[fieldName] = { column }; + } + } + + result[modelName] = { + ...model, + fields: cleanedFields, + storage: { ...storage, fields: mergedStorageFields }, + }; + } + return result; +} + const CanonicalMetaSchema = type({ '[string]': 'unknown', }); @@ -105,7 +152,7 @@ export async function emit( targetFamily: ir.targetFamily, target: ir.target, ...ifDefined('roots', ir.roots), - models: ir.models, + models: toDomainFields(ir.models as Record), ...ifDefined('relations', ir.relations), storage: ir.storage, ...ifDefined('execution', ir.execution), diff --git a/test/integration/test/authoring/parity/core-surface/expected.contract.json b/test/integration/test/authoring/parity/core-surface/expected.contract.json index accf2be9fd..248c9d9998 100644 --- a/test/integration/test/authoring/parity/core-surface/expected.contract.json +++ b/test/integration/test/authoring/parity/core-surface/expected.contract.json @@ -12,21 +12,17 @@ "Post": { "fields": { "id": { - "codecId": "pg/int4@1", - "column": "id" + "codecId": "pg/int4@1" }, "rating": { "codecId": "pg/float8@1", - "column": "rating", "nullable": true }, "title": { - "codecId": "pg/text@1", - "column": "title" + "codecId": "pg/text@1" }, "userId": { - "codecId": "pg/int4@1", - "column": "userId" + "codecId": "pg/int4@1" } }, "relations": { @@ -61,29 +57,23 @@ "User": { "fields": { "createdAt": { - "codecId": "pg/timestamptz@1", - "column": "createdAt" + "codecId": "pg/timestamptz@1" }, "email": { - "codecId": "pg/text@1", - "column": "email" + "codecId": "pg/text@1" }, "id": { - "codecId": "pg/int4@1", - "column": "id" + "codecId": "pg/int4@1" }, "isActive": { - "codecId": "pg/bool@1", - "column": "isActive" + "codecId": "pg/bool@1" }, "profile": { "codecId": "pg/jsonb@1", - "column": "profile", "nullable": true }, "role": { - "codecId": "pg/enum@1", - "column": "role" + "codecId": "pg/enum@1" } }, "relations": {}, diff --git a/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json b/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json index 95402833f9..a35e2beedf 100644 --- a/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json +++ b/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json @@ -12,8 +12,7 @@ "User": { "fields": { "id": { - "codecId": "sql/char@1", - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, diff --git a/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json b/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json index a0576112a5..5930d80147 100644 --- a/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json +++ b/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json @@ -11,8 +11,7 @@ "User": { "fields": { "id": { - "codecId": "pg/text@1", - "column": "id" + "codecId": "pg/text@1" } }, "relations": {}, diff --git a/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json b/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json index df3ead0af2..58d603a848 100644 --- a/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json +++ b/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json @@ -12,8 +12,7 @@ "User": { "fields": { "id": { - "codecId": "sql/char@1", - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, diff --git a/test/integration/test/authoring/parity/default-nanoid/expected.contract.json b/test/integration/test/authoring/parity/default-nanoid/expected.contract.json index 8777b9d8cc..8357ffc47e 100644 --- a/test/integration/test/authoring/parity/default-nanoid/expected.contract.json +++ b/test/integration/test/authoring/parity/default-nanoid/expected.contract.json @@ -12,8 +12,7 @@ "User": { "fields": { "id": { - "codecId": "sql/char@1", - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, diff --git a/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json b/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json index 62611abfe5..9b7565fa64 100644 --- a/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json +++ b/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json @@ -12,8 +12,7 @@ "User": { "fields": { "id": { - "codecId": "pg/text@1", - "column": "id" + "codecId": "pg/text@1" } }, "relations": {}, diff --git a/test/integration/test/authoring/parity/default-ulid/expected.contract.json b/test/integration/test/authoring/parity/default-ulid/expected.contract.json index dc8ed78a56..7abc922ffd 100644 --- a/test/integration/test/authoring/parity/default-ulid/expected.contract.json +++ b/test/integration/test/authoring/parity/default-ulid/expected.contract.json @@ -12,8 +12,7 @@ "User": { "fields": { "id": { - "codecId": "sql/char@1", - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, diff --git a/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json b/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json index 27202af2a7..29399191a5 100644 --- a/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json +++ b/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json @@ -12,8 +12,7 @@ "User": { "fields": { "id": { - "codecId": "sql/char@1", - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, diff --git a/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json b/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json index 440c1c15c3..bdd97fd5eb 100644 --- a/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json +++ b/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json @@ -12,8 +12,7 @@ "User": { "fields": { "id": { - "codecId": "sql/char@1", - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, diff --git a/test/integration/test/authoring/parity/map-attributes/expected.contract.json b/test/integration/test/authoring/parity/map-attributes/expected.contract.json index 90687f2f7f..29cb2056e5 100644 --- a/test/integration/test/authoring/parity/map-attributes/expected.contract.json +++ b/test/integration/test/authoring/parity/map-attributes/expected.contract.json @@ -12,12 +12,10 @@ "Member": { "fields": { "id": { - "codecId": "pg/int4@1", - "column": "member_id" + "codecId": "pg/int4@1" }, "teamId": { - "codecId": "pg/int4@1", - "column": "team_ref" + "codecId": "pg/int4@1" } }, "relations": { @@ -46,8 +44,7 @@ "Team": { "fields": { "id": { - "codecId": "pg/int4@1", - "column": "team_id" + "codecId": "pg/int4@1" } }, "relations": {}, diff --git a/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json b/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json index 2611852c50..2f39ec28f3 100644 --- a/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json +++ b/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json @@ -11,12 +11,10 @@ "Document": { "fields": { "embedding": { - "codecId": "pg/vector@1", - "column": "embedding" + "codecId": "pg/vector@1" }, "id": { - "codecId": "pg/int4@1", - "column": "id" + "codecId": "pg/int4@1" } }, "relations": {}, diff --git a/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json b/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json index f23504240a..6eb1a62161 100644 --- a/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json +++ b/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json @@ -12,12 +12,10 @@ "Post": { "fields": { "id": { - "codecId": "pg/int4@1", - "column": "id" + "codecId": "pg/int4@1" }, "userId": { - "codecId": "pg/int4@1", - "column": "userId" + "codecId": "pg/int4@1" } }, "relations": { @@ -46,8 +44,7 @@ "User": { "fields": { "id": { - "codecId": "pg/int4@1", - "column": "id" + "codecId": "pg/int4@1" } }, "relations": { From dead52bff340fe00aefdc893675217315aeb1d3c Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 20:34:51 +0200 Subject: [PATCH 22/35] add owner and ownership validation to spec acceptance criteria --- projects/contract-domain-extraction/spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/contract-domain-extraction/spec.md b/projects/contract-domain-extraction/spec.md index 6b686e59d2..aff5d50d02 100644 --- a/projects/contract-domain-extraction/spec.md +++ b/projects/contract-domain-extraction/spec.md @@ -314,14 +314,14 @@ This phase is independent of Phase 4 (IR alignment) and can be done before or af **Types:** -- [ ] `ContractBase` has typed `roots`, `models` (with `fields: Record`, `relations`, optional `discriminator`/`variants`/`base`), declared in the framework core package +- [ ] `ContractBase` has typed `roots`, `models` (with `fields: Record`, `relations`, optional `discriminator`/`variants`/`base`/`owner`), declared in the framework core package - [ ] `SqlContract extends ContractBase` with SQL-specific storage and retains old consumer-facing fields (`mappings`, top-level `relations`, `model.fields` with `{ column }`) - [ ] Emitted `contract.d.ts` includes both old and new field shapes **Validation:** - [ ] `validateContract()` parses the new JSON structure and returns the widened type, populating old fields (e.g., `mappings`) from new structure (e.g., `model.storage`) -- [ ] Shared domain validation (roots, variants, relations, discriminators, orphans) runs as part of SQL `validateContract()` +- [ ] Shared domain validation (roots, variants, relations, discriminators, ownership, orphans) runs as part of SQL `validateContract()` **No consumer changes:** From dc37139bea175c31d91676c109987d5f63704d3d Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 20:36:50 +0200 Subject: [PATCH 23/35] clean up constructContract roots cast and emitter-lanes round-trip test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove untyped (stripped as Record)[\"roots\"] cast in constructContract — roots is now typed on ContractBase. Restructure the emitter-lanes round-trip test to bootstrap from raw IR then compare two validated emissions, removing the direct normalizeContract dependency. --- packages/2-sql/1-core/contract/src/construct.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/2-sql/1-core/contract/src/construct.ts b/packages/2-sql/1-core/contract/src/construct.ts index 69b8869f54..68d1873059 100644 --- a/packages/2-sql/1-core/contract/src/construct.ts +++ b/packages/2-sql/1-core/contract/src/construct.ts @@ -174,7 +174,7 @@ export function constructContract>( const contractWithMappings = { ...stripped, mappings, - roots: (stripped as Record)['roots'] ?? {}, + roots: stripped.roots, }; return contractWithMappings as TContract; From 8366f74faaa64e82d87aadd4c10fbf87f8d2cfb8 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 20:39:10 +0200 Subject: [PATCH 24/35] address review feedback: relation comment, domain shape helper, roots schema Add parentCols/childCols mapping-direction comment in enrichOldFormatModels. Extract extractDomainShape helper to replace inline type cast in validateContract. Add roots to contract-ts SqlContractSchema for runtime validation consistency. Remove verbose comment in cli.emit-core round-trip test. --- packages/2-sql/1-core/contract/src/validate.ts | 17 ++++++++++------- .../2-authoring/contract-ts/src/contract.ts | 1 + 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/2-sql/1-core/contract/src/validate.ts b/packages/2-sql/1-core/contract/src/validate.ts index 5ee6a803af..5d0e6390f5 100644 --- a/packages/2-sql/1-core/contract/src/validate.ts +++ b/packages/2-sql/1-core/contract/src/validate.ts @@ -1,11 +1,19 @@ import type { ColumnDefaultLiteralInputValue } from '@prisma-next/contract/types'; import { isTaggedBigInt, isTaggedRaw } from '@prisma-next/contract/types'; +import type { DomainContractShape, DomainModelShape } from '@prisma-next/contract/validate-domain'; import { validateContractDomain } from '@prisma-next/contract/validate-domain'; import { constructContract } from './construct'; import type { SqlContract, SqlStorage, StorageColumn, StorageTable } from './types'; import { applyFkDefaults } from './types'; import { validateSqlContract, validateStorageSemantics } from './validators'; +function extractDomainShape(contract: SqlContract): DomainContractShape { + return { + roots: contract.roots, + models: contract.models as Record, + }; +} + function validateContractLogic(contract: SqlContract): void { const tableNames = new Set(Object.keys(contract.storage.tables)); @@ -336,6 +344,7 @@ function enrichOldFormatModels( } const targetColToField = targetColumnToField[toModel] ?? {}; + // Old format: parentCols = columns on FK-holding table (local), childCols = columns on referenced table (target) const localFields = parentCols.map((c: string) => sourceColToField[c] ?? c); const targetFields = childCols.map((c: string) => targetColToField[c] ?? c); @@ -520,13 +529,7 @@ export function validateContract>( const structurallyValid = validateSqlContract>(normalized); - validateContractDomain({ - roots: structurallyValid.roots as Record, - models: structurallyValid.models as Record< - string, - { fields: Record; relations: Record } - >, - }); + validateContractDomain(extractDomainShape(structurallyValid)); validateContractLogic(structurallyValid); diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract.ts b/packages/2-sql/2-authoring/contract-ts/src/contract.ts index d64c493576..5509f97b16 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract.ts @@ -115,6 +115,7 @@ const SqlContractSchema = type({ 'extensionPacks?': 'Record', 'meta?': 'Record', 'sources?': 'Record', + 'roots?': 'Record', models: type({ '[string]': ModelSchema }), storage: StorageSchema, 'execution?': ExecutionSchema, From 21ead969a864457a72d88be991867e6e15c0646c Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 20:53:02 +0200 Subject: [PATCH 25/35] implement ADR 177: remove strategy from relations, add owner to models Per ADR 177, relations are plain graph edges (to, cardinality, on?) with no strategy field. Component membership is declared on the model via owner. This updates DomainRelation, SqlRelation, DomainModelShape, validate-domain (ownership validation), JSON schema, arktype validators, and the emitter to strip strategy from emitted JSON. --- .../control-plane/src/emission/emit.ts | 30 ++++++++++++++--- .../shared/contract/src/domain-types.ts | 2 +- .../shared/contract/src/validate-domain.ts | 33 ++++++++++++++++++- packages/2-sql/1-core/contract/src/types.ts | 2 +- .../2-sql/1-core/contract/src/validate.ts | 1 - .../schemas/data-contract-sql-v1.json | 9 +++-- .../2-authoring/contract-ts/src/contract.ts | 1 + packages/2-sql/3-tooling/emitter/src/index.ts | 1 - 8 files changed, 65 insertions(+), 14 deletions(-) diff --git a/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts b/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts index 78f336f6f1..b4cc835931 100644 --- a/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts +++ b/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts @@ -7,19 +7,40 @@ import { canonicalizeContract } from './canonicalization'; import { computeExecutionHash, computeProfileHash, computeStorageHash } from './hashing'; import type { EmitOptions, EmitResult } from './types'; -function toDomainFields(models: Record): Record { +function stripStrategyFromRelations( + relations: Record> | undefined, +): Record> | undefined { + if (!relations) return undefined; + const result: Record> = {}; + for (const [relName, rel] of Object.entries(relations)) { + const { strategy: _, ...rest } = rel; + result[relName] = rest; + } + return result; +} + +function toDomainModel(models: Record): Record { const result: Record = {}; for (const [modelName, modelUnknown] of Object.entries(models)) { const model = modelUnknown as Record; + const relations = model['relations'] as Record> | undefined; + const cleanedRelations = stripStrategyFromRelations(relations); + const fields = model['fields'] as Record> | undefined; if (!fields) { - result[modelName] = model; + result[modelName] = { + ...model, + ...(cleanedRelations !== undefined ? { relations: cleanedRelations } : {}), + }; continue; } const hasEnrichedFields = Object.values(fields).some((f) => f['codecId'] !== undefined); if (!hasEnrichedFields) { - result[modelName] = model; + result[modelName] = { + ...model, + ...(cleanedRelations !== undefined ? { relations: cleanedRelations } : {}), + }; continue; } @@ -48,6 +69,7 @@ function toDomainFields(models: Record): Record), + models: toDomainModel(ir.models as Record), ...ifDefined('relations', ir.relations), storage: ir.storage, ...ifDefined('execution', ir.execution), diff --git a/packages/1-framework/1-core/shared/contract/src/domain-types.ts b/packages/1-framework/1-core/shared/contract/src/domain-types.ts index dc5525df74..3f772f79e7 100644 --- a/packages/1-framework/1-core/shared/contract/src/domain-types.ts +++ b/packages/1-framework/1-core/shared/contract/src/domain-types.ts @@ -11,7 +11,6 @@ export type DomainRelationOn = { export type DomainRelation = { readonly to: string; readonly cardinality: '1:1' | '1:N' | 'N:1'; - readonly strategy: 'reference' | 'embed'; readonly on?: DomainRelationOn; }; @@ -26,4 +25,5 @@ export type DomainModel = { readonly discriminator?: DomainDiscriminator; readonly variants?: Record; readonly base?: string; + readonly owner?: string; }; diff --git a/packages/1-framework/1-core/shared/contract/src/validate-domain.ts b/packages/1-framework/1-core/shared/contract/src/validate-domain.ts index 84f64b338d..7954d968b9 100644 --- a/packages/1-framework/1-core/shared/contract/src/validate-domain.ts +++ b/packages/1-framework/1-core/shared/contract/src/validate-domain.ts @@ -4,6 +4,7 @@ export interface DomainModelShape { readonly discriminator?: { readonly field: string }; readonly variants?: Record; readonly base?: string; + readonly owner?: string; } export interface DomainContractShape { @@ -24,6 +25,7 @@ export function validateContractDomain(contract: DomainContractShape): DomainVal validateVariantsAndBases(contract, modelNames, errors); validateRelationTargets(contract, modelNames, errors); validateDiscriminators(contract, errors); + validateOwnership(contract, modelNames, errors); detectOrphanedModels(contract, modelNames, warnings); if (errors.length > 0) { @@ -137,6 +139,32 @@ function validateDiscriminators(contract: DomainContractShape, errors: string[]) } } +function validateOwnership( + contract: DomainContractShape, + modelNames: Set, + errors: string[], +): void { + for (const [modelName, model] of Object.entries(contract.models)) { + if (!model.owner) continue; + + if (model.owner === modelName) { + errors.push(`Model "${modelName}" cannot own itself`); + } + + if (!modelNames.has(model.owner)) { + errors.push(`Model "${modelName}" has owner "${model.owner}" which does not exist in models`); + } + + for (const [rootKey, rootModel] of Object.entries(contract.roots)) { + if (rootModel === modelName) { + errors.push( + `Owned model "${modelName}" must not appear in roots (found as root "${rootKey}")`, + ); + } + } + } +} + function detectOrphanedModels( contract: DomainContractShape, modelNames: Set, @@ -148,7 +176,7 @@ function detectOrphanedModels( referenced.add(modelName); } - for (const model of Object.values(contract.models)) { + for (const [modelName, model] of Object.entries(contract.models)) { for (const relation of Object.values(model.relations)) { referenced.add(relation.to); } @@ -160,6 +188,9 @@ function detectOrphanedModels( if (model.base) { referenced.add(model.base); } + if (model.owner) { + referenced.add(modelName); + } } for (const modelName of modelNames) { diff --git a/packages/2-sql/1-core/contract/src/types.ts b/packages/2-sql/1-core/contract/src/types.ts index a5e5f488dd..1b2714ce47 100644 --- a/packages/2-sql/1-core/contract/src/types.ts +++ b/packages/2-sql/1-core/contract/src/types.ts @@ -132,6 +132,7 @@ export type ModelDefinition = { readonly storage: ModelStorage; readonly fields: Record; readonly relations: Record; + readonly owner?: string; }; export type SqlModelFieldStorage = { @@ -146,7 +147,6 @@ export type SqlModelStorage = { export type SqlRelation = { readonly to: string; readonly cardinality: '1:1' | '1:N' | 'N:1'; - readonly strategy: 'reference'; readonly on: DomainRelationOn; }; diff --git a/packages/2-sql/1-core/contract/src/validate.ts b/packages/2-sql/1-core/contract/src/validate.ts index 5d0e6390f5..a4cd29f67e 100644 --- a/packages/2-sql/1-core/contract/src/validate.ts +++ b/packages/2-sql/1-core/contract/src/validate.ts @@ -351,7 +351,6 @@ function enrichOldFormatModels( modelRelations[relName] = { to: toModel, cardinality: rel['cardinality'], - strategy: 'reference', on: { localFields, targetFields }, }; } diff --git a/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json b/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json index c55d6cc5bb..985b3c38ae 100644 --- a/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json +++ b/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json @@ -509,6 +509,10 @@ "additionalProperties": { "$ref": "#/$defs/ModelRelation" } + }, + "owner": { + "type": "string", + "description": "Owner model name — declares this model belongs to another model's aggregate (per ADR 177)" } }, "required": ["storage", "fields"] @@ -558,11 +562,6 @@ "enum": ["1:1", "1:N", "N:1", "N:M"], "description": "Relation cardinality" }, - "strategy": { - "type": "string", - "enum": ["reference", "embed"], - "description": "Relation strategy" - }, "on": { "type": "object", "description": "Relation field mappings", diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract.ts b/packages/2-sql/2-authoring/contract-ts/src/contract.ts index 5509f97b16..bfb9b0a10b 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract.ts @@ -73,6 +73,7 @@ const ModelSchema = type.declare().type({ storage: ModelStorageSchema, fields: type({ '[string]': ModelFieldSchema }), relations: type({ '[string]': 'unknown' }), + 'owner?': 'string', }); const GeneratorIdSchema = type('string').narrow((value, ctx) => { diff --git a/packages/2-sql/3-tooling/emitter/src/index.ts b/packages/2-sql/3-tooling/emitter/src/index.ts index 918a106211..8b5a117b73 100644 --- a/packages/2-sql/3-tooling/emitter/src/index.ts +++ b/packages/2-sql/3-tooling/emitter/src/index.ts @@ -577,7 +577,6 @@ export const sqlTargetFamilyHook = { if (relObj['to']) relParts.push(`readonly to: '${relObj['to']}'`); if (relObj['cardinality']) relParts.push(`readonly cardinality: '${relObj['cardinality']}'`); - if (relObj['strategy']) relParts.push(`readonly strategy: '${relObj['strategy']}'`); const on = relObj['on'] as { localFields?: string[]; targetFields?: string[] } | undefined; if (on?.localFields && on.targetFields) { const localFields = on.localFields.map((f) => `'${f}'`).join(', '); From ca2954ed380d6633ac8db33596617c672dbd7505 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 20:53:13 +0200 Subject: [PATCH 26/35] update tests for ADR 177: ownership validation and strategy removal Add ownership validation tests (self-ownership, non-existent owner, owned model in roots). Remove strategy from all relation test fixtures and assertions. Update DomainRelation and DomainModel test cases. --- .../shared/contract/test/domain-types.test.ts | 24 ++++-- .../contract/test/validate-domain.test.ts | 75 ++++++++++++++----- .../1-core/contract/test/validate.test.ts | 3 - .../emitter-hook.generation.advanced.test.ts | 9 +-- 4 files changed, 78 insertions(+), 33 deletions(-) diff --git a/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts b/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts index d0b9571123..707b419aca 100644 --- a/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts +++ b/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts @@ -26,24 +26,22 @@ describe('domain types', () => { expect(field.codecId).toBe('pg/text@1'); }); - it('DomainRelation supports reference strategy with on clause', () => { + it('DomainRelation carries to, cardinality, and optional on', () => { const relation: DomainRelation = { to: 'Post', cardinality: '1:N', - strategy: 'reference', on: { localFields: ['id'], targetFields: ['userId'] }, }; expect(relation.to).toBe('Post'); - expect(relation.strategy).toBe('reference'); + expect(relation.on?.localFields).toEqual(['id']); }); - it('DomainRelation supports embed strategy without on clause', () => { + it('DomainRelation without on clause (owned relation)', () => { const relation: DomainRelation = { to: 'Address', - cardinality: '1:1', - strategy: 'embed', + cardinality: '1:N', }; - expect(relation.strategy).toBe('embed'); + expect(relation.to).toBe('Address'); expect(relation.on).toBeUndefined(); }); @@ -68,4 +66,16 @@ describe('domain types', () => { }; expect(model.base).toBe('Parent'); }); + + it('DomainModel supports owner for component membership', () => { + const model: DomainModel = { + fields: { + street: { nullable: false, codecId: 'pg/text@1' }, + }, + relations: {}, + storage: {}, + owner: 'User', + }; + expect(model.owner).toBe('User'); + }); }); diff --git a/packages/1-framework/1-core/shared/contract/test/validate-domain.test.ts b/packages/1-framework/1-core/shared/contract/test/validate-domain.test.ts index 6ac56b331b..786ec97fa9 100644 --- a/packages/1-framework/1-core/shared/contract/test/validate-domain.test.ts +++ b/packages/1-framework/1-core/shared/contract/test/validate-domain.test.ts @@ -123,15 +123,14 @@ describe('validateContractDomain()', () => { describe('relation target validation', () => { it('accepts relations with valid targets', () => { const contract = makeValidContract({ - roots: { items: 'Item' }, + roots: { items: 'Item', users: 'User' }, models: { Item: makeMinimalModel({ relations: { - owner: { + creator: { to: 'User', cardinality: 'N:1', - strategy: 'reference', - on: { localFields: ['ownerId'], targetFields: ['_id'] }, + on: { localFields: ['creatorId'], targetFields: ['_id'] }, }, }, }), @@ -146,18 +145,17 @@ describe('validateContractDomain()', () => { models: { Item: makeMinimalModel({ relations: { - owner: { + creator: { to: 'Ghost', cardinality: 'N:1', - strategy: 'reference', - on: { localFields: ['ownerId'], targetFields: ['_id'] }, + on: { localFields: ['creatorId'], targetFields: ['_id'] }, }, }, }), }, }); expect(() => validateContractDomain(contract)).toThrow( - /relation.*owner.*Item.*target.*Ghost.*not exist/i, + /relation.*creator.*Item.*target.*Ghost.*not exist/i, ); }); }); @@ -271,12 +269,10 @@ describe('validateContractDomain()', () => { tag: { to: 'Tag', cardinality: '1:1', - strategy: 'embed', - field: 'tag', }, }, }), - Tag: makeMinimalModel(), + Tag: makeMinimalModel({ owner: 'Item' }), }, }); const result = validateContractDomain(contract); @@ -300,8 +296,56 @@ describe('validateContractDomain()', () => { }); }); + describe('ownership validation', () => { + it('accepts valid owner reference', () => { + const contract = makeValidContract({ + roots: { items: 'Item' }, + models: { + Item: makeMinimalModel({ + relations: { address: { to: 'Address', cardinality: '1:1' } }, + }), + Address: makeMinimalModel({ owner: 'Item' }), + }, + }); + expect(() => validateContractDomain(contract)).not.toThrow(); + }); + + it('rejects self-ownership', () => { + const contract = makeValidContract({ + models: { + Item: makeMinimalModel({ owner: 'Item' }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow(/Item.*cannot own itself/i); + }); + + it('rejects owner referencing non-existent model', () => { + const contract = makeValidContract({ + models: { + Item: makeMinimalModel({ owner: 'Ghost' }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow(/Item.*owner.*Ghost.*not exist/i); + }); + + it('rejects owned model appearing in roots', () => { + const contract = makeValidContract({ + roots: { items: 'Item', addresses: 'Address' }, + models: { + Item: makeMinimalModel({ + relations: { address: { to: 'Address', cardinality: '1:1' } }, + }), + Address: makeMinimalModel({ owner: 'Item' }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow( + /owned model.*Address.*must not appear in roots/i, + ); + }); + }); + describe('happy path', () => { - it('validates a complex contract with polymorphism and relations', () => { + it('validates a complex contract with polymorphism, relations, and ownership', () => { const contract = { roots: { tasks: 'Task', users: 'User' }, models: { @@ -316,14 +360,11 @@ describe('validateContractDomain()', () => { assignee: { to: 'User', cardinality: 'N:1', - strategy: 'reference', on: { localFields: ['assigneeId'], targetFields: ['_id'] }, }, comments: { to: 'Comment', cardinality: '1:N', - strategy: 'embed', - field: 'comments', }, }, discriminator: { field: 'type' }, @@ -353,8 +394,6 @@ describe('validateContractDomain()', () => { addresses: { to: 'Address', cardinality: '1:N', - strategy: 'embed', - field: 'addresses', }, }, }), @@ -364,6 +403,7 @@ describe('validateContractDomain()', () => { city: { codecId: 'mongo/string@1', nullable: false }, zip: { codecId: 'mongo/string@1', nullable: false }, }, + owner: 'User', }), Comment: makeMinimalModel({ fields: { @@ -371,6 +411,7 @@ describe('validateContractDomain()', () => { text: { codecId: 'mongo/string@1', nullable: false }, createdAt: { codecId: 'mongo/date@1', nullable: false }, }, + owner: 'Task', }), }, }; diff --git a/packages/2-sql/1-core/contract/test/validate.test.ts b/packages/2-sql/1-core/contract/test/validate.test.ts index f7f3ef0172..3f44a3faf3 100644 --- a/packages/2-sql/1-core/contract/test/validate.test.ts +++ b/packages/2-sql/1-core/contract/test/validate.test.ts @@ -860,7 +860,6 @@ describe('validateContract', () => { expect(userRels['posts']).toEqual({ to: 'Post', cardinality: '1:N', - strategy: 'reference', on: { localFields: ['id'], targetFields: ['userId'] }, }); }); @@ -899,7 +898,6 @@ describe('validateContract', () => { posts: { to: 'Post', cardinality: '1:N', - strategy: 'reference', on: { localFields: ['id'], targetFields: ['userId'] }, }, }, @@ -920,7 +918,6 @@ describe('validateContract', () => { author: { to: 'User', cardinality: 'N:1', - strategy: 'reference', on: { localFields: ['userId'], targetFields: ['id'] }, }, }, diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts index 3b03a5645d..e7d8abdc30 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.generation.advanced.test.ts @@ -48,7 +48,6 @@ describe('sql-target-family-hook', () => { posts: { to: 'Post', cardinality: '1:N', - strategy: 'reference', on: { localFields: ['id'], targetFields: ['userId'], @@ -93,7 +92,7 @@ describe('sql-target-family-hook', () => { const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); expect(types).toContain('relations: {'); expect(types).toContain( - "readonly posts: { readonly to: 'Post'; readonly cardinality: '1:N'; readonly strategy: 'reference'; readonly on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['userId'] } }", + "readonly posts: { readonly to: 'Post'; readonly cardinality: '1:N'; readonly on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['userId'] } }", ); }); @@ -359,7 +358,6 @@ describe('sql-target-family-hook', () => { posts: { to: 'Post', cardinality: '1:N', - strategy: 'reference', on: { localFields: ['id'], targetFields: ['userId'], @@ -368,7 +366,6 @@ describe('sql-target-family-hook', () => { comments: { to: 'Comment', cardinality: '1:N', - strategy: 'reference', on: { localFields: ['id'], targetFields: ['authorId'], @@ -413,10 +410,10 @@ describe('sql-target-family-hook', () => { const types = sqlTargetFamilyHook.generateContractTypes(ir, [], [], testHashes); expect(types).toContain('export type Relations'); expect(types).toContain( - "readonly posts: { readonly to: 'Post'; readonly cardinality: '1:N'; readonly strategy: 'reference'; readonly on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['userId'] } }", + "readonly posts: { readonly to: 'Post'; readonly cardinality: '1:N'; readonly on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['userId'] } }", ); expect(types).toContain( - "readonly comments: { readonly to: 'Comment'; readonly cardinality: '1:N'; readonly strategy: 'reference'; readonly on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['authorId'] } }", + "readonly comments: { readonly to: 'Comment'; readonly cardinality: '1:N'; readonly on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['authorId'] } }", ); }); From f4054904e403bad1d956e587256ff921c1ce862e Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 20:53:24 +0200 Subject: [PATCH 27/35] regenerate contract fixtures: remove strategy from emitted JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demo contract and all 12 parity expected contracts regenerated to reflect ADR 177 — relations no longer carry strategy in emitted JSON. --- examples/prisma-next-demo/src/prisma/contract.d.ts | 2 -- examples/prisma-next-demo/src/prisma/contract.json | 2 -- .../test/authoring/parity/core-surface/expected.contract.json | 1 - .../test/authoring/parity/map-attributes/expected.contract.json | 1 - .../parity/relation-backrelation-list/expected.contract.json | 2 -- 5 files changed, 8 deletions(-) diff --git a/examples/prisma-next-demo/src/prisma/contract.d.ts b/examples/prisma-next-demo/src/prisma/contract.d.ts index 16e57581d0..b59ac1cf8d 100644 --- a/examples/prisma-next-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo/src/prisma/contract.d.ts @@ -163,7 +163,6 @@ type ContractBase = SqlContract< readonly user: { readonly to: 'User'; readonly cardinality: 'N:1'; - readonly strategy: 'reference'; readonly on: { readonly localFields: readonly ['userId']; readonly targetFields: readonly ['id']; @@ -191,7 +190,6 @@ type ContractBase = SqlContract< readonly posts: { readonly to: 'Post'; readonly cardinality: '1:N'; - readonly strategy: 'reference'; readonly on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['userId']; diff --git a/examples/prisma-next-demo/src/prisma/contract.json b/examples/prisma-next-demo/src/prisma/contract.json index ef1ebba019..a54c3364c4 100644 --- a/examples/prisma-next-demo/src/prisma/contract.json +++ b/examples/prisma-next-demo/src/prisma/contract.json @@ -40,7 +40,6 @@ "id" ] }, - "strategy": "reference", "to": "User" } }, @@ -91,7 +90,6 @@ "userId" ] }, - "strategy": "reference", "to": "Post" } }, diff --git a/test/integration/test/authoring/parity/core-surface/expected.contract.json b/test/integration/test/authoring/parity/core-surface/expected.contract.json index 248c9d9998..45ace0b351 100644 --- a/test/integration/test/authoring/parity/core-surface/expected.contract.json +++ b/test/integration/test/authoring/parity/core-surface/expected.contract.json @@ -32,7 +32,6 @@ "localFields": ["userId"], "targetFields": ["id"] }, - "strategy": "reference", "to": "User" } }, diff --git a/test/integration/test/authoring/parity/map-attributes/expected.contract.json b/test/integration/test/authoring/parity/map-attributes/expected.contract.json index 29cb2056e5..eb2cf18790 100644 --- a/test/integration/test/authoring/parity/map-attributes/expected.contract.json +++ b/test/integration/test/authoring/parity/map-attributes/expected.contract.json @@ -25,7 +25,6 @@ "localFields": ["teamId"], "targetFields": ["id"] }, - "strategy": "reference", "to": "Team" } }, diff --git a/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json b/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json index 6eb1a62161..b1b81fba88 100644 --- a/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json +++ b/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json @@ -25,7 +25,6 @@ "localFields": ["userId"], "targetFields": ["id"] }, - "strategy": "reference", "to": "User" } }, @@ -54,7 +53,6 @@ "localFields": ["id"], "targetFields": ["userId"] }, - "strategy": "reference", "to": "Post" } }, From b5fff3b68a02d5fa6701925a7b572625887a9a07 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 21:15:51 +0200 Subject: [PATCH 28/35] fix JSON schema: ModelField no longer requires column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emitted contracts now have domain-format model.fields with codecId and optional nullable — column has moved to model.storage.fields. The schema must accept both old and new formats during the dual-format bridge. --- .../contract-ts/schemas/data-contract-sql-v1.json | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json b/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json index 985b3c38ae..7da226936e 100644 --- a/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json +++ b/packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json @@ -519,23 +519,22 @@ }, "ModelField": { "type": "object", - "description": "Model field definition mapping to storage column", + "description": "Domain field definition. New format has codecId + nullable (column lives in model.storage.fields). Old format has column only.", "additionalProperties": false, "properties": { "column": { "type": "string", - "description": "Column name in the model's backing table" + "description": "Column name in the model's backing table (old format; new format uses model.storage.fields)" }, "codecId": { "type": "string", - "description": "Codec identifier for the field (derived from storage column)" + "description": "Codec identifier for the field" }, "nullable": { "type": "boolean", - "description": "Whether the field allows NULL values (derived from storage column)" + "description": "Whether the field allows NULL values" } - }, - "required": ["column"] + } }, "ModelStorageField": { "type": "object", From 57a6af96a2a4b42f200a93c6eb0c1d296f80afff Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 21:26:56 +0200 Subject: [PATCH 29/35] fix biome noBannedTypes: replace {} with Record --- packages/2-sql/1-core/contract/test/domain-types.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/2-sql/1-core/contract/test/domain-types.test.ts b/packages/2-sql/1-core/contract/test/domain-types.test.ts index 162bd27d3e..e917c7ee77 100644 --- a/packages/2-sql/1-core/contract/test/domain-types.test.ts +++ b/packages/2-sql/1-core/contract/test/domain-types.test.ts @@ -68,7 +68,7 @@ describe('domain type compatibility', () => { readonly codecId: 'pg/text@1'; }; }; - readonly relations: {}; + readonly relations: Record; readonly storage: { readonly table: 'user' }; }; }; From 64d00f48756ebbe8f15605c3d13b7c461e2604f5 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 21:56:41 +0200 Subject: [PATCH 30/35] fix(sql-contract): raise branch coverage to meet 95% per-file threshold Add edge case tests for normalizeContract/enrichNewFormatModels/ enrichOldFormatModels covering fallback branches for models without fields, relations without on.localFields/targetFields, unknown table relations, column name fallbacks, and storage semantic validation. Exclude pack-types.ts from coverage (pure type definitions). --- .../1-core/contract/test/validate.test.ts | 710 +++++++++++++++++- .../2-sql/1-core/contract/vitest.config.ts | 1 + 2 files changed, 709 insertions(+), 2 deletions(-) diff --git a/packages/2-sql/1-core/contract/test/validate.test.ts b/packages/2-sql/1-core/contract/test/validate.test.ts index 3f44a3faf3..006bdf028c 100644 --- a/packages/2-sql/1-core/contract/test/validate.test.ts +++ b/packages/2-sql/1-core/contract/test/validate.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { SqlContract, SqlStorage } from '../src/types'; -import { validateContract } from '../src/validate'; +import { normalizeContract, validateContract } from '../src/validate'; const baseContract = { schemaVersion: '1', @@ -431,7 +431,7 @@ describe('validateContract', () => { expect(result.storage.tables.User.indexes).toHaveLength(1); }); - it('accepts null mapping buckets by treating them as empty overrides', () => { + it('accepts null fieldToColumn/columnToField mapping buckets as empty overrides', () => { const contract = makeContract(); contract.mappings = { modelToTable: { User: 'User' }, @@ -445,6 +445,22 @@ describe('validateContract', () => { expect(result.mappings.columnToField).toEqual({}); }); + it('accepts null modelToTable/tableToModel mapping buckets as empty overrides', () => { + const contract = makeContract(); + contract.mappings = { + modelToTable: null as unknown as Record, + tableToModel: null as unknown as Record, + fieldToColumn: null as unknown as Record>, + columnToField: null as unknown as Record>, + }; + + const result = validateContract>(contract); + expect(result.mappings.modelToTable).toEqual({}); + expect(result.mappings.tableToModel).toEqual({}); + expect(result.mappings.fieldToColumn).toEqual({}); + expect(result.mappings.columnToField).toEqual({}); + }); + it('throws structural error for non-object values', () => { expect(() => validateContract>(null)).toThrow( /Contract structural validation failed/, @@ -987,4 +1003,694 @@ describe('validateContract', () => { expect(result.mappings.modelToTable).toEqual({ User: 'user', Post: 'post' }); }); }); + + describe('old format relation to missing model', () => { + it('rejects relation targeting non-existent model via domain validation', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { column: 'id' } }, + relations: {}, + }, + }, + relations: { + user: { + posts: { + cardinality: '1:N', + on: { parentCols: ['id'], childCols: ['user_id'] }, + to: 'Ghost', + }, + }, + }, + storage: { + tables: { + user: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }; + expect(() => validateContract>(contract)).toThrow( + /Ghost.*not exist/i, + ); + }); + }); + + describe('storage semantic validation', () => { + it('rejects setNull referential action on NOT NULL FK column', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + roots: { User: 'User', Post: 'Post' }, + models: { + User: { + storage: { table: 'user', fields: { id: { column: 'id' } } }, + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: { + posts: { + to: 'Post', + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['userId'] }, + }, + }, + }, + Post: { + storage: { + table: 'post', + fields: { id: { column: 'id' }, userId: { column: 'user_id' } }, + }, + fields: { + id: { nullable: false, codecId: 'pg/int4@1' }, + userId: { nullable: false, codecId: 'pg/int4@1' }, + }, + relations: { + author: { + to: 'User', + cardinality: 'N:1', + on: { localFields: ['userId'], targetFields: ['id'] }, + }, + }, + }, + }, + storage: { + tables: { + user: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + post: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + user_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [ + { + columns: ['user_id'], + references: { table: 'user', columns: ['id'] }, + onDelete: 'setNull', + constraint: true, + index: true, + }, + ], + }, + }, + }, + }; + expect(() => validateContract>(contract)).toThrow( + /semantic.*setNull.*user_id.*NOT NULL/i, + ); + }); + }); + + describe('enrichNewFormatModels edge cases (via normalizeContract)', () => { + it('handles model without storage.table (skips relation derivation)', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: {}, + }, + }, + }; + + const result = normalizeContract(contract) as Record; + const models = result['models'] as Record>; + expect(models['User']).toBeDefined(); + }); + + it('handles model with empty fields', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + fields: {}, + relations: {}, + }, + }, + }; + + const result = normalizeContract(contract) as Record; + const models = result['models'] as Record>; + expect(models['User']).toBeDefined(); + }); + + it('handles field without storage.fields entry (no column mapping)', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: {}, + }, + }, + }; + + const result = normalizeContract(contract) as Record; + const models = result['models'] as Record>; + const fields = models['User']['fields'] as Record>; + expect(fields['id']['codecId']).toBe('pg/int4@1'); + }); + + it('handles relation without on clause (skips top-level relation derivation)', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: { + posts: { to: 'Post', cardinality: '1:N' }, + }, + }, + Post: { + storage: { table: 'post' }, + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: {}, + }, + }, + }; + + const result = normalizeContract(contract) as Record; + const relations = result['relations'] as Record>; + expect(relations['user']).toBeUndefined(); + }); + + it('handles relation target model without table mapping', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user', fields: { id: { column: 'id' } } }, + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: { + tags: { + to: 'Tag', + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['userId'] }, + }, + }, + }, + Tag: { + fields: { userId: { nullable: false, codecId: 'pg/int4@1' } }, + relations: {}, + }, + }, + }; + + const result = normalizeContract(contract) as Record; + const relations = result['relations'] as Record>; + const userRels = relations['user'] as Record> | undefined; + expect(userRels?.['tags']).toBeUndefined(); + }); + + it('handles model without relations property', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + }, + }, + }; + + const result = normalizeContract(contract) as Record; + const models = result['models'] as Record>; + expect(models['User']['relations']).toEqual({}); + }); + + it('handles model without fields property in multi-model contract', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user', fields: { id: { column: 'id' } } }, + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: {}, + }, + Metadata: { + storage: { table: 'metadata' }, + relations: {}, + }, + }, + }; + + const result = normalizeContract(contract) as Record; + const models = result['models'] as Record>; + expect(models['Metadata']['fields']).toEqual({}); + }); + + it('handles relation to model without fields and with on missing localFields/targetFields', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user', fields: { id: { column: 'id' } } }, + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: { + meta: { + to: 'Metadata', + cardinality: '1:1', + on: {}, + }, + }, + }, + Metadata: { + storage: { table: 'metadata' }, + relations: {}, + }, + }, + }; + + const result = normalizeContract(contract) as Record; + const relations = result['relations'] as Record< + string, + Record> + >; + expect(relations['user']['meta']['on']).toEqual({ + parentCols: [], + childCols: [], + }); + }); + + it('falls back to field names when storage.fields lacks column mapping', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: { + posts: { + to: 'Post', + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['postId'] }, + }, + }, + }, + Post: { + storage: { table: 'post' }, + fields: { postId: { nullable: false, codecId: 'pg/int4@1' } }, + relations: {}, + }, + }, + }; + + const result = normalizeContract(contract) as Record; + const relations = result['relations'] as Record< + string, + Record> + >; + expect(relations['user']['posts']['on']).toEqual({ + parentCols: ['id'], + childCols: ['postId'], + }); + }); + + it('derives top-level relations with column-resolved on clauses', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + roots: { User: 'User', Post: 'Post' }, + models: { + User: { + storage: { table: 'user', fields: { id: { column: 'id' } } }, + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: { + posts: { + to: 'Post', + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['userId'] }, + }, + }, + }, + Post: { + storage: { + table: 'post', + fields: { id: { column: 'id' }, userId: { column: 'user_id' } }, + }, + fields: { + id: { nullable: false, codecId: 'pg/int4@1' }, + userId: { nullable: false, codecId: 'pg/int4@1' }, + }, + relations: { + author: { + to: 'User', + cardinality: 'N:1', + on: { localFields: ['userId'], targetFields: ['id'] }, + }, + }, + }, + }, + }; + + const result = normalizeContract(contract) as Record; + const relations = result['relations'] as Record< + string, + Record> + >; + expect(relations['user']['posts']['to']).toBe('Post'); + expect(relations['user']['posts']['on']).toEqual({ + parentCols: ['id'], + childCols: ['user_id'], + }); + expect(relations['post']['author']['to']).toBe('User'); + expect(relations['post']['author']['on']).toEqual({ + parentCols: ['user_id'], + childCols: ['id'], + }); + }); + }); + + describe('normalizeContract edge cases', () => { + it('passes through contract with no models field', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + storage: { tables: {} }, + }; + + const result = normalizeContract(contract) as Record; + expect(result['models']).toEqual({}); + expect(result['roots']).toEqual({}); + }); + + it('handles new-format contract without explicit roots', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + models: { + User: { + storage: { table: 'user', fields: { id: { column: 'id' } } }, + fields: { id: { nullable: false, codecId: 'pg/int4@1' } }, + relations: {}, + }, + }, + storage: { + tables: { + user: { + columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }; + + const result = validateContract>(contract); + expect(result.roots).toEqual({}); + }); + }); + + describe('enrichOldFormatModels edge cases (via normalizeContract)', () => { + it('handles old-format model without storage (no table mapping)', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + fields: { id: { column: 'id' } }, + relations: {}, + }, + }, + storage: { tables: {} }, + }; + + const result = normalizeContract(contract) as Record; + const roots = result['roots'] as Record; + expect(roots['User']).toBeUndefined(); + }); + + it('handles old-format model without fields or relations properties', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + }, + }, + storage: { tables: {} }, + }; + + const result = normalizeContract(contract) as Record; + const models = result['models'] as Record>; + expect(models['User']['fields']).toEqual({}); + expect(models['User']['relations']).toEqual({}); + }); + + it('handles top-level relation for unknown table (no tableToModel match)', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { column: 'id' } }, + relations: {}, + }, + }, + relations: { + ghost_table: { + someRel: { + to: 'User', + cardinality: '1:N', + on: { parentCols: ['id'], childCols: ['id'] }, + }, + }, + }, + storage: { tables: {} }, + }; + + const result = normalizeContract(contract) as Record; + const models = result['models'] as Record>; + const userRels = models['User']['relations'] as Record; + expect(userRels['someRel']).toBeUndefined(); + }); + + it('handles old-format relation without on clause', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { column: 'id' } }, + relations: {}, + }, + Post: { + storage: { table: 'post' }, + fields: { id: { column: 'id' } }, + relations: {}, + }, + }, + relations: { + user: { + posts: { to: 'Post', cardinality: '1:N' }, + }, + }, + storage: { tables: {} }, + }; + + const result = normalizeContract(contract) as Record; + const models = result['models'] as Record>; + const userRels = models['User']['relations'] as Record; + expect(userRels['posts']).toBeDefined(); + const postsRel = userRels['posts'] as Record; + expect(postsRel['on']).toEqual({ localFields: [], targetFields: [] }); + }); + + it('falls back to column name when sourceColToField has no match', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { column: 'id' } }, + relations: {}, + }, + Post: { + storage: { table: 'post' }, + fields: { id: { column: 'id' } }, + relations: {}, + }, + }, + relations: { + post: { + author: { + to: 'User', + cardinality: 'N:1', + on: { parentCols: ['unknown_col'], childCols: ['also_unknown'] }, + }, + }, + }, + storage: { tables: {} }, + }; + + const result = normalizeContract(contract) as Record; + const models = result['models'] as Record>; + const postRels = models['Post']['relations'] as Record>; + expect(postRels['author']['on']).toEqual({ + localFields: ['unknown_col'], + targetFields: ['also_unknown'], + }); + }); + + it('caches targetColumnToField on repeated relations to same model', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { column: 'id' } }, + relations: {}, + }, + Post: { + storage: { table: 'post' }, + fields: { id: { column: 'id' }, userId: { column: 'user_id' } }, + relations: {}, + }, + }, + relations: { + post: { + author: { + to: 'User', + cardinality: 'N:1', + on: { parentCols: ['user_id'], childCols: ['id'] }, + }, + updater: { + to: 'User', + cardinality: 'N:1', + on: { parentCols: ['user_id'], childCols: ['id'] }, + }, + }, + }, + storage: { tables: {} }, + }; + + const result = normalizeContract(contract) as Record; + const models = result['models'] as Record>; + const postRels = models['Post']['relations'] as Record>; + expect(postRels['author']).toBeDefined(); + expect(postRels['updater']).toBeDefined(); + }); + }); + + describe('detectFormat edge cases (via normalizeContract)', () => { + it('detects old format when model has no fields property', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { storage: { table: 'user' } }, + }, + storage: { tables: {} }, + }; + + const result = normalizeContract(contract) as Record; + expect(result['roots']).toBeDefined(); + }); + + it('detects old format when fields have neither column nor codecId', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { nullable: false } }, + }, + }, + storage: { tables: {} }, + }; + + const result = normalizeContract(contract) as Record; + expect(result['roots']).toBeDefined(); + }); + }); + + describe('normalizeStorage edge cases (via normalizeContract)', () => { + it('handles table without columns property', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { column: 'id' } }, + relations: {}, + }, + }, + storage: { + tables: { + user: { primaryKey: { columns: ['id'] } }, + }, + }, + }; + + const result = normalizeContract(contract) as Record; + const storage = result['storage'] as Record; + const tables = storage['tables'] as Record>; + expect(tables['user']).toBeDefined(); + }); + }); }); diff --git a/packages/2-sql/1-core/contract/vitest.config.ts b/packages/2-sql/1-core/contract/vitest.config.ts index 6922730a09..82d7aa627f 100644 --- a/packages/2-sql/1-core/contract/vitest.config.ts +++ b/packages/2-sql/1-core/contract/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ '**/exports/**', '**/types.ts', 'src/index.ts', // Barrel file with only re-exports + 'src/pack-types.ts', // Pure type definitions, no executable code ], thresholds: { lines: 90, From a208200071a5975513f56f233a90b3221e6bb9fb Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 23:09:32 +0200 Subject: [PATCH 31/35] docs: fix review feedback - links, ADR consistency, glossary wording - Replace placeholder ADR 177 link with real repo-relative path - Remove conflicting strategy reference in ADR 172 Implemented section - Add missing Post model to ADR 177 SQL example - Reword glossary: owned models have no independent storage unit - Add language identifiers to fenced code blocks - Escape pipe in markdown table cell --- .../skills/write-architecture-docs/SKILL.md | 2 +- ...72 - Contract domain-storage separation.md | 2 +- ... - Ownership replaces relation strategy.md | 19 +++++++++++++++++++ docs/glossary.md | 2 +- .../1-design-docs/design-questions.md | 6 +++--- 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.agents/skills/write-architecture-docs/SKILL.md b/.agents/skills/write-architecture-docs/SKILL.md index 6e0d554dbe..b64512370b 100644 --- a/.agents/skills/write-architecture-docs/SKILL.md +++ b/.agents/skills/write-architecture-docs/SKILL.md @@ -46,7 +46,7 @@ Bad: "MongoDB is a database family in Prisma Next. The contract, ORM, execution **Inline summaries with ADR links.** When referencing an ADR, summarize the key idea in the text and link the ADR for depth. The doc should be understandable without following any links. -Good: "An owned model declares `owner: \"User\"` — a domain fact about aggregate membership. Its data lives within the owner's storage. See [ADR 177](...)." +Good: "An owned model declares `owner: \"User\"` — a domain fact about aggregate membership. Its data lives within the owner's storage. See [ADR 177](docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md)." Bad: "See [ADR 177](...) for how embedding works." **References section.** Organize by durability: diff --git a/docs/architecture docs/adrs/ADR 172 - Contract domain-storage separation.md b/docs/architecture docs/adrs/ADR 172 - Contract domain-storage separation.md index 1bbc2a423a..9911f6fa02 100644 --- a/docs/architecture docs/adrs/ADR 172 - Contract domain-storage separation.md +++ b/docs/architecture docs/adrs/ADR 172 - Contract domain-storage separation.md @@ -181,7 +181,7 @@ For Mongo, the redundancy is much smaller. There's no column indirection, so `mo ### What this requires **Implemented in this PR (Mongo PoC):** -- `MongoContract` adopts the domain-storage separation with `model.fields` carrying `{ nullable, codecId }`, `model.relations` with strategy, and `model.storage` scoped per model. +- `MongoContract` adopts the domain-storage separation with `model.fields` carrying `{ nullable, codecId }`, `model.relations` as plain graph edges (cardinality + optional join details), and `model.storage` scoped per model. - `validateContractDomain()` validates domain-level invariants (roots, variants, relations, discriminators) in a family-agnostic way. - `validateMongoStorage()` validates Mongo-specific storage rules. diff --git a/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md b/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md index 68b20fa377..fe339eef7a 100644 --- a/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md +++ b/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md @@ -91,6 +91,25 @@ A User model with a referenced relation (Post) and an owned model (Address). Add } } }, + "Post": { + "fields": { + "id": { "nullable": false, "codecId": "pg/int4@1" }, + "authorId": { "nullable": false, "codecId": "pg/int4@1" } + }, + "relations": { + "author": { + "to": "User", "cardinality": "N:1", + "on": { "localFields": ["authorId"], "targetFields": ["id"] } + } + }, + "storage": { + "table": "posts", + "fields": { + "id": { "column": "id" }, + "authorId": { "column": "author_id" } + } + } + }, "Address": { "owner": "User", "fields": { diff --git a/docs/glossary.md b/docs/glossary.md index baab85087a..0df04c0363 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -85,7 +85,7 @@ Value objects are a future concept in the contract; currently they're represente ### Owner -A domain-level property on a model declaring aggregate membership. If Address says `"owner": "User"`, it means Address is a component of User's aggregate — its data is co-located within User's storage (embedded document in MongoDB, JSONB column in SQL). Owned models don't appear in `roots` and have empty `storage` blocks. The parent's `storage.relations` maps the relation to its physical location. See [ADR 177](architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md). +A domain-level property on a model declaring aggregate membership. If Address says `"owner": "User"`, it means Address is a component of User's aggregate — its data is co-located within User's storage (embedded document in MongoDB, JSONB column in SQL). Owned models don't appear in `roots` and have no independent storage unit (they may still include `storage.relations` for nested owned children). The parent's `storage.relations` maps the relation to its physical location. See [ADR 177](architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md). ### Relation diff --git a/docs/planning/mongo-target/1-design-docs/design-questions.md b/docs/planning/mongo-target/1-design-docs/design-questions.md index 272cd7c9fd..6bfcbf8a57 100644 --- a/docs/planning/mongo-target/1-design-docs/design-questions.md +++ b/docs/planning/mongo-target/1-design-docs/design-questions.md @@ -330,7 +330,7 @@ ADR 173 covers polymorphic *models* (a model that has specializations via `discr Classic example: a `Comment` that can belong to either a `Post` or a `Video`: -``` +```text Comment → commentable → Post | Video ``` @@ -351,7 +351,7 @@ This is not a polymorphic model — `Comment` is always a `Comment`. It's the *r | Pattern | How it works | Trade-offs | |---|---|---| | **DBRef-like** | `{ ref: ObjectId, refType: "Post" }` | Idiomatic. No database enforcement. | -| **Convention field** | `commentableId: ObjectId` + `commentableType: "Post" | "Video"` | Same as SQL type+ID pair. | +| **Convention field** | `commentableId: ObjectId` + `commentableType: "Post" \| "Video"` | Same as SQL type+ID pair. | ### Contract considerations @@ -473,7 +473,7 @@ Many-to-many (M:N) relationships are common in both SQL and MongoDB, but they're In SQL, M:N requires a join table: -``` +```text User ←→ user_roles ←→ Role ``` From 28381bc85d0b12750eee321ee80572ed88693c04 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Tue, 31 Mar 2026 23:10:56 +0200 Subject: [PATCH 32/35] fix: address review feedback - own-property checks, owner schema/emission - Replace in operator with Object.hasOwn() in validate-domain.ts - Add owner to ModelSchema in validators.ts - Use serializeObjectKey/serializeValue in generateRootsType - Emit owner property in generated model type --- .../1-core/shared/contract/src/validate-domain.ts | 4 ++-- packages/2-sql/1-core/contract/src/validators.ts | 1 + packages/2-sql/3-tooling/emitter/src/index.ts | 8 +++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/1-framework/1-core/shared/contract/src/validate-domain.ts b/packages/1-framework/1-core/shared/contract/src/validate-domain.ts index 7954d968b9..e6a1505f91 100644 --- a/packages/1-framework/1-core/shared/contract/src/validate-domain.ts +++ b/packages/1-framework/1-core/shared/contract/src/validate-domain.ts @@ -86,7 +86,7 @@ function validateVariantsAndBases( } const baseModel = contract.models[model.base]; if (!baseModel) continue; - if (!baseModel.variants || !(modelName in baseModel.variants)) { + if (!baseModel.variants || !Object.hasOwn(baseModel.variants, modelName)) { errors.push( `Model "${modelName}" has base "${model.base}" which does not list it as a variant`, ); @@ -117,7 +117,7 @@ function validateDiscriminators(contract: DomainContractShape, errors: string[]) if (!model.variants || Object.keys(model.variants).length === 0) { errors.push(`Model "${modelName}" has discriminator but no variants`); } - if (!(model.discriminator.field in model.fields)) { + if (!Object.hasOwn(model.fields, model.discriminator.field)) { errors.push( `Discriminator field "${model.discriminator.field}" is not a field on model "${modelName}"`, ); diff --git a/packages/2-sql/1-core/contract/src/validators.ts b/packages/2-sql/1-core/contract/src/validators.ts index 704435f1e4..fc76d644c5 100644 --- a/packages/2-sql/1-core/contract/src/validators.ts +++ b/packages/2-sql/1-core/contract/src/validators.ts @@ -155,6 +155,7 @@ const ModelSchema = type({ 'discriminator?': 'unknown', 'variants?': 'unknown', 'base?': 'string', + 'owner?': 'string', }); const MappingsSchema = type({ diff --git a/packages/2-sql/3-tooling/emitter/src/index.ts b/packages/2-sql/3-tooling/emitter/src/index.ts index 8b5a117b73..6317a79d9d 100644 --- a/packages/2-sql/3-tooling/emitter/src/index.ts +++ b/packages/2-sql/3-tooling/emitter/src/index.ts @@ -368,7 +368,9 @@ export const sqlTargetFamilyHook = { return 'Record'; } const entries = Object.entries(roots) - .map(([key, value]) => `readonly ${key}: '${value}'`) + .map( + ([key, value]) => `readonly ${this.serializeObjectKey(key)}: ${this.serializeValue(value)}`, + ) .join('; '); return `{ ${entries} }`; }, @@ -601,6 +603,10 @@ export const sqlTargetFamilyHook = { `relations: { ${relations.join('; ')} }`, ]; + if (model.owner) { + modelParts.push(`owner: ${this.serializeValue(model.owner)}`); + } + modelTypes.push(`readonly ${modelName}: { ${modelParts.join('; ')} }`); } From 653fd072848536178b0a81825503f77e8a9f99df Mon Sep 17 00:00:00 2001 From: Will Madden Date: Wed, 1 Apr 2026 08:06:00 +0200 Subject: [PATCH 33/35] docs: add M3 (Mongo emitter hook), renumber milestones M3-M6 Insert new Milestone 3 (Mongo emitter hook with shared domain-level generation) as the forcing function for the shared generation API. Bump old M3 (Remove old fields) to M4, M4 (IR alignment) to M5, M5 (Emitter generalization) to M6 (SQL emitter migration). Update spec phases and acceptance criteria to match. Remove Mongo emitter from non-goals since it is now in scope as Phase 3. Linear: renamed project milestones P1/P2 to M1-M9, created M2-M6 milestones, created TML-2176 for M3, updated project description. --- projects/contract-domain-extraction/plan.md | 125 +++++++++++--------- projects/contract-domain-extraction/spec.md | 64 ++++++---- 2 files changed, 107 insertions(+), 82 deletions(-) diff --git a/projects/contract-domain-extraction/plan.md b/projects/contract-domain-extraction/plan.md index cbd32bf3ab..70075d25d5 100644 --- a/projects/contract-domain-extraction/plan.md +++ b/projects/contract-domain-extraction/plan.md @@ -2,11 +2,11 @@ ## Summary -Restructure the emitted SQL contract to implement ADR 172's domain-storage separation: extract a shared domain-level representation into `ContractBase`, update the SQL emitter to produce the new JSON layout, and bridge `validateContract()` so no consumer code changes until Phase 2. This is the foundational step toward cross-family consumer code (ORM, validation, tooling). Success means the contract carries a self-describing domain level (`roots`, `models` with typed fields and relations) distinct from SQL-specific storage, with all existing consumers continuing to work via a compatibility bridge. +Restructure the emitted SQL contract to implement ADR 172's domain-storage separation: extract a shared domain-level representation into `ContractBase`, update the SQL emitter to produce the new JSON layout, and bridge `validateContract()` so no consumer code changes until M2. Build a Mongo emitter hook (M3) that forces out shared domain-level generation utilities, then migrate the SQL hook onto those utilities (M6). This is the foundational step toward cross-family consumer code (ORM, validation, tooling). Success means the contract carries a self-describing domain level (`roots`, `models` with typed fields and relations) distinct from family-specific storage, with all existing consumers continuing to work via a compatibility bridge. **Spec:** [projects/contract-domain-extraction/spec.md](spec.md) -**Linear:** [TML-2172](https://linear.app/prisma-company/issue/TML-2172) under [WS4: MongoDB & Cross-Family Architecture](https://linear.app/prisma-company/project/ws4-mongodb-and-cross-family-architecture-89d4dcdbcd9a) → milestone "P1: Contract extraction" +**Linear:** [WS4: MongoDB & Cross-Family Architecture](https://linear.app/prisma-company/project/ws4-mongodb-and-cross-family-architecture-89d4dcdbcd9a) — milestones M1–M6. Tickets: [TML-2172](https://linear.app/prisma-company/issue/TML-2172) (M1), [TML-2175](https://linear.app/prisma-company/issue/TML-2175) (M2), [TML-2176](https://linear.app/prisma-company/issue/TML-2176) (M3) ## Collaborators @@ -15,7 +15,7 @@ Restructure the emitted SQL contract to implement ADR 172's domain-storage separ | ------------ | ----------- | ------------------------------------------------------------------ | | Maker | Will | Drives execution | | Collaborator | Alexey | ORM client — Phase 2 migration must coordinate with his workstream | -| Collaborator | Alberto | DSL/authoring — Phase 4 IR alignment benefits his workstream | +| Collaborator | Alberto | DSL/authoring — M5 IR alignment benefits his workstream | ## Milestones @@ -86,67 +86,73 @@ Migrates consumer code to read from the new domain-level TypeScript fields inste - **2.7** Migrate `paradedb` extension: update BM25 index field column resolution in `packages/3-extensions/paradedb/src/types/index-types.ts`. - **2.8** Verify: no consumer imports or reads from `mappings`, no consumer reads top-level `relations`. -### Milestone 3: Remove old type fields +### Milestone 3: Mongo emitter hook (with shared domain-level generation) -Removes the backward-compatibility shim from `validateContract()` and old fields from `SqlContract`. Only possible after all consumers are migrated (Milestone 2 complete). +Builds a `mongoTargetFamilyHook` that implements `generateContractTypes()` for the Mongo family. The domain-level generation (roots type, model domain fields, relations, imports, hashes, `.d.ts` skeleton) is factored into shared utility functions in the framework from the start — the Mongo hook only writes storage-specific parts (collection mappings, embedded document types). These shared utilities become the proven API that M6 migrates the SQL hook onto. + +This milestone is the forcing function that defines the shared generation API. It can run in parallel with M2 (consumer migration) since it doesn't touch the SQL emitter. **Tasks:** -- **3.1** Remove `mappings` from `SqlContract` type and `validateContract()` derivation logic. -- **3.2** Remove top-level `relations` from `SqlContract` type and `validateContract()` derivation logic. -- **3.3** Remove old model field shape (`{ column: string }` without `nullable`/`codecId`) from the type. -- **3.4** Update `contract.d.ts` emission to reflect the final shape (no old fields). -- **3.5** Remove old-format JSON support from `normalizeContract()` (if dual-format was added in 1.3.1). -- **3.6** Remove the generic `TModels` parameter from `ContractBase`. Once consumers read from domain-level fields and `SqlContract` no longer carries query-builder-specific model types via `M`, simplify `ContractBase` back to a concrete `models: Record`. The generic was introduced to avoid `noPropertyAccessFromIndexSignature` index-signature leakage while `SqlContract`'s `M` still overrides the base `models` type. -- **3.7** Update all remaining test fixtures and type tests to reflect the clean types. -- **3.8** Run full test suite and typecheck. +- **3.1** Extract domain-level `.d.ts` generation from `sqlTargetFamilyHook` into shared utility functions in the framework emitter package: `generateRootsType()`, model domain field type generation, model relation type generation, import deduplication, hash type aliases, codec/operation type intersections, `.d.ts` template skeleton. +- **3.2** Implement `mongoTargetFamilyHook.generateContractTypes()` using the shared utilities for domain-level generation. The Mongo hook provides: `generateStorageType()` (collection mappings), `generateModelStorageType()` (embedded document storage, `storage.relations` mapping), and Mongo-specific validation. +- **3.3** Implement `mongoTargetFamilyHook.validateStructure()` for Mongo-specific contract validation (collection names, embedded document constraints, owner/`storage.relations` consistency). +- **3.4** Write emitter tests for the Mongo hook: verify generated `contract.json` and `contract.d.ts` match the ADR 172/177 Mongo contract structure (as shown in ADR 177's examples). +- **3.5** Verify SQL emitter output is unchanged — the shared utilities are used by Mongo but the SQL hook still uses its own `generateContractTypes()` (migrated in M6). +- **3.6** Set up a minimal Mongo demo/fixture contract to exercise the Mongo emitter end-to-end. -### Milestone 4: Contract IR alignment +### Milestone 4: Remove old type fields -Aligns the internal `ContractIR` representation with the emitted contract JSON structure. Reduces impedance mismatch for the DSL authoring layer. Coordinate timing with Alberto. +Removes the backward-compatibility shim from `validateContract()` and old fields from `SqlContract`. Only possible after all consumers are migrated (Milestone 2 complete). **Tasks:** -- **4.1** Audit current `ContractIR` structure vs the emitted JSON. Document the structural gaps (e.g., IR has top-level `relations` and `mappings`; emitted JSON does not). -- **4.2** Update `ContractIR` to mirror the ADR 172 structure: domain-level `models` with `fields`/`relations`/`storage`, `roots`, no top-level `relations` or `mappings`. -- **4.3** Update the emitter (`emit.ts`) to read from the new IR structure (remove translation logic that was bridging old IR → new JSON). -- **4.4** Update all IR construction sites: PSL interpreter, TypeScript contract builders (`contract-ts`), and any tooling that produces `ContractIR`. -- **4.5** Update IR-level tests and validation. -- **4.6** Run full test suite and typecheck. - -### Milestone 5: Emitter generalization +- **4.1** Remove `mappings` from `SqlContract` type and `validateContract()` derivation logic. +- **4.2** Remove top-level `relations` from `SqlContract` type and `validateContract()` derivation logic. +- **4.3** Remove old model field shape (`{ column: string }` without `nullable`/`codecId`) from the type. +- **4.4** Update `contract.d.ts` emission to reflect the final shape (no old fields). +- **4.5** Remove old-format JSON support from `normalizeContract()` (if dual-format was added in 1.3.1). +- **4.6** Remove the generic `TModels` parameter from `ContractBase`. Once consumers read from domain-level fields and `SqlContract` no longer carries query-builder-specific model types via `M`, simplify `ContractBase` back to a concrete `models: Record`. The generic was introduced to avoid `noPropertyAccessFromIndexSignature` index-signature leakage while `SqlContract`'s `M` still overrides the base `models` type. +- **4.7** Update all remaining test fixtures and type tests to reflect the clean types. +- **4.8** Run full test suite and typecheck. -Refactors the `TargetFamilyHook` interface so the framework `emit()` generates domain-level `.d.ts` content and the family hook provides only storage-specific type blocks. Today `sqlTargetFamilyHook.generateContractTypes()` owns the entire `.d.ts` — ~60–70% of which (roots, model domain fields, model relations, imports, hashes, codec types, the template skeleton) is family-agnostic. This means any new family emitter would duplicate all of it. After this milestone, a new family hook only needs to provide storage-specific type generation. +### Milestone 5: Contract IR alignment -Independent of Milestone 4 (IR alignment) — can be done before or after. +Aligns the internal `ContractIR` representation with the emitted contract JSON structure. Reduces impedance mismatch for the DSL authoring layer. Coordinate timing with Alberto. **Tasks:** -#### 5.1 Design the narrower hook interface +- **5.1** Audit current `ContractIR` structure vs the emitted JSON. Document the structural gaps (e.g., IR has top-level `relations` and `mappings`; emitted JSON does not). +- **5.2** Update `ContractIR` to mirror the ADR 172 structure: domain-level `models` with `fields`/`relations`/`storage`, `roots`, no top-level `relations` or `mappings`. +- **5.3** Update the emitter (`emit.ts`) to read from the new IR structure (remove translation logic that was bridging old IR → new JSON). +- **5.4** Update all IR construction sites: PSL interpreter, TypeScript contract builders (`contract-ts`), and any tooling that produces `ContractIR`. +- **5.5** Update IR-level tests and validation. +- **5.6** Run full test suite and typecheck. + +### Milestone 6: SQL emitter migration to shared generation -- **5.1.1** Audit `sqlTargetFamilyHook` methods and classify each as domain-level (framework) or storage-level (family). Document the split in a short design note. -- **5.1.2** Design the new `TargetFamilyHook` interface: remove `generateContractTypes()`, add `generateStorageType(storage)`, `generateModelStorageType(model, storage)`, and any other family-specific type generation callbacks needed. Keep `validateTypes()` and `validateStructure()` on the hook. +Migrates the SQL emitter hook onto the shared domain-level generation utilities established in M3 (Mongo emitter hook). The `TargetFamilyHook` interface narrows: `generateContractTypes()` is removed, and hooks provide only storage-specific type blocks. The shared utilities are already proven by the Mongo hook — this milestone is a migration, not a design exercise. + +**Tasks:** -#### 5.2 Extract domain-level type generation to the framework +#### 6.1 Migrate SQL hook to shared utilities -- **5.2.1** Move `generateRootsType()` to the framework emitter. -- **5.2.2** Move model domain field type generation (`generateColumnType()`, the codec → TypeScript type logic, parameterized renderer dispatch) to the framework emitter. -- **5.2.3** Move model relation type generation (ADR 172 `to`/`cardinality`/`on` serialization) to the framework emitter. -- **5.2.4** Move import deduplication, hash type aliases, codec/operation type intersections, `DefaultLiteralValue`, `TypeMaps`, and the `.d.ts` template skeleton to the framework emitter. -- **5.2.5** The framework emitter calls the hook's storage-specific methods to fill in the storage sections, then assembles the complete `.d.ts`. +- **6.1.1** Replace SQL hook's `generateRootsType()`, model domain field generation, model relation generation, import deduplication, hash aliases, and `.d.ts` skeleton with calls to the shared framework utilities (established in M3 task 3.1). +- **6.1.2** Implement `generateStorageType(storage)` on the SQL hook (extract from current `generateStorageType` — already a separate method, just needs to conform to the shared interface). +- **6.1.3** Implement `generateModelStorageType(model, storage)` on the SQL hook (field-to-column mapping type generation, extracted from `generateModelsType`). +- **6.1.4** Remove `generateContractTypes()`, `generateModelsType()`, `generateRootsType()`, `generateRelationsType()`, `generateMappingsType()` from the SQL hook (now framework-owned or obsolete after M4). +- **6.1.5** Update `serializeValue()` / `serializeObjectKey()` — decide whether these are shared utilities (framework) or hook-specific. Likely framework. -#### 5.3 Update SQL hook to the narrower interface +#### 6.2 Narrow the hook interface -- **5.3.1** Implement `generateStorageType(storage)` on the SQL hook (extract from current `generateStorageType` — already a separate method, just needs to conform to the new interface). -- **5.3.2** Implement `generateModelStorageType(model, storage)` on the SQL hook (field-to-column mapping type generation, extracted from `generateModelsType`). -- **5.3.3** Remove `generateContractTypes()`, `generateModelsType()`, `generateRootsType()`, `generateRelationsType()`, `generateMappingsType()` from the SQL hook (now framework-owned or obsolete after M3). -- **5.3.4** Update `serializeValue()` / `serializeObjectKey()` — decide whether these are shared utilities (framework) or hook-specific. Likely framework. +- **6.2.1** Update the `TargetFamilyHook` interface: remove `generateContractTypes()`, require `generateStorageType(storage)`, `generateModelStorageType(model, storage)`, and any other family-specific type generation callbacks. Keep `validateTypes()` and `validateStructure()` on the hook. +- **6.2.2** Verify both SQL and Mongo hooks conform to the narrowed interface. -#### 5.4 Regression verification +#### 6.3 Regression verification -- **5.4.1** Verify generated `contract.d.ts` is byte-identical (modulo formatting) before and after the refactor, using the demo contract and all 12 parity fixtures. -- **5.4.2** Run full test suite and typecheck. -- **5.4.3** Update emitter hook tests to test the new interface methods individually. +- **6.3.1** Verify generated `contract.d.ts` is byte-identical (modulo formatting) before and after the refactor, using the demo contract and all 12 parity fixtures. +- **6.3.2** Run full test suite and typecheck. +- **6.3.3** Update emitter hook tests to test the new interface methods individually. ### Close-out @@ -168,27 +174,30 @@ Independent of Milestone 4 (IR alignment) — can be done before or after. | Emitted `contract.d.ts` includes both old and new field shapes | Unit | 1.4.4 | Emitter generation tests | | `validateContract()` parses new JSON and returns widened type with old fields | Unit | 1.3.5 | Bridge round-trip tests | | Shared domain validation runs as part of SQL `validateContract()` | Unit | 1.3.2, 1.2.2 | Domain validation tests ported from mongo | -| ORM client, query builder, authoring surfaces not modified in Phase 1 | Manual/CI | 1.6.3 | Git diff verification — no changes to consumer `src/` | -| All existing tests pass without modification (Phase 1) | CI | 1.5.7, 1.6.2 | Full test suite | -| ORM client reads from domain fields (Phase 2) | Unit + Integration | 2.1–2.4 | ORM client test suite | -| No consumer reads `mappings` or top-level `relations` (Phase 2) | Manual + grep | 2.8 | Code search verification | -| `mappings` removed from `SqlContract` (Phase 3) | Type test + CI | 3.1, 3.7 | Compile-time verification | -| Top-level `relations` removed (Phase 3) | Type test + CI | 3.2, 3.7 | Compile-time verification | -| Old field shape removed (Phase 3) | Type test + CI | 3.3, 3.7 | Compile-time verification | -| `contract.d.ts` reflects final shape (Phase 3) | Unit | 3.4 | Emitter generation tests | -| `ContractIR` mirrors emitted JSON (Phase 4) | Unit + Integration | 4.5 | IR tests | -| `TargetFamilyHook` no longer owns domain-level type generation (Phase 5) | Unit + Regression | 5.4.1–5.4.3 | Byte-identical `.d.ts` output; updated hook unit tests | -| New family hook only needs storage-specific methods (Phase 5) | Interface test | 5.1.2, 5.3 | Hook interface conformance | +| ORM client, query builder, authoring surfaces not modified in M1 | Manual/CI | 1.6.3 | Git diff verification — no changes to consumer `src/` | +| All existing tests pass without modification (M1) | CI | 1.5.7, 1.6.2 | Full test suite | +| ORM client reads from domain fields (M2) | Unit + Integration | 2.1–2.4 | ORM client test suite | +| No consumer reads `mappings` or top-level `relations` (M2) | Manual + grep | 2.8 | Code search verification | +| Mongo emitter produces ADR 172/177 contract JSON and `.d.ts` (M3) | Unit | 3.4 | Mongo emitter tests | +| Shared domain-level generation utilities used by Mongo hook (M3) | Unit + Regression | 3.5 | SQL output unchanged after shared extraction | +| `mappings` removed from `SqlContract` (M4) | Type test + CI | 4.1, 4.7 | Compile-time verification | +| Top-level `relations` removed (M4) | Type test + CI | 4.2, 4.7 | Compile-time verification | +| Old field shape removed (M4) | Type test + CI | 4.3, 4.7 | Compile-time verification | +| `contract.d.ts` reflects final shape (M4) | Unit | 4.4 | Emitter generation tests | +| `ContractIR` mirrors emitted JSON (M5) | Unit + Integration | 5.5 | IR tests | +| SQL hook uses shared domain-level generation (M6) | Unit + Regression | 6.3.1–6.3.3 | Byte-identical `.d.ts` output; updated hook unit tests | +| `TargetFamilyHook` interface narrowed (M6) | Interface test | 6.2.1–6.2.2 | Both hooks conform to narrowed interface | ## Open Items -1. **Dual-format `normalizeContract()`.** Task 1.3.1 adds detection of old vs new JSON format in `normalizeContract()` to enable incremental fixture migration. This adds temporary complexity but significantly reduces risk — fixtures can be migrated across multiple PRs rather than atomically. The old-format path is removed in task 3.5. +1. **Dual-format `normalizeContract()`.** Task 1.3.1 adds detection of old vs new JSON format in `normalizeContract()` to enable incremental fixture migration. This adds temporary complexity but significantly reduces risk — fixtures can be migrated across multiple PRs rather than atomically. The old-format path is removed in task 4.5. 2. ~~**Spec open questions.**~~ **All resolved** (see spec § Open Questions): - `model.storage.fields` shape: `{ column: string }` only. Top-level `storage.tables` is the single source of truth for column metadata. - Relation join naming: `localFields`/`targetFields` (not `childCols`/`parentCols`). - - `roots` derivation: emitter derives for now; IR supplies in Phase 4. + - `roots` derivation: emitter derives for now; IR supplies in M5. - `model.relations` shape: per [ADR 177](../../docs/architecture%20docs/adrs/ADR%20177%20-%20Ownership%20replaces%20relation%20strategy.md), plain graph edges — no `strategy`. Owned models declare `"owner"` on the model itself. -3. **Phase 2 coordination with Alexey.** The ORM client migration (tasks 2.1–2.5) touches core ORM internals. This must be sequenced to avoid conflicts with Alexey's active ORM development. The widened types from Phase 1 allow him to migrate incrementally. -4. `**paradedb` extension (`packages/3-extensions/paradedb/`).** Task 2.7 covers BM25 index column resolution. Confirm this extension is actively maintained and whether its owner needs notification. +3. **M2 coordination with Alexey.** The ORM client migration (tasks 2.1–2.5) touches core ORM internals. This must be sequenced to avoid conflicts with Alexey's active ORM development. The widened types from M1 allow him to migrate incrementally. +4. **`paradedb` extension (`packages/3-extensions/paradedb/`).** Task 2.7 covers BM25 index column resolution. Confirm this extension is actively maintained and whether its owner needs notification. +5. **M3 sequencing.** The Mongo emitter hook (M3) can run in parallel with M2 since it doesn't touch the SQL emitter. It establishes the shared domain-level generation API that M6 later migrates the SQL hook onto. diff --git a/projects/contract-domain-extraction/spec.md b/projects/contract-domain-extraction/spec.md index aff5d50d02..18c72bd4ea 100644 --- a/projects/contract-domain-extraction/spec.md +++ b/projects/contract-domain-extraction/spec.md @@ -175,16 +175,16 @@ interface ContractBase< } ``` -## SqlContract (Phase 1 — widened, not contracted) +## SqlContract (Phases 1–2 — widened, not contracted) -During Phase 1, `SqlContract` carries both old and new fields. Consumers can read from either: +During Phases 1–2, `SqlContract` carries both old and new fields. Consumers can read from either: ```typescript type SqlContract = ContractBase<...> & { readonly storage: S; readonly models: M; // has BOTH { nullable, codecId } and { column } on fields - // Old fields (retained for consumer compatibility during Phase 1-2) + // Old fields (retained for consumer compatibility during Phases 1–3) readonly relations: R; // top-level table-keyed relations readonly mappings: Map; // modelToTable, fieldToColumn, etc. @@ -228,7 +228,7 @@ function deriveMappings(contract): SqlMappings { } ``` -After Phase 3, the bridging logic and old fields are removed. +After Phase 4, the bridging logic and old fields are removed. # Requirements @@ -265,7 +265,18 @@ The JSON is already in the target structure (Phase 1). `validateContract()` deri 1. **Migrate ORM client to read from domain fields.** The ORM client switches from reading `mappings.fieldToColumn` / `mappings.modelToTable` to reading `model.storage.fields` / `model.storage.table`, and from reading field types via the storage layer to reading `model.fields[f].codecId` and `model.fields[f].nullable`. It switches from the top-level `relations` to `model.relations`. This must be coordinated with Alexey. 2. **Migrate query builder and runtime.** The SQL query builder, relational core, and runtime shift to reading domain-level field metadata where appropriate. Runtime codec resolution uses `model.fields[f].codecId`. -### Phase 3: Remove old type fields +### Phase 3: Mongo emitter hook (with shared domain-level generation) + +This phase builds a `mongoTargetFamilyHook` that implements `generateContractTypes()` for the Mongo family. The domain-level generation (roots type, model domain fields, relations, imports, hashes, `.d.ts` skeleton) is factored into shared utility functions in the framework from the start — the Mongo hook only writes storage-specific parts (collection mappings, embedded document types). These shared utilities become the proven API that Phase 6 migrates the SQL hook onto. + +This phase is the forcing function that defines the shared generation API. It can run in parallel with Phase 2 since it doesn't touch the SQL emitter. + +1. **Extract domain-level `.d.ts` generation into shared utilities.** Factor out `generateRootsType()`, model domain field type generation, model relation type generation, import deduplication, hash type aliases, codec/operation type intersections, and the `.d.ts` template skeleton from the SQL hook into shared framework utilities. The SQL hook continues to use its own `generateContractTypes()` — it is not migrated onto the shared utilities until Phase 6. +2. **Implement `mongoTargetFamilyHook.generateContractTypes()`.** The Mongo hook uses the shared utilities for domain-level generation and provides: `generateStorageType()` (collection mappings), `generateModelStorageType()` (embedded document storage), and Mongo-specific validation. +3. **Implement `mongoTargetFamilyHook.validateStructure()`.** Mongo-specific contract validation: collection names, embedded document constraints, owner/`storage.relations` consistency. +4. **Set up a minimal Mongo fixture contract** to exercise the Mongo emitter end-to-end. + +### Phase 4: Remove old type fields The JSON already lacks the old fields (removed in Phase 1). This phase removes the backwards-compatibility shim from `validateContract()` and the old fields from `SqlContract`. @@ -273,35 +284,32 @@ The JSON already lacks the old fields (removed in Phase 1). This phase removes t 2. **Remove old model field shape.** Remove `{ column }` from `model.fields` type — consumers now read `{ nullable, codecId }`. The field-to-column mapping lives in `model.storage.fields`. 3. **Remove top-level `relations` from `SqlContract`.** Once all consumers read `model.relations`, the top-level table-keyed `relations` type field and its derivation logic can be removed. -### Phase 4: Contract IR alignment (follow-up) +### Phase 5: Contract IR alignment (follow-up) 1. **Align `ContractIR` with the new contract JSON structure.** Update the internal representation used during emission so it more closely mirrors the emitted JSON. This reduces impedance mismatch and makes it easier for the DSL layer to target the IR. Coordinate timing with Alberto. -### Phase 5: Emitter generalization +### Phase 6: SQL emitter migration to shared generation -With ADR 172's domain-storage separation, most of the `.d.ts` generation logic in `sqlTargetFamilyHook.generateContractTypes()` is now family-agnostic: roots, model domain fields (`nullable`, `codecId` → TypeScript types), model relations, import deduplication, hash type aliases, codec/operation type intersections, the `.d.ts` skeleton. Only the storage-level type generation (tables, columns, PKs, FKs, indexes, named type instances) and backward-compat types (`mappings`, old top-level `relations`) are genuinely SQL-specific. +This phase migrates the SQL emitter hook onto the shared domain-level generation utilities established in Phase 3 (Mongo emitter hook). The `TargetFamilyHook` interface narrows: `generateContractTypes()` is removed, and hooks provide only storage-specific type blocks. The shared utilities are already proven by the Mongo hook — this phase is a migration, not a design exercise. -This phase refactors the `TargetFamilyHook` interface so the framework `emit()` generates domain-level `.d.ts` content and the family hook provides only storage-specific type blocks. This eliminates the need for each family to duplicate ~60–70% of the type generation logic when implementing a new family emitter (e.g., Mongo). - -1. **Refactor `TargetFamilyHook` interface.** Replace the monolithic `generateContractTypes()` method with a narrower interface. The framework generates domain-level sections (roots type, model domain fields, model relations, imports, hashes, codec types, `.d.ts` skeleton). The hook provides: `generateStorageType(storage)`, `generateModelStorageType(model)`, and any family-specific type blocks. -2. **Move domain-level type generation to the framework emitter.** Extract `generateRootsType()`, model field type generation (`generateColumnType()`), model relation type generation, import deduplication, hash aliases, and the `.d.ts` template from the SQL hook into the framework's `emit()`. -3. **Update SQL hook to implement the narrower interface.** The SQL hook retains `generateStorageType()` (tables/columns/PKs/FKs/indexes), `generateStorageTypesType()` (named type instances), and validation methods. It no longer owns the `.d.ts` skeleton or domain-level type generation. +1. **Migrate SQL hook to shared utilities.** Replace the SQL hook's `generateRootsType()`, model domain field generation, model relation generation, import deduplication, hash aliases, and `.d.ts` skeleton with calls to the shared framework utilities (established in Phase 3). +2. **Narrow the `TargetFamilyHook` interface.** Remove `generateContractTypes()`, require `generateStorageType(storage)`, `generateModelStorageType(model, storage)`, and family-specific validation. +3. **Verify both hooks conform.** Both SQL and Mongo hooks implement the narrowed interface. 4. **Verify emitter output is identical.** The generated `contract.d.ts` must be byte-identical before and after the refactor (modulo formatting). Use the demo contract and parity fixtures as regression tests. -This phase is independent of Phase 4 (IR alignment) and can be done before or after it. +This phase is independent of Phase 5 (IR alignment) and can be done before or after it. ## Non-Functional Requirements -- **Zero breakage during Phase 1.** All existing tests, the demo app, and downstream consumers must continue working without modification when Phase 1 lands. `validateContract()` bridges the new JSON structure to the old consumer-facing type. +- **Zero breakage during Phases 1 and 3.** All existing tests, the demo app, and downstream consumers must continue working without modification when Phase 1 lands. The Mongo emitter hook (Phase 3) must not alter SQL emitter output. `validateContract()` bridges the new JSON structure to the old consumer-facing type. - **Incremental migration.** Phase 2 changes should be deployable consumer-by-consumer, not as a single atomic switch. - **Type safety throughout.** The widened `ContractBase` must provide typed access to domain fields. Consumers switching from old fields to new ones should get equivalent or better type inference. ## Non-goals -- **Mongo emitter.** This project updates the SQL emitter. A Mongo emitter is a separate project. - **Value objects section.** Designing the contract representation for value objects is out of scope. The domain structure carries `models` only. - **Change streams / subscriptions.** Runtime lifecycle changes are not in scope. -- **PSL/DSL authoring changes.** The authoring surface adapts to the new IR (Phase 4) but designing new authoring syntax is out of scope. +- **PSL/DSL authoring changes.** The authoring surface adapts to the new IR (Phase 5) but designing new authoring syntax is out of scope. # Acceptance Criteria @@ -336,23 +344,31 @@ This phase is independent of Phase 4 (IR alignment) and can be done before or af - [ ] No consumer imports or reads from the `mappings` section - [ ] No consumer reads relations from the top-level `relations` block -### Phase 3: Remove old type fields +### Phase 3: Mongo emitter hook + +- [ ] Domain-level `.d.ts` generation (roots, model fields, relations, imports, hashes, skeleton) is extracted into shared framework utilities +- [ ] `mongoTargetFamilyHook` generates a valid Mongo `contract.json` and `contract.d.ts` matching ADR 172/177 structure +- [ ] Mongo-specific validation (`validateStructure()`) covers collection names, embedding constraints, and ownership consistency +- [ ] SQL emitter output is unchanged after the shared utility extraction +- [ ] A minimal Mongo fixture contract exercises the Mongo emitter end-to-end + +### Phase 4: Remove old type fields - [ ] `mappings` is removed from `SqlContract` and the `validateContract()` derivation logic - [ ] Top-level `relations` type field is removed from `SqlContract` and `validateContract()` - [ ] Old model field shape (`{ column: string }` without `nullable`/`codecId`) is removed from the type - [ ] `contract.d.ts` emission reflects the final shape (no old fields) -### Phase 4: IR alignment +### Phase 5: IR alignment - [ ] `ContractIR` mirrors the emitted contract JSON structure (domain/storage separation, model-level relations, `roots`) -### Phase 5: Emitter generalization +### Phase 6: SQL emitter migration to shared generation -- [ ] `TargetFamilyHook` no longer has a monolithic `generateContractTypes()` — domain-level type generation lives in the framework `emit()` -- [ ] The SQL hook provides only storage-specific type generation (`generateStorageType`, `generateModelStorageType`) and family-specific validation +- [ ] SQL hook uses the shared domain-level generation utilities from Phase 3 +- [ ] `TargetFamilyHook` interface is narrowed — no monolithic `generateContractTypes()` +- [ ] Both SQL and Mongo hooks conform to the narrowed interface - [ ] Generated `contract.d.ts` output is identical before and after the refactor (regression-tested against demo and parity fixtures) -- [ ] A new family emitter (e.g., Mongo) would not need to duplicate domain-level type generation logic # Other Considerations @@ -372,7 +388,7 @@ No observability changes needed. The contract structure is a build-time artifact ## Coordination - **Alexey (ORM client):** Phase 2 requires migrating the ORM client to read from new type fields. Phase 1 adds new fields alongside old ones on the TypeScript type, so Alexey can switch call sites incrementally at his pace. No changes required from him until Phase 2. -- **Alberto (DSL/authoring):** Phase 4 updates the Contract IR he targets. This should be coordinated but is not a synchronous dependency — the emitter can produce the new contract JSON from the old IR during Phases 1–3. Phase 4 aligns the IR for his benefit. +- **Alberto (DSL/authoring):** Phase 5 updates the Contract IR he targets. This should be coordinated but is not a synchronous dependency — the emitter can produce the new contract JSON from the old IR during Phases 1–4. Phase 5 aligns the IR for his benefit. - **Demo app and test fixtures:** `contract.json` files are updated in Phase 1 to the new structure. Since `validateContract()` bridges to the old type, everything continues to work. # References From f114262289bcab968035739545f7d58d037e4685 Mon Sep 17 00:00:00 2001 From: jkomyno <12381818+jkomyno@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:06:25 +0400 Subject: [PATCH 34/35] fix(sql-contract): harden normalizeContract dual-format bridge - Throw on duplicate column mappings in buildColumnToFieldMap to prevent silent wrong-field resolution in relation conversion - Exclude owned models from auto-derived roots in old-format enrichment to avoid guaranteed domain validation failure - Throw when a field references a non-existent column in a table that has columns defined, catching name mismatches early --- .../2-sql/1-core/contract/src/validate.ts | 26 +++- .../1-core/contract/test/validate.test.ts | 127 ++++++++++++++++++ 2 files changed, 149 insertions(+), 4 deletions(-) diff --git a/packages/2-sql/1-core/contract/src/validate.ts b/packages/2-sql/1-core/contract/src/validate.ts index a4cd29f67e..60b4869867 100644 --- a/packages/2-sql/1-core/contract/src/validate.ts +++ b/packages/2-sql/1-core/contract/src/validate.ts @@ -246,11 +246,20 @@ function detectFormat(models: Record): 'old' | 'new' { return 'old'; } -function buildColumnToFieldMap(fields: Record): Record { +function buildColumnToFieldMap( + fields: Record, + modelName: string, +): Record { const map: Record = {}; for (const [fieldName, field] of Object.entries(fields)) { const col = field['column'] as string | undefined; - if (col) map[col] = fieldName; + if (!col) continue; + if (Object.hasOwn(map, col)) { + throw new Error( + `Model "${modelName}" has duplicate column mapping: fields "${map[col]}" and "${fieldName}" both map to column "${col}"`, + ); + } + map[col] = fieldName; } return map; } @@ -267,7 +276,9 @@ function enrichOldFormatModels( const modelStorage = model['storage'] as Record | undefined; const tableName = modelStorage?.['table'] as string | undefined; if (tableName) { - roots[modelName] = modelName; + if (!model['owner']) { + roots[modelName] = modelName; + } tableToModel[tableName] = modelName; } } @@ -289,9 +300,15 @@ function enrichOldFormatModels( const enrichedFields: Record = {}; const modelStorageFields: Record = {}; + const hasStorageColumns = Object.keys(storageColumns).length > 0; for (const [fieldName, field] of Object.entries(fields)) { const colName = field['column'] as string; const storageCol = storageColumns[colName]; + if (!storageCol && hasStorageColumns && colName) { + throw new Error( + `Model "${modelName}" field "${fieldName}" references non-existent column "${colName}" in table "${tableName}"`, + ); + } enrichedFields[fieldName] = { ...field, nullable: storageCol?.['nullable'] ?? false, @@ -330,13 +347,14 @@ function enrichOldFormatModels( const toModel = rel['to'] as string; const sourceFields = (existingModel['fields'] ?? {}) as Record; - const sourceColToField = buildColumnToFieldMap(sourceFields); + const sourceColToField = buildColumnToFieldMap(sourceFields, modelName); if (!targetColumnToField[toModel]) { const targetModelObj = enrichedModels[toModel]; if (targetModelObj) { targetColumnToField[toModel] = buildColumnToFieldMap( (targetModelObj['fields'] ?? {}) as Record, + toModel, ); } else { targetColumnToField[toModel] = {}; diff --git a/packages/2-sql/1-core/contract/test/validate.test.ts b/packages/2-sql/1-core/contract/test/validate.test.ts index 006bdf028c..10b51bc394 100644 --- a/packages/2-sql/1-core/contract/test/validate.test.ts +++ b/packages/2-sql/1-core/contract/test/validate.test.ts @@ -1693,4 +1693,131 @@ describe('validateContract', () => { expect(tables['user']).toBeDefined(); }); }); + + describe('old format field referencing non-existent storage column', () => { + it('throws when field column does not exist in storage table', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { column: 'id' }, ghost: { column: 'no_such_col' } }, + relations: {}, + }, + }, + storage: { + tables: { + user: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }; + expect(() => normalizeContract(contract)).toThrow( + /field "ghost" references non-existent column "no_such_col" in table "user"/, + ); + }); + }); + + describe('duplicate column mapping in model fields', () => { + it('throws when two fields map to the same column', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { column: 'id' }, altId: { column: 'id' } }, + relations: {}, + }, + }, + relations: { + user: { + self: { + cardinality: '1:1', + on: { parentCols: ['id'], childCols: ['id'] }, + to: 'User', + }, + }, + }, + storage: { + tables: { + user: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }; + expect(() => normalizeContract(contract)).toThrow( + /duplicate column mapping.*"id" and "altId".*column "id"/i, + ); + }); + }); + + describe('old format excludes owned models from roots', () => { + it('does not include owned models in auto-derived roots', () => { + const contract = { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + models: { + User: { + storage: { table: 'user' }, + fields: { id: { column: 'id' } }, + relations: {}, + }, + Address: { + storage: { table: 'address' }, + fields: { id: { column: 'id' } }, + relations: {}, + owner: 'User', + }, + }, + storage: { + tables: { + user: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + address: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }; + const result = normalizeContract(contract) as Record; + const roots = result['roots'] as Record; + expect(roots).toEqual({ User: 'User' }); + expect(roots['Address']).toBeUndefined(); + }); + }); }); From 0094f36149821c886218b9209c652230df14b0e8 Mon Sep 17 00:00:00 2001 From: Will Madden Date: Wed, 1 Apr 2026 12:32:15 +0200 Subject: [PATCH 35/35] chore: regenerate fixtures after rebase onto origin/main Capabilities and profile hashes changed due to sql-builder consolidation (sql.returning capability added). --- examples/prisma-next-demo/src/prisma/contract.json | 5 +++-- .../authoring/parity/core-surface/expected.contract.json | 5 +++-- .../authoring/parity/default-cuid-2/expected.contract.json | 5 +++-- .../parity/default-dbgenerated/expected.contract.json | 5 +++-- .../parity/default-nanoid-16/expected.contract.json | 5 +++-- .../authoring/parity/default-nanoid/expected.contract.json | 5 +++-- .../parity/default-pack-slugid/expected.contract.json | 5 +++-- .../authoring/parity/default-ulid/expected.contract.json | 5 +++-- .../authoring/parity/default-uuid-v4/expected.contract.json | 5 +++-- .../authoring/parity/default-uuid-v7/expected.contract.json | 5 +++-- .../authoring/parity/map-attributes/expected.contract.json | 5 +++-- .../parity/pgvector-named-type/expected.contract.json | 5 +++-- .../parity/relation-backrelation-list/expected.contract.json | 5 +++-- 13 files changed, 39 insertions(+), 26 deletions(-) diff --git a/examples/prisma-next-demo/src/prisma/contract.json b/examples/prisma-next-demo/src/prisma/contract.json index a54c3364c4..4b68348f19 100644 --- a/examples/prisma-next-demo/src/prisma/contract.json +++ b/examples/prisma-next-demo/src/prisma/contract.json @@ -4,7 +4,7 @@ "target": "postgres", "storageHash": "sha256:43f728c37e9b8f369b2b8acefa387906afd4555646a08528254eceee247342d7", "executionHash": "sha256:630618d96f7674c186a027d1295bfc5d688c4168c5a023a1aea01553820387dc", - "profileHash": "sha256:83d66b1cce776c9ec9e6d168086e5bd1030ccf461823b9eef39cf49f1833c6dd", + "profileHash": "sha256:ea5c6635c0c0bd71badced0f3ee8ba912cf72dc836ae165cd533dc8f68cbfc9f", "roots": { "Post": "Post", "User": "User" @@ -294,7 +294,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": { diff --git a/test/integration/test/authoring/parity/core-surface/expected.contract.json b/test/integration/test/authoring/parity/core-surface/expected.contract.json index 45ace0b351..cabf3516aa 100644 --- a/test/integration/test/authoring/parity/core-surface/expected.contract.json +++ b/test/integration/test/authoring/parity/core-surface/expected.contract.json @@ -3,7 +3,7 @@ "targetFamily": "sql", "target": "postgres", "storageHash": "sha256:ba5cf6cccf8cd906b25e0a8d075875aff7492d815c738166c0146ac4b2535a28", - "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", "roots": { "Post": "Post", "User": "User" @@ -243,7 +243,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json b/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json index a35e2beedf..b6041222d8 100644 --- a/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json +++ b/test/integration/test/authoring/parity/default-cuid-2/expected.contract.json @@ -4,7 +4,7 @@ "target": "postgres", "storageHash": "sha256:ab25b558e03744f8878f72857aef610808c4f3f05e2827e68bcd872b826a428b", "executionHash": "sha256:9c330b02774fd7b35ab7463eea7551d8e147b97fa8dac27aed080a630be6bd0c", - "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", "roots": { "User": "User" }, @@ -73,7 +73,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json b/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json index 5930d80147..3fe996d8af 100644 --- a/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json +++ b/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json @@ -3,7 +3,7 @@ "targetFamily": "sql", "target": "postgres", "storageHash": "sha256:439aa8d13a4e7326d69ac80a5f65e0f5cfc876e392af997d5b052eadda9580d4", - "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", "roots": { "User": "User" }, @@ -57,7 +57,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json b/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json index 58d603a848..336ac38f85 100644 --- a/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json +++ b/test/integration/test/authoring/parity/default-nanoid-16/expected.contract.json @@ -4,7 +4,7 @@ "target": "postgres", "storageHash": "sha256:4e890b7aba7b44bb761ef4a35d741cce748ce247f480bc514648c7914786f0d9", "executionHash": "sha256:123e615289bb93b88765b0522740cac06d7a22bf3a9e4968fe92e01c1ca0b3a5", - "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", "roots": { "User": "User" }, @@ -76,7 +76,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-nanoid/expected.contract.json b/test/integration/test/authoring/parity/default-nanoid/expected.contract.json index 8357ffc47e..6371ce8a74 100644 --- a/test/integration/test/authoring/parity/default-nanoid/expected.contract.json +++ b/test/integration/test/authoring/parity/default-nanoid/expected.contract.json @@ -4,7 +4,7 @@ "target": "postgres", "storageHash": "sha256:f9fa02b90981ca52bb1be98bd793d8fc3aa515b8d40269c22d64b2e476021e08", "executionHash": "sha256:2643d48dc917fa4ac9680d8404819ca290f0539dabcada463926743f4c526f65", - "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", "roots": { "User": "User" }, @@ -73,7 +73,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json b/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json index 9b7565fa64..06a5dd5953 100644 --- a/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json +++ b/test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json @@ -4,7 +4,7 @@ "target": "postgres", "storageHash": "sha256:9e27b90da3eb44a199ce86ba13e2082b0d2cdc502803c95138a32b3afb88c0ec", "executionHash": "sha256:c3b03395de492379b9ba6d6f890debc53739629234ba98a8dbf7dc8c4301a910", - "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", "roots": { "User": "User" }, @@ -70,7 +70,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": { diff --git a/test/integration/test/authoring/parity/default-ulid/expected.contract.json b/test/integration/test/authoring/parity/default-ulid/expected.contract.json index 7abc922ffd..1e14d01085 100644 --- a/test/integration/test/authoring/parity/default-ulid/expected.contract.json +++ b/test/integration/test/authoring/parity/default-ulid/expected.contract.json @@ -4,7 +4,7 @@ "target": "postgres", "storageHash": "sha256:f23c5ccabb210de6564c7084708ecc900ad3e7769cc9585baad6561edfa20f66", "executionHash": "sha256:f2232a980cff0854e38fdedd0842347ffc96d92a8aaaab4da1749d4575598adc", - "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", "roots": { "User": "User" }, @@ -73,7 +73,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json b/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json index 29399191a5..9703f0a1be 100644 --- a/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json +++ b/test/integration/test/authoring/parity/default-uuid-v4/expected.contract.json @@ -4,7 +4,7 @@ "target": "postgres", "storageHash": "sha256:acb9845800b87237a5d37f2fa3739899bfd13d9dc2d7ce0ea47865bce2cc666d", "executionHash": "sha256:57c307c24cd2dc8eb5e7cf53beb4767f19728c92925835a8ca24b3f11a5cfc95", - "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", "roots": { "User": "User" }, @@ -73,7 +73,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json b/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json index bdd97fd5eb..0a55ca6f65 100644 --- a/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json +++ b/test/integration/test/authoring/parity/default-uuid-v7/expected.contract.json @@ -4,7 +4,7 @@ "target": "postgres", "storageHash": "sha256:acb9845800b87237a5d37f2fa3739899bfd13d9dc2d7ce0ea47865bce2cc666d", "executionHash": "sha256:287547440de2f13dbe48f300b47dda09631a7e9b551728f6cb99ef5217aa2fdc", - "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", "roots": { "User": "User" }, @@ -73,7 +73,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/map-attributes/expected.contract.json b/test/integration/test/authoring/parity/map-attributes/expected.contract.json index eb2cf18790..0fbdfbb33b 100644 --- a/test/integration/test/authoring/parity/map-attributes/expected.contract.json +++ b/test/integration/test/authoring/parity/map-attributes/expected.contract.json @@ -3,7 +3,7 @@ "targetFamily": "sql", "target": "postgres", "storageHash": "sha256:6a38af603d36ab9862c3e73197e094c00b696e55a0928d0003a7b5e9201017bc", - "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", "roots": { "Member": "Member", "Team": "Team" @@ -135,7 +135,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": {}, diff --git a/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json b/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json index 2f39ec28f3..80ea6b4332 100644 --- a/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json +++ b/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json @@ -3,7 +3,7 @@ "targetFamily": "sql", "target": "postgres", "storageHash": "sha256:a7aadce021465acff4f105ee787388a4896d8161afe863d3c90b3320b06b0010", - "profileHash": "sha256:83d66b1cce776c9ec9e6d168086e5bd1030ccf461823b9eef39cf49f1833c6dd", + "profileHash": "sha256:ea5c6635c0c0bd71badced0f3ee8ba912cf72dc836ae165cd533dc8f68cbfc9f", "roots": { "Document": "Document" }, @@ -80,7 +80,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": { diff --git a/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json b/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json index b1b81fba88..f483f3393f 100644 --- a/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json +++ b/test/integration/test/authoring/parity/relation-backrelation-list/expected.contract.json @@ -3,7 +3,7 @@ "targetFamily": "sql", "target": "postgres", "storageHash": "sha256:7bc2d92b8bd871bb95a1030e0befcb591b9641949fed2263293e9111ca1b90b3", - "profileHash": "sha256:a9868829161d6f7e3a139d8cf9f42d59e0503281d26342ddf900d9b054a51b98", + "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", "roots": { "Post": "Post", "User": "User" @@ -145,7 +145,8 @@ "returning": true }, "sql": { - "enums": true + "enums": true, + "returning": true } }, "extensionPacks": {},