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 < **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/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..fe339eef7a --- /dev/null +++ b/docs/architecture docs/adrs/ADR 177 - Ownership replaces relation strategy.md @@ -0,0 +1,255 @@ +# 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" } + } + } + }, + "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": { + "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.**~~ **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 + +- [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 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..0df04c0363 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 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). -- **`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 b7406166f3..6bfcbf8a57 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. @@ -318,3 +321,346 @@ 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`: + +```text +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", + "on": { "localFields": ["authorId"], "targetFields": ["id"] } +} +``` + +A polymorphic association would need something like: + +```json +{ + "commentable": { + "cardinality": "N:1", + "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: + +```text +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", + "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", + "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? *(resolved)* + +**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. + +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` + +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. | 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/examples/prisma-next-demo/src/prisma/contract.d.ts b/examples/prisma-next-demo/src/prisma/contract.d.ts index 6df3613f88..b59ac1cf8d 100644 --- a/examples/prisma-next-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo/src/prisma/contract.d.ts @@ -142,7 +142,16 @@ 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']; @@ -150,15 +159,43 @@ type ContractBase = SqlContract< readonly createdAt: CodecTypes['pg/timestamptz@1']['output']; readonly embedding: Vector<1536> | null; }; + relations: { + readonly user: { + readonly to: 'User'; + readonly cardinality: 'N:1'; + 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'; }; + relations: { + readonly posts: { + readonly to: 'Post'; + readonly cardinality: '1:N'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['userId']; + }; + }; + }; }; }, { @@ -222,6 +259,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..4b68348f19 100644 --- a/examples/prisma-next-demo/src/prisma/contract.json +++ b/examples/prisma-next-demo/src/prisma/contract.json @@ -5,47 +5,109 @@ "storageHash": "sha256:43f728c37e9b8f369b2b8acefa387906afd4555646a08528254eceee247342d7", "executionHash": "sha256:630618d96f7674c186a027d1295bfc5d688c4168c5a023a1aea01553820387dc", "profileHash": "sha256:ea5c6635c0c0bd71badced0f3ee8ba912cf72dc836ae165cd533dc8f68cbfc9f", + "roots": { + "Post": "Post", + "User": "User" + }, "models": { "Post": { "fields": { "createdAt": { - "column": "createdAt" + "codecId": "pg/timestamptz@1" }, "embedding": { - "column": "embedding" + "codecId": "pg/vector@1", + "nullable": true }, "id": { - "column": "id" + "codecId": "sql/char@1" }, "title": { - "column": "title" + "codecId": "pg/text@1" }, "userId": { - "column": "userId" + "codecId": "pg/text@1" + } + }, + "relations": { + "user": { + "cardinality": "N:1", + "on": { + "localFields": [ + "userId" + ], + "targetFields": [ + "id" + ] + }, + "to": "User" } }, - "relations": {}, "storage": { + "fields": { + "createdAt": { + "column": "createdAt" + }, + "embedding": { + "column": "embedding" + }, + "id": { + "column": "id" + }, + "title": { + "column": "title" + }, + "userId": { + "column": "userId" + } + }, "table": "post" } }, "User": { "fields": { "createdAt": { - "column": "createdAt" + "codecId": "pg/timestamptz@1" }, "email": { - "column": "email" + "codecId": "pg/text@1" }, "id": { - "column": "id" + "codecId": "sql/char@1" }, "kind": { - "column": "kind" + "codecId": "pg/enum@1" + } + }, + "relations": { + "posts": { + "cardinality": "1:N", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "userId" + ] + }, + "to": "Post" } }, - "relations": {}, "storage": { + "fields": { + "createdAt": { + "column": "createdAt" + }, + "email": { + "column": "email" + }, + "id": { + "column": "id" + }, + "kind": { + "column": "kind" + } + }, "table": "user" } } 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/1-core/migration/control-plane/src/emission/emit.ts b/packages/1-framework/1-core/migration/control-plane/src/emission/emit.ts index 8d6f895ce9..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,6 +7,75 @@ import { canonicalizeContract } from './canonicalization'; import { computeExecutionHash, computeProfileHash, computeStorageHash } from './hashing'; import type { EmitOptions, EmitResult } from './types'; +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, + ...(cleanedRelations !== undefined ? { relations: cleanedRelations } : {}), + }; + continue; + } + + const hasEnrichedFields = Object.values(fields).some((f) => f['codecId'] !== undefined); + if (!hasEnrichedFields) { + result[modelName] = { + ...model, + ...(cleanedRelations !== undefined ? { relations: cleanedRelations } : {}), + }; + 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, + ...(cleanedRelations !== undefined ? { relations: cleanedRelations } : {}), + storage: { ...storage, fields: mergedStorageFields }, + }; + } + return result; +} + const CanonicalMetaSchema = type({ '[string]': 'unknown', }); @@ -17,7 +86,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 +128,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 +173,9 @@ export async function emit( schemaVersion: ir.schemaVersion, targetFamily: ir.targetFamily, target: ir.target, - models: ir.models, - relations: ir.relations, + ...ifDefined('roots', ir.roots), + models: toDomainModel(ir.models as Record), + ...ifDefined('relations', ir.relations), storage: ir.storage, ...ifDefined('execution', ir.execution), extensionPacks: ir.extensionPacks, 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/domain-types.ts b/packages/1-framework/1-core/shared/contract/src/domain-types.ts new file mode 100644 index 0000000000..3f772f79e7 --- /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 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; + readonly owner?: 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/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/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/1-framework/1-core/shared/contract/src/types.ts b/packages/1-framework/1-core/shared/contract/src/types.ts index 28569c3508..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; @@ -71,6 +73,8 @@ export interface ContractBase< readonly meta: Record; 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/src/validate-domain.ts b/packages/1-framework/1-core/shared/contract/src/validate-domain.ts new file mode 100644 index 0000000000..e6a1505f91 --- /dev/null +++ b/packages/1-framework/1-core/shared/contract/src/validate-domain.ts @@ -0,0 +1,203 @@ +export interface DomainModelShape { + readonly fields: Record; + readonly relations: Record; + readonly discriminator?: { readonly field: string }; + readonly variants?: Record; + readonly base?: string; + readonly owner?: 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); + validateOwnership(contract, modelNames, 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 || !Object.hasOwn(baseModel.variants, modelName)) { + 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 (!Object.hasOwn(model.fields, model.discriminator.field)) { + 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 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, + warnings: string[], +): void { + const referenced = new Set(); + + for (const modelName of Object.values(contract.roots)) { + referenced.add(modelName); + } + + for (const [modelName, model] of Object.entries(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); + } + if (model.owner) { + referenced.add(modelName); + } + } + + 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/domain-types.test.ts b/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts new file mode 100644 index 0000000000..707b419aca --- /dev/null +++ b/packages/1-framework/1-core/shared/contract/test/domain-types.test.ts @@ -0,0 +1,81 @@ +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 carries to, cardinality, and optional on', () => { + const relation: DomainRelation = { + to: 'Post', + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['userId'] }, + }; + expect(relation.to).toBe('Post'); + expect(relation.on?.localFields).toEqual(['id']); + }); + + it('DomainRelation without on clause (owned relation)', () => { + const relation: DomainRelation = { + to: 'Address', + cardinality: '1:N', + }; + expect(relation.to).toBe('Address'); + 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'); + }); + + 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 new file mode 100644 index 0000000000..786ec97fa9 --- /dev/null +++ b/packages/1-framework/1-core/shared/contract/test/validate-domain.test.ts @@ -0,0 +1,422 @@ +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', users: 'User' }, + models: { + Item: makeMinimalModel({ + relations: { + creator: { + to: 'User', + cardinality: 'N:1', + on: { localFields: ['creatorId'], targetFields: ['_id'] }, + }, + }, + }), + User: makeMinimalModel(), + }, + }); + expect(() => validateContractDomain(contract)).not.toThrow(); + }); + + it('rejects relation targeting non-existent model', () => { + const contract = makeValidContract({ + models: { + Item: makeMinimalModel({ + relations: { + creator: { + to: 'Ghost', + cardinality: 'N:1', + on: { localFields: ['creatorId'], targetFields: ['_id'] }, + }, + }, + }), + }, + }); + expect(() => validateContractDomain(contract)).toThrow( + /relation.*creator.*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', + }, + }, + }), + Tag: makeMinimalModel({ owner: 'Item' }), + }, + }); + 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('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, relations, and ownership', () => { + 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', + on: { localFields: ['assigneeId'], targetFields: ['_id'] }, + }, + comments: { + to: 'Comment', + cardinality: '1:N', + }, + }, + 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', + }, + }, + }), + Address: makeMinimalModel({ + fields: { + street: { codecId: 'mongo/string@1', nullable: false }, + city: { codecId: 'mongo/string@1', nullable: false }, + zip: { codecId: 'mongo/string@1', nullable: false }, + }, + owner: 'User', + }), + Comment: makeMinimalModel({ + fields: { + _id: { codecId: 'mongo/objectId@1', nullable: false }, + text: { codecId: 'mongo/string@1', nullable: false }, + createdAt: { codecId: 'mongo/date@1', nullable: false }, + }, + owner: 'Task', + }), + }, + }; + 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/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-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'; diff --git a/packages/2-sql/1-core/contract/src/construct.ts b/packages/2-sql/1-core/contract/src/construct.ts index 4054cbf2c3..68d1873059 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.roots, }; return contractWithMappings as TContract; 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..1b2714ce47 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, @@ -131,6 +132,22 @@ export type ModelDefinition = { readonly storage: ModelStorage; readonly fields: Record; readonly relations: Record; + readonly owner?: string; +}; + +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 on: DomainRelationOn; }; export type SqlMappings = { @@ -210,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; diff --git a/packages/2-sql/1-core/contract/src/validate.ts b/packages/2-sql/1-core/contract/src/validate.ts index ee10c60610..60b4869867 100644 --- a/packages/2-sql/1-core/contract/src/validate.ts +++ b/packages/2-sql/1-core/contract/src/validate.ts @@ -1,10 +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)); @@ -172,81 +181,356 @@ 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, + modelName: string, +): Record { + const map: Record = {}; + for (const [fieldName, field] of Object.entries(fields)) { + const col = field['column'] as string | undefined; + 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; +} + +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) { + if (!model['owner']) { + 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 = {}; + + 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, + 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, modelName); + + if (!targetColumnToField[toModel]) { + const targetModelObj = enrichedModels[toModel]; + if (targetModelObj) { + targetColumnToField[toModel] = buildColumnToFieldMap( + (targetModelObj['fields'] ?? {}) as Record, + toModel, + ); } else { - normalizedTables[tableName] = tableObj; + targetColumnToField[toModel] = {}; } } + const targetColToField = targetColumnToField[toModel] ?? {}; - normalizedStorage = { - ...storage, - tables: normalizedTables, + // 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); + + modelRelations[relName] = { + to: toModel, + cardinality: rel['cardinality'], + on: { localFields, targetFields }, }; } + + enrichedModels[modelName] = { + ...existingModel, + relations: modelRelations, + }; } - 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'] ?? {}, + 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; + } + + 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 +543,11 @@ export function validateContract>( value: unknown, ): TContract { const normalized = normalizeContract(value); + const structurallyValid = validateSqlContract>(normalized); + + validateContractDomain(extractDomainShape(structurallyValid)); + 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..fc76d644c5 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,29 @@ 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', + 'owner?': 'string', }); const MappingsSchema = type({ @@ -178,6 +187,7 @@ const SqlContractSchema = type({ 'meta?': ContractMetaSchema, 'sources?': 'Record', 'relations?': type({ '[string]': 'unknown' }), + 'roots?': 'Record', 'mappings?': MappingsSchema, models: type({ '[string]': ModelSchema }), storage: StorageSchema, @@ -220,7 +230,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/domain-types.test.ts b/packages/2-sql/1-core/contract/test/domain-types.test.ts new file mode 100644 index 0000000000..e917c7ee77 --- /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: Record; + 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'); + }); + }); +}); 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..10b51bc394 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/, @@ -766,4 +782,1042 @@ 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', + 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', + 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 }, + 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' }); + }); + }); + + 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(); + }); + }); + + 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(); + }); + }); }); 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, 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..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 @@ -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"] @@ -487,13 +509,36 @@ "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"] }, "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 (old format; new format uses model.storage.fields)" + }, + "codecId": { + "type": "string", + "description": "Codec identifier for the field" + }, + "nullable": { + "type": "boolean", + "description": "Whether the field allows NULL values" + } + } + }, + "ModelStorageField": { + "type": "object", + "description": "Per-field storage mapping", "additionalProperties": false, "properties": { "column": { @@ -505,8 +550,7 @@ }, "ModelRelation": { "type": "object", - "description": "Model relation definition", - "additionalProperties": false, + "description": "Model relation definition (domain format with localFields/targetFields, or storage format with parentCols/childCols)", "properties": { "to": { "type": "string", @@ -520,27 +564,31 @@ "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/packages/2-sql/2-authoring/contract-ts/src/contract.ts b/packages/2-sql/2-authoring/contract-ts/src/contract.ts index 8e9fb53a5e..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) => { @@ -115,6 +116,7 @@ const SqlContractSchema = type({ 'extensionPacks?': 'Record', 'meta?': 'Record', 'sources?': 'Record', + 'roots?': 'Record', models: type({ '[string]': ModelSchema }), storage: StorageSchema, 'execution?': ExecutionSchema, @@ -281,7 +283,8 @@ function validateContractLogic(structurallyValidatedContract: SqlContract { // 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/src/index.ts b/packages/2-sql/3-tooling/emitter/src/index.ts index ecb8e4da53..6317a79d9d 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,18 @@ 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 ${this.serializeObjectKey(key)}: ${this.serializeValue(value)}`, + ) + .join('; '); + return `{ ${entries} }`; + }, + generateStorageType(storage: SqlStorage): string { const tables: string[] = []; for (const [tableName, table] of Object.entries(storage.tables)) { @@ -527,6 +541,7 @@ export const sqlTargetFamilyHook = { 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]; @@ -535,6 +550,7 @@ export const sqlTargetFamilyHook = { 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; } @@ -545,34 +561,50 @@ export const sqlTargetFamilyHook = { 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[] = []; - 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 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']}'`); + 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}] }`, + ); + } + if (relParts.length > 0) { + relations.push(`readonly ${relName}: { ${relParts.join('; ')} }`); } } + 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('; ')} }`); + if (model.owner) { + modelParts.push(`owner: ${this.serializeValue(model.owner)}`); } 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..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 @@ -46,9 +46,11 @@ describe('sql-target-family-hook', () => { }, relations: { posts: { + to: 'Post', + cardinality: '1:N', on: { - parentCols: ['id'], - childCols: ['userId'], + localFields: ['id'], + targetFields: ['userId'], }, }, }, @@ -90,7 +92,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 on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['userId'] } }", ); }); @@ -354,15 +356,19 @@ describe('sql-target-family-hook', () => { }, relations: { posts: { + to: 'Post', + cardinality: '1:N', on: { - parentCols: ['id'], - childCols: ['userId'], + localFields: ['id'], + targetFields: ['userId'], }, }, comments: { + to: 'Comment', + cardinality: '1:N', on: { - parentCols: ['id'], - childCols: ['authorId'], + localFields: ['id'], + targetFields: ['authorId'], }, }, }, @@ -403,11 +409,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 on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['userId'] } }", + ); + expect(types).toContain( + "readonly comments: { readonly to: 'Comment'; readonly cardinality: '1:N'; readonly on: { readonly localFields: readonly ['id']; readonly targetFields: readonly ['authorId'] } }", + ); }); it('generates relations type as empty when no relations', () => { @@ -561,10 +568,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 +586,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 +765,9 @@ describe('sql-target-family-hook', () => { parameterizedRenderers, }); - expect(types).toContain("CodecTypes['pg/vector@1']['output'] & { length: 1536 }"); + expect(types).toContain( + "readonly vector: CodecTypes['pg/vector@1']['output'] & { length: 1536 }", + ); }); it('renders column type using typeRef with parameterized renderer', () => { @@ -815,6 +821,8 @@ describe('sql-target-family-hook', () => { parameterizedRenderers, }); - expect(types).toContain("CodecTypes['pg/vector@1']['output'] & { length: 1536 }"); + expect(types).toContain( + "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 7ad0a832f0..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 @@ -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', () => { @@ -704,7 +705,6 @@ 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"); }); @@ -754,9 +754,7 @@ 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']"); }); @@ -790,10 +788,8 @@ 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).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..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,7 +68,6 @@ 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>'); }); @@ -110,7 +109,6 @@ 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']"); }); @@ -386,7 +384,6 @@ 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>'); @@ -432,7 +429,6 @@ 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']"); }); @@ -480,7 +476,6 @@ 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']"); }); @@ -527,7 +522,6 @@ 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']"); }); 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: {}, diff --git a/projects/contract-domain-extraction/plan.md b/projects/contract-domain-extraction/plan.md new file mode 100644 index 0000000000..70075d25d5 --- /dev/null +++ b/projects/contract-domain-extraction/plan.md @@ -0,0 +1,203 @@ +# 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 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:** [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 + + +| Role | Person/Team | Context | +| ------------ | ----------- | ------------------------------------------------------------------ | +| Maker | Will | Drives execution | +| Collaborator | Alexey | ORM client — Phase 2 migration must coordinate with his workstream | +| Collaborator | Alberto | DSL/authoring — M5 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; 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. + +#### 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, 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`) + +- **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 `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. + +#### 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: Mongo emitter hook (with shared domain-level generation) + +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** 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: 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:** + +- **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. + +### Milestone 5: 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:** + +- **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 + +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:** + +#### 6.1 Migrate SQL hook to shared utilities + +- **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. + +#### 6.2 Narrow the hook interface + +- **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. + +#### 6.3 Regression verification + +- **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 + +- **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 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 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 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. **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 new file mode 100644 index 0000000000..18c72bd4ea --- /dev/null +++ b/projects/contract-domain-extraction/spec.md @@ -0,0 +1,418 @@ +# 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", + "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 `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 + +## 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; + readonly owner?: string; + }>; +} +``` + +## SqlContract (Phases 1–2 — widened, not contracted) + +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 Phases 1–3) + 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 4, 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 `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`/`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. + +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, 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 + +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: 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`. + +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 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 6: SQL emitter migration to shared generation + +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. + +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 5 (IR alignment) and can be done before or after it. + +## Non-Functional Requirements + +- **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 + +- **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 5) 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`/`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, ownership, 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: 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 5: IR alignment + +- [ ] `ContractIR` mirrors the emitted contract JSON structure (domain/storage separation, model-level relations, `roots`) + +### Phase 6: SQL emitter migration to shared generation + +- [ ] 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) + + +# 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 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 + +- [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 +- 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` + +# Resolved Questions + + +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. + 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..0c0254eeec --- /dev/null +++ b/projects/contract-domain-extraction/type-design.ts @@ -0,0 +1,618 @@ +/** + * 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; + +// ============================================================================ +// §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. + +// First, verify the raw intersection type for models +type ExampleModelsIntersection = ExampleContract['models']; + +// 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 = 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 + +// model.relations: domain-level relation properties with literal types from M +type _VerifyModelRelTo = ExampleUserModel['relations']['posts']['to']; +const _verifyModelRelTo: _VerifyModelRelTo = 'Post'; + +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'; + +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'; + +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) 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..cabf3516aa 100644 --- a/test/integration/test/authoring/parity/core-surface/expected.contract.json +++ b/test/integration/test/authoring/parity/core-surface/expected.contract.json @@ -4,50 +4,99 @@ "target": "postgres", "storageHash": "sha256:ba5cf6cccf8cd906b25e0a8d075875aff7492d815c738166c0146ac4b2535a28", "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "roots": { + "Post": "Post", + "User": "User" + }, "models": { "Post": { "fields": { "id": { - "column": "id" + "codecId": "pg/int4@1" }, "rating": { - "column": "rating" + "codecId": "pg/float8@1", + "nullable": true }, "title": { - "column": "title" + "codecId": "pg/text@1" }, "userId": { - "column": "userId" + "codecId": "pg/int4@1" + } + }, + "relations": { + "author": { + "cardinality": "N:1", + "on": { + "localFields": ["userId"], + "targetFields": ["id"] + }, + "to": "User" } }, - "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + }, + "rating": { + "column": "rating" + }, + "title": { + "column": "title" + }, + "userId": { + "column": "userId" + } + }, "table": "post" } }, "User": { "fields": { "createdAt": { - "column": "createdAt" + "codecId": "pg/timestamptz@1" }, "email": { - "column": "email" + "codecId": "pg/text@1" }, "id": { - "column": "id" + "codecId": "pg/int4@1" }, "isActive": { - "column": "isActive" + "codecId": "pg/bool@1" }, "profile": { - "column": "profile" + "codecId": "pg/jsonb@1", + "nullable": true }, "role": { - "column": "role" + "codecId": "pg/enum@1" } }, "relations": {}, "storage": { + "fields": { + "createdAt": { + "column": "createdAt" + }, + "email": { + "column": "email" + }, + "id": { + "column": "id" + }, + "isActive": { + "column": "isActive" + }, + "profile": { + "column": "profile" + }, + "role": { + "column": "role" + } + }, "table": "user" } } 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..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 @@ -5,15 +5,23 @@ "storageHash": "sha256:ab25b558e03744f8878f72857aef610808c4f3f05e2827e68bcd872b826a428b", "executionHash": "sha256:9c330b02774fd7b35ab7463eea7551d8e147b97fa8dac27aed080a630be6bd0c", "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } 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..3fe996d8af 100644 --- a/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json +++ b/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json @@ -4,15 +4,23 @@ "target": "postgres", "storageHash": "sha256:439aa8d13a4e7326d69ac80a5f65e0f5cfc876e392af997d5b052eadda9580d4", "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { - "column": "id" + "codecId": "pg/text@1" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } 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..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 @@ -5,15 +5,23 @@ "storageHash": "sha256:4e890b7aba7b44bb761ef4a35d741cce748ce247f480bc514648c7914786f0d9", "executionHash": "sha256:123e615289bb93b88765b0522740cac06d7a22bf3a9e4968fe92e01c1ca0b3a5", "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } 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..6371ce8a74 100644 --- a/test/integration/test/authoring/parity/default-nanoid/expected.contract.json +++ b/test/integration/test/authoring/parity/default-nanoid/expected.contract.json @@ -5,15 +5,23 @@ "storageHash": "sha256:f9fa02b90981ca52bb1be98bd793d8fc3aa515b8d40269c22d64b2e476021e08", "executionHash": "sha256:2643d48dc917fa4ac9680d8404819ca290f0539dabcada463926743f4c526f65", "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } 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..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 @@ -5,15 +5,23 @@ "storageHash": "sha256:9e27b90da3eb44a199ce86ba13e2082b0d2cdc502803c95138a32b3afb88c0ec", "executionHash": "sha256:c3b03395de492379b9ba6d6f890debc53739629234ba98a8dbf7dc8c4301a910", "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { - "column": "id" + "codecId": "pg/text@1" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } 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..1e14d01085 100644 --- a/test/integration/test/authoring/parity/default-ulid/expected.contract.json +++ b/test/integration/test/authoring/parity/default-ulid/expected.contract.json @@ -5,15 +5,23 @@ "storageHash": "sha256:f23c5ccabb210de6564c7084708ecc900ad3e7769cc9585baad6561edfa20f66", "executionHash": "sha256:f2232a980cff0854e38fdedd0842347ffc96d92a8aaaab4da1749d4575598adc", "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } 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..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 @@ -5,15 +5,23 @@ "storageHash": "sha256:acb9845800b87237a5d37f2fa3739899bfd13d9dc2d7ce0ea47865bce2cc666d", "executionHash": "sha256:57c307c24cd2dc8eb5e7cf53beb4767f19728c92925835a8ca24b3f11a5cfc95", "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } } 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..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 @@ -5,15 +5,23 @@ "storageHash": "sha256:acb9845800b87237a5d37f2fa3739899bfd13d9dc2d7ce0ea47865bce2cc666d", "executionHash": "sha256:287547440de2f13dbe48f300b47dda09631a7e9b551728f6cb99ef5217aa2fdc", "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "roots": { + "User": "User" + }, "models": { "User": { "fields": { "id": { - "column": "id" + "codecId": "sql/char@1" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "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 36ea3982b0..0fbdfbb33b 100644 --- a/test/integration/test/authoring/parity/map-attributes/expected.contract.json +++ b/test/integration/test/authoring/parity/map-attributes/expected.contract.json @@ -4,29 +4,55 @@ "target": "postgres", "storageHash": "sha256:6a38af603d36ab9862c3e73197e094c00b696e55a0928d0003a7b5e9201017bc", "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "roots": { + "Member": "Member", + "Team": "Team" + }, "models": { "Member": { "fields": { "id": { - "column": "member_id" + "codecId": "pg/int4@1" }, "teamId": { - "column": "team_ref" + "codecId": "pg/int4@1" + } + }, + "relations": { + "team": { + "cardinality": "N:1", + "on": { + "localFields": ["teamId"], + "targetFields": ["id"] + }, + "to": "Team" } }, - "relations": {}, "storage": { + "fields": { + "id": { + "column": "member_id" + }, + "teamId": { + "column": "team_ref" + } + }, "table": "team_member" } }, "Team": { "fields": { "id": { - "column": "team_id" + "codecId": "pg/int4@1" } }, "relations": {}, "storage": { + "fields": { + "id": { + "column": "team_id" + } + }, "table": "org_team" } } 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..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 @@ -4,18 +4,29 @@ "target": "postgres", "storageHash": "sha256:a7aadce021465acff4f105ee787388a4896d8161afe863d3c90b3320b06b0010", "profileHash": "sha256:ea5c6635c0c0bd71badced0f3ee8ba912cf72dc836ae165cd533dc8f68cbfc9f", + "roots": { + "Document": "Document" + }, "models": { "Document": { "fields": { "embedding": { - "column": "embedding" + "codecId": "pg/vector@1" }, "id": { - "column": "id" + "codecId": "pg/int4@1" } }, "relations": {}, "storage": { + "fields": { + "embedding": { + "column": "embedding" + }, + "id": { + "column": "id" + } + }, "table": "document" } } 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..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 @@ -4,29 +4,64 @@ "target": "postgres", "storageHash": "sha256:7bc2d92b8bd871bb95a1030e0befcb591b9641949fed2263293e9111ca1b90b3", "profileHash": "sha256:1628d322a01cac03524fe340fe22d19882ec5ecf0f71f9a32c6c2dfa648a903a", + "roots": { + "Post": "Post", + "User": "User" + }, "models": { "Post": { "fields": { "id": { - "column": "id" + "codecId": "pg/int4@1" }, "userId": { - "column": "userId" + "codecId": "pg/int4@1" + } + }, + "relations": { + "user": { + "cardinality": "N:1", + "on": { + "localFields": ["userId"], + "targetFields": ["id"] + }, + "to": "User" } }, - "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + }, + "userId": { + "column": "userId" + } + }, "table": "post" } }, "User": { "fields": { "id": { - "column": "id" + "codecId": "pg/int4@1" + } + }, + "relations": { + "posts": { + "cardinality": "1:N", + "on": { + "localFields": ["id"], + "targetFields": ["userId"] + }, + "to": "Post" } }, - "relations": {}, "storage": { + "fields": { + "id": { + "column": "id" + } + }, "table": "user" } }