From 58b4a5a641e5e032f39b30142204a0fb36cbf127 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 14:49:44 +0200 Subject: [PATCH 01/49] =?UTF-8?q?docs(postgres-rls):=20slice=202=20schema-?= =?UTF-8?q?node-tree-restructure=20=E2=80=94=20spec=20+=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape the forward slice sequence and shape slice 2: replace the conflated PostgresSchemaIR with a single-purpose schema-node tree (database → namespace → table → policy; roles on the root), split the diff nodes from the Contract-IR entities, make introspect return the tree (a node; consumers ensure + walk), and move database→PSL inference onto the Postgres target. Behaviour-neutral. spec.md is the prescriptive build (R1–R9, 7 units, alternatives); design.md is the durable architecture reference. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- projects/postgres-rls/plan.md | 81 +++++++++--- .../schema-node-tree-restructure/design.md | 120 +++++++++++++++++ .../schema-node-tree-restructure/spec.md | 121 ++++++++++++++++++ 3 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 projects/postgres-rls/slices/schema-node-tree-restructure/design.md create mode 100644 projects/postgres-rls/slices/schema-node-tree-restructure/spec.md diff --git a/projects/postgres-rls/plan.md b/projects/postgres-rls/plan.md index 75988ab15a..a6d88f2c5e 100644 --- a/projects/postgres-rls/plan.md +++ b/projects/postgres-rls/plan.md @@ -7,7 +7,7 @@ ## At a glance -Slice 1 makes **SELECT policies dependable end-to-end** — full lifecycle (create, change, remove), drift makes `db verify` fail, proven in the Supabase example app. Slice 1.5 (`entity-kind-migration-seam`), discovered during slice 1, builds the **generic two-sided derivation seam** so a target-contributed entity kind works on every migration command — notably `migration plan`, which slice 1 defers with a fail-loud stopgap. Slice 2 makes **drift handling correct per variation** and introduces the **policy→role traversal** that seeds the dependency graph. Slice 3 extends everything to **the other policy types**. Slice 4 adds the **TypeScript authoring surface** with PSL parity. +Slice 1 makes **SELECT policies dependable end-to-end** — full lifecycle (create, change, remove), drift makes `db verify` fail, proven in the Supabase example app. Slice 1.5 (`entity-kind-migration-seam`), discovered during slice 1, builds the **generic two-sided derivation seam** so a target-contributed entity kind works on every migration command — notably `migration plan`, which slice 1 defers with a fail-loud stopgap. The forward work then runs as an ordered sequence: **slice 2 `schema-node-tree-restructure`** gives the schema-diff tree a real single-purpose node at every level (a `PostgresDatabaseSchemaNode` root above per-namespace nodes — the conflated `PostgresSchemaIR` root is retired) with no behavior change; **slice 3 `explicit-rls-control`** adds explicit `@@rls` enablement, table-level `managed`/`external` grading, and policy rename; **slice 4 `migration-support-for-roles`** makes roles diffable off the new root (the **policy→role** dependency-graph seed); **slice 5 `support-all-rls-policy-types`** extends everything to INSERT/UPDATE/DELETE/ALL; **slice 6 `rls-ts-authoring`** adds the TypeScript authoring surface with PSL parity. RLS rides the generic schema-diff architecture (unchanged — see § Architecture decisions): generic differ + `{coordinate, outcome}` issues; zero RLS symbols in framework/SQL-family; content-addressed wire names; side-by-side with the untouched legacy relational verifier/planner. The relational port and dependency-aware planner ordering remain independent follow-on projects. @@ -26,7 +26,7 @@ A developer can declare a SELECT policy and **rely on it**: it gets created, edi Already landed on the branch: the architecture (generic differ, content-addressed naming + normalizer, introspection, PSL `policy_select` authoring through the production interpreter, create/enable ops, planner diff-wiring, the verify `extensionIssues` channel) and two PGlite e2e spines. **Remaining to slice DoD:** 1. **Fix the build** (review F01): `extensionIssues` made required without updating three constructors (mongo verify, CLI `db-verify`, `combine-schema-results`) — workspace `pnpm typecheck` must be green; add the workspace typecheck to the standing gates. -2. **Edit replaces, never accumulates** (kills the edit-trap) via **strict content-addressed drop** (F07 withdrawn — same-prefix rule superseded): the generic differ (`diffNodes`) matches on full `EntityCoordinate` identity. A `mismatch` outcome (same prefix, new hash) produces `DropPostgresRlsPolicyCall` + `CreatePostgresRlsPolicyCall`; an `extra` outcome (policy in the DB but not in the contract) produces `DropPostgresRlsPolicyCall`. Both drop calls are gated by the migration operation policy: they are only emitted when `destructive` is in `allowedOperationClasses` (i.e., `db update` with widening/destructive policy, not `db init`). Under additive-only policy (`INIT_ADDITIVE_POLICY`), drop calls are suppressed — only create/enable ops are emitted. **Deferred to slice 2:** `DISABLE ROW LEVEL SECURITY` on last-policy-removed, and `managed`/`external` grading for general extra-drop safety. +2. **Edit replaces, never accumulates** (kills the edit-trap) via **strict content-addressed drop** (F07 withdrawn — same-prefix rule superseded): the generic differ (`diffNodes`) matches on full `EntityCoordinate` identity. A `mismatch` outcome (same prefix, new hash) produces `DropPostgresRlsPolicyCall` + `CreatePostgresRlsPolicyCall`; an `extra` outcome (policy in the DB but not in the contract) produces `DropPostgresRlsPolicyCall`. Both drop calls are gated by the migration operation policy: they are only emitted when `destructive` is in `allowedOperationClasses` (i.e., `db update` with widening/destructive policy, not `db init`). Under additive-only policy (`INIT_ADDITIVE_POLICY`), drop calls are suppressed — only create/enable ops are emitted. **Shipped in slice 1.5:** the `extra`→`DropPostgresRlsPolicyCall` mapping and the unowned-*namespace* extra filter (so removal-drop works at the namespace grain). **Deferred to slice 3 (`explicit-rls-control`):** explicit `@@rls` table enablement (so RLS enable/disable is driven by the marker, not policy presence) and per-*table* `managed`/`external` grading. 3. **Drift errors out** (resolves F02 as wire-it-now, bluntly): any non-empty `extensionIssues` fails the verify verdict — fold into `ok`/counts at the family assembly and thread through `combineSchemaResults`. Nuanced per-kind severity is slice 2; slice 1's rule is simply *any RLS drift → verify fails with a message naming the policy*. 4. **Supabase example app e2e**: extend `bootstrapSupabaseShim` with the Postgres roles (`anon`, `authenticated`, `service_role`) and the `auth.uid()` GUC-reading function (verified 2026-06-10: the shim seeds only schemas/tables today; roles are platform-provided in real Supabase, so the shim emulates that — this project never authors or migrates roles); add a SELECT policy to `examples/supabase` `Profile`; e2e proves: migrate → rows filtered under the role → `db verify` clean → drop the policy out-of-band → `db verify` **fails**. 5. Review follow-ups in scope: F03 (role-name rendering shim hardening or input constraint), F05 (a parsed extension block with no registered factory must not be silently dropped), F07 (`rlsEnabledByTable` keyed by bare table name — cross-schema collision), the structural anti-leak test (assert no RLS tokens in framework/SQL-core, since `lint:deps` can't catch this class). @@ -47,36 +47,77 @@ Per the accepted schema-diff ADR: - **Roles projected, not yet diffed.** project-from-contract populates roles so the IR is symmetric, but only policies are diffed here (the schema root yields policies, not roles) — role drift (missing-role detection, the policy→role edge) stays slice 2. Net observable change of this slice: `migration plan` emits RLS; `db init` / `db update` / `db verify` behave exactly as before. - **DoD (operator-observable):** `migration plan` on a contract with a SELECT policy emits `CREATE POLICY` in the generated migration; both diff sides are homogeneous schema IRs (the contract is not read directly on either side); RLS still works on `db init` / `db update` / `db verify`; SQLite + Mongo untouched. -### Slice 2 — `drift-handled-correctly` · [TML-2869](https://linear.app/prisma-company/issue/TML-2869) +The forward work is a single ordered sequence. Slices 1 and 1.5 are done; the rest run in this order, each with its own Linear ticket. + +### Slice 2 — `schema-node-tree-restructure` · _(Linear: new ticket, blockedBy TML-2931)_ + +**Status: ⬜ next up.** Foundational and behavior-neutral — done first so the role/dependency work isn't dancing around a conflated root for the next several slices. + +Slice 1.5 made `PostgresSchemaIR` carry three unrelated jobs at once: a node in the schema-diff tree, a Postgres schema (namespace), and the root of the tree. That conflation is the thing to remove before anything is built on top of it. This slice gives the schema-diff tree a real, single-purpose node at every level, modelled on Postgres's own object hierarchy: + +- **`PostgresDatabaseSchemaNode`** (root) — children are namespaces; also holds roles (held, **not yet diffed** — that arrives in slice 4). +- **`PostgresNamespaceSchemaNode`** — one per schema/namespace; children are tables. Has the same `.tables` shape the old flat `PostgresSchemaIR` had, so the legacy per-schema consumers take a namespace node unchanged (below). +- **`PostgresTableSchemaNode`** (rename of `PostgresTableIR`) — children are policies; will carry the `rlsEnabled` flag (slice 3). +- **`PostgresPolicySchemaNode`** / **`PostgresRoleSchemaNode`** (leaves) — **new** diff nodes. The authored entities `PostgresRlsPolicy` / `PostgresRole` stay as **Contract IR** (they are serialized into `contract.json`); they lose `DiffableNode` and move out of `schema-ir/`. Tables already split this way (`StorageTable` contract vs `PostgresTableIR` diff node); policies/roles now match. + +The `…SchemaNode` suffix marks these as nodes in the **schema-diff** tree (the derived database-state representation), distinct from the **Contract IR** entities — bare `…IR` is dropped because the repo has several IRs and the suffix said nothing. + +- **Introspect returns the root.** The RLS differ walks the whole tree. The **legacy relational verify, planner, and CLI schema view are unchanged** — they take a `PostgresNamespaceSchemaNode` (same `.tables` shape as before); the caller walks root→namespaces and feeds each to that per-schema code (exactly one node in the single-schema common case). No flat-read of the root, no shim, no dual representation. This also retires the old multi-schema "merge into one flat IR" (and its silent cross-schema table-name collision). +- **Inference moves to the Postgres target.** Database→PSL inference is target logic — it walks the tree and owns the Postgres type/default maps (currently a layering violation: `sql-schema-ir-to-psl-ast.ts` hardcodes `createPostgresTypeMap`/`createPostgresDefaultMapping` in SQL-family code). The Postgres target descriptor gains `inferPslContract(tree)`; the family instance delegates to it; the flat `sqlSchemaIrToPslAst`/`buildPslDocumentAst` walkers are deleted. The framework keeps `PslDocumentAst` + `printPsl` (the view and printer); the control adapter is untouched. (TS contract inference stays a future sibling — same target-owned shape — not built here.) +- **No behavior change.** `migration plan` / `db init` / `db update` / `db verify` and `contract infer` output are byte-for-byte unchanged. SQLite + Mongo untouched. +- **DoD:** the node family + the `PostgresDatabaseSchemaNode` root are in place; policies/roles are split into Contract-IR entities and schema-diff nodes; inference is target-owned; all RLS migration + `contract infer` suites green with unchanged output. (Structural slice — its "observable" guarantee is *no* observable change; operator-visible behavior lands in slices 3–4 on top of the clean tree.) + +### Slice 3 — `explicit-rls-control` · [TML-2869](https://linear.app/prisma-company/issue/TML-2869) + +**Status: ⬜ not started.** + +RLS enablement becomes explicit, and the in-a-single-schema drift variations are handled **correctly** (not just "error"). + +- **Explicit `@@rls` enablement** (replaces slice 1's deferred "disable on last policy"): a model marks its table RLS-controlled with a `@@rls` block, independent of whether any policy references it. + + ```prisma + model User { + @@rls + } + + policy user_isolation { + model = User + // … + } + ``` + + `ENABLE ROW LEVEL SECURITY` is emitted when the marker is present and the live table has RLS off; `DISABLE ROW LEVEL SECURITY` (destructive-gated) when the marker is removed. Removing the last policy from an `@@rls` model leaves RLS **on** — the table denies all access (fail-closed) rather than silently dropping authorization. A policy on a model without `@@rls` is an authoring error. RLS-enabled becomes the first real **table-attribute diff** (`PostgresTableSchemaNode.isEqualTo` compares it; introspection reads `pg_class.relhasrowsecurity`), which also subsumes the "RLS-disabled-with-policies-declared" drift case. +- **Table-level `managed`/`external` grading:** route RLS drop calls through the existing `partitionCallsByControlPolicy` so a table's `control` grade decides reconciliation — `managed` tables drop their extra policies, `external` tables are left untouched. Slice 1.5 already filters unowned-*namespace* extras and drops owned extras under the destructive gate; this adds the per-*table* grade so `managed` and `external` tables in the **same** schema are distinguished. The authoring surface (`StorageTable.control` / `defaultControlPolicy`) already exists — this slice only makes the RLS diff path consult it. +- **Policy rename:** a same-body, different-prefix policy currently emits DROP+CREATE; a planner post-pass pairs a `missing`+`extra` by content-hash on the same table and emits `ALTER POLICY … RENAME TO` instead (new op). The blunt slice-1 "any drift errors" rule refines into per-variation verdicts. +- **DoD (operator-observable):** add `@@rls` + a policy + migrate → table has RLS enabled with the policy; remove the last policy → table still RLS-enabled (deny-all), no DISABLE emitted; remove `@@rls` → DISABLE emitted (destructive-gated); rename a policy prefix → migrate emits a RENAME (verifiable in plan output); an `external` table's extra policies are left untouched while a `managed` table's are dropped. + +### Slice 4 — `migration-support-for-roles` · _(Linear: new ticket, blockedBy TML-2869)_ **Status: ⬜ not started.** -Every drift variation for a SELECT policy is handled **correctly** (not just "error"), and the schema graph gains its first edge. +The schema graph gains its first edge: a policy depends on the roles it references. The `PostgresRoleSchemaNode` leaves that slice 2 hung off the root (held, not diffed) become diffable here. -- **Removal auto-drop + `managed`/`external` grading** (deferred here from slice 1): a policy removed from the contract is auto-dropped on migrate (not just reported as drift), and `DISABLE ROW LEVEL SECURITY` is emitted when a table's last managed policy is removed — made safe by the table's control policy distinguishing framework-`managed` policies (drop the extra) from `external` ones (leave alone, tolerate). This is what generalizes slice 1's narrow same-prefix replace into correct "extra → drop". -- **Drift variations, each implemented + e2e-tested:** rename (matching content-hash, different prefix → `ALTER POLICY … RENAME TO`, not drop+create); role traversal (a policy referencing a role absent from `pg_roles` surfaces the missing role); RLS-disabled-with-policies-declared; extra/unmanaged policies (severity via the table's control policy — `managed` fails, `external` tolerates); the blunt slice-1 "any drift errors" rule is refined into per-variation verdicts through the control-policy disposition. -- **Policy → Role traversal (the dependency-graph seed):** the SchemaIR gains traversal from a top-level RLS policy node to its referent Role node(s). Roles become diffable: a policy referencing a role absent from `pg_roles` is an issue. Issue processing is **leaves-first, then up the tree** — the role leaf's issue surfaces before/along the dependent policy's — establishing the edge model the future dependency-aware planner (follow-on B) builds on. -- **DoD (operator-observable):** rename a policy prefix → migrate emits a RENAME (verifiable in the plan output); reference a role that doesn't exist → verify fails naming the role; extra/unmanaged policies graded by control policy. +- **Roles become diffable nodes** off `PostgresDatabaseSchemaNode` — diffed once at the database level, not once per schema (the reason the root had to exist before this slice). +- **Policy → role traversal (the dependency-graph seed):** a policy referencing a role absent from `pg_roles` surfaces a missing-role issue. Issue processing is **leaves-first, then up the tree** — the role leaf's issue surfaces before/along the dependent policy's — establishing the edge model the future dependency-aware planner (follow-on B) builds on. +- **DoD (operator-observable):** reference a role that doesn't exist → verify fails naming the role; the missing-role issue is ordered before its dependent policy's. -### Slice 3 — `all-policy-types` · [TML-2870](https://linear.app/prisma-company/issue/TML-2870) +### Slice 5 — `support-all-rls-policy-types` · [TML-2870](https://linear.app/prisma-company/issue/TML-2870) **Status: ⬜ not started.** -Everything slices 1–2 made dependable for SELECT works for **INSERT / UPDATE / DELETE / ALL** policies. +Everything slices 1–4 made dependable for SELECT works for **INSERT / UPDATE / DELETE / ALL** policies. -- PSL `policy_insert | policy_update | policy_delete | policy_all` block descriptors lowering through the same generic interpreter pass; `withCheck` handling (INSERT/UPDATE) end-to-end; the lifecycle + drift behaviors from slices 1–2 verified per type (the content-hash already covers operation + withCheck, so this is descriptors + DDL rendering + per-type e2e, not new architecture). +- PSL `policy_insert | policy_update | policy_delete | policy_all` block descriptors lowering through the same generic interpreter pass; `withCheck` handling (INSERT/UPDATE) end-to-end; the lifecycle + drift behaviors from earlier slices verified per type (the content-hash already covers operation + withCheck, so this is descriptors + DDL rendering + per-type e2e, not new architecture). - **DoD (operator-observable):** the slice-1 example-app scenario repeated with an UPDATE-own policy (`using` + `withCheck`): a user can update only their own row; editing/removing/drifting behaves exactly as for SELECT. -### Slice 4 — `typescript-authoring` · _(Linear: see § Linear sync)_ +### Slice 6 — `rls-ts-authoring` · [TML-2883](https://linear.app/prisma-company/issue/TML-2883) **Status: ⬜ not started.** -**Goal:** make the IR reachable through TS authoring. After this slice, an app contract can declare RLS policies and produce valid `PostgresRlsPolicy` IR. - A developer can author the same policies in **TypeScript** instead of PSL, with identical results. - Top-level Postgres-contributed policy helpers taking the model handle (the decided surface — the `enum`/`entityTypes` mechanism, invisible to SQLite/Mongo authors; **not** a model-builder method; rationale in [`specs/design-rls-authoring-surface.md`](specs/design-rls-authoring-surface.md)). Settles the still-open **per-operation (`policySelect(…)`) vs single-array** helper-signature decision at slice pickup. -- The `ref()` predicate helper (reads `{namespaceId, tableName}` off `extensionModel(…)` handles so predicates track renames); model-level RLS enable/disable; duplicate-prefix/name diagnostics. +- The `ref()` predicate helper (reads `{namespaceId, tableName}` off `extensionModel(…)` handles so predicates track renames); model-level RLS enable/disable (the TS form of `@@rls`); duplicate-prefix/name diagnostics. - **TS/PSL parity test:** the same policies authored both ways lower to structurally identical IR with identical wire names. - **DoD (operator-observable):** the slice-1 example-app scenario authored in TS instead of PSL behaves identically (filtered rows, lifecycle, drift→verify-fails); the parity test pins identical contracts. @@ -111,4 +152,12 @@ Refined by the schema-diff ADR ([`specs/adr-schema-diff-over-structured-ir.md`]( ## Linear sync -TML-2868 → `select-policies-dependable` (re-scoped; PR #771 continues under it). [TML-2931](https://linear.app/prisma-company/issue/TML-2931) → `entity-kind-migration-seam` (slice 1.5, added 2026-06-18; the generic two-sided derivation seam). TML-2869 → `drift-handled-correctly`. TML-2870 → `all-policy-types`. [TML-2883](https://linear.app/prisma-company/issue/TML-2883) → `typescript-authoring` (slice 4, re-added 2026-06-10). TML-2871 → canceled (contents split: example-app skeleton → slice 1; role existence → slice 2; cross-space role validation → dropped). Blocking: 2931 blockedBy 2868; 2869 blockedBy 2931; 2870 blockedBy 2869; 2883 blockedBy 2870. +TML-2868 → `select-policies-dependable` (re-scoped; PR #771 continues under it). [TML-2931](https://linear.app/prisma-company/issue/TML-2931) → `entity-kind-migration-seam` (slice 1.5; PR #868 merged 2026-06-28). The forward sequence and its tickets: + +- **Slice 2 `schema-node-tree-restructure`** — **new top-level ticket** (not yet created), blockedBy TML-2931, blocking TML-2869. +- **Slice 3 `explicit-rls-control`** — TML-2869 (re-scoped from `drift-handled-correctly`). +- **Slice 4 `migration-support-for-roles`** — **new top-level ticket** (not yet created), blockedBy TML-2869, blocking TML-2870. +- **Slice 5 `support-all-rls-policy-types`** — [TML-2870](https://linear.app/prisma-company/issue/TML-2870). +- **Slice 6 `rls-ts-authoring`** — [TML-2883](https://linear.app/prisma-company/issue/TML-2883). + +Per [[no-linear-sub-issues]] the two new tickets are sibling issues wired with blocks/blockedBy relations, not sub-issues. TML-2871 → canceled (contents split: example-app skeleton → slice 1; role existence → slice 4; cross-space role validation → dropped). diff --git a/projects/postgres-rls/slices/schema-node-tree-restructure/design.md b/projects/postgres-rls/slices/schema-node-tree-restructure/design.md new file mode 100644 index 0000000000..c6d851a462 --- /dev/null +++ b/projects/postgres-rls/slices/schema-node-tree-restructure/design.md @@ -0,0 +1,120 @@ +# Design reference: the schema-diff architecture + +Companion to [`spec.md`](spec.md). The spec says **what to build** in this slice; this doc is the durable map of the **design components** the slice touches, so they are not re-derived. Where a boundary has been crossed repeatedly, § "What this is NOT" states the rule. + +Related project-level design: [`adr-schema-diff-over-structured-ir.md`](../../specs/adr-schema-diff-over-structured-ir.md), [`design-generic-schema-differ.md`](../../specs/design-generic-schema-differ.md). + +## The shape in one picture + +```mermaid +flowchart TD + contract["Contract IR
(authored, serialized in contract.json)"] -->|project-from-contract| expected["Expected tree
(schema nodes)"] + db[("Live database")] -->|"introspect = project-from-database"| actual["Actual tree
(schema nodes)"] + expected --> differ{{"diffSchemas (generic)"}} + actual --> differ + differ -->|issues| planner["Planner → migration ops"] + differ -->|issues| verify["Verify → assert issues empty"] + actual --> view["Schema view → CoreSchemaView (print; no diff)"] + actual --> infer["Inference → PslDocumentAst → printPsl → .prisma"] +``` + +The schema IR is a **tree of nodes**. Two trees (one derived from the contract, one from the live DB) are **diffed** by a generic walker; the resulting issues feed the **planner** (→ migration) and **verify** (→ assert empty). The same actual tree is independently **printed** (schema view) and **inferred to PSL** (contract infer). Nothing reads a flat table map. + +## Components + +### 1. The schema node tree + +- A schema is a tree: **database → namespace → table → policy**; **roles** are leaves at the **database** level (cluster-scoped — never under a table). +- Every node implements `DiffableNode` (`id`, `isEqualTo`, `children()`). +- Three-layer polymorphic IR (the repo's standard pattern): framework node base → family (`SqlSchemaIRNode`) → target (Postgres adds policies on tables, roles on the root). +- The node **is** the representation. There is no parallel flat structure. + +### 2. Contract IR entities ≠ schema nodes + +Two distinct class families: + +| | Contract IR entity | Schema node | +| --- | --- | --- | +| Examples | `StorageTable`, `PostgresRlsPolicy`, `PostgresRole` | `Postgres{Database,Namespace,Table,Policy,Role}SchemaNode` | +| Authored / serialized into `contract.json`? | Yes (entity kinds, extend `SqlNode`) | No | +| Walked by the differ (`DiffableNode`)? | No | Yes | +| Built by | the author (PSL/TS) | project-from-contract / introspection | + +A class is **one or the other, never both**. Tables already model this (`StorageTable` contract vs `PostgresTableIR` node); policies/roles now match. + +### 3. Two derivations produce the tree + +- **project-from-contract** — contract IR → schema tree (the *expected* side). +- **project-from-database (introspection)** — live DB → schema tree (the *actual* side). Returns the **target's node**. +- Both produce the same node shape, so the differ compares like with like. + +### 4. The generic differ + +- `diffSchemas(expected, actual)` walks two `DiffableNode` trees and emits `{ path, outcome }` issues. +- It is **generic** — it knows nothing about RLS, tables, policies, or any target kind. It lives in the framework. Per-kind meaning lives in the nodes' `isEqualTo` and in how the planner maps issues. + +### 5. `introspect()` returns a node + +- Its declared return type is **a node** (generic), **not** `SqlSchemaIR`. +- To *use* it, you pass it to a method that knows the target-specific node type, which `ensure`s and walks it — the pattern the planner already uses: `options.schema: unknown` → `ensurePostgresDatabaseSchemaNode(...)`. +- SQLite returns its own node from the same family method. There is no shared flat schema type and nothing branches on a "uniform view". + +### 6. The consumers of a schema — two operations, nothing else + +Everything done with a schema is one of these. None reads a flat `.tables` off the root. + +- **Diff** — derive two trees, run the generic differ. Two endings: + - **Planner**: issues → migration ops. + - **Verify**: ask the differ/planner for issues and assert they're empty. Verify does **not** walk the tree itself. +- **Print** — the **schema view** walks a generic tree of printable nodes into a `CoreSchemaView` through its own output interface. It is agnostic to the schema-IR structure and does not diff. + +Only the **diff machinery** (differ + planner) is structure-aware — it `ensure`s the concrete target node and walks the tree. Verify consumes its issues; the view walks printable nodes. Neither verify nor the view interrogates the tree. + +### 7. The legacy relational diff (side-by-side) + +- The relational table/column comparison still runs its own pre-generic-differ logic, retired by **follow-on A** (the relational port), not here. +- It is part of the **diff machinery** — like the generic differ and the planner, it `ensure`s the target node and walks the tree. It is **not** a separate consumer that flat-reads `.tables`, and **verify** does not interrogate it — verify only asks for the combined issues and asserts them empty. +- The new tree path emits only new native structures; the legacy diff keeps working until follow-on A ports it onto the generic differ. + +### 8. Inference (DB → PSL) is target logic + +- Walks the tree → `PslDocumentAst` → `printPsl` → `.prisma`. Used by `contract infer`. +- It lives on the **target descriptor** (`stack.target`, beside `contractSerializer`); the family instance's `inferPslContract` **delegates** to it. It is **not** on the control adapter (no DB I/O) and **not** family-resident (that is the current layering violation: `sql-schema-ir-to-psl-ast.ts` hardcodes `createPostgresTypeMap`/`createPostgresDefaultMapping`). +- **Framework owns** the view (`PslDocumentAst`) and the printer (`printPsl` / `@prisma-next/psl-printer`) — reuse, do not reinvent. +- **Target owns** the dialect knowledge — the type map (`int4 → Int`, `varchar → String @db.VarChar`) and default map (`now() → @default(now())`). The shape-neutral helpers (name transforms, relation inference, generic `mapDefault`) are plain utilities the target imports. +- The target walks its **own tree** — there is no flat document builder. Emitting top-level entities (policies/roles → PSL extension blocks) is a later slice; this slice emits relational-only PSL, byte-identical to today. +- **TS contract inference** does not exist yet (only `PslContractInferCapable`). It is a future **sibling** capability of the same shape — target-owned, walks the same tree, its own `printTs`. The PSL-specific name (`inferPslContract`) already anticipates it. + +### 9. Where logic lives — the three target surfaces + +| Surface | Holds | Examples | +| --- | --- | --- | +| **Target descriptor** (`stack.target`) | target **logic** — pure transforms | contract serializer, **inference**, entity kinds, authoring | +| **Control adapter** (`stack.adapter`) | DB **communication** — I/O | **introspect** (reads the DB), markers, ledger, query lowering | +| **Family instance** | shared **behavior**, generic over the node type | delegates logic → descriptor, I/O → adapter | + +`introspect` is on the adapter because it reads the database. Inference is on the descriptor because it is a pure tree→PSL transform. That distinction is the whole reason they live in different places. + +### 10. Package / layer placement + +- **Framework** (`1-framework`): `DiffableNode` + `diffSchemas`; `PslDocumentAst` + `printPsl`. +- **Family** (`2-sql`): `SqlSchemaIRNode` base; the generic PSL-infer **utilities**; `verifySqlSchema` (legacy relational verify, shared with SQLite). +- **Target** (`3-targets/postgres`): the schema-node tree (`schema-ir/`); the Postgres **contract-IR entities**; **inference** + the dialect maps; the differ/planner wiring. +- `schema-ir/` holds **only** the five `…SchemaNode` classes — the only `DiffableNode` implementors in the target. + +## Naming + +- Diff nodes carry the **`…SchemaNode`** suffix (bare `…IR` is dropped — the repo has several IRs). +- **Generic machinery is generic** — never RLS-prefixed. It is *the* differ, *the* planner — they handle any entity kind. +- Node guards are **static methods**: `PostgresTableSchemaNode.is(node)` / `.assert(node)` / `.ensure(node)`. + +## What this is NOT — the boundaries we keep crossing + +- `introspect()` does **not** return a flat `SqlSchemaIR`. It returns **a node**; consumers `ensure` the target type and walk it. +- The differ and planner are **not** RLS-specific. They are **generic**. RLS is one entity kind among many. +- **Verify** does **not** walk the tree — it asks the differ/planner for issues and asserts them empty. The **schema view** walks printable nodes and is agnostic to the schema-IR structure. Neither is a structure-aware consumer; only the differ + planner are. +- The DB **driver** and the control **adapter** are **not** involved in diffing, verifying, or printing — those are pure logic over nodes; the driver/adapter are DB I/O. +- Inference is **not** on the adapter (that is DB I/O) and **not** in the family (that hardcodes dialect). It is **target logic**, on the target descriptor. +- Schema nodes do **not** expose a flat `.tables` / merged view for legacy consumers. The **namespace node is** the per-schema shape; the diff machinery walks to it like the planner. No shim, no `toLegacyFlat*` adapter, no dual representation. +- The family `SqlSchemaIR` does **not** need to become a tree. The namespace node satisfies the per-schema shape; the family methods are typed to a node and downcast on use. +- The policy/role **schema nodes are not** the policy/role **contract entities**. They are separate classes built by the projection. diff --git a/projects/postgres-rls/slices/schema-node-tree-restructure/spec.md b/projects/postgres-rls/slices/schema-node-tree-restructure/spec.md new file mode 100644 index 0000000000..cba241d5a7 --- /dev/null +++ b/projects/postgres-rls/slices/schema-node-tree-restructure/spec.md @@ -0,0 +1,121 @@ +# Slice 2: schema-node-tree-restructure + +Parent project: `projects/postgres-rls/` ([spec](../../spec.md) · [plan](../../plan.md)). Linear: new top-level ticket (TBD), blockedBy TML-2931, blocking TML-2869. + +Audience: an implementer with no prior context. The design is stated first and is prescriptive — names, hierarchy, field placement, and consumer wiring are fixed. § Requirements names the properties the design satisfies; each unit and acceptance criterion cites them. § Alternatives records rejected options — read it only for rationale; it does not describe what to build. + +## Decision + +Replace the single `PostgresSchemaIR` — today simultaneously a diff-tree node, a Postgres schema, and the tree root — with a **five-class schema-diff tree** that mirrors Postgres's object hierarchy, **separate those diff nodes from the authored Contract-IR entities**, make **introspection return the tree**, and move **database→PSL inference onto the Postgres target** (where it belongs — it currently sits in SQL-family code hardcoding Postgres types). + +``` +PostgresDatabaseSchemaNode root — children: namespaces; also holds roles (not yet diffed) +└── PostgresNamespaceSchemaNode one per Postgres schema — children: tables + └── PostgresTableSchemaNode children: policies + └── PostgresPolicySchemaNode leaf +PostgresRoleSchemaNode leaf, on the root (a sibling of namespaces, never under a table) +``` + +**No behaviour change.** `migration plan` / `db init` / `db update` / `db verify` and `contract infer` output are byte-for-byte unchanged; SQLite and Mongo are untouched. This is a purely structural slice — its value is that slices 3–4 build on a clean tree. + +## Requirements + +- **R1 — one job per node.** No class is a tree node *and* a schema *and* the root. +- **R2 — diff nodes ≠ Contract-IR entities.** The diff nodes (walked by the differ, never serialized) are distinct classes from the authored, serialized Contract-IR entities — the split tables already have (`StorageTable` vs `PostgresTableIR`). +- **R3 — the tree mirrors Postgres.** database → namespace → table → policy; roles are database-level leaves. +- **R4 — roles carried, not yet diffed.** Populated on the root, but the root's `children()` excludes them this slice (role diffing is slice 4). +- **R5 — the namespace level is real.** Tables are grouped under their owning namespace, not flattened into one bare-keyed record. This removes the current silent cross-schema table-name collision in introspection. +- **R6 — only the differ and planner are structure-aware.** They `ensure` the target node and walk the tree. **Verify** asks them for issues and asserts they're empty; the **schema view** walks a generic tree of printable nodes. Neither verify nor the view interrogates the schema-IR structure, so neither changes. No flat-read of the root, no shim, no dual representation. +- **R7 — inference is target logic.** Database→PSL inference walks the tree and owns the Postgres type/default maps, on the Postgres **target** (not the family, not the communication adapter). The framework keeps the `PslDocumentAst` view and the `printPsl` printer. No SQL-family code references Postgres types. +- **R8 — names say which tree.** Diff nodes carry the `…SchemaNode` suffix. Bare `…IR` is dropped — the repo has several IRs. +- **R9 — behaviour-neutral.** Migration and `contract infer` output byte-identical; SQLite + Mongo untouched. + +## Design + +### Two class families (R1, R2) + +**Contract-IR entities** — authored, validated, serialized into `contract.json`, registered as entity kinds, extend `SqlNode`. `PostgresRlsPolicy` / `PostgresRole` **keep their names**, **lose `DiffableNode`** (delete `id` / `children()` / `isEqualTo()` and the `implements`), and **move out of `schema-ir/`** to sit with the other Postgres contract-IR / entity-kind definitions (`entity-kinds.ts`, `postgres-schema.ts`). Their entity-kind registration, validators, `kind`, and `freezeNode` are unchanged. The guards `isPostgresRlsPolicy` / `assertPostgresRlsPolicy` move with them and afterward have only Contract-IR callers. + +**Schema nodes** — derived, walked by the differ, never serialized, implement `DiffableNode`, live in `schema-ir/`. After this slice these five classes are the only residents of `schema-ir/` and the only `DiffableNode` implementors in the target. + +### The five schema-node classes + +All extend `SqlSchemaIRNode` directly and call `freezeNode(this)` at the end of their constructor (the `SqlTableIR`-freezes-in-its-constructor wart is carried, see § Alternatives). + +| Class | `id` | `isEqualTo` | `children()` | Carries | +| --- | --- | --- | --- | --- | +| `PostgresDatabaseSchemaNode` (root) | `'database'` (no siblings; never emitted) | `true` | `Object.values(this.namespaces)` — **namespaces only** (R4) | `namespaces`, `roles`, `existingSchemas`, `pgVersion` | +| `PostgresNamespaceSchemaNode` | `this.schemaName` | `true` | `Object.values(this.tables)` | `schemaName`, `tables`, `nativeEnumTypeNames` — **and whatever the per-schema `SqlSchemaIR` interface requires** so the legacy per-schema consumers (R6) take it unchanged | +| `PostgresTableSchemaNode` (rename of `PostgresTableIR`) | `this.name` | `true` (table-attr diffing is slice 3) | `this.policies` | the `SqlTableIR` fields + `policies: readonly PostgresPolicySchemaNode[]` (renamed from `rlsPolicies`) | +| `PostgresPolicySchemaNode` (new) | `this.name` | wire-name equality¹ | `[]` | `name`, `prefix`, `tableName`, `namespaceId`, `operation`, `roles`, `using?`, `withCheck?`, `permissive` | +| `PostgresRoleSchemaNode` (new) | `this.name` | name equality | `[]` | `name`, `namespaceId` | + +Each class exposes its guard as a **static `is()` method** (e.g. `PostgresTableSchemaNode.is(node)`), replacing the free `isPostgresTableIR` etc. The root's `is()` narrows on the enumerable `nodeTarget === 'postgres'` discriminant the current `isPostgresSchemaIR` uses (it must survive the `projectSchemaToSpace` spread); the root also ships static `assert()` / `ensure()` (the `ensure` reconstructs from a spread-flattened plain object, replacing `ensurePostgresSchemaIR`). + +The new nodes carry **no `annotations.pg` bag** (obsolete — [annotations-bag-is-obsolete]); enum names are the typed `nativeEnumTypeNames` field. + +¹ Wire-name equality, unchanged rationale: the name is `_`, so name-equality is body-equality; never byte-compare predicate bodies (Postgres reprints them). + +### Producers build the tree (R3, R5) + +- **Projection** (`contract-to-postgres-schema-ir.ts` → renamed `contract-to-postgres-database-schema-node.ts` / `contractToPostgresDatabaseSchemaNode`): group tables by owning namespace; per namespace build a `PostgresNamespaceSchemaNode` holding its `PostgresTableSchemaNode`s and their `PostgresPolicySchemaNode`s (built from the `PostgresRlsPolicy` contract entities; DDL schema resolved once per namespace via `resolveDdlSchemaForNamespaceStorage`). Roles → `PostgresRoleSchemaNode[]` on the root. Preserve the malformed-contract assert (policy whose table is absent throws). +- **Introspection** (`control-adapter.ts`): the `new PostgresSchemaIR(...)` sites build the tree; the multi-schema merge that flattened per-schema IRs into one bare-keyed record (keeping only `first.pgSchemaName`) is **replaced** by a `PostgresDatabaseSchemaNode` with one namespace node per schema (R5). `introspect()` returns `PostgresDatabaseSchemaNode`. + +### Consumers (R6) + +`introspect()` returns a **node** — the target's schema root (generic type, **not** `SqlSchemaIR`; SQLite returns its own node from the same method). Only the **differ** and the **planner** are structure-aware: they `ensure` the concrete target node and walk the tree, exactly as the planner already does with `options.schema: unknown` + `ensurePostgres…`. Nothing else reads the schema's structure; nothing branches on a "uniform view"; SQLite is untouched. + +- **The differ + the planner** (`diff-postgres-schema.ts`, `planner.ts`): `ensure` the `PostgresDatabaseSchemaNode` and walk the whole tree. Guards switch `isPostgresRlsPolicy` → `PostgresPolicySchemaNode.is`. `filterIssuesByOwnership` still reads `i.actual.namespaceId` off the policy node — unchanged. The policy-issue `path` gains a namespace segment (`[ 'database', schemaName, tableName, policyName ]`); the `policy "name" on "schema"."table"` message is preserved. No production code reads `path` positionally. +- **Verify and `toSchemaView` need no change.** Verify asks the differ/planner for issues and asserts they're empty — it never walks the tree. `toSchemaView` walks a generic tree of printable nodes and is already agnostic to the schema-IR structure. (The legacy relational diff stays side-by-side until follow-on A ports it onto the generic differ; it is part of the diff machinery — it `ensure`s and walks the target node like the planner, not a separate flat reader.) + +### Inference moves to the Postgres target (R7) + +Database→PSL inference is target logic — it walks the tree and knows Postgres's type/default mappings. Today it lives in SQL-family code (`sqlSchemaIrToPslAst`) hardcoding `createPostgresTypeMap` / `createPostgresDefaultMapping` — a layering violation. + +- The **Postgres target descriptor** gains `inferPslContract(tree): PslDocumentAst`, beside its existing `contractSerializer`. It walks the tree (one `PslNamespace` per namespace node; each table node → a `PslModel`) and owns the Postgres type/default maps, which **move from `2-sql/9-family/psl-contract-infer/` into the Postgres target**. (Top-level entities — policies/roles → extension-block entries — are a later slice; this slice emits the same relational-only PSL as today.) +- The **family instance's `inferPslContract`** ([control-instance.ts:892](../../../../packages/2-sql/9-family/src/core/control-instance.ts)) delegates to the target's method (read off `target`, like `targetSerializer`). It still satisfies `PslContractInferCapable`; absent when a target doesn't provide it (Mongo). +- **Delete** `sqlSchemaIrToPslAst` and the flat document walker `buildPslDocumentAst`. The genuinely shape-neutral leaf transforms (name transforms, relation inference, `mapDefault`, the `PslTypeMap` types) become plain **utility functions** the Postgres target imports — not on the control instance. The Postgres tree-walk replaces the flat `.tables` iteration. +- The **framework** keeps `PslDocumentAst` + `printPsl`; the **control adapter** is untouched (inference is not communication). + +## Units (build order) + +Nodes first (new vocabulary), then producers/consumers/inference (depend on the nodes). + +1. **Leaf split** (R1, R2) — strip `DiffableNode` from `PostgresRlsPolicy`/`PostgresRole`, move them out of `schema-ir/` to the Postgres contract-IR home; add `PostgresPolicySchemaNode` / `PostgresRoleSchemaNode` in `schema-ir/`. +2. **Table node rename** (R8) — `PostgresTableIR` → `PostgresTableSchemaNode` (+ `Input`, guard); field `rlsPolicies` → `policies`. +3. **Namespace node** (R3, R5, R6) — new `PostgresNamespaceSchemaNode`, shaped to satisfy the per-schema `SqlSchemaIR` interface. +4. **Database root** (R1, R3, R4) — new `PostgresDatabaseSchemaNode` (+ guard/assert/ensure), replacing `PostgresSchemaIR`. +5. **Producers** (R3, R5) — projection + introspection build the tree; `introspect()` returns the root. +6. **Consumers** (R6) — the differ + planner `ensure` the root and walk it; the legacy verify / relational planning / view operate on a namespace node; `introspect()` returns a generic node and each consumer `ensure`s the target type. +7. **Inference to target** (R7) — move the maps + projection to the Postgres target descriptor; family delegates; delete the flat walker; leaf transforms become utilities. + +## Tests (write first) + +- New node tests (`postgres-database-schema-node.test.ts`, `…-namespace-…`, `…-policy-…`, `…-role-…`): `id` / `isEqualTo` / `children` per the table; the root's `children()` returns **namespaces, not roles** (R4); a namespace node satisfies the per-schema `SqlSchemaIR` shape (R6). +- `rls-diffable-nodes.test.ts`: rebuilt; assert `PostgresRlsPolicy`/`PostgresRole` are **not** `DiffableNode` (R2). +- `diff-postgres-schema.test.ts`: fixtures build the nested tree; policy path now `[ 'database', schemaName, tableName, policyName ]`; all current behaviours hold (same-wire-name-on-two-tables, initial-migration missing-policy, multi-schema unbound→public, ownership at the caller, the message). +- Projection/planner/introspection/walking-skeleton suites rebuilt for the tree; `migration plan` still emits `ENABLE RLS` + `CREATE POLICY`. +- **Legacy relational `planner.*.test.ts` + `verify-postgres-namespaces.test.ts` + the `toSchemaView` path**: fed namespace nodes; **expected output unchanged** (R6, R9). +- **`contract infer` fixtures**: unchanged output, proving target-owned inference reproduces today's PSL (R7, R9). + +## Acceptance criteria + +- **AC-1 (R1, R8)** The five `…SchemaNode` classes are the only residents of `schema-ir/` and the only `DiffableNode` implementors in the target. +- **AC-2 (R2)** `PostgresRlsPolicy`/`PostgresRole` are Contract-IR only — no `DiffableNode`, out of `schema-ir/`, still entity kinds, `contract.json` byte-identical. +- **AC-3 (R3, R5)** The tree is database → namespace → table → policy, roles on the root and not in `children()`; no cross-namespace flattening; the old merge's collision is gone. +- **AC-4 (R6)** The relational verify/planner/view logic is unchanged and operate on a namespace node; nothing flat-reads the root; no shim and no second representation. +- **AC-5 (R7)** Inference runs on the Postgres target descriptor; no SQL-family file imports Postgres type/default maps; `sqlSchemaIrToPslAst` and the flat walker are deleted; framework `PslDocumentAst` + `printPsl` and the adapter are untouched. +- **AC-6 (R9)** Migration + `contract infer` output byte-identical; SQLite + Mongo untouched. Full gate set green: build, `typecheck --force`, `turbo run lint`, `lint:deps`, `lint:casts`, `check:upgrade-coverage --mode pr`, `fixtures:check`, all suites. + +## Alternatives considered + +Captured so the rejected paths are not re-litigated. None is what we build. + +- **Flat accessors on the root** (`get tables()` flattening namespaces). Rejected (R6): bolts the old flat-schema shape back onto the clean node — the conflation disguised as a getter. +- **A `toLegacyFlatPostgresSchema` adapter / a tree→flat "projection seam" for the legacy consumers.** Rejected: a `PostgresNamespaceSchemaNode` *is* the per-schema `SqlSchemaIR` shape, so the legacy consumers take a namespace node directly. No conversion exists. +- **Make the family `SqlSchemaIR` itself a namespace tree** (SQLite = one namespace). Unnecessary — the legacy consumers take an unchanged-shape namespace node; no family/SQLite reshape. +- **Fold relational diffing into the generic differ** (give table/column nodes real `isEqualTo`). That is follow-on A (the relational port); not needed here. The legacy relational verify/planner keep hand-rolling their diff, just fed a namespace node. +- **Keep inference in the family / add an inference method to `SqlControlAdapter`.** Wrong layer: inference is target logic (no DB I/O), so it lives on the target descriptor — not in shared family code (the current layering violation) and not on the communication adapter. +- **Rename the policy/role leaves to `…SchemaNode` and leave them dual-purpose.** Rejected (R2): they're the authored, serialized Contract-IR entities; naming them `…SchemaNode` mislabels them. Split instead. +- **Fix the `freezeNode`-in-constructor wart** so nodes extend `SqlTableIR`/`SqlSchemaIR`. A family-base change touching SQLite; orthogonal; carried deliberately. +- **TS contract inference now.** Out of scope and doesn't exist (only `PslContractInferCapable`, PSL-only). The target-owned, format-specific design accommodates a future `inferTsContract` sibling walking the same tree; not built here. From 17148ddc4a2115c5fa1739328a1e9cd94226fd8c Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 15:02:33 +0200 Subject: [PATCH 02/49] =?UTF-8?q?refactor(postgres):=20leaf=20split=20?= =?UTF-8?q?=E2=80=94=20RLS=20policy/role=20contract=20entities=20vs=20sche?= =?UTF-8?q?ma-diff=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 (schema-node-tree-restructure), unit 1 of 7. PostgresRlsPolicy / PostgresRole were doing two jobs: authored, serialized Contract-IR entities AND diff-tree leaves. Split them: - The entities keep their names, lose DiffableNode (id/children/ isEqualTo gone), and move out of schema-ir/ to core/ alongside the other contract-IR / entity-kind definitions. - New PostgresPolicySchemaNode / PostgresRoleSchemaNode in schema-ir/ are the diff-tree leaves (DiffableNode, static is() guards). Tables already split this way (StorageTable vs PostgresTableIR). Intermediate unit: the differ/planner still reference the entity as a node and break here; they are rewired in unit 6. The branch goes green across the unit sequence; the new-node tests pass (37/37). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-targets/postgres/src/core/authoring.ts | 4 +- .../postgres/src/core/entity-kinds.ts | 4 +- .../contract-to-postgres-schema-ir.ts | 2 +- .../core/migrations/diff-postgres-schema.ts | 2 +- .../src/core/migrations/op-factory-call.ts | 2 +- .../src/core/migrations/operations/rls.ts | 2 +- .../postgres/src/core/migrations/planner.ts | 2 +- .../{schema-ir => }/postgres-rls-policy.ts | 53 +-- .../src/core/{schema-ir => }/postgres-role.ts | 27 +- .../postgres/src/core/postgres-schema.ts | 4 +- .../schema-ir/postgres-policy-schema-node.ts | 82 ++++ .../schema-ir/postgres-role-schema-node.ts | 55 +++ .../src/core/schema-ir/postgres-schema-ir.ts | 4 +- .../src/core/schema-ir/postgres-table-ir.ts | 2 +- .../3-targets/postgres/src/exports/types.ts | 20 +- .../contract-to-postgres-schema-ir.test.ts | 2 +- .../migrations/diff-postgres-schema.test.ts | 5 +- .../postgres/test/migrations/rls-ops.test.ts | 2 +- .../test/migrations/rls-planner.test.ts | 2 +- .../test/postgres-contract-serializer.test.ts | 4 +- .../postgres/test/postgres-table-ir.test.ts | 2 +- .../test/psl-policy-authoring.test.ts | 2 +- .../postgres/test/rls-diffable-nodes.test.ts | 367 ++++-------------- .../postgres/test/rls-ir-kinds.test.ts | 4 +- .../test/schema-ir-leaf-nodes.test.ts | 159 ++++++++ 25 files changed, 433 insertions(+), 381 deletions(-) rename packages/3-targets/3-targets/postgres/src/core/{schema-ir => }/postgres-rls-policy.ts (63%) rename packages/3-targets/3-targets/postgres/src/core/{schema-ir => }/postgres-role.ts (59%) create mode 100644 packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts create mode 100644 packages/3-targets/3-targets/postgres/test/schema-ir-leaf-nodes.test.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/authoring.ts b/packages/3-targets/3-targets/postgres/src/core/authoring.ts index 98bdd0d6a4..0e20c254ed 100644 --- a/packages/3-targets/3-targets/postgres/src/core/authoring.ts +++ b/packages/3-targets/3-targets/postgres/src/core/authoring.ts @@ -7,10 +7,10 @@ import type { AuthoringTypeNamespace, PslExtensionBlock, } from '@prisma-next/framework-components/authoring'; +import { PostgresRlsPolicy } from './postgres-rls-policy'; +import { PostgresRole, type PostgresRoleInput } from './postgres-role'; import { PostgresRlsPolicySchema, PostgresRoleSchema } from './postgres-validators'; import { computeContentHash, normalizePredicate } from './rls/canonicalize'; -import { PostgresRlsPolicy } from './schema-ir/postgres-rls-policy'; -import { PostgresRole, type PostgresRoleInput } from './schema-ir/postgres-role'; export const postgresAuthoringTypes = {} as const satisfies AuthoringTypeNamespace; diff --git a/packages/3-targets/3-targets/postgres/src/core/entity-kinds.ts b/packages/3-targets/3-targets/postgres/src/core/entity-kinds.ts index e76ddf9f2b..3c05eaec5f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/entity-kinds.ts +++ b/packages/3-targets/3-targets/postgres/src/core/entity-kinds.ts @@ -1,7 +1,7 @@ import type { EntityKindDescriptor } from '@prisma-next/framework-components/ir'; +import { PostgresRlsPolicy, type PostgresRlsPolicyInput } from './postgres-rls-policy'; +import { PostgresRole, type PostgresRoleInput } from './postgres-role'; import { PostgresRlsPolicySchema, PostgresRoleSchema } from './postgres-validators'; -import { PostgresRlsPolicy, type PostgresRlsPolicyInput } from './schema-ir/postgres-rls-policy'; -import { PostgresRole, type PostgresRoleInput } from './schema-ir/postgres-role'; export const policyEntityKind: EntityKindDescriptor = { kind: 'policy', diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts index e8b34bc3a2..228178f1c5 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts @@ -1,9 +1,9 @@ import type { ContractToSchemaIROptions } from '@prisma-next/family-sql/control'; import { contractToSchemaIR } from '@prisma-next/family-sql/control'; import { ifDefined } from '@prisma-next/utils/defined'; +import { PostgresRlsPolicy } from '../postgres-rls-policy'; import type { PostgresContract } from '../postgres-schema'; import { isPostgresSchema } from '../postgres-schema'; -import { PostgresRlsPolicy } from '../schema-ir/postgres-rls-policy'; import { PostgresSchemaIR } from '../schema-ir/postgres-schema-ir'; import { resolveDdlSchemaForNamespaceStorage } from '../schema-ir/postgres-schema-ir-annotations'; import { PostgresTableIR } from '../schema-ir/postgres-table-ir'; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts index d4002fffc9..03a505e2a2 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts @@ -1,6 +1,6 @@ import type { SchemaDiffIssue } from '@prisma-next/framework-components/control'; import { diffSchemas } from '@prisma-next/framework-components/control'; -import { isPostgresRlsPolicy, type PostgresRlsPolicy } from '../schema-ir/postgres-rls-policy'; +import { isPostgresRlsPolicy, type PostgresRlsPolicy } from '../postgres-rls-policy'; import { ensurePostgresSchemaIR, type PostgresSchemaIR } from '../schema-ir/postgres-schema-ir'; // Renders a display-only reference string for the diff message. If policy diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts index 386a8816b4..453340ba06 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts @@ -40,7 +40,7 @@ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { columnExistsAst, tableExistsAst } from '../../contract-free/checks'; import * as contractFreeDdl from '../../contract-free/ddl'; -import type { PostgresRlsPolicy } from '../schema-ir/postgres-rls-policy'; +import type { PostgresRlsPolicy } from '../postgres-rls-policy'; import { escapeLiteral, quoteIdentifier } from '../sql-utils'; import type { PostgresColumnDefault } from '../types'; import { boundSchema } from './bound-schema'; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/rls.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/rls.ts index c7453020d3..0fb24fd741 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/rls.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/rls.ts @@ -2,7 +2,7 @@ import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adap import { ifDefined } from '@prisma-next/utils/defined'; import { rlsEnabledAst, rlsPolicyExistsAst } from '../../../contract-free/checks'; import { createPolicy, dropPolicy } from '../../../contract-free/ddl'; -import type { PostgresRlsPolicy } from '../../schema-ir/postgres-rls-policy'; +import type { PostgresRlsPolicy } from '../../postgres-rls-policy'; import { qualifyTableName } from '../planner-sql-checks'; import { type Op, step, targetDetails } from './shared'; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 4b81b48876..b18a986c55 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -25,8 +25,8 @@ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { blindCast } from '@prisma-next/utils/casts'; import { parsePostgresDefault } from '../default-normalizer'; import { normalizeSchemaNativeType } from '../native-type-normalizer'; +import { assertPostgresRlsPolicy } from '../postgres-rls-policy'; import type { PostgresContract } from '../postgres-schema'; -import { assertPostgresRlsPolicy } from '../schema-ir/postgres-rls-policy'; import { assertPostgresSchemaIR, ensurePostgresSchemaIR } from '../schema-ir/postgres-schema-ir'; import { resolveDdlSchemaForNamespaceStorage } from '../schema-ir/postgres-schema-ir-annotations'; import { contractToPostgresSchemaIR } from './contract-to-postgres-schema-ir'; diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-rls-policy.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts similarity index 63% rename from packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-rls-policy.ts rename to packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts index 6a632f19ef..182c3db83a 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-rls-policy.ts +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts @@ -1,4 +1,3 @@ -import type { DiffableNode } from '@prisma-next/framework-components/control'; import { freezeNode } from '@prisma-next/framework-components/ir'; import { SqlNode } from '@prisma-next/sql-contract/types'; @@ -25,7 +24,11 @@ export interface PostgresRlsPolicyInput { } /** - * Postgres IR class for a row-level security policy (`CREATE POLICY … ON …`). + * Postgres contract-IR class for a row-level security policy (`CREATE POLICY … ON …`). + * + * This is an authored, serialized Contract-IR entity — it is registered as an entity + * kind, extends `SqlNode`, and is stored in `contract.json`. It is NOT a DiffableNode; + * the schema-diff tree uses `PostgresPolicySchemaNode` for that role. * * Target-only concept — no SQL-family abstract. Extends `SqlNode` directly. * Frozen at construction via `freezeNode(this)`. The `kind: 'policy'` @@ -33,7 +36,7 @@ export interface PostgresRlsPolicyInput { * survives JSON serialization and enables dispatch. The literal matches the * entries key (one-string rule: node.kind === entries key === entity kind). */ -export class PostgresRlsPolicy extends SqlNode implements DiffableNode { +export class PostgresRlsPolicy extends SqlNode { override readonly kind = 'policy' as const; readonly name: string; readonly prefix: string; @@ -58,42 +61,24 @@ export class PostgresRlsPolicy extends SqlNode implements DiffableNode { this.permissive = input.permissive; freezeNode(this); } - - get id(): string { - return this.name; - } - - children(): readonly DiffableNode[] { - return []; - } - - /** - * Equality by wire name only. The wire name is `_`, - * so name-equality IS body-equality — two policies with different bodies - * have different hashes and therefore different names. We deliberately - * skip comparing bodies directly because Postgres reprints predicate - * expressions (e.g. strips outer parentheses), so a byte-compare against - * the authored body would produce false mismatches on a clean re-verify. - */ - isEqualTo(other: DiffableNode): boolean { - if (!isPostgresRlsPolicy(other)) { - throw new Error( - `PostgresRlsPolicy.isEqualTo: expected a PostgresRlsPolicy, got ${other.constructor?.name ?? typeof other}`, - ); - } - return this.name === other.name; - } } -export function isPostgresRlsPolicy(node: DiffableNode | undefined): node is PostgresRlsPolicy { - return node !== undefined && 'kind' in node && node.kind === 'policy'; +export function isPostgresRlsPolicy(node: unknown): node is PostgresRlsPolicy { + return ( + node !== undefined && + node !== null && + typeof node === 'object' && + 'kind' in node && + (node as { kind: unknown }).kind === 'policy' + ); } -export function assertPostgresRlsPolicy( - node: DiffableNode | undefined, -): asserts node is PostgresRlsPolicy { +export function assertPostgresRlsPolicy(node: unknown): asserts node is PostgresRlsPolicy { if (!isPostgresRlsPolicy(node)) { - const kind = node !== undefined && 'kind' in node ? String(node.kind) : typeof node; + const kind = + node !== undefined && node !== null && typeof node === 'object' && 'kind' in node + ? String((node as { kind: unknown }).kind) + : typeof node; throw new Error( `planPostgresSchemaDiff: expected a PostgresRlsPolicy on the policy-diff path but got ${kind}`, ); diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-role.ts similarity index 59% rename from packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role.ts rename to packages/3-targets/3-targets/postgres/src/core/postgres-role.ts index 3c9b596d0f..e90f1de04f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role.ts +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-role.ts @@ -1,4 +1,3 @@ -import type { DiffableNode } from '@prisma-next/framework-components/control'; import { freezeNode } from '@prisma-next/framework-components/ir'; import { SqlNode } from '@prisma-next/sql-contract/types'; @@ -12,7 +11,11 @@ export interface PostgresRoleInput { } /** - * Postgres IR class for a database role (`CREATE ROLE …`). + * Postgres contract-IR class for a database role (`CREATE ROLE …`). + * + * This is an authored, serialized Contract-IR entity — it is registered as an entity + * kind, extends `SqlNode`, and is stored in `contract.json`. It is NOT a DiffableNode; + * the schema-diff tree uses `PostgresRoleSchemaNode` for that role. * * Roles are cluster-scoped, so their namespace coordinate is always * `UNBOUND_NAMESPACE_ID`. Target-only concept — no SQL-family abstract. @@ -20,7 +23,7 @@ export interface PostgresRoleInput { * The `kind: 'role'` discriminant is enumerable so it survives JSON. * Matches the entries key (one-string rule). */ -export class PostgresRole extends SqlNode implements DiffableNode { +export class PostgresRole extends SqlNode { override readonly kind = 'role' as const; readonly name: string; readonly namespaceId: string; @@ -31,22 +34,4 @@ export class PostgresRole extends SqlNode implements DiffableNode { this.namespaceId = input.namespaceId; freezeNode(this); } - - /** Roles are cluster-unique; the name alone is sufficient as the id. */ - get id(): string { - return this.name; - } - - children(): readonly DiffableNode[] { - return []; - } - - isEqualTo(other: DiffableNode): boolean { - if (!(other instanceof PostgresRole)) { - throw new Error( - `PostgresRole.isEqualTo: expected a PostgresRole, got ${other.constructor?.name ?? typeof other}`, - ); - } - return this.name === other.name; - } } diff --git a/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts index 0dbfef8dfd..9458851b5c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts @@ -20,8 +20,8 @@ import { ifDefined } from '@prisma-next/utils/defined'; import { PostgresTableSource } from './ast/table-source'; import { PG_TEXT_CODEC_ID } from './codec-ids'; import { policyEntityKind, roleEntityKind } from './entity-kinds'; -import type { PostgresRlsPolicy } from './schema-ir/postgres-rls-policy'; -import type { PostgresRole } from './schema-ir/postgres-role'; +import type { PostgresRlsPolicy } from './postgres-rls-policy'; +import type { PostgresRole } from './postgres-role'; import { escapeLiteral } from './sql-utils'; export type PostgresContract = Contract & { readonly target: 'postgres' }; diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts new file mode 100644 index 0000000000..8adb8d7dd7 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts @@ -0,0 +1,82 @@ +import type { DiffableNode } from '@prisma-next/framework-components/control'; +import { freezeNode } from '@prisma-next/framework-components/ir'; +import { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; +import type { RlsPolicyOperation } from '../postgres-rls-policy'; + +export interface PostgresPolicySchemaNodeInput { + /** Full wire name: `_<8hex>`. */ + readonly name: string; + /** User-supplied prefix (the part before the `_<8hex>` suffix). */ + readonly prefix: string; + /** Name of the table this policy attaches to, by name within the same schema. */ + readonly tableName: string; + /** Namespace coordinate (schema name). */ + readonly namespaceId: string; + readonly operation: RlsPolicyOperation; + /** Sorted role names rendered in `TO `. */ + readonly roles: readonly string[]; + /** USING predicate SQL string, if present. */ + readonly using?: string; + /** WITH CHECK predicate SQL string, if present. */ + readonly withCheck?: string; + /** `true` = `AS PERMISSIVE`, `false` = `AS RESTRICTIVE`. */ + readonly permissive: boolean; +} + +/** + * Schema-diff leaf node for a Postgres row-level security policy. + * + * This is a derived, transient node walked by the differ — it is NEVER serialized. + * Built by project-from-contract and project-from-database from their respective + * `PostgresRlsPolicy` contract entities / introspected rows. + * + * `id` is the wire name (`_`), so name-equality is + * body-equality. `isEqualTo` compares names only — never byte-compare predicate + * bodies, because Postgres reprints them. + */ +export class PostgresPolicySchemaNode extends SqlSchemaIRNode implements DiffableNode { + readonly name: string; + readonly prefix: string; + readonly tableName: string; + readonly namespaceId: string; + readonly operation: RlsPolicyOperation; + readonly roles: readonly string[]; + declare readonly using?: string; + declare readonly withCheck?: string; + readonly permissive: boolean; + + constructor(input: PostgresPolicySchemaNodeInput) { + super(); + this.name = input.name; + this.prefix = input.prefix; + this.tableName = input.tableName; + this.namespaceId = input.namespaceId; + this.operation = input.operation; + this.roles = Object.freeze([...input.roles]); + if (input.using !== undefined) this.using = input.using; + if (input.withCheck !== undefined) this.withCheck = input.withCheck; + this.permissive = input.permissive; + freezeNode(this); + } + + get id(): string { + return this.name; + } + + children(): readonly DiffableNode[] { + return []; + } + + isEqualTo(other: DiffableNode): boolean { + if (!PostgresPolicySchemaNode.is(other)) { + throw new Error( + `PostgresPolicySchemaNode.isEqualTo: expected a PostgresPolicySchemaNode, got ${other.constructor?.name ?? typeof other}`, + ); + } + return this.name === other.name; + } + + static is(node: DiffableNode): node is PostgresPolicySchemaNode { + return node instanceof PostgresPolicySchemaNode; + } +} diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts new file mode 100644 index 0000000000..7e7d14eb03 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts @@ -0,0 +1,55 @@ +import type { DiffableNode } from '@prisma-next/framework-components/control'; +import { freezeNode } from '@prisma-next/framework-components/ir'; +import { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; + +export interface PostgresRoleSchemaNodeInput { + readonly name: string; + /** + * Namespace coordinate. Roles are cluster-scoped; callers pass + * `UNBOUND_NAMESPACE_ID` from `@prisma-next/framework-components/ir`. + */ + readonly namespaceId: string; +} + +/** + * Schema-diff leaf node for a Postgres database role. + * + * This is a derived, transient node walked by the differ — it is NEVER serialized. + * Built by project-from-contract and project-from-database from their respective + * `PostgresRole` contract entities / introspected rows. + * + * Roles are cluster-scoped, so `id` is the role name alone. `isEqualTo` compares + * names — name-equality is role-equality for cluster-scoped objects. + */ +export class PostgresRoleSchemaNode extends SqlSchemaIRNode implements DiffableNode { + readonly name: string; + readonly namespaceId: string; + + constructor(input: PostgresRoleSchemaNodeInput) { + super(); + this.name = input.name; + this.namespaceId = input.namespaceId; + freezeNode(this); + } + + get id(): string { + return this.name; + } + + children(): readonly DiffableNode[] { + return []; + } + + isEqualTo(other: DiffableNode): boolean { + if (!PostgresRoleSchemaNode.is(other)) { + throw new Error( + `PostgresRoleSchemaNode.isEqualTo: expected a PostgresRoleSchemaNode, got ${other.constructor?.name ?? typeof other}`, + ); + } + return this.name === other.name; + } + + static is(node: DiffableNode): node is PostgresRoleSchemaNode { + return node instanceof PostgresRoleSchemaNode; + } +} diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts index b4acc9a06b..72bd0aaa87 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts @@ -8,8 +8,8 @@ import { type SqlTableIRInput, } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; -import type { PostgresRlsPolicy } from './postgres-rls-policy'; -import type { PostgresRole } from './postgres-role'; +import type { PostgresRlsPolicy } from '../postgres-rls-policy'; +import type { PostgresRole } from '../postgres-role'; import { PostgresTableIR } from './postgres-table-ir'; export interface PostgresSchemaIRInput { diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-ir.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-ir.ts index 8e0399edbb..975b901dcd 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-ir.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-ir.ts @@ -11,7 +11,7 @@ import { type SqlTableIRInput, SqlUniqueIR, } from '@prisma-next/sql-schema-ir/types'; -import type { PostgresRlsPolicy } from './postgres-rls-policy'; +import type { PostgresRlsPolicy } from '../postgres-rls-policy'; export interface PostgresTableIRInput extends SqlTableIRInput { readonly rlsPolicies?: readonly PostgresRlsPolicy[]; diff --git a/packages/3-targets/3-targets/postgres/src/exports/types.ts b/packages/3-targets/3-targets/postgres/src/exports/types.ts index 5b50c34226..18c8c4ae9e 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/types.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/types.ts @@ -1,3 +1,11 @@ +export { + assertPostgresRlsPolicy, + isPostgresRlsPolicy, + PostgresRlsPolicy, + type PostgresRlsPolicyInput, + type RlsPolicyOperation, +} from '../core/postgres-rls-policy'; +export { PostgresRole, type PostgresRoleInput } from '../core/postgres-role'; export { isPostgresSchema, type PostgresContract, @@ -6,11 +14,13 @@ export { postgresCreateNamespace, } from '../core/postgres-schema'; export { - PostgresRlsPolicy, - type PostgresRlsPolicyInput, - type RlsPolicyOperation, -} from '../core/schema-ir/postgres-rls-policy'; -export { PostgresRole, type PostgresRoleInput } from '../core/schema-ir/postgres-role'; + PostgresPolicySchemaNode, + type PostgresPolicySchemaNodeInput, +} from '../core/schema-ir/postgres-policy-schema-node'; +export { + PostgresRoleSchemaNode, + type PostgresRoleSchemaNodeInput, +} from '../core/schema-ir/postgres-role-schema-node'; export { assertPostgresSchemaIR, ensurePostgresSchemaIR, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts index 8d030dad0f..e5bbbdbb39 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts @@ -3,8 +3,8 @@ import { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { contractToPostgresSchemaIR } from '../../src/core/migrations/contract-to-postgres-schema-ir'; +import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { type PostgresContract, PostgresSchema } from '../../src/core/postgres-schema'; -import { PostgresRlsPolicy } from '../../src/core/schema-ir/postgres-rls-policy'; import { isPostgresSchemaIR } from '../../src/core/schema-ir/postgres-schema-ir'; import { isPostgresTableIR } from '../../src/core/schema-ir/postgres-table-ir'; import { postgresRenderDefault } from '../../src/exports/control'; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts index 9d1dc33e1b..90d347ac90 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts @@ -8,11 +8,8 @@ import { diffPostgresSchema, filterIssuesByOwnership, } from '../../src/core/migrations/diff-postgres-schema'; +import { isPostgresRlsPolicy, PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { PostgresSchema } from '../../src/core/postgres-schema'; -import { - isPostgresRlsPolicy, - PostgresRlsPolicy, -} from '../../src/core/schema-ir/postgres-rls-policy'; import { PostgresSchemaIR } from '../../src/core/schema-ir/postgres-schema-ir'; import { PostgresTableIR } from '../../src/core/schema-ir/postgres-table-ir'; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-ops.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-ops.test.ts index c1a02bd268..2455630007 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rls-ops.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-ops.test.ts @@ -11,7 +11,7 @@ import { dropRlsPolicy, enableRowLevelSecurity, } from '../../src/core/migrations/operations/rls'; -import { PostgresRlsPolicy } from '../../src/core/schema-ir/postgres-rls-policy'; +import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { PostgresCreatePolicy, PostgresDropPolicy } from '../../src/exports/ddl'; function recordingCheckLowerer(): { lowerer: ExecuteRequestLowerer; received: unknown[] } { diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts index ae7dbc8306..bb84d8e194 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts @@ -20,8 +20,8 @@ import { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { PostgresSchema } from '../../src/core/postgres-schema'; -import { PostgresRlsPolicy } from '../../src/core/schema-ir/postgres-rls-policy'; import { PostgresSchemaIR } from '../../src/core/schema-ir/postgres-schema-ir'; import { PostgresTableIR } from '../../src/core/schema-ir/postgres-table-ir'; import { PostgresCreatePolicy } from '../../src/exports/ddl'; diff --git a/packages/3-targets/3-targets/postgres/test/postgres-contract-serializer.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-contract-serializer.test.ts index d6577b7b33..433af97606 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-contract-serializer.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-contract-serializer.test.ts @@ -24,9 +24,9 @@ import { blindCast } from '@prisma-next/utils/casts'; import { type } from 'arktype'; import { describe, expect, it } from 'vitest'; import { PostgresContractSerializer } from '../src/core/postgres-contract-serializer'; +import { PostgresRlsPolicy } from '../src/core/postgres-rls-policy'; +import { PostgresRole } from '../src/core/postgres-role'; import { PostgresSchema, postgresCreateNamespace } from '../src/core/postgres-schema'; -import { PostgresRlsPolicy } from '../src/core/schema-ir/postgres-rls-policy'; -import { PostgresRole } from '../src/core/schema-ir/postgres-role'; import postgresTargetDescriptor from '../src/exports/control'; function makeValidContractJson() { diff --git a/packages/3-targets/3-targets/postgres/test/postgres-table-ir.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-table-ir.test.ts index 98dafe120e..805e5ddef8 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-table-ir.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-table-ir.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { PostgresRlsPolicy } from '../src/core/schema-ir/postgres-rls-policy'; +import { PostgresRlsPolicy } from '../src/core/postgres-rls-policy'; import { isPostgresTableIR, PostgresTableIR } from '../src/core/schema-ir/postgres-table-ir'; const basePolicy = new PostgresRlsPolicy({ diff --git a/packages/3-targets/3-targets/postgres/test/psl-policy-authoring.test.ts b/packages/3-targets/3-targets/postgres/test/psl-policy-authoring.test.ts index 844ec8477d..6194afe110 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-policy-authoring.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-policy-authoring.test.ts @@ -24,9 +24,9 @@ import { postgresAuthoringPslBlockDescriptors, } from '../src/core/authoring'; import { PostgresContractSerializer } from '../src/core/postgres-contract-serializer'; +import { PostgresRlsPolicy } from '../src/core/postgres-rls-policy'; import { PostgresSchema, postgresCreateNamespace } from '../src/core/postgres-schema'; import { computeContentHash } from '../src/core/rls/canonicalize'; -import { PostgresRlsPolicy } from '../src/core/schema-ir/postgres-rls-policy'; const assembled = assembleAuthoringContributions([ { diff --git a/packages/3-targets/3-targets/postgres/test/rls-diffable-nodes.test.ts b/packages/3-targets/3-targets/postgres/test/rls-diffable-nodes.test.ts index 3d1e637e54..b17244eeb5 100644 --- a/packages/3-targets/3-targets/postgres/test/rls-diffable-nodes.test.ts +++ b/packages/3-targets/3-targets/postgres/test/rls-diffable-nodes.test.ts @@ -1,353 +1,132 @@ -import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +/** + * Asserts that PostgresRlsPolicy and PostgresRole are Contract-IR entities only — + * they do not implement DiffableNode (no `id`, `children`, or `isEqualTo`). + * The DiffableNode role belongs to PostgresPolicySchemaNode / PostgresRoleSchemaNode. + */ import { describe, expect, it } from 'vitest'; import { assertPostgresRlsPolicy, isPostgresRlsPolicy, PostgresRlsPolicy, -} from '../src/core/schema-ir/postgres-rls-policy'; -import { PostgresRole } from '../src/core/schema-ir/postgres-role'; -import { PostgresSchemaIR } from '../src/core/schema-ir/postgres-schema-ir'; -import { isPostgresTableIR, PostgresTableIR } from '../src/core/schema-ir/postgres-table-ir'; +} from '../src/core/postgres-rls-policy'; +import { PostgresRole } from '../src/core/postgres-role'; -describe('PostgresRlsPolicy DiffableNode', () => { - const baseInput = { +describe('PostgresRlsPolicy — Contract-IR entity, not a DiffableNode', () => { + const policy = new PostgresRlsPolicy({ name: 'read_own_profiles_a1b2c3d4', prefix: 'read_own_profiles', tableName: 'profiles', namespaceId: 'public', - operation: 'select' as const, + operation: 'select', roles: ['app_user'], using: "owner_id = current_setting('app.uid')::int", permissive: true, - }; - - it('id returns the bare wire name', () => { - const policy = new PostgresRlsPolicy(baseInput); - expect(policy.id).toBe('read_own_profiles_a1b2c3d4'); - }); - - it('id returns bare wire name regardless of namespaceId and tableName', () => { - const policy = new PostgresRlsPolicy({ - ...baseInput, - namespaceId: 'my_schema', - tableName: 'orders', - }); - expect(policy.id).toBe('read_own_profiles_a1b2c3d4'); }); - it('children() returns an empty list (leaf node)', () => { - const policy = new PostgresRlsPolicy(baseInput); - expect(policy.children()).toEqual([]); + it('has no id property', () => { + expect('id' in policy).toBe(false); }); - it('isEqualTo() returns true for two policies with the same wire name', () => { - const a = new PostgresRlsPolicy(baseInput); - const b = new PostgresRlsPolicy({ ...baseInput }); - expect(a.isEqualTo(b)).toBe(true); + it('has no children method', () => { + expect('children' in policy).toBe(false); }); - it('isEqualTo() returns false for policies with different wire names', () => { - const a = new PostgresRlsPolicy(baseInput); - const b = new PostgresRlsPolicy({ ...baseInput, name: 'read_own_profiles_deadbeef' }); - expect(a.isEqualTo(b)).toBe(false); + it('has no isEqualTo method', () => { + expect('isEqualTo' in policy).toBe(false); }); - it('isEqualTo() throws when other is not a PostgresRlsPolicy', () => { - const policy = new PostgresRlsPolicy(baseInput); - const notAPolicy = new PostgresRole({ name: 'app_user', namespaceId: 'public' }); - expect(() => policy.isEqualTo(notAPolicy)).toThrow(); + it('retains kind, name, and all data fields', () => { + expect(policy.kind).toBe('policy'); + expect(policy.name).toBe('read_own_profiles_a1b2c3d4'); + expect(policy.prefix).toBe('read_own_profiles'); + expect(policy.tableName).toBe('profiles'); + expect(policy.namespaceId).toBe('public'); + expect(policy.operation).toBe('select'); + expect(policy.roles).toEqual(['app_user']); + expect(policy.permissive).toBe(true); }); - it('id and isEqualTo() are accessible on frozen instances (defined on prototype)', () => { - const policy = new PostgresRlsPolicy(baseInput); + it('is frozen', () => { expect(Object.isFrozen(policy)).toBe(true); - expect(typeof policy.id).toBe('string'); - expect(typeof policy.isEqualTo).toBe('function'); - }); - - it('two policies on different tables with the same wire name have the same id (uniqueness is at table-node level)', () => { - const policyOnProfiles = new PostgresRlsPolicy({ ...baseInput, tableName: 'profiles' }); - const policyOnOrders = new PostgresRlsPolicy({ ...baseInput, tableName: 'orders' }); - expect(policyOnProfiles.id).toBe(policyOnOrders.id); - expect(policyOnProfiles.id).toBe('read_own_profiles_a1b2c3d4'); - }); - - describe('isPostgresRlsPolicy guard', () => { - it('returns true for a real PostgresRlsPolicy', () => { - expect(isPostgresRlsPolicy(new PostgresRlsPolicy(baseInput))).toBe(true); - }); - - it('returns false for a node with a different kind', () => { - const role = new PostgresRole({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); - expect(isPostgresRlsPolicy(role)).toBe(false); - }); - - it('returns false for undefined', () => { - expect(isPostgresRlsPolicy(undefined)).toBe(false); - }); - }); - - describe('assertPostgresRlsPolicy guard', () => { - it('does not throw for a real PostgresRlsPolicy', () => { - expect(() => assertPostgresRlsPolicy(new PostgresRlsPolicy(baseInput))).not.toThrow(); - }); - - it('throws with a descriptive message when given a non-policy node', () => { - const role = new PostgresRole({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); - expect(() => assertPostgresRlsPolicy(role)).toThrow( - /planPostgresSchemaDiff: expected a PostgresRlsPolicy/, - ); - }); - - it('throws mentioning the actual kind when given a non-policy node', () => { - const role = new PostgresRole({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); - expect(() => assertPostgresRlsPolicy(role)).toThrow(/role/); - }); }); - describe('content-addressed equality invariant', () => { - it('same prefix + different body → different wire names → isEqualTo false (no collision)', () => { - const bodyV1 = new PostgresRlsPolicy({ - ...baseInput, - name: 'read_own_profiles_a1b2c3d4', - using: "(owner_id = current_setting('app.uid')::int)", - }); - const bodyV2 = new PostgresRlsPolicy({ - ...baseInput, - name: 'read_own_profiles_deadbeef', - using: '(owner_id = auth.uid())', - }); - expect(bodyV1.isEqualTo(bodyV2)).toBe(false); - expect(bodyV2.isEqualTo(bodyV1)).toBe(false); - expect(bodyV1.name).not.toBe(bodyV2.name); - }); - - it('same body → same wire name → isEqualTo true', () => { - const authored = new PostgresRlsPolicy({ - ...baseInput, - name: 'read_own_profiles_a1b2c3d4', - using: "(owner_id = current_setting('app.uid')::int)", - }); - const introspected = new PostgresRlsPolicy({ - ...baseInput, - name: 'read_own_profiles_a1b2c3d4', - using: "(owner_id = current_setting('app.uid')::int)", - }); - expect(authored.isEqualTo(introspected)).toBe(true); - }); + it('kind survives JSON round-trip', () => { + const json = JSON.parse(JSON.stringify(policy)) as Record; + expect(json['kind']).toBe('policy'); }); }); -describe('PostgresTableIR as diff-tree node', () => { - const basePolicy = new PostgresRlsPolicy({ - name: 'read_own_a1b2c3d4', - prefix: 'read_own', - tableName: 'profiles', - namespaceId: 'public', - operation: 'select' as const, - roles: ['authenticated'], - using: '(auth.uid() = user_id)', - permissive: true, - }); +describe('PostgresRole — Contract-IR entity, not a DiffableNode', () => { + const role = new PostgresRole({ name: 'app_user', namespaceId: 'public' }); - it('id returns the table name', () => { - const table = new PostgresTableIR({ - name: 'profiles', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - rlsPolicies: [basePolicy], - }); - expect(table.id).toBe('profiles'); + it('has no id property', () => { + expect('id' in role).toBe(false); }); - it('isEqualTo() always returns true', () => { - const a = new PostgresTableIR({ - name: 'profiles', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - rlsPolicies: [basePolicy], - }); - const b = new PostgresTableIR({ - name: 'profiles', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - rlsPolicies: [], - }); - expect(a.isEqualTo(b)).toBe(true); + it('has no children method', () => { + expect('children' in role).toBe(false); }); - it('children() returns the policy nodes', () => { - const table = new PostgresTableIR({ - name: 'profiles', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - rlsPolicies: [basePolicy], - }); - expect(table.children()).toEqual([basePolicy]); + it('has no isEqualTo method', () => { + expect('isEqualTo' in role).toBe(false); }); - it('instance is frozen', () => { - const table = new PostgresTableIR({ - name: 'profiles', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - }); - expect(Object.isFrozen(table)).toBe(true); - }); - - describe('isPostgresTableIR guard', () => { - it('returns true for a PostgresTableIR', () => { - const table = new PostgresTableIR({ - name: 'profiles', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - }); - expect(isPostgresTableIR(table)).toBe(true); - }); - - it('returns false for a PostgresRlsPolicy', () => { - expect(isPostgresTableIR(basePolicy)).toBe(false); - }); - }); -}); - -describe('PostgresRole DiffableNode', () => { - it('id returns the role name (roles are cluster-unique)', () => { - const role = new PostgresRole({ name: 'app_user', namespaceId: 'public' }); - expect(role.id).toBe('app_user'); - }); - - it('id propagates the role name from input', () => { - const role = new PostgresRole({ name: 'anon', namespaceId: 'sentinel_namespace' }); - expect(role.id).toBe('anon'); - }); - - it('children() returns an empty list (leaf node)', () => { - const role = new PostgresRole({ name: 'app_user', namespaceId: 'public' }); - expect(role.children()).toEqual([]); + it('retains kind, name, and namespaceId', () => { + expect(role.kind).toBe('role'); + expect(role.name).toBe('app_user'); + expect(role.namespaceId).toBe('public'); }); - it('isEqualTo() returns true for two roles with the same name', () => { - const a = new PostgresRole({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); - const b = new PostgresRole({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); - expect(a.isEqualTo(b)).toBe(true); + it('is frozen', () => { + expect(Object.isFrozen(role)).toBe(true); }); - it('isEqualTo() returns false for roles with different names', () => { - const a = new PostgresRole({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); - const b = new PostgresRole({ name: 'anon', namespaceId: UNBOUND_NAMESPACE_ID }); - expect(a.isEqualTo(b)).toBe(false); + it('kind survives JSON round-trip', () => { + const json = JSON.parse(JSON.stringify(role)) as Record; + expect(json['kind']).toBe('role'); }); +}); - it('isEqualTo() throws when other is not a PostgresRole', () => { - const role = new PostgresRole({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); - const notARole = new PostgresRlsPolicy({ - name: 'read_own_a1b2c3d4', - prefix: 'read_own', - tableName: 'profiles', +describe('isPostgresRlsPolicy guard', () => { + it('returns true for a PostgresRlsPolicy', () => { + const policy = new PostgresRlsPolicy({ + name: 'p_a1b2c3d4', + prefix: 'p', + tableName: 'users', namespaceId: 'public', operation: 'select', roles: [], permissive: true, }); - expect(() => role.isEqualTo(notARole)).toThrow(); + expect(isPostgresRlsPolicy(policy)).toBe(true); }); - it('id and isEqualTo() are accessible on frozen instances', () => { - const role = new PostgresRole({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); - expect(Object.isFrozen(role)).toBe(true); - expect(typeof role.id).toBe('string'); - expect(typeof role.isEqualTo).toBe('function'); + it('returns false for a PostgresRole', () => { + const role = new PostgresRole({ name: 'anon', namespaceId: 'public' }); + expect(isPostgresRlsPolicy(role)).toBe(false); }); -}); - -describe('PostgresSchemaIR tables-as-nodes and rlsPolicies', () => { - const makeRlsPolicy = (name: string, tableName: string, namespaceId: string) => - new PostgresRlsPolicy({ - name, - prefix: name.replace(/_[0-9a-f]{8}$/, ''), - tableName, - namespaceId, - operation: 'select' as const, - roles: ['authenticated'], - using: '(true)', - permissive: true, - }); - it('id is the pgSchemaName', () => { - const ir = new PostgresSchemaIR({ - tables: {}, - pgSchemaName: 'myschema', - pgVersion: 'unknown', - roles: [], - existingSchemas: [], - nativeEnumTypeNames: [], - }); - expect(typeof ir.id).toBe('string'); - expect(ir.id).toBe('myschema'); + it('returns false for undefined', () => { + expect(isPostgresRlsPolicy(undefined)).toBe(false); }); +}); - it('children() returns the PostgresTableIR instances in tables', () => { - const p1 = makeRlsPolicy('pol_a1b2c3d4', 'profiles', 'public'); - const table = new PostgresTableIR({ - name: 'profiles', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - rlsPolicies: [p1], - }); - const ir = new PostgresSchemaIR({ - tables: { profiles: table }, - pgSchemaName: 'public', - pgVersion: 'unknown', +describe('assertPostgresRlsPolicy guard', () => { + it('does not throw for a PostgresRlsPolicy', () => { + const policy = new PostgresRlsPolicy({ + name: 'p_a1b2c3d4', + prefix: 'p', + tableName: 'users', + namespaceId: 'public', + operation: 'select', roles: [], - existingSchemas: [], - nativeEnumTypeNames: [], + permissive: true, }); - expect(ir.children()).toEqual([table]); + expect(() => assertPostgresRlsPolicy(policy)).not.toThrow(); }); - it('rlsPolicies getter returns all policies from all tables', () => { - const p1 = makeRlsPolicy('pol_a1b2c3d4', 'profiles', 'public'); - const p2 = makeRlsPolicy('pol_deadbeef', 'orders', 'public'); - const ir = new PostgresSchemaIR({ - tables: { - profiles: new PostgresTableIR({ - name: 'profiles', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - rlsPolicies: [p1], - }), - orders: new PostgresTableIR({ - name: 'orders', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - rlsPolicies: [p2], - }), - }, - pgSchemaName: 'public', - pgVersion: 'unknown', - roles: [], - existingSchemas: [], - nativeEnumTypeNames: [], - }); - expect(ir.rlsPolicies).toEqual([p1, p2]); + it('throws with a descriptive message for a non-policy value', () => { + const role = new PostgresRole({ name: 'anon', namespaceId: 'public' }); + expect(() => assertPostgresRlsPolicy(role)).toThrow(/planPostgresSchemaDiff/); }); }); diff --git a/packages/3-targets/3-targets/postgres/test/rls-ir-kinds.test.ts b/packages/3-targets/3-targets/postgres/test/rls-ir-kinds.test.ts index 67b7a4e058..393ec314ac 100644 --- a/packages/3-targets/3-targets/postgres/test/rls-ir-kinds.test.ts +++ b/packages/3-targets/3-targets/postgres/test/rls-ir-kinds.test.ts @@ -1,9 +1,9 @@ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { StorageTable } from '@prisma-next/sql-contract/types'; import { describe, expect, it } from 'vitest'; +import { PostgresRlsPolicy } from '../src/core/postgres-rls-policy'; +import { PostgresRole } from '../src/core/postgres-role'; import { PostgresSchema } from '../src/core/postgres-schema'; -import { PostgresRlsPolicy } from '../src/core/schema-ir/postgres-rls-policy'; -import { PostgresRole } from '../src/core/schema-ir/postgres-role'; const emptyTableInput = { columns: {}, diff --git a/packages/3-targets/3-targets/postgres/test/schema-ir-leaf-nodes.test.ts b/packages/3-targets/3-targets/postgres/test/schema-ir-leaf-nodes.test.ts new file mode 100644 index 0000000000..95c45e7dde --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/schema-ir-leaf-nodes.test.ts @@ -0,0 +1,159 @@ +import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { PostgresPolicySchemaNode } from '../src/core/schema-ir/postgres-policy-schema-node'; +import { PostgresRoleSchemaNode } from '../src/core/schema-ir/postgres-role-schema-node'; + +const basePolicyInput = { + name: 'read_own_profiles_a1b2c3d4', + prefix: 'read_own_profiles', + tableName: 'profiles', + namespaceId: 'public', + operation: 'select' as const, + roles: ['app_user'], + using: "owner_id = current_setting('app.uid')::int", + permissive: true, +}; + +describe('PostgresPolicySchemaNode', () => { + it('id returns the wire name', () => { + const node = new PostgresPolicySchemaNode(basePolicyInput); + expect(node.id).toBe('read_own_profiles_a1b2c3d4'); + }); + + it('children() returns empty array (leaf)', () => { + const node = new PostgresPolicySchemaNode(basePolicyInput); + expect(node.children()).toEqual([]); + }); + + it('isEqualTo returns true for same wire name', () => { + const a = new PostgresPolicySchemaNode(basePolicyInput); + const b = new PostgresPolicySchemaNode({ ...basePolicyInput }); + expect(a.isEqualTo(b)).toBe(true); + }); + + it('isEqualTo returns false for different wire name', () => { + const a = new PostgresPolicySchemaNode(basePolicyInput); + const b = new PostgresPolicySchemaNode({ + ...basePolicyInput, + name: 'read_own_profiles_deadbeef', + }); + expect(a.isEqualTo(b)).toBe(false); + }); + + it('isEqualTo throws when other is not a PostgresPolicySchemaNode', () => { + const a = new PostgresPolicySchemaNode(basePolicyInput); + const b = new PostgresRoleSchemaNode({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); + expect(() => a.isEqualTo(b)).toThrow(); + }); + + it('carries all fields from input', () => { + const node = new PostgresPolicySchemaNode(basePolicyInput); + expect(node.name).toBe(basePolicyInput.name); + expect(node.prefix).toBe(basePolicyInput.prefix); + expect(node.tableName).toBe(basePolicyInput.tableName); + expect(node.namespaceId).toBe(basePolicyInput.namespaceId); + expect(node.operation).toBe(basePolicyInput.operation); + expect(node.roles).toEqual(basePolicyInput.roles); + expect(node.using).toBe(basePolicyInput.using); + expect(node.permissive).toBe(basePolicyInput.permissive); + }); + + it('withCheck is absent when not provided', () => { + const node = new PostgresPolicySchemaNode(basePolicyInput); + expect(Object.hasOwn(node, 'withCheck')).toBe(false); + }); + + it('using is absent when not provided', () => { + const { using: _dropped, ...rest } = basePolicyInput; + const node = new PostgresPolicySchemaNode({ + ...rest, + withCheck: 'true', + }); + expect(Object.hasOwn(node, 'using')).toBe(false); + }); + + it('instance is frozen', () => { + const node = new PostgresPolicySchemaNode(basePolicyInput); + expect(Object.isFrozen(node)).toBe(true); + }); + + describe('PostgresPolicySchemaNode.is', () => { + it('returns true for a PostgresPolicySchemaNode', () => { + const node = new PostgresPolicySchemaNode(basePolicyInput); + expect(PostgresPolicySchemaNode.is(node)).toBe(true); + }); + + it('returns false for a PostgresRoleSchemaNode', () => { + const role = new PostgresRoleSchemaNode({ + name: 'app_user', + namespaceId: UNBOUND_NAMESPACE_ID, + }); + expect(PostgresPolicySchemaNode.is(role)).toBe(false); + }); + }); +}); + +describe('PostgresRoleSchemaNode', () => { + it('id returns the role name', () => { + const node = new PostgresRoleSchemaNode({ + name: 'app_user', + namespaceId: UNBOUND_NAMESPACE_ID, + }); + expect(node.id).toBe('app_user'); + }); + + it('children() returns empty array (leaf)', () => { + const node = new PostgresRoleSchemaNode({ + name: 'app_user', + namespaceId: UNBOUND_NAMESPACE_ID, + }); + expect(node.children()).toEqual([]); + }); + + it('isEqualTo returns true for same name', () => { + const a = new PostgresRoleSchemaNode({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); + const b = new PostgresRoleSchemaNode({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); + expect(a.isEqualTo(b)).toBe(true); + }); + + it('isEqualTo returns false for different name', () => { + const a = new PostgresRoleSchemaNode({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); + const b = new PostgresRoleSchemaNode({ name: 'anon', namespaceId: UNBOUND_NAMESPACE_ID }); + expect(a.isEqualTo(b)).toBe(false); + }); + + it('isEqualTo throws when other is not a PostgresRoleSchemaNode', () => { + const a = new PostgresRoleSchemaNode({ name: 'app_user', namespaceId: UNBOUND_NAMESPACE_ID }); + const b = new PostgresPolicySchemaNode(basePolicyInput); + expect(() => a.isEqualTo(b)).toThrow(); + }); + + it('carries all fields from input', () => { + const node = new PostgresRoleSchemaNode({ name: 'app_user', namespaceId: 'public' }); + expect(node.name).toBe('app_user'); + expect(node.namespaceId).toBe('public'); + }); + + it('instance is frozen', () => { + const node = new PostgresRoleSchemaNode({ + name: 'app_user', + namespaceId: UNBOUND_NAMESPACE_ID, + }); + expect(Object.isFrozen(node)).toBe(true); + }); + + describe('PostgresRoleSchemaNode.is', () => { + it('returns true for a PostgresRoleSchemaNode', () => { + const node = new PostgresRoleSchemaNode({ + name: 'app_user', + namespaceId: UNBOUND_NAMESPACE_ID, + }); + expect(PostgresRoleSchemaNode.is(node)).toBe(true); + }); + + it('returns false for a PostgresPolicySchemaNode', () => { + const policy = new PostgresPolicySchemaNode(basePolicyInput); + expect(PostgresRoleSchemaNode.is(policy)).toBe(false); + }); + }); +}); From 6705391a53683abf609d98344314c768f50ac04b Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 15:09:30 +0200 Subject: [PATCH 03/49] =?UTF-8?q?refactor(postgres):=20rename=20PostgresTa?= =?UTF-8?q?bleIR=20=E2=86=92=20PostgresTableSchemaNode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 (schema-node-tree-restructure), unit 2 of 7. Mechanical rename of the table diff-node to the …SchemaNode scheme, and point its policy children at the Unit-1 node type: - PostgresTableIR → PostgresTableSchemaNode (file, class, Input). - Free guard isPostgresTableIR → static PostgresTableSchemaNode.is(). - Field rlsPolicies → policies: readonly PostgresPolicySchemaNode[]; children() returns this.policies (resolves the Unit-1 DiffableNode type break on the table node). Still extends SqlSchemaIRNode, freezeNode, isEqualTo => true. Intermediate unit: the projection/introspection still pass entity instances where policy nodes are now expected (Unit 5 fixes construction) and the differ/planner still use the entity guard (Unit 6). Table-node tests pass (11/11). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../contract-to-postgres-schema-ir.ts | 8 +- .../src/core/schema-ir/postgres-schema-ir.ts | 16 ++-- ...le-ir.ts => postgres-table-schema-node.ts} | 30 +++--- .../3-targets/postgres/src/exports/types.ts | 7 +- .../contract-to-postgres-schema-ir.test.ts | 16 ++-- .../migrations/diff-postgres-schema.test.ts | 48 +++++----- .../test/migrations/rls-planner.test.ts | 6 +- .../postgres/test/postgres-table-ir.test.ts | 91 ------------------- .../test/postgres-table-schema-node.test.ts | 91 +++++++++++++++++++ 9 files changed, 157 insertions(+), 156 deletions(-) rename packages/3-targets/3-targets/postgres/src/core/schema-ir/{postgres-table-ir.ts => postgres-table-schema-node.ts} (73%) delete mode 100644 packages/3-targets/3-targets/postgres/test/postgres-table-ir.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/postgres-table-schema-node.test.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts index 228178f1c5..f8fe991d75 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts @@ -6,7 +6,7 @@ import type { PostgresContract } from '../postgres-schema'; import { isPostgresSchema } from '../postgres-schema'; import { PostgresSchemaIR } from '../schema-ir/postgres-schema-ir'; import { resolveDdlSchemaForNamespaceStorage } from '../schema-ir/postgres-schema-ir-annotations'; -import { PostgresTableIR } from '../schema-ir/postgres-table-ir'; +import { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; export function contractToPostgresSchemaIR( contract: PostgresContract | null, @@ -52,9 +52,9 @@ export function contractToPostgresSchemaIR( // Attach policies to each table from the relational projection. A policy that // references a table absent from the schema IR is a malformed contract — the // loop below throws rather than fabricating a stub table. - const tables: Record = {}; + const tables: Record = {}; for (const [tableName, sqlTable] of Object.entries(sqlIr.tables)) { - tables[tableName] = new PostgresTableIR({ + tables[tableName] = new PostgresTableSchemaNode({ name: sqlTable.name, columns: sqlTable.columns, foreignKeys: sqlTable.foreignKeys, @@ -63,7 +63,7 @@ export function contractToPostgresSchemaIR( ...(sqlTable.primaryKey !== undefined ? { primaryKey: sqlTable.primaryKey } : {}), ...(sqlTable.annotations !== undefined ? { annotations: sqlTable.annotations } : {}), ...(sqlTable.checks !== undefined ? { checks: sqlTable.checks } : {}), - rlsPolicies: policiesByTable.get(tableName) ?? [], + policies: policiesByTable.get(tableName) ?? [], }); } for (const [tableName, policies] of policiesByTable) { diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts index 72bd0aaa87..4787ae408c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts @@ -8,14 +8,14 @@ import { type SqlTableIRInput, } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; -import type { PostgresRlsPolicy } from '../postgres-rls-policy'; import type { PostgresRole } from '../postgres-role'; -import { PostgresTableIR } from './postgres-table-ir'; +import type { PostgresPolicySchemaNode } from './postgres-policy-schema-node'; +import { PostgresTableSchemaNode } from './postgres-table-schema-node'; export interface PostgresSchemaIRInput { readonly tables: Record< string, - PostgresTableIR | (SqlTableIRInput & { rlsPolicies?: readonly PostgresRlsPolicy[] }) + PostgresTableSchemaNode | (SqlTableIRInput & { policies?: readonly PostgresPolicySchemaNode[] }) >; readonly pgSchemaName: string; readonly pgVersion: string; @@ -35,7 +35,7 @@ export interface PostgresSchemaIRInput { * `SqlSchemaIR` structure and freezes itself at the end of its own * constructor. * - * `tables` holds `PostgresTableIR` instances which carry their own RLS + * `tables` holds `PostgresTableSchemaNode` instances which carry their own RLS * policies. `children()` returns the tables directly — the table instances * ARE the diff-tree nodes. * @@ -43,7 +43,7 @@ export interface PostgresSchemaIRInput { */ export class PostgresSchemaIR extends SqlSchemaIRNode implements DiffableNode { readonly nodeTarget: SqlSchemaTarget = 'postgres'; - readonly tables: Readonly>; + readonly tables: Readonly>; declare readonly annotations?: SqlAnnotations; readonly pgSchemaName: string; readonly pgVersion: string; @@ -57,7 +57,7 @@ export class PostgresSchemaIR extends SqlSchemaIRNode implements DiffableNode { Object.fromEntries( Object.entries(input.tables).map(([key, t]) => [ key, - t instanceof PostgresTableIR ? t : new PostgresTableIR(t), + t instanceof PostgresTableSchemaNode ? t : new PostgresTableSchemaNode(t), ]), ), ); @@ -87,8 +87,8 @@ export class PostgresSchemaIR extends SqlSchemaIRNode implements DiffableNode { return this.pgSchemaName; } - get rlsPolicies(): readonly PostgresRlsPolicy[] { - return Object.values(this.tables).flatMap((t) => t.rlsPolicies); + get rlsPolicies(): readonly PostgresPolicySchemaNode[] { + return Object.values(this.tables).flatMap((t) => t.policies); } isEqualTo(_other: DiffableNode): boolean { diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-ir.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts similarity index 73% rename from packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-ir.ts rename to packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts index 975b901dcd..69d7209e39 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-ir.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts @@ -11,25 +11,25 @@ import { type SqlTableIRInput, SqlUniqueIR, } from '@prisma-next/sql-schema-ir/types'; -import type { PostgresRlsPolicy } from '../postgres-rls-policy'; +import type { PostgresPolicySchemaNode } from './postgres-policy-schema-node'; -export interface PostgresTableIRInput extends SqlTableIRInput { - readonly rlsPolicies?: readonly PostgresRlsPolicy[]; +export interface PostgresTableSchemaNodeInput extends SqlTableIRInput { + readonly policies?: readonly PostgresPolicySchemaNode[]; } /** - * Postgres-specific table IR node. Carries all `SqlTableIR` fields plus - * `rlsPolicies`, and implements `DiffableNode` so the table instance is + * Postgres-specific table schema-diff node. Carries all `SqlTableIR` fields + * plus `policies`, and implements `DiffableNode` so the table instance is * directly the diff-tree node — no separate wrapper needed. * * Extends `SqlSchemaIRNode` directly rather than `SqlTableIR` because * `SqlTableIR` calls `freezeNode` in its own constructor, which prevents - * subclass field initialisation. Same pattern as `PostgresSchemaIR`. + * subclass field initialisation. * - * `id` is the table name. `children()` returns the policies on this table. + * `id` is the table name. `children()` returns the policy nodes on this table. * `isEqualTo` is always true — table-level attributes are not diffed yet. */ -export class PostgresTableIR extends SqlSchemaIRNode implements DiffableNode { +export class PostgresTableSchemaNode extends SqlSchemaIRNode implements DiffableNode { readonly name: string; readonly columns: Readonly>; readonly foreignKeys: ReadonlyArray; @@ -38,9 +38,9 @@ export class PostgresTableIR extends SqlSchemaIRNode implements DiffableNode { declare readonly primaryKey?: PrimaryKey; declare readonly annotations?: SqlAnnotations; declare readonly checks?: ReadonlyArray; - readonly rlsPolicies: readonly PostgresRlsPolicy[]; + readonly policies: readonly PostgresPolicySchemaNode[]; - constructor(input: PostgresTableIRInput) { + constructor(input: PostgresTableSchemaNodeInput) { super(); this.name = input.name; this.columns = Object.freeze( @@ -74,7 +74,7 @@ export class PostgresTableIR extends SqlSchemaIRNode implements DiffableNode { ), ); } - this.rlsPolicies = Object.freeze([...(input.rlsPolicies ?? [])]); + this.policies = Object.freeze([...(input.policies ?? [])]); freezeNode(this); } @@ -87,10 +87,10 @@ export class PostgresTableIR extends SqlSchemaIRNode implements DiffableNode { } children(): readonly DiffableNode[] { - return this.rlsPolicies; + return this.policies; } -} -export function isPostgresTableIR(node: DiffableNode): node is PostgresTableIR { - return node instanceof PostgresTableIR; + static is(node: DiffableNode): node is PostgresTableSchemaNode { + return node instanceof PostgresTableSchemaNode; + } } diff --git a/packages/3-targets/3-targets/postgres/src/exports/types.ts b/packages/3-targets/3-targets/postgres/src/exports/types.ts index 18c8c4ae9e..7725dfa1f2 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/types.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/types.ts @@ -29,8 +29,7 @@ export { type PostgresSchemaIRInput, } from '../core/schema-ir/postgres-schema-ir'; export { - isPostgresTableIR, - PostgresTableIR, - type PostgresTableIRInput, -} from '../core/schema-ir/postgres-table-ir'; + PostgresTableSchemaNode, + type PostgresTableSchemaNodeInput, +} from '../core/schema-ir/postgres-table-schema-node'; export type { PostgresColumnDefault } from '../core/types'; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts index e5bbbdbb39..ea886ee1f9 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts @@ -6,7 +6,7 @@ import { contractToPostgresSchemaIR } from '../../src/core/migrations/contract-t import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { type PostgresContract, PostgresSchema } from '../../src/core/postgres-schema'; import { isPostgresSchemaIR } from '../../src/core/schema-ir/postgres-schema-ir'; -import { isPostgresTableIR } from '../../src/core/schema-ir/postgres-table-ir'; +import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; import { postgresRenderDefault } from '../../src/exports/control'; const TABLE_NAME = 'profiles'; @@ -70,26 +70,28 @@ const projectionOptions = { } as const; describe('contractToPostgresSchemaIR', () => { - it('projects a SELECT policy into rlsPolicies attached to the table', () => { + it('projects a SELECT policy into policies attached to the table', () => { const policy = makePolicy('read_own_profiles_a1b2c3d4'); const contract = makeContract([policy]); const ir = contractToPostgresSchemaIR(contract, projectionOptions); expect(isPostgresSchemaIR(ir)).toBe(true); - expect(ir.rlsPolicies).toContainEqual(policy); + expect(ir.rlsPolicies).toContainEqual(expect.objectContaining({ name: policy.name })); expect(Object.keys(ir.tables)).toEqual([TABLE_NAME]); - expect(isPostgresTableIR(ir.tables[TABLE_NAME]!)).toBe(true); - expect(ir.tables[TABLE_NAME]?.rlsPolicies).toContainEqual(policy); + expect(PostgresTableSchemaNode.is(ir.tables[TABLE_NAME]!)).toBe(true); + expect(ir.tables[TABLE_NAME]?.policies).toContainEqual( + expect.objectContaining({ name: policy.name }), + ); }); - it('tables are PostgresTableIR instances', () => { + it('tables are PostgresTableSchemaNode instances', () => { const policy = makePolicy('read_own_profiles_a1b2c3d4'); const contract = makeContract([policy]); const ir = contractToPostgresSchemaIR(contract, projectionOptions); - expect(Object.values(ir.tables).every(isPostgresTableIR)).toBe(true); + expect(Object.values(ir.tables).every(PostgresTableSchemaNode.is)).toBe(true); }); it('returns no policies for a null contract', () => { diff --git a/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts index 90d347ac90..bca307b7f6 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts @@ -11,7 +11,7 @@ import { import { isPostgresRlsPolicy, PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { PostgresSchema } from '../../src/core/postgres-schema'; import { PostgresSchemaIR } from '../../src/core/schema-ir/postgres-schema-ir'; -import { PostgresTableIR } from '../../src/core/schema-ir/postgres-table-ir'; +import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; const TABLE_NAME = 'profiles'; const SCHEMA_NAME = 'public'; @@ -85,10 +85,10 @@ function makeSchema(actualPolicies: readonly PostgresRlsPolicy[]): PostgresSchem } const tableNames = new Set([TABLE_NAME, ...policiesByTable.keys()]); - const tables: Record = {}; + const tables: Record = {}; for (const name of tableNames) { if (name === TABLE_NAME) { - tables[name] = new PostgresTableIR({ + tables[name] = new PostgresTableSchemaNode({ name: TABLE_NAME, columns: { id: { name: 'id', nativeType: 'int4', nullable: false }, @@ -97,16 +97,16 @@ function makeSchema(actualPolicies: readonly PostgresRlsPolicy[]): PostgresSchem foreignKeys: [], uniques: [], indexes: [], - rlsPolicies: policiesByTable.get(name) ?? [], + policies: policiesByTable.get(name) ?? [], }); } else { - tables[name] = new PostgresTableIR({ + tables[name] = new PostgresTableSchemaNode({ name, columns: {}, foreignKeys: [], uniques: [], indexes: [], - rlsPolicies: policiesByTable.get(name) ?? [], + policies: policiesByTable.get(name) ?? [], }); } } @@ -282,21 +282,21 @@ describe('diffPostgresSchema', () => { const schemaWithBothNamespaces = new PostgresSchemaIR({ tables: { - users: new PostgresTableIR({ + users: new PostgresTableSchemaNode({ name: 'users', columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } }, foreignKeys: [], uniques: [], indexes: [], - rlsPolicies: [authPolicy], + policies: [authPolicy], }), - profile: new PostgresTableIR({ + profile: new PostgresTableSchemaNode({ name: 'profile', columns: {}, foreignKeys: [], uniques: [], indexes: [], - rlsPolicies: [foreignPublicPolicy], + policies: [foreignPublicPolicy], }), }, pgSchemaName: 'auth', @@ -359,21 +359,21 @@ describe('diffPostgresSchema', () => { const schemaWithBothNamespaces = new PostgresSchemaIR({ tables: { - users: new PostgresTableIR({ + users: new PostgresTableSchemaNode({ name: 'users', columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } }, foreignKeys: [], uniques: [], indexes: [], - rlsPolicies: [authPolicy], + policies: [authPolicy], }), - profile: new PostgresTableIR({ + profile: new PostgresTableSchemaNode({ name: 'profile', columns: {}, foreignKeys: [], uniques: [], indexes: [], - rlsPolicies: [foreignPublicPolicy], + policies: [foreignPublicPolicy], }), }, pgSchemaName: 'auth', @@ -428,13 +428,13 @@ describe('diffPostgresSchema', () => { const schema = new PostgresSchemaIR({ tables: { - users: new PostgresTableIR({ + users: new PostgresTableSchemaNode({ name: 'users', columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } }, foreignKeys: [], uniques: [], indexes: [], - rlsPolicies: [ownedExtra], + policies: [ownedExtra], }), }, pgSchemaName: 'auth', @@ -514,7 +514,7 @@ describe('diffPostgresSchema', () => { const schema = new PostgresSchemaIR({ tables: { - profiles: new PostgresTableIR({ + profiles: new PostgresTableSchemaNode({ name: 'profiles', columns: { id: { name: 'id', nativeType: 'int4', nullable: false }, @@ -523,9 +523,9 @@ describe('diffPostgresSchema', () => { foreignKeys: [], uniques: [], indexes: [], - rlsPolicies: [policyOnProfiles], + policies: [policyOnProfiles], }), - orders: new PostgresTableIR({ + orders: new PostgresTableSchemaNode({ name: 'orders', columns: { id: { name: 'id', nativeType: 'int4', nullable: false }, @@ -534,7 +534,7 @@ describe('diffPostgresSchema', () => { foreignKeys: [], uniques: [], indexes: [], - rlsPolicies: [policyOnOrders], + policies: [policyOnOrders], }), }, pgSchemaName: SCHEMA_NAME, @@ -676,13 +676,13 @@ describe('diffPostgresSchema', () => { const actual = new PostgresSchemaIR({ tables: { - profiles: new PostgresTableIR({ + profiles: new PostgresTableSchemaNode({ name: 'profiles', columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, foreignKeys: [], uniques: [], indexes: [], - rlsPolicies: [introspectedPolicy], + policies: [introspectedPolicy], }), }, pgSchemaName: 'public', @@ -761,13 +761,13 @@ describe('diffPostgresSchema', () => { ); const actual = new PostgresSchemaIR({ tables: { - orders: new PostgresTableIR({ + orders: new PostgresTableSchemaNode({ name: 'orders', columns: {}, foreignKeys: [], uniques: [], indexes: [], - rlsPolicies: [unownedExtra], + policies: [unownedExtra], }), }, pgSchemaName: 'public', diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts index bb84d8e194..0dabadf098 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts @@ -23,7 +23,7 @@ import { createPostgresMigrationPlanner } from '../../src/core/migrations/planne import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { PostgresSchema } from '../../src/core/postgres-schema'; import { PostgresSchemaIR } from '../../src/core/schema-ir/postgres-schema-ir'; -import { PostgresTableIR } from '../../src/core/schema-ir/postgres-table-ir'; +import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; import { PostgresCreatePolicy } from '../../src/exports/ddl'; const stubLowerer: ExecuteRequestLowerer = { @@ -115,7 +115,7 @@ function buildContractWith(policies: readonly PostgresRlsPolicy[]): Contract { - it('id returns the table name', () => { - const table = new PostgresTableIR({ ...tableInput, rlsPolicies: [] }); - expect(table.id).toBe('profiles'); - }); - - it('id matches the name field', () => { - const table = new PostgresTableIR({ - name: 'orders', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - }); - expect(table.id).toBe('orders'); - }); - - it('isEqualTo always returns true', () => { - const a = new PostgresTableIR({ ...tableInput, rlsPolicies: [basePolicy] }); - const b = new PostgresTableIR({ ...tableInput, rlsPolicies: [] }); - expect(a.isEqualTo(b)).toBe(true); - }); - - it('children() returns its rlsPolicies', () => { - const table = new PostgresTableIR({ ...tableInput, rlsPolicies: [basePolicy] }); - expect(table.children()).toEqual([basePolicy]); - }); - - it('children() returns empty array when no policies', () => { - const table = new PostgresTableIR({ ...tableInput, rlsPolicies: [] }); - expect(table.children()).toEqual([]); - }); - - it('rlsPolicies defaults to empty when not supplied', () => { - const table = new PostgresTableIR({ ...tableInput }); - expect(table.rlsPolicies).toEqual([]); - }); - - it('carries columns from SqlTableIR', () => { - const table = new PostgresTableIR({ ...tableInput }); - expect(Object.keys(table.columns)).toEqual(['id', 'user_id']); - expect(table.columns['id']?.nativeType).toBe('int4'); - }); - - it('instance is frozen', () => { - const table = new PostgresTableIR({ ...tableInput }); - expect(Object.isFrozen(table)).toBe(true); - }); - - it('name field is set', () => { - const table = new PostgresTableIR({ ...tableInput }); - expect(table.name).toBe('profiles'); - }); - - describe('isPostgresTableIR guard', () => { - it('returns true for a PostgresTableIR', () => { - const table = new PostgresTableIR({ ...tableInput }); - expect(isPostgresTableIR(table)).toBe(true); - }); - - it('returns false for a PostgresRlsPolicy', () => { - expect(isPostgresTableIR(basePolicy)).toBe(false); - }); - }); -}); diff --git a/packages/3-targets/3-targets/postgres/test/postgres-table-schema-node.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-table-schema-node.test.ts new file mode 100644 index 0000000000..b05d00dc9d --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/postgres-table-schema-node.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { PostgresPolicySchemaNode } from '../src/core/schema-ir/postgres-policy-schema-node'; +import { PostgresTableSchemaNode } from '../src/core/schema-ir/postgres-table-schema-node'; + +const basePolicy = new PostgresPolicySchemaNode({ + name: 'read_own_a1b2c3d4', + prefix: 'read_own', + tableName: 'profiles', + namespaceId: 'public', + operation: 'select' as const, + roles: ['authenticated'], + using: '(auth.uid() = user_id)', + permissive: true, +}); + +const tableInput = { + name: 'profiles', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + user_id: { name: 'user_id', nativeType: 'int4', nullable: false }, + }, + foreignKeys: [], + uniques: [], + indexes: [], +}; + +describe('PostgresTableSchemaNode', () => { + it('id returns the table name', () => { + const table = new PostgresTableSchemaNode({ ...tableInput, policies: [] }); + expect(table.id).toBe('profiles'); + }); + + it('id matches the name field', () => { + const table = new PostgresTableSchemaNode({ + name: 'orders', + columns: {}, + foreignKeys: [], + uniques: [], + indexes: [], + }); + expect(table.id).toBe('orders'); + }); + + it('isEqualTo always returns true', () => { + const a = new PostgresTableSchemaNode({ ...tableInput, policies: [basePolicy] }); + const b = new PostgresTableSchemaNode({ ...tableInput, policies: [] }); + expect(a.isEqualTo(b)).toBe(true); + }); + + it('children() returns its policies', () => { + const table = new PostgresTableSchemaNode({ ...tableInput, policies: [basePolicy] }); + expect(table.children()).toEqual([basePolicy]); + }); + + it('children() returns empty array when no policies', () => { + const table = new PostgresTableSchemaNode({ ...tableInput, policies: [] }); + expect(table.children()).toEqual([]); + }); + + it('policies defaults to empty when not supplied', () => { + const table = new PostgresTableSchemaNode({ ...tableInput }); + expect(table.policies).toEqual([]); + }); + + it('carries columns from SqlTableIR', () => { + const table = new PostgresTableSchemaNode({ ...tableInput }); + expect(Object.keys(table.columns)).toEqual(['id', 'user_id']); + expect(table.columns['id']?.nativeType).toBe('int4'); + }); + + it('instance is frozen', () => { + const table = new PostgresTableSchemaNode({ ...tableInput }); + expect(Object.isFrozen(table)).toBe(true); + }); + + it('name field is set', () => { + const table = new PostgresTableSchemaNode({ ...tableInput }); + expect(table.name).toBe('profiles'); + }); + + describe('PostgresTableSchemaNode.is guard', () => { + it('returns true for a PostgresTableSchemaNode', () => { + const table = new PostgresTableSchemaNode({ ...tableInput }); + expect(PostgresTableSchemaNode.is(table)).toBe(true); + }); + + it('returns false for a PostgresPolicySchemaNode', () => { + expect(PostgresTableSchemaNode.is(basePolicy)).toBe(false); + }); + }); +}); From c4463733c5407adbd629725f337456e99ded90c2 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 15:30:56 +0200 Subject: [PATCH 04/49] feat(postgres): namespace + database-root schema nodes (units 3+4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 (schema-node-tree-restructure), units 3 and 4 of 7. - PostgresNamespaceSchemaNode: one node per Postgres schema; children() = its tables; satisfies the per-schema SqlSchemaIR shape so the legacy per-schema consumers take it unchanged in unit 6. - PostgresDatabaseSchemaNode: the real tree root; children() = namespaces (roles held, not yet diffed); static is()/assert()/ensure(); narrows on nodeTarget + a nodeKind discriminant that survives the projectSchemaToSpace spread. Additive — does not yet wire/retire PostgresSchemaIR (units 5-6). New-node tests pass; reviewer SATISFIED. Also closes the carried cast/lint findings from rounds 1-2 (bare casts in postgres-rls-policy.ts removed by narrowing; root is()/assert() use blindCast like ensure(); vitest imports merged). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../postgres/src/core/postgres-rls-policy.ts | 4 +- .../postgres-database-schema-node.ts | 97 +++++++++++ .../postgres-namespace-schema-node.ts | 69 ++++++++ .../postgres-database-schema-node.test.ts | 154 ++++++++++++++++++ .../postgres-namespace-schema-node.test.ts | 139 ++++++++++++++++ 5 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts create mode 100644 packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts index 182c3db83a..cf9944aee7 100644 --- a/packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts @@ -69,7 +69,7 @@ export function isPostgresRlsPolicy(node: unknown): node is PostgresRlsPolicy { node !== null && typeof node === 'object' && 'kind' in node && - (node as { kind: unknown }).kind === 'policy' + node.kind === 'policy' ); } @@ -77,7 +77,7 @@ export function assertPostgresRlsPolicy(node: unknown): asserts node is Postgres if (!isPostgresRlsPolicy(node)) { const kind = node !== undefined && node !== null && typeof node === 'object' && 'kind' in node - ? String((node as { kind: unknown }).kind) + ? String(node.kind) : typeof node; throw new Error( `planPostgresSchemaDiff: expected a PostgresRlsPolicy on the policy-diff path but got ${kind}`, diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts new file mode 100644 index 0000000000..f7013a74f9 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts @@ -0,0 +1,97 @@ +import type { DiffableNode } from '@prisma-next/framework-components/control'; +import { freezeNode } from '@prisma-next/framework-components/ir'; +import { SqlSchemaIRNode, type SqlSchemaTarget } from '@prisma-next/sql-schema-ir/types'; +import { blindCast } from '@prisma-next/utils/casts'; +import type { PostgresNamespaceSchemaNode } from './postgres-namespace-schema-node'; +import type { PostgresRoleSchemaNode } from './postgres-role-schema-node'; + +export interface PostgresDatabaseSchemaNodeInput { + readonly namespaces: Readonly>; + readonly roles: readonly PostgresRoleSchemaNode[]; + readonly existingSchemas: readonly string[]; + readonly pgVersion: string; +} + +/** + * The real root of the Postgres schema-diff tree: one node per database. + * + * `id` is the fixed sentinel `'database'` — the root has no siblings and + * the value is never emitted into migration paths. `isEqualTo` is always + * true. `children()` returns namespace nodes only; roles are held on the + * root but NOT yielded (role diffing is a later slice, R4). + * + * `nodeTarget = 'postgres'` is an enumerable own field so it survives the + * `{ ...node }` spread that `projectSchemaToSpace` produces. `nodeKind` is + * a second enumerable discriminant that distinguishes the database root + * from `PostgresNamespaceSchemaNode` (which also carries `nodeTarget = + * 'postgres'`) after a spread. + */ +export class PostgresDatabaseSchemaNode extends SqlSchemaIRNode implements DiffableNode { + readonly nodeTarget: SqlSchemaTarget = 'postgres'; + readonly nodeKind = 'postgres-database' as const; + readonly namespaces: Readonly>; + readonly roles: readonly PostgresRoleSchemaNode[]; + readonly existingSchemas: readonly string[]; + readonly pgVersion: string; + + constructor(input: PostgresDatabaseSchemaNodeInput) { + super(); + this.namespaces = input.namespaces; + this.roles = Object.freeze([...input.roles]); + this.existingSchemas = Object.freeze([...input.existingSchemas]); + this.pgVersion = input.pgVersion; + freezeNode(this); + } + + get id(): string { + return 'database'; + } + + isEqualTo(_other: DiffableNode): boolean { + return true; + } + + children(): readonly DiffableNode[] { + return Object.values(this.namespaces); + } + + static is(node: unknown): node is PostgresDatabaseSchemaNode { + if (node instanceof PostgresDatabaseSchemaNode) return true; + if (typeof node !== 'object' || node === null) return false; + const n = blindCast< + Record, + 'narrowed to a non-null object; reading enumerable own discriminants that survive the projectSchemaToSpace spread' + >(node); + return n['nodeTarget'] === 'postgres' && n['nodeKind'] === 'postgres-database'; + } + + static assert(node: unknown): asserts node is PostgresDatabaseSchemaNode { + if (!PostgresDatabaseSchemaNode.is(node)) { + const target = + typeof node === 'object' && node !== null + ? String( + blindCast< + Record, + 'narrowed to a non-null object; reading the nodeTarget discriminant for the error message' + >(node)['nodeTarget'] ?? typeof node, + ) + : typeof node; + throw new Error(`Expected a PostgresDatabaseSchemaNode but got nodeTarget=${target}`); + } + } + + /** + * Returns `node` as-is when it is a real instance, or reconstructs one when + * `projectSchemaToSpace` has spread the class into a plain object (losing + * prototype methods but preserving all own-enumerable fields). + */ + static ensure(node: PostgresDatabaseSchemaNode): PostgresDatabaseSchemaNode { + if (node instanceof PostgresDatabaseSchemaNode) return node; + return new PostgresDatabaseSchemaNode( + blindCast< + PostgresDatabaseSchemaNodeInput, + 'spread objects from projectSchemaToSpace preserve all own-enumerable fields' + >(node), + ); + } +} diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts new file mode 100644 index 0000000000..af606b8d90 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts @@ -0,0 +1,69 @@ +import type { DiffableNode } from '@prisma-next/framework-components/control'; +import { freezeNode } from '@prisma-next/framework-components/ir'; +import { + type SqlAnnotations, + SqlSchemaIRNode, + type SqlSchemaTarget, +} from '@prisma-next/sql-schema-ir/types'; +import type { PostgresTableSchemaNode } from './postgres-table-schema-node'; + +export interface PostgresNamespaceSchemaNodeInput { + readonly schemaName: string; + readonly tables: Readonly>; + readonly nativeEnumTypeNames: readonly string[]; +} + +/** + * One-per-Postgres-schema diff-tree node. Groups the tables belonging to a + * single namespace and satisfies the `SqlSchemaIR` shape so legacy per-schema + * consumers (verifySqlSchema, the relational planner, toSchemaView) can + * accept it unchanged in Unit 6. + * + * `id` is the schema name. `isEqualTo` is always true — namespace-level + * attribute diffing is not needed yet. `children()` returns the table nodes. + * + * The `annotations.pg` bag mirrors what `PostgresSchemaIR` carried for the + * per-schema slot (`schema` + `nativeEnumTypeNames`). `existingSchemas` is + * database-level and belongs on `PostgresDatabaseSchemaNode`, not here. + * The bag is carried only for legacy compatibility and will be retired with + * the annotations bag (TML-2936). + */ +export class PostgresNamespaceSchemaNode extends SqlSchemaIRNode implements DiffableNode { + readonly nodeTarget: SqlSchemaTarget = 'postgres'; + readonly schemaName: string; + readonly tables: Readonly>; + declare readonly annotations?: SqlAnnotations; + readonly nativeEnumTypeNames: readonly string[]; + + constructor(input: PostgresNamespaceSchemaNodeInput) { + super(); + this.schemaName = input.schemaName; + this.tables = input.tables; + this.nativeEnumTypeNames = Object.freeze([...input.nativeEnumTypeNames]); + this.annotations = { + pg: { + schema: input.schemaName, + ...(input.nativeEnumTypeNames.length > 0 && { + nativeEnumTypeNames: input.nativeEnumTypeNames, + }), + }, + }; + freezeNode(this); + } + + get id(): string { + return this.schemaName; + } + + isEqualTo(_other: DiffableNode): boolean { + return true; + } + + children(): readonly DiffableNode[] { + return Object.values(this.tables); + } + + static is(node: DiffableNode): node is PostgresNamespaceSchemaNode { + return node instanceof PostgresNamespaceSchemaNode; + } +} diff --git a/packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts new file mode 100644 index 0000000000..b9ecf9410a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts @@ -0,0 +1,154 @@ +import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { + PostgresDatabaseSchemaNode, + type PostgresDatabaseSchemaNodeInput, +} from '../src/core/schema-ir/postgres-database-schema-node'; +import { PostgresNamespaceSchemaNode } from '../src/core/schema-ir/postgres-namespace-schema-node'; +import { PostgresRoleSchemaNode } from '../src/core/schema-ir/postgres-role-schema-node'; +import { PostgresTableSchemaNode } from '../src/core/schema-ir/postgres-table-schema-node'; + +const tableA = new PostgresTableSchemaNode({ + name: 'profiles', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [], +}); + +const nsPublic = new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { profiles: tableA }, + nativeEnumTypeNames: [], +}); + +const nsApp = new PostgresNamespaceSchemaNode({ + schemaName: 'app', + tables: {}, + nativeEnumTypeNames: [], +}); + +const role = new PostgresRoleSchemaNode({ + name: 'app_user', + namespaceId: UNBOUND_NAMESPACE_ID, +}); + +const baseInput: PostgresDatabaseSchemaNodeInput = { + namespaces: { public: nsPublic, app: nsApp }, + roles: [role], + existingSchemas: ['public', 'app'], + pgVersion: '15.2', +}; + +describe('PostgresDatabaseSchemaNode', () => { + it('id returns fixed sentinel "database"', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + expect(node.id).toBe('database'); + }); + + it('isEqualTo always returns true', () => { + const a = new PostgresDatabaseSchemaNode(baseInput); + const b = new PostgresDatabaseSchemaNode({ ...baseInput, pgVersion: '16.0' }); + expect(a.isEqualTo(b)).toBe(true); + }); + + it('children() returns namespace nodes only (R4)', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + expect(node.children()).toEqual([nsPublic, nsApp]); + }); + + it('children() does not include roles (roles not diffed yet)', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + const children = node.children(); + for (const child of children) { + expect(PostgresRoleSchemaNode.is(child)).toBe(false); + } + }); + + it('carries namespaces keyed by schema name', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + expect(Object.keys(node.namespaces)).toEqual(['public', 'app']); + expect(node.namespaces['public']).toBe(nsPublic); + }); + + it('carries roles', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + expect(node.roles).toEqual([role]); + }); + + it('carries existingSchemas', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + expect(node.existingSchemas).toEqual(['public', 'app']); + }); + + it('carries pgVersion', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + expect(node.pgVersion).toBe('15.2'); + }); + + it('instance is frozen', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + expect(Object.isFrozen(node)).toBe(true); + }); + + it('nodeTarget discriminant is "postgres"', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + expect(node.nodeTarget).toBe('postgres'); + }); + + describe('PostgresDatabaseSchemaNode.is', () => { + it('returns true for a PostgresDatabaseSchemaNode', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + expect(PostgresDatabaseSchemaNode.is(node)).toBe(true); + }); + + it('returns true for a spread plain object that retains nodeTarget', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + const spread = { ...node }; + expect(PostgresDatabaseSchemaNode.is(spread as unknown as PostgresDatabaseSchemaNode)).toBe( + true, + ); + }); + + it('returns false for a PostgresNamespaceSchemaNode', () => { + expect(PostgresDatabaseSchemaNode.is(nsPublic as unknown as PostgresDatabaseSchemaNode)).toBe( + false, + ); + }); + + it('returns false for an object without nodeTarget', () => { + const bare = { id: 'database' } as unknown as PostgresDatabaseSchemaNode; + expect(PostgresDatabaseSchemaNode.is(bare)).toBe(false); + }); + }); + + describe('PostgresDatabaseSchemaNode.assert', () => { + it('does not throw for a valid node', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + expect(() => PostgresDatabaseSchemaNode.assert(node)).not.toThrow(); + }); + + it('throws for an object with wrong nodeTarget', () => { + const bad = { nodeTarget: 'sql' } as unknown as PostgresDatabaseSchemaNode; + expect(() => PostgresDatabaseSchemaNode.assert(bad)).toThrow(); + }); + }); + + describe('PostgresDatabaseSchemaNode.ensure', () => { + it('returns the same instance when already a real instance', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + expect(PostgresDatabaseSchemaNode.ensure(node)).toBe(node); + }); + + it('reconstructs from a spread-flattened plain object', () => { + const node = new PostgresDatabaseSchemaNode(baseInput); + const spread = { ...node } as unknown as PostgresDatabaseSchemaNode; + const reconstructed = PostgresDatabaseSchemaNode.ensure(spread); + expect(reconstructed).toBeInstanceOf(PostgresDatabaseSchemaNode); + expect(reconstructed.id).toBe('database'); + expect(reconstructed.pgVersion).toBe('15.2'); + expect(Object.keys(reconstructed.namespaces)).toEqual(['public', 'app']); + }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts new file mode 100644 index 0000000000..24116d722a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts @@ -0,0 +1,139 @@ +import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import { describe, expect, expectTypeOf, it, test } from 'vitest'; +import { PostgresNamespaceSchemaNode } from '../src/core/schema-ir/postgres-namespace-schema-node'; +import { PostgresPolicySchemaNode } from '../src/core/schema-ir/postgres-policy-schema-node'; +import { PostgresRoleSchemaNode } from '../src/core/schema-ir/postgres-role-schema-node'; +import { PostgresTableSchemaNode } from '../src/core/schema-ir/postgres-table-schema-node'; + +const policy = new PostgresPolicySchemaNode({ + name: 'read_own_a1b2c3d4', + prefix: 'read_own', + tableName: 'profiles', + namespaceId: 'public', + operation: 'select' as const, + roles: ['authenticated'], + using: '(auth.uid() = user_id)', + permissive: true, +}); + +const tableA = new PostgresTableSchemaNode({ + name: 'profiles', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [policy], +}); + +const tableB = new PostgresTableSchemaNode({ + name: 'orders', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [], +}); + +const baseInput = { + schemaName: 'public', + tables: { profiles: tableA, orders: tableB }, + nativeEnumTypeNames: ['status_enum'], +}; + +describe('PostgresNamespaceSchemaNode', () => { + it('id returns schemaName', () => { + const node = new PostgresNamespaceSchemaNode(baseInput); + expect(node.id).toBe('public'); + }); + + it('isEqualTo always returns true', () => { + const a = new PostgresNamespaceSchemaNode(baseInput); + const b = new PostgresNamespaceSchemaNode({ ...baseInput, schemaName: 'other' }); + expect(a.isEqualTo(b)).toBe(true); + }); + + it('children() returns table nodes', () => { + const node = new PostgresNamespaceSchemaNode(baseInput); + expect(node.children()).toEqual([tableA, tableB]); + }); + + it('children() returns empty array when no tables', () => { + const node = new PostgresNamespaceSchemaNode({ + schemaName: 'empty', + tables: {}, + nativeEnumTypeNames: [], + }); + expect(node.children()).toEqual([]); + }); + + it('children() does not include roles (roles are database-level)', () => { + const node = new PostgresNamespaceSchemaNode(baseInput); + const children = node.children(); + for (const child of children) { + expect(PostgresRoleSchemaNode.is(child)).toBe(false); + } + }); + + it('carries schemaName', () => { + const node = new PostgresNamespaceSchemaNode(baseInput); + expect(node.schemaName).toBe('public'); + }); + + it('carries nativeEnumTypeNames', () => { + const node = new PostgresNamespaceSchemaNode(baseInput); + expect(node.nativeEnumTypeNames).toEqual(['status_enum']); + }); + + it('carries tables keyed by name', () => { + const node = new PostgresNamespaceSchemaNode(baseInput); + expect(Object.keys(node.tables)).toEqual(['profiles', 'orders']); + expect(node.tables['profiles']).toBe(tableA); + }); + + it('annotations.pg carries schema and nativeEnumTypeNames', () => { + const node = new PostgresNamespaceSchemaNode(baseInput); + const pg = node.annotations?.['pg'] as Record | undefined; + expect(pg?.['schema']).toBe('public'); + expect(pg?.['nativeEnumTypeNames']).toEqual(['status_enum']); + }); + + it('annotations.pg omits nativeEnumTypeNames when empty', () => { + const node = new PostgresNamespaceSchemaNode({ + ...baseInput, + nativeEnumTypeNames: [], + }); + const pg = node.annotations?.['pg'] as Record | undefined; + expect(pg?.['nativeEnumTypeNames']).toBeUndefined(); + }); + + it('annotations.pg does not carry existingSchemas (database-level field)', () => { + const node = new PostgresNamespaceSchemaNode(baseInput); + const pg = node.annotations?.['pg'] as Record | undefined; + expect(pg?.['existingSchemas']).toBeUndefined(); + }); + + it('instance is frozen', () => { + const node = new PostgresNamespaceSchemaNode(baseInput); + expect(Object.isFrozen(node)).toBe(true); + }); + + describe('PostgresNamespaceSchemaNode.is', () => { + it('returns true for a PostgresNamespaceSchemaNode', () => { + const node = new PostgresNamespaceSchemaNode(baseInput); + expect(PostgresNamespaceSchemaNode.is(node)).toBe(true); + }); + + it('returns false for a PostgresTableSchemaNode', () => { + expect(PostgresNamespaceSchemaNode.is(tableA)).toBe(false); + }); + + it('returns false for a PostgresPolicySchemaNode', () => { + expect(PostgresNamespaceSchemaNode.is(policy)).toBe(false); + }); + }); +}); + +test('PostgresNamespaceSchemaNode is assignable to SqlSchemaIR', () => { + const node = new PostgresNamespaceSchemaNode(baseInput); + expectTypeOf(node).toExtend(); +}); From 87ee213eb13b9ec3de052dbc1599872e89fd4987 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 15:32:21 +0200 Subject: [PATCH 05/49] =?UTF-8?q?docs(postgres-rls):=20pin=20CF-1=20?= =?UTF-8?q?=E2=80=94=20existingSchemas=20consumer=20rewire=20as=20a=20unit?= =?UTF-8?q?-6=20requirement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced by the unit-3/4 review: verify-postgres-namespaces reads existingSchemas off the flat schema via isPostgresSchemaIR, so handing it a namespace node would silently fall back to the [public] default and regress R9. Pinned into the spec so unit 6 rewires the consumer to read existingSchemas from the database root. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../postgres-rls/slices/schema-node-tree-restructure/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/postgres-rls/slices/schema-node-tree-restructure/spec.md b/projects/postgres-rls/slices/schema-node-tree-restructure/spec.md index cba241d5a7..c59398011c 100644 --- a/projects/postgres-rls/slices/schema-node-tree-restructure/spec.md +++ b/projects/postgres-rls/slices/schema-node-tree-restructure/spec.md @@ -86,7 +86,7 @@ Nodes first (new vocabulary), then producers/consumers/inference (depend on the 3. **Namespace node** (R3, R5, R6) — new `PostgresNamespaceSchemaNode`, shaped to satisfy the per-schema `SqlSchemaIR` interface. 4. **Database root** (R1, R3, R4) — new `PostgresDatabaseSchemaNode` (+ guard/assert/ensure), replacing `PostgresSchemaIR`. 5. **Producers** (R3, R5) — projection + introspection build the tree; `introspect()` returns the root. -6. **Consumers** (R6) — the differ + planner `ensure` the root and walk it; the legacy verify / relational planning / view operate on a namespace node; `introspect()` returns a generic node and each consumer `ensure`s the target type. +6. **Consumers** (R6) — the differ + planner `ensure` the root and walk it; the legacy verify / relational planning / view operate on a namespace node; `introspect()` returns a generic node and each consumer `ensure`s the target type. **CF-1 (R9 trap):** `verify-postgres-namespaces.ts` (`existingSchemasFromSchema`) reads `existingSchemas` off the flat schema via `isPostgresSchemaIR` today; once it's handed a namespace node it must read `existingSchemas` from the **database root**, not fall through to the `['public']` default — otherwise namespace presence silently regresses. Rewire this consumer here. 7. **Inference to target** (R7) — move the maps + projection to the Postgres target descriptor; family delegates; delete the flat walker; leaf transforms become utilities. ## Tests (write first) From e3524e1600943ae010183ab5bc66b5f856e18b9c Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 15:50:55 +0200 Subject: [PATCH 06/49] docs(postgres-rls): rewrite plan as a concise scannable overview Collapse the 164-line plan to a status table + brief notes for the not-yet-done slices + compact locked decisions. Mark slice 1 (select-policies-dependable, #771) and slice 1.5 (#868) as merged; slice 2 as in progress. Implementation detail lives in the slice specs, not the plan. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- projects/postgres-rls/plan.md | 172 +++++++--------------------------- 1 file changed, 32 insertions(+), 140 deletions(-) diff --git a/projects/postgres-rls/plan.md b/projects/postgres-rls/plan.md index a6d88f2c5e..46a28c5cc7 100644 --- a/projects/postgres-rls/plan.md +++ b/projects/postgres-rls/plan.md @@ -1,163 +1,55 @@ # postgres-rls — Plan -**Spec:** `projects/postgres-rls/spec.md` -**Linear Project:** [Postgres RLS](https://linear.app/prisma-company/project/postgres-rls-b7329340dbb2) · project issue [TML-2501](https://linear.app/prisma-company/issue/TML-2501) · parent umbrella [Supabase Integration](https://linear.app/prisma-company/project/supabase-integration-08e7667f5de4) +**Spec:** `spec.md` · **Linear:** [Postgres RLS](https://linear.app/prisma-company/project/postgres-rls-b7329340dbb2) ([TML-2501](https://linear.app/prisma-company/issue/TML-2501)) under [Supabase Integration](https://linear.app/prisma-company/project/supabase-integration-08e7667f5de4) -> **Re-cut 2026-06-10 (operator).** The previous cut named slices after layers ("authoring breadth", "verify/plan breadth") and let user-invisible machinery count as delivery — the local PR review exposed the consequences (drift was "detected" into a channel nothing reads; editing a policy silently left the stale one active). The new cut names each slice after the thing a user can rely on when it merges, and every slice AC is an **operator-observable behavior**, never an artifact. - -## At a glance - -Slice 1 makes **SELECT policies dependable end-to-end** — full lifecycle (create, change, remove), drift makes `db verify` fail, proven in the Supabase example app. Slice 1.5 (`entity-kind-migration-seam`), discovered during slice 1, builds the **generic two-sided derivation seam** so a target-contributed entity kind works on every migration command — notably `migration plan`, which slice 1 defers with a fail-loud stopgap. The forward work then runs as an ordered sequence: **slice 2 `schema-node-tree-restructure`** gives the schema-diff tree a real single-purpose node at every level (a `PostgresDatabaseSchemaNode` root above per-namespace nodes — the conflated `PostgresSchemaIR` root is retired) with no behavior change; **slice 3 `explicit-rls-control`** adds explicit `@@rls` enablement, table-level `managed`/`external` grading, and policy rename; **slice 4 `migration-support-for-roles`** makes roles diffable off the new root (the **policy→role** dependency-graph seed); **slice 5 `support-all-rls-policy-types`** extends everything to INSERT/UPDATE/DELETE/ALL; **slice 6 `rls-ts-authoring`** adds the TypeScript authoring surface with PSL parity. - -RLS rides the generic schema-diff architecture (unchanged — see § Architecture decisions): generic differ + `{coordinate, outcome}` issues; zero RLS symbols in framework/SQL-family; content-addressed wire names; side-by-side with the untouched legacy relational verifier/planner. The relational port and dependency-aware planner ordering remain independent follow-on projects. - -## Composition - -This project can run in parallel with [cross-contract-refs](../cross-contract-refs/spec.md) and [runtime-target-layer](../../docs/architecture%20docs/adrs/ADR%20230%20-%20Runtime%20target%20layer%20session-coupled%20connections.md). The TS `ref()` helper consumes cross-contract model handles transparently — no integration work between the two projects beyond the brand contract already established by cross-contract-refs. +Each slice is named for what a developer can **rely on** when it merges; every DoD is an operator-observable behavior, never an artifact. Slices 1, 1.5, 2 are foundational (1.5 and 2 ship no user-visible change); the user-facing RLS behaviors land in 3–6. ## Slices -### Slice 1 — `select-policies-dependable` · [TML-2868](https://linear.app/prisma-company/issue/TML-2868) · PR [#771](https://github.com/prisma/prisma-next/pull/771) (continues) - -**Status: 🚧 in progress (PR #771).** - -A developer can declare a SELECT policy and **rely on it**: it gets created, edits replace it, removals drop it, drift errors out, and the Supabase example app proves the whole thing. - -Already landed on the branch: the architecture (generic differ, content-addressed naming + normalizer, introspection, PSL `policy_select` authoring through the production interpreter, create/enable ops, planner diff-wiring, the verify `extensionIssues` channel) and two PGlite e2e spines. **Remaining to slice DoD:** - -1. **Fix the build** (review F01): `extensionIssues` made required without updating three constructors (mongo verify, CLI `db-verify`, `combine-schema-results`) — workspace `pnpm typecheck` must be green; add the workspace typecheck to the standing gates. -2. **Edit replaces, never accumulates** (kills the edit-trap) via **strict content-addressed drop** (F07 withdrawn — same-prefix rule superseded): the generic differ (`diffNodes`) matches on full `EntityCoordinate` identity. A `mismatch` outcome (same prefix, new hash) produces `DropPostgresRlsPolicyCall` + `CreatePostgresRlsPolicyCall`; an `extra` outcome (policy in the DB but not in the contract) produces `DropPostgresRlsPolicyCall`. Both drop calls are gated by the migration operation policy: they are only emitted when `destructive` is in `allowedOperationClasses` (i.e., `db update` with widening/destructive policy, not `db init`). Under additive-only policy (`INIT_ADDITIVE_POLICY`), drop calls are suppressed — only create/enable ops are emitted. **Shipped in slice 1.5:** the `extra`→`DropPostgresRlsPolicyCall` mapping and the unowned-*namespace* extra filter (so removal-drop works at the namespace grain). **Deferred to slice 3 (`explicit-rls-control`):** explicit `@@rls` table enablement (so RLS enable/disable is driven by the marker, not policy presence) and per-*table* `managed`/`external` grading. -3. **Drift errors out** (resolves F02 as wire-it-now, bluntly): any non-empty `extensionIssues` fails the verify verdict — fold into `ok`/counts at the family assembly and thread through `combineSchemaResults`. Nuanced per-kind severity is slice 2; slice 1's rule is simply *any RLS drift → verify fails with a message naming the policy*. -4. **Supabase example app e2e**: extend `bootstrapSupabaseShim` with the Postgres roles (`anon`, `authenticated`, `service_role`) and the `auth.uid()` GUC-reading function (verified 2026-06-10: the shim seeds only schemas/tables today; roles are platform-provided in real Supabase, so the shim emulates that — this project never authors or migrates roles); add a SELECT policy to `examples/supabase` `Profile`; e2e proves: migrate → rows filtered under the role → `db verify` clean → drop the policy out-of-band → `db verify` **fails**. -5. Review follow-ups in scope: F03 (role-name rendering shim hardening or input constraint), F05 (a parsed extension block with no registered factory must not be silently dropped), F07 (`rlsEnabledByTable` keyed by bare table name — cross-schema collision), the structural anti-leak test (assert no RLS tokens in framework/SQL-core, since `lint:deps` can't catch this class). - -- **DoD (operator-observable):** declare a SELECT policy in the example app → migrate → only permitted rows visible under the role; edit the predicate → migrate → **exactly one policy active**, with the new predicate (the old version dropped via same-prefix replace); remove it from the contract → `db verify` reports the now-orphaned DB policy as drift (exits non-zero naming it) — auto-drop-on-removal is slice 2; drop/alter it out-of-band → `prisma db verify` exits non-zero naming the policy. Workspace typecheck green; all suites green. - -### Slice 1.5 — `entity-kind-migration-seam` · [TML-2931](https://linear.app/prisma-company/issue/TML-2931) - -**Status: ⬜ design complete, build not started.** Design: [`specs/adr-schema-diff-over-structured-ir.md`](specs/adr-schema-diff-over-structured-ir.md) (accepted) · seed [`specs/extension-migration-participation.md`](specs/extension-migration-participation.md). Slice spec: [`slices/entity-kind-migration-seam/spec.md`](slices/entity-kind-migration-seam/spec.md). - -Foundational seam, discovered during slice 1. A target-contributed entity kind only half-participates in migrations: the live-database derivation is hardcoded in the Postgres reader, and the contract→schema derivation drops target-specific objects — so on the `migration plan` path the diff has no policies on either side, and on the live-DB paths it reaches into the contract object directly for the expected side. The consequence is that `migration plan` cannot emit RLS at all — slice 1 ships a fail-loud stopgap (it refuses to plan when the contract declares policies). This slice makes **both diff sides homogeneous, derived schema IRs** so a contributed node type works on **every** command. - -Per the accepted schema-diff ADR: - -- **project-from-contract builds a populated schema IR.** Postgres's `contractToSchema` returns a `PostgresSchemaIR` carrying its policies and roles, instead of a bare relational `SqlSchemaIR` that drops them. Both derivations — project-from-contract and project-from-database (introspection) — emit the same shape. Written directly in the Postgres target; **no registry** (deferred — follow-on C). -- **The differ walks two schema-IR roots.** A generic framework `diffSchemas(expected, actual)` walks two full roots (`identity()`→`coord()`, `children()` added). `diffPostgresSchema` (the Postgres schema-diff strategy) projects the contract's policies for the expected side, walks the **full** introspected tree for the actual side, and suppresses unowned-namespace `extra` issues **post-diff** via the generic `filterSchemaIssuesByOwnership` (the supabase multi-space fix, preserved as an outcome filter, not a pre-filter on the tree — slice 2 folds it into the unified control-policy disposition). The differ itself never reads the contract. -- **The planner becomes provenance-agnostic.** Remove the `migration plan` fail-loud stopgap and the two `isPostgresSchemaIR` command-branches in `planner.ts`; the diff path runs identically regardless of which derivation fed each side. -- **Roles projected, not yet diffed.** project-from-contract populates roles so the IR is symmetric, but only policies are diffed here (the schema root yields policies, not roles) — role drift (missing-role detection, the policy→role edge) stays slice 2. Net observable change of this slice: `migration plan` emits RLS; `db init` / `db update` / `db verify` behave exactly as before. -- **DoD (operator-observable):** `migration plan` on a contract with a SELECT policy emits `CREATE POLICY` in the generated migration; both diff sides are homogeneous schema IRs (the contract is not read directly on either side); RLS still works on `db init` / `db update` / `db verify`; SQLite + Mongo untouched. - -The forward work is a single ordered sequence. Slices 1 and 1.5 are done; the rest run in this order, each with its own Linear ticket. - -### Slice 2 — `schema-node-tree-restructure` · _(Linear: new ticket, blockedBy TML-2931)_ - -**Status: ⬜ next up.** Foundational and behavior-neutral — done first so the role/dependency work isn't dancing around a conflated root for the next several slices. - -Slice 1.5 made `PostgresSchemaIR` carry three unrelated jobs at once: a node in the schema-diff tree, a Postgres schema (namespace), and the root of the tree. That conflation is the thing to remove before anything is built on top of it. This slice gives the schema-diff tree a real, single-purpose node at every level, modelled on Postgres's own object hierarchy: - -- **`PostgresDatabaseSchemaNode`** (root) — children are namespaces; also holds roles (held, **not yet diffed** — that arrives in slice 4). -- **`PostgresNamespaceSchemaNode`** — one per schema/namespace; children are tables. Has the same `.tables` shape the old flat `PostgresSchemaIR` had, so the legacy per-schema consumers take a namespace node unchanged (below). -- **`PostgresTableSchemaNode`** (rename of `PostgresTableIR`) — children are policies; will carry the `rlsEnabled` flag (slice 3). -- **`PostgresPolicySchemaNode`** / **`PostgresRoleSchemaNode`** (leaves) — **new** diff nodes. The authored entities `PostgresRlsPolicy` / `PostgresRole` stay as **Contract IR** (they are serialized into `contract.json`); they lose `DiffableNode` and move out of `schema-ir/`. Tables already split this way (`StorageTable` contract vs `PostgresTableIR` diff node); policies/roles now match. - -The `…SchemaNode` suffix marks these as nodes in the **schema-diff** tree (the derived database-state representation), distinct from the **Contract IR** entities — bare `…IR` is dropped because the repo has several IRs and the suffix said nothing. - -- **Introspect returns the root.** The RLS differ walks the whole tree. The **legacy relational verify, planner, and CLI schema view are unchanged** — they take a `PostgresNamespaceSchemaNode` (same `.tables` shape as before); the caller walks root→namespaces and feeds each to that per-schema code (exactly one node in the single-schema common case). No flat-read of the root, no shim, no dual representation. This also retires the old multi-schema "merge into one flat IR" (and its silent cross-schema table-name collision). -- **Inference moves to the Postgres target.** Database→PSL inference is target logic — it walks the tree and owns the Postgres type/default maps (currently a layering violation: `sql-schema-ir-to-psl-ast.ts` hardcodes `createPostgresTypeMap`/`createPostgresDefaultMapping` in SQL-family code). The Postgres target descriptor gains `inferPslContract(tree)`; the family instance delegates to it; the flat `sqlSchemaIrToPslAst`/`buildPslDocumentAst` walkers are deleted. The framework keeps `PslDocumentAst` + `printPsl` (the view and printer); the control adapter is untouched. (TS contract inference stays a future sibling — same target-owned shape — not built here.) -- **No behavior change.** `migration plan` / `db init` / `db update` / `db verify` and `contract infer` output are byte-for-byte unchanged. SQLite + Mongo untouched. -- **DoD:** the node family + the `PostgresDatabaseSchemaNode` root are in place; policies/roles are split into Contract-IR entities and schema-diff nodes; inference is target-owned; all RLS migration + `contract infer` suites green with unchanged output. (Structural slice — its "observable" guarantee is *no* observable change; operator-visible behavior lands in slices 3–4 on top of the clean tree.) - -### Slice 3 — `explicit-rls-control` · [TML-2869](https://linear.app/prisma-company/issue/TML-2869) - -**Status: ⬜ not started.** - -RLS enablement becomes explicit, and the in-a-single-schema drift variations are handled **correctly** (not just "error"). - -- **Explicit `@@rls` enablement** (replaces slice 1's deferred "disable on last policy"): a model marks its table RLS-controlled with a `@@rls` block, independent of whether any policy references it. - - ```prisma - model User { - @@rls - } - - policy user_isolation { - model = User - // … - } - ``` - - `ENABLE ROW LEVEL SECURITY` is emitted when the marker is present and the live table has RLS off; `DISABLE ROW LEVEL SECURITY` (destructive-gated) when the marker is removed. Removing the last policy from an `@@rls` model leaves RLS **on** — the table denies all access (fail-closed) rather than silently dropping authorization. A policy on a model without `@@rls` is an authoring error. RLS-enabled becomes the first real **table-attribute diff** (`PostgresTableSchemaNode.isEqualTo` compares it; introspection reads `pg_class.relhasrowsecurity`), which also subsumes the "RLS-disabled-with-policies-declared" drift case. -- **Table-level `managed`/`external` grading:** route RLS drop calls through the existing `partitionCallsByControlPolicy` so a table's `control` grade decides reconciliation — `managed` tables drop their extra policies, `external` tables are left untouched. Slice 1.5 already filters unowned-*namespace* extras and drops owned extras under the destructive gate; this adds the per-*table* grade so `managed` and `external` tables in the **same** schema are distinguished. The authoring surface (`StorageTable.control` / `defaultControlPolicy`) already exists — this slice only makes the RLS diff path consult it. -- **Policy rename:** a same-body, different-prefix policy currently emits DROP+CREATE; a planner post-pass pairs a `missing`+`extra` by content-hash on the same table and emits `ALTER POLICY … RENAME TO` instead (new op). The blunt slice-1 "any drift errors" rule refines into per-variation verdicts. -- **DoD (operator-observable):** add `@@rls` + a policy + migrate → table has RLS enabled with the policy; remove the last policy → table still RLS-enabled (deny-all), no DISABLE emitted; remove `@@rls` → DISABLE emitted (destructive-gated); rename a policy prefix → migrate emits a RENAME (verifiable in plan output); an `external` table's extra policies are left untouched while a `managed` table's are dropped. - -### Slice 4 — `migration-support-for-roles` · _(Linear: new ticket, blockedBy TML-2869)_ - -**Status: ⬜ not started.** - -The schema graph gains its first edge: a policy depends on the roles it references. The `PostgresRoleSchemaNode` leaves that slice 2 hung off the root (held, not diffed) become diffable here. - -- **Roles become diffable nodes** off `PostgresDatabaseSchemaNode` — diffed once at the database level, not once per schema (the reason the root had to exist before this slice). -- **Policy → role traversal (the dependency-graph seed):** a policy referencing a role absent from `pg_roles` surfaces a missing-role issue. Issue processing is **leaves-first, then up the tree** — the role leaf's issue surfaces before/along the dependent policy's — establishing the edge model the future dependency-aware planner (follow-on B) builds on. -- **DoD (operator-observable):** reference a role that doesn't exist → verify fails naming the role; the missing-role issue is ordered before its dependent policy's. - -### Slice 5 — `support-all-rls-policy-types` · [TML-2870](https://linear.app/prisma-company/issue/TML-2870) - -**Status: ⬜ not started.** - -Everything slices 1–4 made dependable for SELECT works for **INSERT / UPDATE / DELETE / ALL** policies. - -- PSL `policy_insert | policy_update | policy_delete | policy_all` block descriptors lowering through the same generic interpreter pass; `withCheck` handling (INSERT/UPDATE) end-to-end; the lifecycle + drift behaviors from earlier slices verified per type (the content-hash already covers operation + withCheck, so this is descriptors + DDL rendering + per-type e2e, not new architecture). -- **DoD (operator-observable):** the slice-1 example-app scenario repeated with an UPDATE-own policy (`using` + `withCheck`): a user can update only their own row; editing/removing/drifting behaves exactly as for SELECT. - -### Slice 6 — `rls-ts-authoring` · [TML-2883](https://linear.app/prisma-company/issue/TML-2883) +| # | Slice | Delivers | Status | Ticket / PR | +| --- | --- | --- | --- | --- | +| 1 | `select-policies-dependable` | A SELECT policy is dependable end-to-end — create / edit-replaces / remove, drift fails `db verify`, proven in the Supabase example app. | ✅ merged | [TML-2868](https://linear.app/prisma-company/issue/TML-2868) · [#771](https://github.com/prisma/prisma-next/pull/771) | +| 1.5 | `entity-kind-migration-seam` | Foundational: both diff sides are derived schema IRs, so `migration plan` emits RLS like every other command. | ✅ merged | [TML-2931](https://linear.app/prisma-company/issue/TML-2931) · [#868](https://github.com/prisma/prisma-next/pull/868) | +| 2 | `schema-node-tree-restructure` | Foundational: a real `database → namespace → table → policy` node tree; inference moves to the Postgres target. Behavior-neutral. | 🚧 in progress | new ticket (TBD) | +| 3 | `explicit-rls-control` | `@@rls` enablement, policy rename, per-table `managed`/`external` grading. | ⬜ | [TML-2869](https://linear.app/prisma-company/issue/TML-2869) | +| 4 | `migration-support-for-roles` | A policy referencing a missing role fails verify (policy→role edge; dependency-graph seed). | ⬜ | new ticket (TBD) | +| 5 | `support-all-rls-policy-types` | INSERT / UPDATE / DELETE / ALL policies, same lifecycle as SELECT. | ⬜ | [TML-2870](https://linear.app/prisma-company/issue/TML-2870) | +| 6 | `rls-ts-authoring` | Author the same policies in TypeScript, identical result. | ⬜ | [TML-2883](https://linear.app/prisma-company/issue/TML-2883) | -**Status: ⬜ not started.** +## Not-yet-done slices -A developer can author the same policies in **TypeScript** instead of PSL, with identical results. +### 2 — `schema-node-tree-restructure` (in progress) -- Top-level Postgres-contributed policy helpers taking the model handle (the decided surface — the `enum`/`entityTypes` mechanism, invisible to SQLite/Mongo authors; **not** a model-builder method; rationale in [`specs/design-rls-authoring-surface.md`](specs/design-rls-authoring-surface.md)). Settles the still-open **per-operation (`policySelect(…)`) vs single-array** helper-signature decision at slice pickup. -- The `ref()` predicate helper (reads `{namespaceId, tableName}` off `extensionModel(…)` handles so predicates track renames); model-level RLS enable/disable (the TS form of `@@rls`); duplicate-prefix/name diagnostics. -- **TS/PSL parity test:** the same policies authored both ways lower to structurally identical IR with identical wire names. -- **DoD (operator-observable):** the slice-1 example-app scenario authored in TS instead of PSL behaves identically (filtered rows, lifecycle, drift→verify-fails); the parity test pins identical contracts. +Retire the conflated `PostgresSchemaIR` (it was a tree node, a schema, and the root at once). New single-purpose tree: **`PostgresDatabaseSchemaNode`** (root; holds roles) → **`PostgresNamespaceSchemaNode`** → **`PostgresTableSchemaNode`** → **`PostgresPolicySchemaNode`** / **`PostgresRoleSchemaNode`** leaves. Diff nodes are split from the authored Contract-IR entities (`PostgresRlsPolicy` / `PostgresRole` stay as the serialized entities). `introspect()` returns the root as a node; consumers `ensure` the target type and walk. Database→PSL inference moves onto the Postgres target (fixing a SQL-family layering violation). **No behavior change.** Spec + design: [`slices/schema-node-tree-restructure/`](slices/schema-node-tree-restructure/). -## Management model — locked decision (2026-06-16) +### 3 — `explicit-rls-control` -**Default is exclusive management at the table level.** +- **`@@rls`** marks a model RLS-controlled independent of any policy → drives ENABLE/DISABLE. Removing the last policy leaves RLS **on** (deny-all, fail-closed); DISABLE only on marker removal. A policy on an unmarked model is an authoring error. First real table-attribute diff. +- **`managed`/`external` grading** per table, via the existing `partitionCallsByControlPolicy`. +- **Policy rename** → `ALTER POLICY … RENAME TO` (planner post-pass pairing `missing`+`extra` by content-hash). -A table is either `managed` (the contract owns its full policy set) or `external` (the contract does not touch it). There is no per-policy granularity — no "is this policy ours?" flag on individual policies. +### 4 — `migration-support-for-roles` -Consequences: -- A policy present in the DB but absent from the contract on a **managed** table is `extra` → dropped on migrate. -- A policy on an **external** table is never reconciled — the differ skips it entirely; no `extra` issued. -- The `external` vs `managed` distinction is table-level authoring (on `StorageTable`'s Postgres-target annotation, not on `StorageTable` itself — no SQL-family leak). +Roles become diffable off the root; a policy referencing a role absent from `pg_roles` fails verify, surfaced before the dependent policy (leaves-first). Seeds the dependency graph (follow-on B). -The slice-4 resolution: when introspecting for the differ, only collect policies for tables that are `managed`; for `external` tables, the introspected policy set is treated as empty (nothing to diff against). This avoids needing per-policy provenance metadata or a `prismaManaged` flag on the catalog row. +### 5 — `support-all-rls-policy-types` -## Not in this project's plan-of-record (operator cut, 2026-06-10) +PSL `policy_insert | policy_update | policy_delete | policy_all` descriptors + `withCheck`; the slice-3/4 lifecycle and drift behaviors verified per type. Descriptors + DDL + e2e, no new architecture. -- **Cross-space role-ref *validation* / role authoring** — roles are external (platform-provided; shim-seeded in tests); policies reference them by name. The substrate's cross-space pass-through stands; real role-ref validation arrives with slice 2's role traversal only to the extent of "referenced role exists in the DB," not authoring-time cross-space resolution. -- Independent follow-on projects: **A** — port the 25 legacy relational verifier node types onto the generic differ; **B** — dependency-aware generic planner ordering (slice 2's policy→role edges are its seed); **C** — promote the per-target derivations (project-from-contract / project-from-database) into a generic registration surface, once a second consuming node type — the relational port, or another extension — makes the shared shape concrete (deferred from slice 1.5 per the schema-diff ADR). +### 6 — `rls-ts-authoring` -## Architecture decisions (locked 2026-06-09 — unchanged by the re-cut) +Top-level Postgres policy helpers taking the model handle (not a model-builder method), the `ref()` predicate helper, TS `@@rls`. A TS/PSL parity test pins structurally identical IR with identical wire names. Rationale: [`specs/design-rls-authoring-surface.md`](specs/design-rls-authoring-surface.md). -1. Generic differ, same-hierarchy comparison; issues are `{coordinate, outcome: missing|extra|mismatch}` — the framework never enumerates target kinds. -2. `identity()` / `isEqualTo()` as virtual methods on nodes; policy identity = content-addressed wire name. -3. Per-node-kind planner dispatch (`create/delete/update → OpFactoryCall[]`); coarse buckets now, dependency graph later (slice 2 seeds the edges; follow-on B builds the ordering). -4. Derivation/introspection hold per-kind smarts; the diff stays a pure walk. -5. Side-by-side with the legacy verifier/planner until follow-on A retires it; the new path emits only new-native structures. -6. Layering invariant: zero RLS symbols in `packages/1-framework` / `packages/2-sql` — to be enforced by a structural test (slice 1 item 5), not review vigilance. +## Locked decisions -Refined by the schema-diff ADR ([`specs/adr-schema-diff-over-structured-ir.md`](specs/adr-schema-diff-over-structured-ir.md)): the differ walks two derived schema-IR roots rather than flat node lists; the node alignment method is `coord()` (renamed from `identity()`) and nodes expose `children()`; both diff sides are homogeneous derived IRs produced by project-from-contract / project-from-database. +- **Architecture** ([ADR](specs/adr-schema-diff-over-structured-ir.md)): a generic differ walks two derived schema-IR trees → `{path, outcome}` issues; the framework never enumerates target kinds; node identity = content-addressed wire name; per-node-kind planner dispatch. **Zero RLS symbols in `1-framework` / `2-sql`** (enforced by a structural test). The legacy relational verifier/planner runs side-by-side until follow-on A retires it. +- **Management model:** a table is `managed` (contract owns its full policy set; extras dropped) or `external` (untouched) — table-level only, no per-policy flag. Authored on the table's Postgres-target annotation (no SQL-family leak). -## Linear sync +## Out of scope / follow-on projects -TML-2868 → `select-policies-dependable` (re-scoped; PR #771 continues under it). [TML-2931](https://linear.app/prisma-company/issue/TML-2931) → `entity-kind-migration-seam` (slice 1.5; PR #868 merged 2026-06-28). The forward sequence and its tickets: +- Role-ref **authoring** validation — roles are platform-provided; policies only reference them by name (slice 4 checks existence in the DB, nothing more). +- **A** — port the legacy relational verifier onto the generic differ. **B** — dependency-aware planner ordering (slice 4's edges seed it). **C** — a generic project-from-contract / project-from-database registration surface, once a second node type needs the shared shape. -- **Slice 2 `schema-node-tree-restructure`** — **new top-level ticket** (not yet created), blockedBy TML-2931, blocking TML-2869. -- **Slice 3 `explicit-rls-control`** — TML-2869 (re-scoped from `drift-handled-correctly`). -- **Slice 4 `migration-support-for-roles`** — **new top-level ticket** (not yet created), blockedBy TML-2869, blocking TML-2870. -- **Slice 5 `support-all-rls-policy-types`** — [TML-2870](https://linear.app/prisma-company/issue/TML-2870). -- **Slice 6 `rls-ts-authoring`** — [TML-2883](https://linear.app/prisma-company/issue/TML-2883). +## Linear -Per [[no-linear-sub-issues]] the two new tickets are sibling issues wired with blocks/blockedBy relations, not sub-issues. TML-2871 → canceled (contents split: example-app skeleton → slice 1; role existence → slice 4; cross-space role validation → dropped). +Tickets: slice 1 → TML-2868, 1.5 → TML-2931, 3 → TML-2869, 5 → TML-2870, 6 → TML-2883. **Slices 2 and 4 need new top-level tickets** (sibling issues with blocks/blockedBy relations, not sub-issues — [[no-linear-sub-issues]]). Blocking chain: 2931 → ⟨slice 2⟩ → TML-2869 → ⟨slice 4⟩ → TML-2870 → TML-2883. TML-2871 canceled (its contents folded into slices 1 and 4). From 519b8f99ca9363ed7b775bf389200c239acb966e Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 15:56:43 +0200 Subject: [PATCH 07/49] refactor(postgres): producers build the schema-node tree (unit 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 (schema-node-tree-restructure), unit 5 of 7. Both derivations now build PostgresDatabaseSchemaNode and introspect() returns the root: - Projection (contract-to-postgres-schema-ir.ts → contract-to-postgres- database-schema-node.ts): tables grouped by owning namespace; policies built as PostgresPolicySchemaNode from the contract entities; roles + existingSchemas + pgVersion on the root. Malformed-contract assert strengthened to per-namespace. - Introspection: the flat bare-keyed introspectNamespaces merge (silent cross-schema table collision; kept only the first schema name) is gone, replaced by one namespace node per schema; cluster roles collected once. Intermediate unit: the build is red on the consumers and the family introspect() return-type seam (Promise, shared with SQLite) — both rewired in unit 6 (introspect returns a node per design §5). SQLite and CF-1 untouched here. Producer tests pass (8 projection + 121 introspection); reviewer SATISFIED. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- ...ntract-to-postgres-database-schema-node.ts | 115 +++++++++++++ .../contract-to-postgres-schema-ir.ts | 91 ----------- .../postgres/src/core/migrations/planner.ts | 4 +- .../3-targets/postgres/src/exports/control.ts | 4 +- .../3-targets/postgres/src/exports/planner.ts | 2 +- .../3-targets/postgres/src/exports/types.ts | 8 + ...t-to-postgres-database-schema-node.test.ts | 153 ++++++++++++++++++ .../contract-to-postgres-schema-ir.test.ts | 103 ------------ .../postgres/src/core/control-adapter.ts | 142 ++++++++-------- .../control-adapter.check-constraints.test.ts | 6 +- .../test/control-adapter.defaults.test.ts | 2 +- .../postgres/test/control-adapter.test.ts | 111 +++++++------ 12 files changed, 418 insertions(+), 323 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts delete mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts delete mode 100644 packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts new file mode 100644 index 0000000000..e2f13648db --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts @@ -0,0 +1,115 @@ +import type { ContractToSchemaIROptions } from '@prisma-next/family-sql/control'; +import { contractToSchemaIR } from '@prisma-next/family-sql/control'; +import { ifDefined } from '@prisma-next/utils/defined'; +import type { PostgresRlsPolicy } from '../postgres-rls-policy'; +import type { PostgresContract } from '../postgres-schema'; +import { isPostgresSchema } from '../postgres-schema'; +import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import { PostgresNamespaceSchemaNode } from '../schema-ir/postgres-namespace-schema-node'; +import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; +import { PostgresRoleSchemaNode } from '../schema-ir/postgres-role-schema-node'; +import { resolveDdlSchemaForNamespaceStorage } from '../schema-ir/postgres-schema-ir-annotations'; +import { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; + +function toPolicyNode(policy: PostgresRlsPolicy, namespaceId: string): PostgresPolicySchemaNode { + return new PostgresPolicySchemaNode({ + name: policy.name, + prefix: policy.prefix, + tableName: policy.tableName, + namespaceId, + operation: policy.operation, + roles: [...policy.roles], + ...ifDefined('using', policy.using), + ...ifDefined('withCheck', policy.withCheck), + permissive: policy.permissive, + }); +} + +/** + * Projects a Postgres contract into the expected schema-diff tree: a + * `PostgresDatabaseSchemaNode` root holding one `PostgresNamespaceSchemaNode` + * per Postgres namespace, each holding its `PostgresTableSchemaNode`s with + * their `PostgresPolicySchemaNode`s, plus the database roles on the root. + * + * Tables are grouped by their owning namespace (resolved DDL schema name) so + * the tree mirrors Postgres's object hierarchy. The DDL schema name is + * resolved once per namespace. + * + * A policy that references a table absent from its namespace is a malformed + * contract — the loop throws rather than fabricating a stub table. + */ +export function contractToPostgresDatabaseSchemaNode( + contract: PostgresContract | null, + options: ContractToSchemaIROptions, +): PostgresDatabaseSchemaNode { + if (contract === null) { + return new PostgresDatabaseSchemaNode({ + namespaces: {}, + roles: [], + existingSchemas: [], + pgVersion: '', + }); + } + + const sqlIr = contractToSchemaIR(contract, options); + + const namespaces: Record = {}; + const roles: PostgresRoleSchemaNode[] = []; + const ownedSchemas: string[] = []; + + for (const ns of Object.values(contract.storage.namespaces)) { + if (!isPostgresSchema(ns)) continue; + const ddlSchema = resolveDdlSchemaForNamespaceStorage(contract.storage, ns.id); + ownedSchemas.push(ddlSchema); + + const policiesByTable = new Map(); + for (const policy of Object.values(ns.policy)) { + const list = policiesByTable.get(policy.tableName) ?? []; + list.push(toPolicyNode(policy, ddlSchema)); + policiesByTable.set(policy.tableName, list); + } + + const tables: Record = {}; + for (const tableName of Object.keys(ns.table)) { + const sqlTable = sqlIr.tables[tableName]; + if (sqlTable === undefined) continue; + tables[tableName] = new PostgresTableSchemaNode({ + name: sqlTable.name, + columns: sqlTable.columns, + foreignKeys: sqlTable.foreignKeys, + uniques: sqlTable.uniques, + indexes: sqlTable.indexes, + ...ifDefined('primaryKey', sqlTable.primaryKey), + ...ifDefined('annotations', sqlTable.annotations), + ...ifDefined('checks', sqlTable.checks), + policies: policiesByTable.get(tableName) ?? [], + }); + } + + for (const [tableName, tablePolicies] of policiesByTable) { + if (!(tableName in tables)) { + const policyName = tablePolicies[0]?.name ?? '(unknown)'; + throw new Error( + `contract-to-postgres-database-schema-node: policy "${policyName}" references table "${tableName}" not present in namespace "${ddlSchema}"`, + ); + } + } + + namespaces[ddlSchema] = new PostgresNamespaceSchemaNode({ + schemaName: ddlSchema, + tables, + nativeEnumTypeNames: [], + }); + + for (const role of Object.values(ns.role)) { + roles.push(new PostgresRoleSchemaNode({ name: role.name, namespaceId: role.namespaceId })); + } + } + + return new PostgresDatabaseSchemaNode({ + namespaces, + roles, + existingSchemas: ownedSchemas, + pgVersion: '', + }); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts deleted file mode 100644 index f8fe991d75..0000000000 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-schema-ir.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { ContractToSchemaIROptions } from '@prisma-next/family-sql/control'; -import { contractToSchemaIR } from '@prisma-next/family-sql/control'; -import { ifDefined } from '@prisma-next/utils/defined'; -import { PostgresRlsPolicy } from '../postgres-rls-policy'; -import type { PostgresContract } from '../postgres-schema'; -import { isPostgresSchema } from '../postgres-schema'; -import { PostgresSchemaIR } from '../schema-ir/postgres-schema-ir'; -import { resolveDdlSchemaForNamespaceStorage } from '../schema-ir/postgres-schema-ir-annotations'; -import { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; - -export function contractToPostgresSchemaIR( - contract: PostgresContract | null, - options: ContractToSchemaIROptions, -): PostgresSchemaIR { - const sqlIr = contractToSchemaIR(contract, options); - const ownedSchemas = - contract === null - ? [] - : Object.values(contract.storage.namespaces) - .filter((ns) => isPostgresSchema(ns)) - .map((ns) => resolveDdlSchemaForNamespaceStorage(contract.storage, ns.id)); - - // Build a map of tableName → PostgresRlsPolicy[], resolving the DDL schema - // name once per namespace (not per policy). - const policiesByTable = new Map(); - if (contract !== null) { - for (const ns of Object.values(contract.storage.namespaces)) { - if (!isPostgresSchema(ns)) continue; - const resolvedSchema = resolveDdlSchemaForNamespaceStorage(contract.storage, ns.id); - for (const policy of Object.values(ns.policy)) { - const resolved = - resolvedSchema === policy.namespaceId - ? policy - : new PostgresRlsPolicy({ - name: policy.name, - prefix: policy.prefix, - tableName: policy.tableName, - namespaceId: resolvedSchema, - operation: policy.operation, - roles: [...policy.roles], - ...ifDefined('using', policy.using), - ...ifDefined('withCheck', policy.withCheck), - permissive: policy.permissive, - }); - const list = policiesByTable.get(policy.tableName) ?? []; - list.push(resolved); - policiesByTable.set(policy.tableName, list); - } - } - } - - // Attach policies to each table from the relational projection. A policy that - // references a table absent from the schema IR is a malformed contract — the - // loop below throws rather than fabricating a stub table. - const tables: Record = {}; - for (const [tableName, sqlTable] of Object.entries(sqlIr.tables)) { - tables[tableName] = new PostgresTableSchemaNode({ - name: sqlTable.name, - columns: sqlTable.columns, - foreignKeys: sqlTable.foreignKeys, - uniques: sqlTable.uniques, - indexes: sqlTable.indexes, - ...(sqlTable.primaryKey !== undefined ? { primaryKey: sqlTable.primaryKey } : {}), - ...(sqlTable.annotations !== undefined ? { annotations: sqlTable.annotations } : {}), - ...(sqlTable.checks !== undefined ? { checks: sqlTable.checks } : {}), - policies: policiesByTable.get(tableName) ?? [], - }); - } - for (const [tableName, policies] of policiesByTable) { - if (!(tableName in tables)) { - const policyName = policies[0]?.name ?? '(unknown)'; - throw new Error( - `contract-to-postgres-schema-ir: policy "${policyName}" references table "${tableName}" not present in the schema`, - ); - } - } - - return new PostgresSchemaIR({ - tables, - roles: - contract === null - ? [] - : Object.values(contract.storage.namespaces).flatMap((ns) => - isPostgresSchema(ns) ? Object.values(ns.role) : [], - ), - pgSchemaName: 'public', - pgVersion: '', - existingSchemas: ownedSchemas, - nativeEnumTypeNames: [], - }); -} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index b18a986c55..73b1556f02 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -29,7 +29,7 @@ import { assertPostgresRlsPolicy } from '../postgres-rls-policy'; import type { PostgresContract } from '../postgres-schema'; import { assertPostgresSchemaIR, ensurePostgresSchemaIR } from '../schema-ir/postgres-schema-ir'; import { resolveDdlSchemaForNamespaceStorage } from '../schema-ir/postgres-schema-ir-annotations'; -import { contractToPostgresSchemaIR } from './contract-to-postgres-schema-ir'; +import { contractToPostgresDatabaseSchemaNode } from './contract-to-postgres-database-schema-node'; import { formatPostgresControlPolicySubjectLabel, resolvePostgresCallControlPolicySubject, @@ -267,7 +267,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr options: PlannerOptionsWithComponents, ): readonly PostgresOpFactoryCall[] { assertPostgresSchemaIR(options.schema); - const expected = contractToPostgresSchemaIR( + const expected = contractToPostgresDatabaseSchemaNode( blindCast( options.contract, ), diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index 88d88b6958..85213be411 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -11,7 +11,7 @@ import type { StorageColumn } from '@prisma-next/sql-contract/types'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { postgresTargetDescriptorMeta } from '../core/descriptor-meta'; -import { contractToPostgresSchemaIR } from '../core/migrations/contract-to-postgres-schema-ir'; +import { contractToPostgresDatabaseSchemaNode } from '../core/migrations/contract-to-postgres-database-schema-node'; import { createPostgresMigrationPlanner } from '../core/migrations/planner'; import { renderDefaultLiteral } from '../core/migrations/planner-ddl-builders'; import type { PostgresPlanTargetDetails } from '../core/migrations/planner-target-details'; @@ -65,7 +65,7 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP PostgresContract | null, 'the family resolver only binds this hook for a Postgres-target contract' >(contract); - return contractToPostgresSchemaIR(postgresContract, { + return contractToPostgresDatabaseSchemaNode(postgresContract, { annotationNamespace: 'pg', ...ifDefined('expandNativeType', expander), renderDefault: postgresRenderDefault, diff --git a/packages/3-targets/3-targets/postgres/src/exports/planner.ts b/packages/3-targets/3-targets/postgres/src/exports/planner.ts index ccaf83faca..7be195dda6 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/planner.ts @@ -1,4 +1,4 @@ -export { contractToPostgresSchemaIR } from '../core/migrations/contract-to-postgres-schema-ir'; +export { contractToPostgresDatabaseSchemaNode } from '../core/migrations/contract-to-postgres-database-schema-node'; export { diffPostgresSchema, filterIssuesByOwnership, diff --git a/packages/3-targets/3-targets/postgres/src/exports/types.ts b/packages/3-targets/3-targets/postgres/src/exports/types.ts index 7725dfa1f2..db3aefd36c 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/types.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/types.ts @@ -13,6 +13,14 @@ export { PostgresUnboundSchema, postgresCreateNamespace, } from '../core/postgres-schema'; +export { + PostgresDatabaseSchemaNode, + type PostgresDatabaseSchemaNodeInput, +} from '../core/schema-ir/postgres-database-schema-node'; +export { + PostgresNamespaceSchemaNode, + type PostgresNamespaceSchemaNodeInput, +} from '../core/schema-ir/postgres-namespace-schema-node'; export { PostgresPolicySchemaNode, type PostgresPolicySchemaNodeInput, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts new file mode 100644 index 0000000000..7c1f6b39c1 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts @@ -0,0 +1,153 @@ +import { coreHash, profileHash } from '@prisma-next/contract/types'; +import { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; +import { applicationDomainOf } from '@prisma-next/test-utils'; +import { describe, expect, it } from 'vitest'; +import { contractToPostgresDatabaseSchemaNode } from '../../src/core/migrations/contract-to-postgres-database-schema-node'; +import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; +import { PostgresRole } from '../../src/core/postgres-role'; +import { type PostgresContract, PostgresSchema } from '../../src/core/postgres-schema'; +import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; +import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; +import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; +import { postgresRenderDefault } from '../../src/exports/control'; + +const TABLE_NAME = 'profiles'; +const SCHEMA_NAME = 'public'; + +function makePolicy(name: string): PostgresRlsPolicy { + return new PostgresRlsPolicy({ + name, + prefix: name.replace(/_[0-9a-f]{8}$/, ''), + tableName: TABLE_NAME, + namespaceId: SCHEMA_NAME, + operation: 'select', + roles: ['authenticated'], + using: '(auth.uid() = user_id)', + permissive: true, + }); +} + +const profilesTable = () => + new StorageTable({ + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + user_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }); + +function makeContract(options: { + readonly policies?: readonly PostgresRlsPolicy[]; + readonly roles?: readonly PostgresRole[]; +}): PostgresContract { + const policyEntries: Record = {}; + for (const p of options.policies ?? []) { + policyEntries[p.name] = p; + } + const roleEntries: Record = {}; + for (const r of options.roles ?? []) { + roleEntries[r.name] = r; + } + const schema = new PostgresSchema({ + id: SCHEMA_NAME, + entries: { + table: { [TABLE_NAME]: profilesTable() }, + policy: policyEntries, + role: roleEntries, + }, + }); + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('sha256:project-from-contract-test'), + storage: new SqlStorage({ + storageHash: coreHash('sha256:project-from-contract-test'), + namespaces: { [SCHEMA_NAME]: schema }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensionPacks: {}, + meta: {}, + }; +} + +const projectionOptions = { + annotationNamespace: 'pg', + renderDefault: postgresRenderDefault, +} as const; + +describe('contractToPostgresDatabaseSchemaNode', () => { + it('returns a PostgresDatabaseSchemaNode root', () => { + const root = contractToPostgresDatabaseSchemaNode(makeContract({}), projectionOptions); + expect(PostgresDatabaseSchemaNode.is(root)).toBe(true); + expect(root.id).toBe('database'); + }); + + it('groups tables under a namespace node', () => { + const root = contractToPostgresDatabaseSchemaNode(makeContract({}), projectionOptions); + expect(Object.keys(root.namespaces)).toEqual([SCHEMA_NAME]); + const ns = root.namespaces[SCHEMA_NAME]; + expect(PostgresNamespaceSchemaNode.is(ns!)).toBe(true); + expect(Object.keys(ns!.tables)).toEqual([TABLE_NAME]); + expect(PostgresTableSchemaNode.is(ns!.tables[TABLE_NAME]!)).toBe(true); + }); + + it('children() of the root are namespace nodes', () => { + const root = contractToPostgresDatabaseSchemaNode(makeContract({}), projectionOptions); + expect(root.children()).toEqual([root.namespaces[SCHEMA_NAME]]); + }); + + it('attaches a SELECT policy to its table within the namespace', () => { + const policy = makePolicy('read_own_profiles_a1b2c3d4'); + const root = contractToPostgresDatabaseSchemaNode( + makeContract({ policies: [policy] }), + projectionOptions, + ); + const table = root.namespaces[SCHEMA_NAME]?.tables[TABLE_NAME]; + expect(table?.policies).toContainEqual(expect.objectContaining({ name: policy.name })); + }); + + it('carries owned DDL schema names in existingSchemas on the root', () => { + const root = contractToPostgresDatabaseSchemaNode(makeContract({}), projectionOptions); + expect(root.existingSchemas).toEqual([SCHEMA_NAME]); + }); + + it('puts roles on the root, not in children()', () => { + const role = new PostgresRole({ name: 'app_user', namespaceId: 'public' }); + const root = contractToPostgresDatabaseSchemaNode( + makeContract({ roles: [role] }), + projectionOptions, + ); + expect(root.roles).toContainEqual(expect.objectContaining({ name: 'app_user' })); + for (const child of root.children()) { + expect(PostgresNamespaceSchemaNode.is(child)).toBe(true); + } + }); + + it('returns an empty root for a null contract', () => { + const root = contractToPostgresDatabaseSchemaNode(null, projectionOptions); + expect(PostgresDatabaseSchemaNode.is(root)).toBe(true); + expect(root.namespaces).toEqual({}); + expect(root.roles).toEqual([]); + expect(root.existingSchemas).toEqual([]); + }); + + it('throws when a policy references a table absent from its namespace', () => { + const orphan = new PostgresRlsPolicy({ + name: 'read_orphan_deadbeef', + prefix: 'read_orphan', + tableName: 'missing_table', + namespaceId: SCHEMA_NAME, + operation: 'select', + roles: ['authenticated'], + permissive: true, + }); + expect(() => + contractToPostgresDatabaseSchemaNode(makeContract({ policies: [orphan] }), projectionOptions), + ).toThrow(/missing_table/); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts deleted file mode 100644 index ea886ee1f9..0000000000 --- a/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-schema-ir.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { coreHash, profileHash } from '@prisma-next/contract/types'; -import { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; -import { applicationDomainOf } from '@prisma-next/test-utils'; -import { describe, expect, it } from 'vitest'; -import { contractToPostgresSchemaIR } from '../../src/core/migrations/contract-to-postgres-schema-ir'; -import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; -import { type PostgresContract, PostgresSchema } from '../../src/core/postgres-schema'; -import { isPostgresSchemaIR } from '../../src/core/schema-ir/postgres-schema-ir'; -import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; -import { postgresRenderDefault } from '../../src/exports/control'; - -const TABLE_NAME = 'profiles'; -const SCHEMA_NAME = 'public'; - -function makePolicy(name: string): PostgresRlsPolicy { - return new PostgresRlsPolicy({ - name, - prefix: name.replace(/_[0-9a-f]{8}$/, ''), - tableName: TABLE_NAME, - namespaceId: SCHEMA_NAME, - operation: 'select', - roles: ['authenticated'], - using: '(auth.uid() = user_id)', - permissive: true, - }); -} - -function makeContract(policies: readonly PostgresRlsPolicy[]): PostgresContract { - const policyEntries: Record = {}; - for (const p of policies) { - policyEntries[p.name] = p; - } - const schema = new PostgresSchema({ - id: SCHEMA_NAME, - entries: { - table: { - [TABLE_NAME]: new StorageTable({ - columns: { - id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, - user_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, - }, - primaryKey: { columns: ['id'] }, - foreignKeys: [], - uniques: [], - indexes: [], - }), - }, - policy: policyEntries, - }, - }); - return { - target: 'postgres', - targetFamily: 'sql', - profileHash: profileHash('sha256:project-from-contract-test'), - storage: new SqlStorage({ - storageHash: coreHash('sha256:project-from-contract-test'), - namespaces: { [SCHEMA_NAME]: schema }, - }), - roots: {}, - domain: applicationDomainOf({ models: {} }), - capabilities: {}, - extensionPacks: {}, - meta: {}, - }; -} - -const projectionOptions = { - annotationNamespace: 'pg', - renderDefault: postgresRenderDefault, -} as const; - -describe('contractToPostgresSchemaIR', () => { - it('projects a SELECT policy into policies attached to the table', () => { - const policy = makePolicy('read_own_profiles_a1b2c3d4'); - const contract = makeContract([policy]); - - const ir = contractToPostgresSchemaIR(contract, projectionOptions); - - expect(isPostgresSchemaIR(ir)).toBe(true); - expect(ir.rlsPolicies).toContainEqual(expect.objectContaining({ name: policy.name })); - expect(Object.keys(ir.tables)).toEqual([TABLE_NAME]); - expect(PostgresTableSchemaNode.is(ir.tables[TABLE_NAME]!)).toBe(true); - expect(ir.tables[TABLE_NAME]?.policies).toContainEqual( - expect.objectContaining({ name: policy.name }), - ); - }); - - it('tables are PostgresTableSchemaNode instances', () => { - const policy = makePolicy('read_own_profiles_a1b2c3d4'); - const contract = makeContract([policy]); - - const ir = contractToPostgresSchemaIR(contract, projectionOptions); - - expect(Object.values(ir.tables).every(PostgresTableSchemaNode.is)).toBe(true); - }); - - it('returns no policies for a null contract', () => { - const ir = contractToPostgresSchemaIR(null, projectionOptions); - expect(ir.rlsPolicies).toEqual([]); - expect(ir.tables).toEqual({}); - expect(isPostgresSchemaIR(ir)).toBe(true); - }); -}); diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index 661901f516..fac8b6661a 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -60,7 +60,7 @@ import type { import { parsePostgresDefault } from '@prisma-next/target-postgres/default-normalizer'; import { normalizeSchemaNativeType } from '@prisma-next/target-postgres/native-type-normalizer'; import { - contractToPostgresSchemaIR, + contractToPostgresDatabaseSchemaNode, diffPostgresSchema, filterIssuesByOwnership, } from '@prisma-next/target-postgres/planner'; @@ -68,10 +68,11 @@ import { escapeLiteral, quoteIdentifier } from '@prisma-next/target-postgres/sql import { ensurePostgresSchemaIR, isPostgresSchemaIR, - PostgresRlsPolicy, - PostgresRole, - PostgresSchemaIR, - PostgresTableIR, + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + PostgresPolicySchemaNode, + PostgresRoleSchemaNode, + PostgresTableSchemaNode, } from '@prisma-next/target-postgres/types'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; @@ -137,7 +138,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { `Postgres schema diff requires a PostgresSchemaIR; got ${(schema as { constructor?: { name?: string } }).constructor?.name ?? typeof schema}`, ); } - const expected = contractToPostgresSchemaIR( + const expected = contractToPostgresDatabaseSchemaNode( blindCast< PostgresContract, 'collectSchemaDiffIssues is only called with a postgres contract' @@ -593,23 +594,53 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { driver: SqlControlDriverInstance<'postgres'>, contract?: unknown, schema = 'public', - ): Promise { + ): Promise { const declaredNamespaces = extractContractNamespaceIds(contract); - const ir = + const resolvedSchemas = declaredNamespaces.length > 0 - ? await this.introspectNamespaces(driver, declaredNamespaces) - : await this.introspectSchema(driver, schema); + ? await this.resolveNamespaceSchemas(driver, declaredNamespaces) + : [schema]; + + // Walk schemas sequentially: every introspectSchema call shares the one + // control connection, so a parallel walk only serialises behind the wire + // protocol and trips pg's "already executing a query" deprecation. + const namespaces: Record = {}; + let pgVersion = 'unknown'; + for (const resolved of resolvedSchemas) { + const { namespace, pgVersion: version } = await this.introspectSchema(driver, resolved); + namespaces[resolved] = namespace; + pgVersion = version; + } + + const roles = await this.introspectRoles(driver); const existingSchemas = await this.listExistingSchemas(driver); - return new PostgresSchemaIR({ - tables: ir.tables, - pgSchemaName: ir.pgSchemaName, - pgVersion: ir.pgVersion, - roles: ir.roles, + return new PostgresDatabaseSchemaNode({ + namespaces, + roles, existingSchemas, - nativeEnumTypeNames: ir.nativeEnumTypeNames, + pgVersion, }); } + /** + * Reads cluster-scoped database roles. Roles are not schema-qualified, so + * this is queried once for the whole database rather than per namespace. + */ + private async introspectRoles( + driver: SqlControlDriverInstance<'postgres'>, + ): Promise { + const rolesResult = await driver.query<{ rolname: string }>( + `SELECT rolname + FROM pg_catalog.pg_roles + WHERE rolname NOT LIKE 'pg_%' + AND rolname != 'postgres' + ORDER BY rolname`, + ); + return rolesResult.rows.map( + (row) => new PostgresRoleSchemaNode({ name: row.rolname, namespaceId: UNBOUND_NAMESPACE_ID }), + ); + } + /** * Lists every non-system schema present in the connected database. * The introspection consumer (`verifyPostgresNamespacePresence`) @@ -633,16 +664,16 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { } /** - * Walks every declared namespace, resolving `UNBOUND_NAMESPACE_ID` to - * the connection's `current_schema()`, and merges the per-schema results - * into a single `SqlSchemaIR`. The merged `tables` map is flat (keyed by - * table name) so callers that look up by `tableName` see every contract - * table regardless of which namespace it lives in. + * Resolves the declared namespace ids to their live DDL schema names, + * mapping `UNBOUND_NAMESPACE_ID` to the connection's `current_schema()` + * and de-duplicating. The caller introspects one namespace node per + * resolved schema — there is no flat cross-schema merge, so two schemas + * holding a same-named table no longer collide. */ - private async introspectNamespaces( + private async resolveNamespaceSchemas( driver: SqlControlDriverInstance<'postgres'>, namespaceIds: readonly string[], - ): Promise { + ): Promise { const resolvedSchemas: string[] = []; for (const id of namespaceIds) { if (id === UNBOUND_NAMESPACE_ID) { @@ -654,45 +685,19 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { resolvedSchemas.push(id); } } - const uniqueSchemas = Array.from(new Set(resolvedSchemas)); - - // Walk schemas sequentially: every introspectSchema call shares the one - // control connection, so a parallel walk only serialises behind the wire - // protocol and trips pg's "already executing a query" deprecation. - const perSchema: PostgresSchemaIR[] = []; - for (const schema of uniqueSchemas) { - perSchema.push(await this.introspectSchema(driver, schema)); - } - - const mergedTables: Record = {}; - const mergedRoles: PostgresRole[] = []; - for (const ir of perSchema) { - for (const [tableName, table] of Object.entries(ir.tables)) { - mergedTables[tableName] = table; - } - mergedRoles.push(...ir.roles); - } - - const first = perSchema[0]; - return new PostgresSchemaIR({ - tables: mergedTables, - pgSchemaName: first?.pgSchemaName ?? 'public', - pgVersion: first?.pgVersion ?? 'unknown', - roles: mergedRoles, - existingSchemas: first?.existingSchemas ?? ['public'], - nativeEnumTypeNames: first?.nativeEnumTypeNames ?? [], - }); + return Array.from(new Set(resolvedSchemas)); } /** - * Introspects a single Postgres schema and returns a raw SqlSchemaIR - * containing only the tables in that schema. Used by `introspect` as + * Introspects a single Postgres schema and returns the namespace node for + * that schema (its tables, their policies, and its native enum type names), + * alongside the cluster-scoped Postgres version. Used by `introspect` as * the per-namespace walk. */ private async introspectSchema( driver: SqlControlDriverInstance<'postgres'>, schema: string, - ): Promise { + ): Promise<{ readonly namespace: PostgresNamespaceSchemaNode; readonly pgVersion: string }> { // Issue the schema-wide queries one at a time. A single control connection // serialises queries anyway, so Promise.all buys no parallelism here and // makes pg emit a "client is already executing a query" deprecation. One @@ -1167,14 +1172,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { ORDER BY tablename, policyname`, [schema], ); - const rolesResult = await driver.query<{ rolname: string }>( - `SELECT rolname - FROM pg_catalog.pg_roles - WHERE rolname NOT LIKE 'pg_%' - AND rolname != 'postgres' - ORDER BY rolname`, - ); - const policiesByTable = new Map(); + const policiesByTable = new Map(); for (const row of policiesResult.rows) { const operation = mapPgCmd(row.cmd); const policyRoles = [ @@ -1183,7 +1181,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { const permissive = row.permissive.toUpperCase() === 'PERMISSIVE'; const hashSuffixMatch = /^(.+)_([0-9a-f]{8})$/.exec(row.policyname); const prefix = hashSuffixMatch?.[1] ?? row.policyname; - const policy = new PostgresRlsPolicy({ + const policy = new PostgresPolicySchemaNode({ name: row.policyname, prefix, tableName: row.tablename, @@ -1199,26 +1197,20 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { policiesByTable.set(row.tablename, list); } - const tables: Record = {}; + const tables: Record = {}; for (const [tableName, input] of Object.entries(tableInputs)) { - tables[tableName] = new PostgresTableIR({ + tables[tableName] = new PostgresTableSchemaNode({ ...input, - rlsPolicies: policiesByTable.get(tableName) ?? [], + policies: policiesByTable.get(tableName) ?? [], }); } - const roles: PostgresRole[] = rolesResult.rows.map( - (row) => new PostgresRole({ name: row.rolname, namespaceId: UNBOUND_NAMESPACE_ID }), - ); - - return new PostgresSchemaIR({ + const namespace = new PostgresNamespaceSchemaNode({ + schemaName: schema, tables, - pgSchemaName: schema, - pgVersion: await this.getPostgresVersion(driver), - roles, - existingSchemas: [], nativeEnumTypeNames, }); + return { namespace, pgVersion: await this.getPostgresVersion(driver) }; } /** diff --git a/packages/3-targets/6-adapters/postgres/test/control-adapter.check-constraints.test.ts b/packages/3-targets/6-adapters/postgres/test/control-adapter.check-constraints.test.ts index 02e5a534fe..c21aa8d3f5 100644 --- a/packages/3-targets/6-adapters/postgres/test/control-adapter.check-constraints.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/control-adapter.check-constraints.test.ts @@ -134,7 +134,7 @@ describe('PostgresControlAdapter.introspect — check constraints', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['post']?.checks).toEqual([ + expect(Object.values(result.namespaces)[0]?.tables['post']?.checks).toEqual([ { name: 'post_status_check', column: 'status', @@ -189,7 +189,7 @@ describe('PostgresControlAdapter.introspect — check constraints', () => { const result = await adapter.introspect(mockDriver); // The free-form CHECK predicate is silently skipped - expect(result.tables['order']?.checks).toBeUndefined(); + expect(Object.values(result.namespaces)[0]?.tables['order']?.checks).toBeUndefined(); }); it('does not add checks property when table has no check constraints', async () => { @@ -226,6 +226,6 @@ describe('PostgresControlAdapter.introspect — check constraints', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.checks).toBeUndefined(); + expect(Object.values(result.namespaces)[0]?.tables['user']?.checks).toBeUndefined(); }); }); diff --git a/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts b/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts index 9deed5303d..7013f5778f 100644 --- a/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts @@ -189,7 +189,7 @@ describe('PostgresControlAdapter column defaults', () => { ]); const result = await adapter.introspect(mockDriver); - const columns = result.tables['user']?.columns ?? {}; + const columns = Object.values(result.namespaces)[0]?.tables['user']?.columns ?? {}; // Defaults are stored as raw strings from the database expect(columns['id']).toMatchObject({ diff --git a/packages/3-targets/6-adapters/postgres/test/control-adapter.test.ts b/packages/3-targets/6-adapters/postgres/test/control-adapter.test.ts index 8298df1351..03e50de5a1 100644 --- a/packages/3-targets/6-adapters/postgres/test/control-adapter.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/control-adapter.test.ts @@ -1,11 +1,32 @@ import { CliStructuredError } from '@prisma-next/errors/control'; import type { SqlControlDriverInstance } from '@prisma-next/sql-contract/types'; import { normalizeSchemaNativeType } from '@prisma-next/target-postgres/native-type-normalizer'; +import type { + PostgresDatabaseSchemaNode, + PostgresTableSchemaNode, +} from '@prisma-next/target-postgres/types'; import { timeouts } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createPostgresBuiltinCodecLookup } from '../src/core/codec-lookup'; import { PostgresControlAdapter, parsePgReloptions } from '../src/core/control-adapter'; +/** + * These tests introspect a single schema, so the root holds exactly one + * namespace node. This helper returns that namespace's tables, replacing the + * old flat `result.tables` access. + */ +function tablesOf( + result: PostgresDatabaseSchemaNode, +): Readonly> { + const namespaces = Object.values(result.namespaces); + return namespaces[0]?.tables ?? {}; +} + +/** The sole introspected namespace's schema name. */ +function schemaNameOf(result: PostgresDatabaseSchemaNode): string | undefined { + return Object.values(result.namespaces)[0]?.schemaName; +} + type QueryHandler = { readonly match: (sql: string) => boolean; readonly rows: ReadonlyArray>; @@ -48,8 +69,8 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables).toEqual({}); - expect(result.pgSchemaName).toBe('public'); + expect(tablesOf(result)).toEqual({}); + expect(schemaNameOf(result)).toBe('public'); expect(result.pgVersion).toEqual(expect.any(String)); expect(result.existingSchemas).toEqual([]); }); @@ -154,13 +175,13 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables).toHaveProperty('user'); - expect(result.tables['user']?.columns).toHaveProperty('id'); - expect(result.tables['user']?.columns).toHaveProperty('email'); - expect(result.tables['user']?.columns['id']?.nativeType).toBe('int4'); - expect(result.tables['user']?.columns['email']?.nativeType).toBe('character varying(255)'); - expect(result.tables['user']?.columns['id']?.nullable).toBe(false); - expect(result.tables['user']?.primaryKey).toEqual({ + expect(tablesOf(result)).toHaveProperty('user'); + expect(tablesOf(result)['user']?.columns).toHaveProperty('id'); + expect(tablesOf(result)['user']?.columns).toHaveProperty('email'); + expect(tablesOf(result)['user']?.columns['id']?.nativeType).toBe('int4'); + expect(tablesOf(result)['user']?.columns['email']?.nativeType).toBe('character varying(255)'); + expect(tablesOf(result)['user']?.columns['id']?.nullable).toBe(false); + expect(tablesOf(result)['user']?.primaryKey).toEqual({ columns: ['id'], name: 'user_pkey', }); @@ -218,7 +239,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.columns['text_col']?.nativeType).toBe('character varying'); + expect(tablesOf(result)['user']?.columns['text_col']?.nativeType).toBe('character varying'); }); it('handles numeric with precision and scale', async () => { @@ -273,7 +294,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.columns['price']?.nativeType).toBe('numeric(10,2)'); + expect(tablesOf(result)['user']?.columns['price']?.nativeType).toBe('numeric(10,2)'); }); it('handles numeric with precision only', async () => { @@ -328,7 +349,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.columns['amount']?.nativeType).toBe('numeric(10)'); + expect(tablesOf(result)['user']?.columns['amount']?.nativeType).toBe('numeric(10)'); }); it('handles numeric without precision', async () => { @@ -383,7 +404,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.columns['value']?.nativeType).toBe('numeric'); + expect(tablesOf(result)['user']?.columns['value']?.nativeType).toBe('numeric'); }); it('maps json and jsonb columns to native types', async () => { @@ -448,8 +469,8 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['event']?.columns['payload']?.nativeType).toBe('jsonb'); - expect(result.tables['event']?.columns['raw']?.nativeType).toBe('json'); + expect(tablesOf(result)['event']?.columns['payload']?.nativeType).toBe('jsonb'); + expect(tablesOf(result)['event']?.columns['raw']?.nativeType).toBe('json'); }); it('uses formatted_type for bit length', async () => { @@ -482,7 +503,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.columns['flags']?.nativeType).toBe('bit(8)'); + expect(tablesOf(result)['user']?.columns['flags']?.nativeType).toBe('bit(8)'); }); it('normalizes formatted_type variants', async () => { @@ -581,13 +602,13 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.columns['name']?.nativeType).toBe('character varying(255)'); - expect(result.tables['user']?.columns['code']?.nativeType).toBe('character(4)'); - expect(result.tables['user']?.columns['flags']?.nativeType).toBe('bit varying(6)'); - expect(result.tables['user']?.columns['seen_at']?.nativeType).toBe('timestamptz(3)'); - expect(result.tables['user']?.columns['created_at']?.nativeType).toBe('timestamp(6)'); - expect(result.tables['user']?.columns['local_time']?.nativeType).toBe('time(0)'); - expect(result.tables['user']?.columns['zoned_time']?.nativeType).toBe('timetz(2)'); + expect(tablesOf(result)['user']?.columns['name']?.nativeType).toBe('character varying(255)'); + expect(tablesOf(result)['user']?.columns['code']?.nativeType).toBe('character(4)'); + expect(tablesOf(result)['user']?.columns['flags']?.nativeType).toBe('bit varying(6)'); + expect(tablesOf(result)['user']?.columns['seen_at']?.nativeType).toBe('timestamptz(3)'); + expect(tablesOf(result)['user']?.columns['created_at']?.nativeType).toBe('timestamp(6)'); + expect(tablesOf(result)['user']?.columns['local_time']?.nativeType).toBe('time(0)'); + expect(tablesOf(result)['user']?.columns['zoned_time']?.nativeType).toBe('timetz(2)'); }); it('handles foreign keys', async () => { @@ -654,7 +675,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['post']?.foreignKeys).toEqual([ + expect(tablesOf(result)['post']?.foreignKeys).toEqual([ { columns: ['user_id'], referencedTable: 'user', @@ -740,7 +761,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['order']?.foreignKeys).toEqual([ + expect(tablesOf(result)['order']?.foreignKeys).toEqual([ { columns: ['user_id', 'account_id'], referencedTable: 'account', @@ -811,7 +832,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.uniques).toEqual([ + expect(tablesOf(result)['user']?.uniques).toEqual([ { columns: ['email'], name: 'user_email_key', @@ -877,7 +898,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.uniques).toEqual([ + expect(tablesOf(result)['user']?.uniques).toEqual([ { columns: ['email', 'tenant_id'], name: 'user_email_tenant_key', @@ -945,7 +966,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.indexes).toEqual([ + expect(tablesOf(result)['user']?.indexes).toEqual([ { columns: ['name'], name: 'user_name_idx', @@ -1014,7 +1035,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.indexes).toEqual([ + expect(tablesOf(result)['user']?.indexes).toEqual([ { columns: ['email', 'tenant_id'], name: 'user_email_tenant_idx', @@ -1101,8 +1122,8 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.indexes).toHaveLength(1); - expect(result.tables['user']?.indexes[0]?.columns).toEqual(['id']); + expect(tablesOf(result)['user']?.indexes).toHaveLength(1); + expect(tablesOf(result)['user']?.indexes[0]?.columns).toEqual(['id']); }); it('handles custom schema name', async () => { @@ -1130,7 +1151,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver, undefined, 'custom_schema'); - expect(result.pgSchemaName).toBe('custom_schema'); + expect(schemaNameOf(result)).toBe('custom_schema'); }); it( @@ -1241,7 +1262,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.primaryKey).toBeUndefined(); + expect(tablesOf(result)['user']?.primaryKey).toBeUndefined(); }); it('handles primary key without constraint name', async () => { @@ -1305,10 +1326,10 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.primaryKey).toEqual({ + expect(tablesOf(result)['user']?.primaryKey).toEqual({ columns: ['id'], }); - expect(result.tables['user']?.primaryKey?.name).toBeUndefined(); + expect(tablesOf(result)['user']?.primaryKey?.name).toBeUndefined(); }); it('normalizes integer/float/bool formatted types', async () => { @@ -1396,12 +1417,12 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['metrics']?.columns['id']?.nativeType).toBe('int4'); - expect(result.tables['metrics']?.columns['small']?.nativeType).toBe('int2'); - expect(result.tables['metrics']?.columns['big']?.nativeType).toBe('int8'); - expect(result.tables['metrics']?.columns['real_col']?.nativeType).toBe('float4'); - expect(result.tables['metrics']?.columns['double_col']?.nativeType).toBe('float8'); - expect(result.tables['metrics']?.columns['active']?.nativeType).toBe('bool'); + expect(tablesOf(result)['metrics']?.columns['id']?.nativeType).toBe('int4'); + expect(tablesOf(result)['metrics']?.columns['small']?.nativeType).toBe('int2'); + expect(tablesOf(result)['metrics']?.columns['big']?.nativeType).toBe('int8'); + expect(tablesOf(result)['metrics']?.columns['real_col']?.nativeType).toBe('float4'); + expect(tablesOf(result)['metrics']?.columns['double_col']?.nativeType).toBe('float8'); + expect(tablesOf(result)['metrics']?.columns['active']?.nativeType).toBe('bool'); }); it('sorts multi-column primary key by ordinal position and skips PK from uniques', async () => { @@ -1469,11 +1490,11 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); - expect(result.tables['user']?.primaryKey).toEqual({ + expect(tablesOf(result)['user']?.primaryKey).toEqual({ columns: ['tenant_id', 'id'], name: 'user_pkey', }); - expect(result.tables['user']?.uniques).toEqual([]); + expect(tablesOf(result)['user']?.uniques).toEqual([]); }); }); @@ -1510,7 +1531,7 @@ describe('PostgresControlAdapter', () => { // format_type() returns '"BillingState"' for mixed-case enums; // introspection must strip the quotes so it matches the contract's unquoted name - expect(result.tables['Organization']?.columns['billingState']?.nativeType).toBe( + expect(tablesOf(result)['Organization']?.columns['billingState']?.nativeType).toBe( 'BillingState', ); }); @@ -1546,7 +1567,7 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); // Lowercase enum names are not quoted by format_type(), should pass through unchanged - expect(result.tables['user']?.columns['role']?.nativeType).toBe('role'); + expect(tablesOf(result)['user']?.columns['role']?.nativeType).toBe('role'); }); }); From 912b2f095519892dc8d86699dce3c4214966e494 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 19:06:42 +0200 Subject: [PATCH 08/49] =?UTF-8?q?wip(postgres):=20unit=206=20=E2=80=94=20f?= =?UTF-8?q?amily=20consumers=20walk=20the=20introspected=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit introspect()/verifySchema/planner-options/toSchemaView/collectSchemaDiffIssues now type the schema as the family-base SqlSchemaIRNode. The relational verify walks one per-schema namespace node at a time (namespaceSchemaNodes), never a flat merge — single-schema is one pass, byte-identical to the pre-tree verify; multi-schema contract scoping is CF-2. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-core/schema-ir/src/ir/sql-schema-ir.ts | 5 +- .../9-family/src/core/control-adapter.ts | 15 ++- .../9-family/src/core/control-instance.ts | 116 +++++++++++++++--- .../9-family/src/core/migrations/types.ts | 10 +- .../core/schema-verify/verify-sql-schema.ts | 31 ++++- .../9-family/src/exports/schema-verify.ts | 2 +- 6 files changed, 153 insertions(+), 26 deletions(-) diff --git a/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts b/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts index 4b08a1166b..e19a385305 100644 --- a/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts +++ b/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts @@ -31,8 +31,9 @@ export interface SqlSchemaIRInput { export class SqlSchemaIR extends SqlSchemaIRNode { // Optional on the type so plain-data `SqlSchemaIR`-shaped literals (common in // tests and the contract-derived schema) still satisfy it without restating - // the field; instances always carry the concrete `'sql'`. `isPostgresSchemaIR` - // reads it — an absent value is correctly not Postgres. + // the field; instances always carry the concrete `'sql'`. + // `PostgresDatabaseSchemaNode.is` reads it — an absent value is correctly not + // Postgres. readonly nodeTarget?: SqlSchemaTarget = 'sql'; readonly tables: Readonly>; declare readonly annotations?: SqlAnnotations; diff --git a/packages/2-sql/9-family/src/core/control-adapter.ts b/packages/2-sql/9-family/src/core/control-adapter.ts index 42ffc019fb..eb5f0d426c 100644 --- a/packages/2-sql/9-family/src/core/control-adapter.ts +++ b/packages/2-sql/9-family/src/core/control-adapter.ts @@ -16,7 +16,7 @@ import type { LowererContext, SqlExecuteRequest, } from '@prisma-next/sql-relational-core/ast'; -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import type { DefaultNormalizer, NativeTypeNormalizer } from './schema-verify/verify-sql-schema'; /** @@ -162,22 +162,27 @@ export interface SqlControlAdapter ): Promise; /** - * Introspects a database schema and returns a raw SqlSchemaIR. + * Introspects a database schema and returns the target's schema-IR node. * * This is a pure schema discovery operation that queries the database catalog * and returns the schema structure without type mapping or contract enrichment. * Type mapping and enrichment are handled separately by enrichment helpers. * + * The return type is the family-base `SqlSchemaIRNode` so each target returns + * its own node shape: SQLite returns a flat `SqlSchemaIR`, Postgres returns a + * `PostgresDatabaseSchemaNode` tree root. Consumers `ensure` the concrete + * target type before walking it. + * * @param driver - ControlDriverInstance instance for executing queries (target-specific) * @param contract - Optional contract for contract-guided introspection (filtering, optimization) * @param schema - Schema name to introspect (defaults to 'public') - * @returns Promise resolving to SqlSchemaIR representing the live database schema + * @returns Promise resolving to the live database schema node */ introspect( driver: SqlControlDriverInstance, contract?: unknown, schema?: string, - ): Promise; + ): Promise; /** * Optional target-specific normalizer for raw database default expressions. @@ -202,7 +207,7 @@ export interface SqlControlAdapter */ collectSchemaDiffIssues?( contract: Contract, - schema: SqlSchemaIR, + schema: SqlSchemaIRNode, ): readonly SchemaDiffIssue[]; /** diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index b835855e87..2ca29c78a0 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -44,7 +44,7 @@ import type { SqlExecuteRequest, } from '@prisma-next/sql-relational-core/ast'; import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming'; -import type { SqlSchemaIR, SqlTableIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIR, SqlSchemaIRNode, SqlTableIR } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { SqlControlAdapter } from './control-adapter'; @@ -55,7 +55,8 @@ import type { } from './migrations/types'; import { sqlOperationsToPreview } from './operation-preview'; import { sqlSchemaIrToPslAst } from './psl-contract-infer/sql-schema-ir-to-psl-ast'; -import { verifySqlSchema } from './schema-verify/verify-sql-schema'; +import type { DefaultNormalizer, NativeTypeNormalizer } from './schema-verify/verify-sql-schema'; +import { namespaceSchemaNodes, verifySqlSchema } from './schema-verify/verify-sql-schema'; import { collectSupportedCodecTypeIds } from './verify'; function extractCodecTypeIdsFromContract(contract: unknown): readonly string[] { @@ -192,9 +193,9 @@ interface SqlFamilyInstanceState { } export interface SqlControlFamilyInstance - extends ControlFamilyInstance<'sql', SqlSchemaIR>, - SchemaViewCapable, - PslContractInferCapable, + extends ControlFamilyInstance<'sql', SqlSchemaIRNode>, + SchemaViewCapable, + PslContractInferCapable, OperationPreviewCapable, SqlFamilyInstanceState { /** @@ -227,7 +228,7 @@ export interface SqlControlFamilyInstance */ verifySchema(options: { readonly contract: unknown; - readonly schema: SqlSchemaIR; + readonly schema: SqlSchemaIRNode; readonly strict: boolean; readonly frameworkComponents: ReadonlyArray>; }): VerifyDatabaseSchemaResult; @@ -242,9 +243,9 @@ export interface SqlControlFamilyInstance introspect(options: { readonly driver: SqlControlDriverInstance; readonly contract?: unknown; - }): Promise; + }): Promise; - inferPslContract(schemaIR: SqlSchemaIR): PslDocumentAst; + inferPslContract(schemaIR: SqlSchemaIRNode): PslDocumentAst; lowerAst( ast: AnyQueryAst | DdlNode, @@ -687,21 +688,27 @@ export function createSqlFamilyInstance( verifySchema(options: { readonly contract: unknown; - readonly schema: SqlSchemaIR; + readonly schema: SqlSchemaIRNode; readonly strict: boolean; readonly frameworkComponents: ReadonlyArray>; }): VerifyDatabaseSchemaResult { const contract = deserializeWithTargetSerializer(options.contract) as Contract; const controlAdapter = getControlAdapter(); - const sqlResult = verifySqlSchema({ + // The relational verify walks one per-schema namespace node at a time + // (never a merged flat schema — that would collide same-named tables + // across schemas). A flat schema (SQLite) is its own single namespace. + const namespaceNodes = namespaceSchemaNodes(options.schema); + const sqlResult = verifyContractAgainstNamespaceNodes({ contract, - schema: options.schema, + namespaceNodes, strict: options.strict, typeMetadataRegistry, frameworkComponents: options.frameworkComponents, ...ifDefined('normalizeDefault', controlAdapter.normalizeDefault), ...ifDefined('normalizeNativeType', controlAdapter.normalizeNativeType), }); + // The target's RLS diff machinery ensures and walks the tree root, so it + // receives the node unchanged (not the per-namespace relational view). const rawSchemaDiffIssues = controlAdapter.collectSchemaDiffIssues?.(contract, options.schema) ?? []; const schemaDiffIssues = filterSchemaDiffIssues( @@ -885,11 +892,11 @@ export function createSqlFamilyInstance( async introspect(options: { readonly driver: SqlControlDriverInstance; readonly contract?: unknown; - }): Promise { + }): Promise { return getControlAdapter().introspect(options.driver, options.contract); }, - inferPslContract(schemaIR: SqlSchemaIR): PslDocumentAst { + inferPslContract(schemaIR: SqlSchemaIRNode): PslDocumentAst { return sqlSchemaIrToPslAst(schemaIR); }, @@ -912,8 +919,15 @@ export function createSqlFamilyInstance( return sqlOperationsToPreview(operations); }, - toSchemaView(schema: SqlSchemaIR): CoreSchemaView { - const tableNodes: readonly SchemaTreeNode[] = Object.entries(schema.tables).map( + toSchemaView(schema: SqlSchemaIRNode): CoreSchemaView { + // Walk root → namespaces → tables into one flat list of table nodes. The + // single-schema common case (one namespace node) renders the same + // table-level view as today — no synthetic namespace level. A flat schema + // (SQLite) is its own single namespace. + const tableEntries: ReadonlyArray<[string, SqlTableIR]> = namespaceSchemaNodes( + schema, + ).flatMap((namespace) => Object.entries(namespace.tables)); + const tableNodes: readonly SchemaTreeNode[] = tableEntries.map( ([tableName, table]: [string, SqlTableIR]) => { const children: SchemaTreeNode[] = []; @@ -1032,6 +1046,78 @@ export function createSqlFamilyInstance( }; } +/** + * Runs the relational verify against the introspected namespace nodes — one + * `verifySqlSchema` pass per node, the nodes never merged so same-named tables + * in different schemas can't collide. + * + * Single-schema (one node, the common case) is one pass of the whole contract + * against the sole namespace node — byte-identical to the pre-tree flat verify. + * SQLite (a flat schema, its own single namespace) is likewise one pass. + * + * Genuine multi-schema (more than one node) is the open CF-2 item: the expected + * (contract) side still routes through the flat `contractToSchemaIR`, so it is + * not yet scoped per namespace. Pairing each contract namespace to its live + * node needs target DDL-schema resolution, which the family layer can't do + * generically. Until that lands, each node is verified against the whole + * contract; tables a node doesn't own surface as `missing_table`. This is + * flagged, not worked around with a collision-prone flat merge. + */ +function verifyContractAgainstNamespaceNodes(options: { + readonly contract: Contract; + readonly namespaceNodes: readonly SqlSchemaIR[]; + readonly strict: boolean; + readonly typeMetadataRegistry: ReadonlyMap; + readonly frameworkComponents: ReadonlyArray>; + readonly normalizeDefault?: DefaultNormalizer; + readonly normalizeNativeType?: NativeTypeNormalizer; +}): VerifyDatabaseSchemaResult { + const baseOptions = { + contract: options.contract, + strict: options.strict, + typeMetadataRegistry: options.typeMetadataRegistry, + frameworkComponents: options.frameworkComponents, + ...ifDefined('normalizeDefault', options.normalizeDefault), + ...ifDefined('normalizeNativeType', options.normalizeNativeType), + }; + const [first, ...rest] = options.namespaceNodes; + const firstResult = verifySqlSchema({ ...baseOptions, schema: first ?? { tables: {} } }); + if (rest.length === 0) return firstResult; + return rest.reduce( + (acc, node) => mergeVerifyResults(acc, verifySqlSchema({ ...baseOptions, schema: node })), + firstResult, + ); +} + +/** + * Combines two `VerifyDatabaseSchemaResult`s by concatenating issues and + * summing counts. Used to fold the per-namespace verify passes of a + * multi-schema database (CF-2) into one result. The `root` verification node + * of the first pass is retained — multi-schema verify-tree shaping is part of + * the open CF-2 work, not this slice. + */ +function mergeVerifyResults( + a: VerifyDatabaseSchemaResult, + b: VerifyDatabaseSchemaResult, +): VerifyDatabaseSchemaResult { + return { + ...a, + ok: a.ok && b.ok, + ...ifDefined('code', a.code ?? b.code), + schema: { + ...a.schema, + issues: [...a.schema.issues, ...b.schema.issues], + schemaDiffIssues: [...a.schema.schemaDiffIssues, ...b.schema.schemaDiffIssues], + counts: { + pass: a.schema.counts.pass + b.schema.counts.pass, + warn: a.schema.counts.warn + b.schema.counts.warn, + fail: a.schema.counts.fail + b.schema.counts.fail, + totalNodes: a.schema.counts.totalNodes + b.schema.counts.totalNodes, + }, + }, + }; +} + /** * Filters schema diff issues (from `collectSchemaDiffIssues`) through the * contract's `defaultControlPolicy`. Issues whose outcome maps to a suppressed diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index ea0c757b4f..a309f0f791 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -30,7 +30,7 @@ import type { StorageTypeInstance, } from '@prisma-next/sql-contract/types'; import type { SqlOperationDescriptors } from '@prisma-next/sql-operations'; -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import type { Result } from '@prisma-next/utils/result'; import type { SqlControlAdapter } from '../control-adapter'; import type { SqlControlFamilyInstance } from '../control-instance'; @@ -308,7 +308,13 @@ export type SqlPlannerResult = export interface SqlMigrationPlannerPlanOptions { readonly contract: Contract; - readonly schema: SqlSchemaIR; + /** + * The "from"/live schema as the target's introspected node. SQLite returns a + * flat `SqlSchemaIR`; Postgres returns a `PostgresDatabaseSchemaNode` tree + * root. Structure-aware consumers (the differ, the relational verify glue) + * `ensure`/flatten the concrete shape before walking it. + */ + readonly schema: SqlSchemaIRNode; readonly policy: MigrationOperationPolicy; readonly schemaName?: string; /** diff --git a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts index c84de38d81..5899142d00 100644 --- a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts +++ b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts @@ -25,8 +25,9 @@ import { type StorageTable, type StorageTypeInstance, } from '@prisma-next/sql-contract/types'; -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { canonicalStringify } from '@prisma-next/utils/canonical-stringify'; +import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { extractCodecControlHooks } from '../assembly'; import { resolveValueSetValues } from '../migrations/contract-to-schema-ir'; @@ -43,6 +44,34 @@ import { verifyUniqueConstraints, } from './verify-helpers'; +/** + * Returns the per-schema namespace nodes of an introspected schema node, for + * the relational verify and schema-view to consume one at a time. + * + * Structure-agnostic — it imports no target node class. A tree root that + * exposes a `namespaces` record (Postgres) yields its namespace nodes; the + * nodes are NEVER merged, so same-named tables in different schemas cannot + * collide. A flat schema (SQLite, or a single namespace node) has no + * `namespaces` and is its own single namespace, so it yields itself. + * Duck-typing mirrors `projectSchemaToSpace`, which spreads these nodes into + * plain objects, so the helper also handles spread-flattened input. + */ +export function namespaceSchemaNodes(schema: SqlSchemaIRNode): readonly SqlSchemaIR[] { + const obj = blindCast< + { readonly namespaces?: Readonly> }, + 'structural read of an own-enumerable namespaces record; survives the projectSchemaToSpace spread' + >(schema); + if (obj.namespaces !== undefined) { + return Object.values(obj.namespaces); + } + return [ + blindCast< + SqlSchemaIR, + 'a flat schema node (no namespaces) is its own single namespace, exposing the per-schema { tables } shape' + >(schema), + ]; +} + /** * Function type for normalizing raw database default expressions into ColumnDefault. * Target-specific implementations handle database dialect differences. diff --git a/packages/2-sql/9-family/src/exports/schema-verify.ts b/packages/2-sql/9-family/src/exports/schema-verify.ts index 04ecacd7a5..ed312e7d2e 100644 --- a/packages/2-sql/9-family/src/exports/schema-verify.ts +++ b/packages/2-sql/9-family/src/exports/schema-verify.ts @@ -15,4 +15,4 @@ export type { NativeTypeNormalizer, VerifySqlSchemaOptions, } from '../core/schema-verify/verify-sql-schema'; -export { verifySqlSchema } from '../core/schema-verify/verify-sql-schema'; +export { namespaceSchemaNodes, verifySqlSchema } from '../core/schema-verify/verify-sql-schema'; From 5e11ab3f09a47a9ca400e170aa39577f078a2756 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 19:07:08 +0200 Subject: [PATCH 09/49] =?UTF-8?q?wip(postgres):=20unit=206=20=E2=80=94=20d?= =?UTF-8?q?iffer/planner=20walk=20the=20root;=20retire=20PostgresSchemaIR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The differ + planner ensure the PostgresDatabaseSchemaNode root and walk the tree; guards switch to PostgresPolicySchemaNode.is/.assert. collectSchemaIssues verifies per namespace node (empty root → one empty pass so contract tables surface as missing). CF-1: existingSchemasFromSchema reads existingSchemas off the database root, never the public default. PostgresSchemaIR + its is/assert/ensure are deleted; consumers use PostgresDatabaseSchemaNode statics. Node ensure() deep-reconstructs namespace/table/policy nodes from the projectSchemaToSpace spread so the differ always sees real DiffableNodes. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../core/migrations/diff-postgres-schema.ts | 30 ++-- .../postgres/src/core/migrations/planner.ts | 134 +++++++++++++---- .../postgres/src/core/migrations/runner.ts | 39 +++-- .../migrations/verify-postgres-namespaces.ts | 31 ++-- .../postgres-database-schema-node.ts | 31 +++- .../postgres-namespace-schema-node.ts | 20 ++- .../schema-ir/postgres-policy-schema-node.ts | 8 + .../postgres-schema-ir-annotations.ts | 13 +- .../src/core/schema-ir/postgres-schema-ir.ts | 138 ------------------ .../schema-ir/postgres-table-schema-node.ts | 15 +- .../3-targets/postgres/src/exports/types.ts | 7 - .../postgres/src/core/control-adapter.ts | 22 +-- 12 files changed, 241 insertions(+), 247 deletions(-) delete mode 100644 packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts index 03a505e2a2..7c1479d648 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts @@ -1,19 +1,20 @@ import type { SchemaDiffIssue } from '@prisma-next/framework-components/control'; import { diffSchemas } from '@prisma-next/framework-components/control'; -import { isPostgresRlsPolicy, type PostgresRlsPolicy } from '../postgres-rls-policy'; -import { ensurePostgresSchemaIR, type PostgresSchemaIR } from '../schema-ir/postgres-schema-ir'; +import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; // Renders a display-only reference string for the diff message. If policy // rendering grows, route it through the adapter's SQL renderer so the message // can't diverge from the emitted policy SQL. -function renderPostgresPolicyReference(policy: PostgresRlsPolicy): string { +function renderPostgresPolicyReference(policy: PostgresPolicySchemaNode): string { return `policy "${policy.name}" on "${policy.namespaceId}"."${policy.tableName}"`; } /** - * Computes schema drift between two derived schema IRs. + * Computes schema drift between two derived schema trees. * - * 1. Runs the framework total diff. + * 1. Runs the framework total diff over the two `PostgresDatabaseSchemaNode` + * roots (database → namespace → table → policy). * 2. Filters to policy-subject issues only — this is transitional: the generic * differ walks the whole tree, but the legacy relational verifier still owns * table/column drift, so non-policy issues are dropped here. @@ -23,24 +24,27 @@ function renderPostgresPolicyReference(policy: PostgresRlsPolicy): string { * own) is the caller's responsibility — use `filterIssuesByOwnership`. */ export function diffPostgresSchema( - expected: PostgresSchemaIR, - actual: PostgresSchemaIR, + expected: PostgresDatabaseSchemaNode, + actual: PostgresDatabaseSchemaNode, ): readonly SchemaDiffIssue[] { - const safeActual = ensurePostgresSchemaIR(actual); + const safeActual = PostgresDatabaseSchemaNode.ensure(actual); const issues = diffSchemas(expected, safeActual); return issues - .filter((i) => isPostgresRlsPolicy(i.expected ?? i.actual)) + .filter((i) => { + const node = i.expected ?? i.actual; + return node !== undefined && PostgresPolicySchemaNode.is(node); + }) .map((i) => { const policy = i.expected ?? i.actual; - if (!isPostgresRlsPolicy(policy)) return i; + if (policy === undefined || !PostgresPolicySchemaNode.is(policy)) return i; return { ...i, message: `${i.outcome}: ${renderPostgresPolicyReference(policy)}` }; }); } /** * Filters `extra` policy issues to those in owned namespaces. Call after - * `diffPostgresSchema` with the union of namespace ids from the expected IR's + * `diffPostgresSchema` with the union of namespace ids from the expected tree's * policies and its `existingSchemas`. */ export function filterIssuesByOwnership( @@ -50,6 +54,8 @@ export function filterIssuesByOwnership( return issues.filter( (i) => i.outcome !== 'extra' || - (isPostgresRlsPolicy(i.actual) && ownedSchemaNames.has(i.actual.namespaceId)), + (i.actual !== undefined && + PostgresPolicySchemaNode.is(i.actual) && + ownedSchemaNames.has(i.actual.namespaceId)), ); } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 73b1556f02..0c9d2c48bf 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -13,7 +13,7 @@ import { plannerFailure, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; -import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; +import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { MigrationPlanner, @@ -22,12 +22,15 @@ import type { SchemaIssue, } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; +import { ifDefined } from '@prisma-next/utils/defined'; import { parsePostgresDefault } from '../default-normalizer'; import { normalizeSchemaNativeType } from '../native-type-normalizer'; -import { assertPostgresRlsPolicy } from '../postgres-rls-policy'; +import { PostgresRlsPolicy } from '../postgres-rls-policy'; import type { PostgresContract } from '../postgres-schema'; -import { assertPostgresSchemaIR, ensurePostgresSchemaIR } from '../schema-ir/postgres-schema-ir'; +import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; import { resolveDdlSchemaForNamespaceStorage } from '../schema-ir/postgres-schema-ir-annotations'; import { contractToPostgresDatabaseSchemaNode } from './contract-to-postgres-database-schema-node'; import { @@ -165,6 +168,13 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr const schemaIssues = this.collectSchemaIssues(options); const codecHooks = extractCodecControlHooks(options.frameworkComponents); const storageTypes = options.contract.storage.types ?? {}; + // The strategy layer reads the live schema by bare table name for + // existence checks (shared-temp-default safety, FK/unique probes). It + // takes the per-schema namespace node, never the whole tree root — and + // never a flat merge of every namespace (that would collide same-named + // tables across schemas). Single-schema is the one node matching the + // planner's resolved schema name; multi-schema scoping is CF-2. + const relationalSchema = relationalNamespaceNode(options.schema, schemaName); // Input-side control-policy partition. `external` / `observed` subjects // — and non-creation issues for `tolerated` subjects — are dropped from @@ -195,7 +205,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr schemaName, codecHooks, storageTypes, - schema: options.schema, + ...ifDefined('schema', relationalSchema), policy: options.policy, frameworkComponents: options.frameworkComponents, strategies: postgresPlannerStrategies, @@ -266,19 +276,19 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr private planPostgresSchemaDiff( options: PlannerOptionsWithComponents, ): readonly PostgresOpFactoryCall[] { - assertPostgresSchemaIR(options.schema); + PostgresDatabaseSchemaNode.assert(options.schema); const expected = contractToPostgresDatabaseSchemaNode( blindCast( options.contract, ), { annotationNamespace: 'pg' }, ); - const actual = ensurePostgresSchemaIR(options.schema); + const actual = PostgresDatabaseSchemaNode.ensure(options.schema); const rawIssues = diffPostgresSchema(expected, actual); - const ownedSchemaNames = new Set([ - ...expected.rlsPolicies.map((p) => p.namespaceId), - ...expected.existingSchemas, - ]); + const expectedPolicyNamespaces = Object.values(expected.namespaces).flatMap((ns) => + Object.values(ns.tables).flatMap((t) => t.policies.map((p) => p.namespaceId)), + ); + const ownedSchemaNames = new Set([...expectedPolicyNamespaces, ...expected.existingSchemas]); const filteredDiffIssues = filterIssuesByOwnership(rawIssues, ownedSchemaNames); const allowsDestructive = options.policy.allowedOperationClasses.includes('destructive'); @@ -290,13 +300,12 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // encodes the body hash, so two policies sharing a local key (same name) // are always equal and isEqualTo never returns false. if (issue.outcome === 'missing') { - assertPostgresRlsPolicy(issue.expected); + PostgresPolicySchemaNode.assert(issue.expected); // issue.expected.namespaceId is the DDL schema name (resolved during projection); // this re-resolution is a no-op as long as PostgresSchema.ddlSchemaName() returns this.id. const schemaForTable = resolveDdlSchemaForNamespaceStorage( options.contract.storage, issue.expected.namespaceId, - options.schema, ); const tableKey = `${schemaForTable}.${issue.expected.tableName}`; if (!seenEnableTables.has(tableKey)) { @@ -304,14 +313,17 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr calls.push(new EnableRowLevelSecurityCall(schemaForTable, issue.expected.tableName)); } calls.push( - new CreatePostgresRlsPolicyCall(schemaForTable, issue.expected.tableName, issue.expected), + new CreatePostgresRlsPolicyCall( + schemaForTable, + issue.expected.tableName, + policyNodeToContractPolicy(issue.expected), + ), ); } else if (issue.outcome === 'extra' && allowsDestructive) { - assertPostgresRlsPolicy(issue.actual); + PostgresPolicySchemaNode.assert(issue.actual); const schemaForTable = resolveDdlSchemaForNamespaceStorage( options.contract.storage, issue.actual.namespaceId, - options.schema, ); calls.push( new DropPostgresRlsPolicyCall(schemaForTable, issue.actual.tableName, issue.actual.name), @@ -341,29 +353,97 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // destructive) must inspect extras to reconcile strict equality. const allowed = options.policy.allowedOperationClasses; const strict = allowed.includes('widening') || allowed.includes('destructive'); - const verifyOptions: VerifySqlSchemaOptionsWithComponents = { - contract: options.contract, - schema: options.schema, - strict, - typeMetadataRegistry: new Map(), - frameworkComponents: options.frameworkComponents, - normalizeDefault: parsePostgresDefault, - normalizeNativeType: normalizeSchemaNativeType, - }; - const verifyResult = verifySqlSchema(verifyOptions); + // The relational verify walks one per-schema namespace node at a time, + // never a flat merge of every namespace — a merge would silently collide + // same-named tables across schemas (the dual-representation flatten the + // tree restructure removed). Single-schema (the common case) is the sole + // namespace node, byte-identical to the pre-tree flat verify; multi-schema + // verify-side contract scoping is CF-2. A fresh database (empty root, no + // namespaces) still runs one pass against an empty schema so the contract's + // tables surface as `missing_table` — the same as the pre-tree empty + // flat schema. + const verifyIssues = relationalSchemaNodes(options.schema).flatMap((namespaceNode) => { + const verifyOptions: VerifySqlSchemaOptionsWithComponents = { + contract: options.contract, + schema: namespaceNode, + strict, + typeMetadataRegistry: new Map(), + frameworkComponents: options.frameworkComponents, + normalizeDefault: parsePostgresDefault, + normalizeNativeType: normalizeSchemaNativeType, + }; + return verifySqlSchema(verifyOptions).schema.issues; + }); // Schema presence is a Postgres-specific concern (no equivalent in // SQLite / Mongo), so the issue emission lives in the target layer // rather than in the family verifier. Stitch it in here so a single // `SchemaIssue[]` flows through `planIssues` and the planner emits // CREATE SCHEMA in the dep bucket before any CreateTableCall. + // It reads `existingSchemas` off the database root (CF-1), so it takes the + // whole tree, not a per-namespace node. // Schema drift is handled separately via diffPostgresSchema → planPostgresSchemaDiff. const namespaceIssues = verifyPostgresNamespacePresence({ contract: options.contract, schema: options.schema, }); if (namespaceIssues.length === 0) { - return verifyResult.schema.issues; + return verifyIssues; } - return [...namespaceIssues, ...verifyResult.schema.issues]; + return [...namespaceIssues, ...verifyIssues]; } } + +/** + * The per-schema namespace nodes the relational verify runs against, one pass + * each. A fresh database (empty root, no namespaces) yields a single empty + * schema so the contract's tables surface as `missing_table` — the pre-tree + * empty flat schema behaviour. + */ +function relationalSchemaNodes(schema: SqlSchemaIRNode): readonly SqlSchemaIR[] { + const namespaceNodes = namespaceSchemaNodes(schema); + return namespaceNodes.length > 0 ? namespaceNodes : [{ tables: {} }]; +} + +/** + * Selects the per-schema namespace node the relational strategy layer probes + * for live-table existence. Prefers the node matching the planner's resolved + * schema name; otherwise the sole namespace node (the single-schema common + * case). Returns `undefined` when the tree carries no namespaces, so the + * strategy context falls back to its empty-schema default. + * + * Multi-schema selection by name is CF-2: the relational strategies key tables + * by bare name, so only one namespace's tables can be probed at a time. + */ +function relationalNamespaceNode( + schema: SqlSchemaIRNode, + schemaName: string, +): SqlSchemaIR | undefined { + const namespaceNodes = namespaceSchemaNodes(schema); + const byName = namespaceNodes.find( + (node) => + blindCast<{ readonly schemaName?: string }, 'reading the namespace node schema name'>(node) + .schemaName === schemaName, + ); + return byName ?? namespaceNodes[0]; +} + +/** + * Rebuilds the serialized `PostgresRlsPolicy` contract entity from a policy + * schema node. The migration op (`CreatePostgresRlsPolicyCall`) carries the + * authored contract entity — its `renderTypeScript`/`createRlsPolicy` paths + * serialize it — so the planner converts the diff node back to the entity the + * call type expects, preserving byte-identical migration output. + */ +function policyNodeToContractPolicy(node: PostgresPolicySchemaNode): PostgresRlsPolicy { + return new PostgresRlsPolicy({ + name: node.name, + prefix: node.prefix, + tableName: node.tableName, + namespaceId: node.namespaceId, + operation: node.operation, + roles: [...node.roles], + ...ifDefined('using', node.using), + ...ifDefined('withCheck', node.withCheck), + permissive: node.permissive, + }); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts index f479618b95..618b4d372f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts @@ -12,7 +12,7 @@ import type { SqlMigrationRunnerSuccessValue, } from '@prisma-next/family-sql/control'; import { runnerFailure, runnerSuccess } from '@prisma-next/family-sql/control'; -import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; +import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import type { MigrationRunnerResult } from '@prisma-next/framework-components/control'; import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; @@ -130,25 +130,32 @@ class PostgresMigrationRunner implements SqlMigrationRunner; - readonly schema: SqlSchemaIR; + readonly schema: SqlSchemaIRNode; }): readonly SchemaIssue[] { const { contract, schema } = input; const existing = new Set(existingSchemasFromSchema(schema)); diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts index f7013a74f9..5d10a9f731 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts @@ -2,12 +2,17 @@ import type { DiffableNode } from '@prisma-next/framework-components/control'; import { freezeNode } from '@prisma-next/framework-components/ir'; import { SqlSchemaIRNode, type SqlSchemaTarget } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; -import type { PostgresNamespaceSchemaNode } from './postgres-namespace-schema-node'; -import type { PostgresRoleSchemaNode } from './postgres-role-schema-node'; +import { + PostgresNamespaceSchemaNode, + type PostgresNamespaceSchemaNodeInput, +} from './postgres-namespace-schema-node'; +import { PostgresRoleSchemaNode } from './postgres-role-schema-node'; export interface PostgresDatabaseSchemaNodeInput { - readonly namespaces: Readonly>; - readonly roles: readonly PostgresRoleSchemaNode[]; + readonly namespaces: Readonly< + Record + >; + readonly roles: readonly (PostgresRoleSchemaNode | { name: string; namespaceId: string })[]; readonly existingSchemas: readonly string[]; readonly pgVersion: string; } @@ -36,8 +41,22 @@ export class PostgresDatabaseSchemaNode extends SqlSchemaIRNode implements Diffa constructor(input: PostgresDatabaseSchemaNodeInput) { super(); - this.namespaces = input.namespaces; - this.roles = Object.freeze([...input.roles]); + // Reconstruct namespace/role nodes from plain objects: `projectSchemaToSpace` + // spreads the tree into plain objects (losing prototypes) before this root + // is `ensure`d, so the differ must still see real `DiffableNode`s. + this.namespaces = Object.freeze( + Object.fromEntries( + Object.entries(input.namespaces).map(([key, ns]) => [ + key, + ns instanceof PostgresNamespaceSchemaNode ? ns : new PostgresNamespaceSchemaNode(ns), + ]), + ), + ); + this.roles = Object.freeze( + input.roles.map((r) => + r instanceof PostgresRoleSchemaNode ? r : new PostgresRoleSchemaNode(r), + ), + ); this.existingSchemas = Object.freeze([...input.existingSchemas]); this.pgVersion = input.pgVersion; freezeNode(this); diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts index af606b8d90..2cf1d8ecf3 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts @@ -5,11 +5,14 @@ import { SqlSchemaIRNode, type SqlSchemaTarget, } from '@prisma-next/sql-schema-ir/types'; -import type { PostgresTableSchemaNode } from './postgres-table-schema-node'; +import { + PostgresTableSchemaNode, + type PostgresTableSchemaNodeInput, +} from './postgres-table-schema-node'; export interface PostgresNamespaceSchemaNodeInput { readonly schemaName: string; - readonly tables: Readonly>; + readonly tables: Readonly>; readonly nativeEnumTypeNames: readonly string[]; } @@ -38,7 +41,16 @@ export class PostgresNamespaceSchemaNode extends SqlSchemaIRNode implements Diff constructor(input: PostgresNamespaceSchemaNodeInput) { super(); this.schemaName = input.schemaName; - this.tables = input.tables; + // Reconstruct table nodes from plain objects: `projectSchemaToSpace` + // spreads the tree into plain objects before a consumer `ensure`s the root. + this.tables = Object.freeze( + Object.fromEntries( + Object.entries(input.tables).map(([key, t]) => [ + key, + t instanceof PostgresTableSchemaNode ? t : new PostgresTableSchemaNode(t), + ]), + ), + ); this.nativeEnumTypeNames = Object.freeze([...input.nativeEnumTypeNames]); this.annotations = { pg: { @@ -63,7 +75,7 @@ export class PostgresNamespaceSchemaNode extends SqlSchemaIRNode implements Diff return Object.values(this.tables); } - static is(node: DiffableNode): node is PostgresNamespaceSchemaNode { + static is(node: unknown): node is PostgresNamespaceSchemaNode { return node instanceof PostgresNamespaceSchemaNode; } } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts index 8adb8d7dd7..64d7033024 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts @@ -79,4 +79,12 @@ export class PostgresPolicySchemaNode extends SqlSchemaIRNode implements Diffabl static is(node: DiffableNode): node is PostgresPolicySchemaNode { return node instanceof PostgresPolicySchemaNode; } + + static assert(node: DiffableNode | undefined): asserts node is PostgresPolicySchemaNode { + if (node === undefined || !PostgresPolicySchemaNode.is(node)) { + throw new Error( + `Expected a PostgresPolicySchemaNode, got ${node?.constructor?.name ?? typeof node}`, + ); + } + } } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir-annotations.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir-annotations.ts index cf93aaceb9..cc07e1b6a4 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir-annotations.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir-annotations.ts @@ -1,18 +1,19 @@ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { isPostgresSchema } from '../postgres-schema'; -import { isPostgresSchemaIR } from './postgres-schema-ir'; +/** + * Resolves a namespace coordinate to its live DDL schema name. Named + * Postgres namespaces dispatch to `ddlSchemaName(storage)`; the unbound + * sentinel resolves to `public` (the search-path default for offline + * planning); bare object payloads fall back to the coordinate itself. + */ export function resolveDdlSchemaForNamespaceStorage( storage: SqlStorage, namespaceId: string, - schemaIr?: SqlSchemaIR, ): string { if (namespaceId === UNBOUND_NAMESPACE_ID) { - return ( - (schemaIr && isPostgresSchemaIR(schemaIr) ? schemaIr.pgSchemaName : undefined) ?? 'public' - ); + return 'public'; } const namespace = storage.namespaces[namespaceId]; if (namespace && isPostgresSchema(namespace)) { diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts deleted file mode 100644 index 4787ae408c..0000000000 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { DiffableNode } from '@prisma-next/framework-components/control'; -import { freezeNode } from '@prisma-next/framework-components/ir'; -import { - type SqlAnnotations, - type SqlSchemaIR, - SqlSchemaIRNode, - type SqlSchemaTarget, - type SqlTableIRInput, -} from '@prisma-next/sql-schema-ir/types'; -import { blindCast } from '@prisma-next/utils/casts'; -import type { PostgresRole } from '../postgres-role'; -import type { PostgresPolicySchemaNode } from './postgres-policy-schema-node'; -import { PostgresTableSchemaNode } from './postgres-table-schema-node'; - -export interface PostgresSchemaIRInput { - readonly tables: Record< - string, - PostgresTableSchemaNode | (SqlTableIRInput & { policies?: readonly PostgresPolicySchemaNode[] }) - >; - readonly pgSchemaName: string; - readonly pgVersion: string; - readonly roles: readonly PostgresRole[]; - readonly existingSchemas: readonly string[]; - readonly nativeEnumTypeNames: readonly string[]; -} - -/** - * Postgres-specific schema IR. Mirrors the structure of `SqlSchemaIR` - * (same `tables` + optional `annotations` fields) and adds typed fields for - * data the postgres adapter collects during introspection. - * - * Extends `SqlSchemaIRNode` directly rather than `SqlSchemaIR` because - * `SqlSchemaIR` calls `freezeNode` in its constructor, which prevents - * subclass field initialisation. `PostgresSchemaIR` replicates the minimal - * `SqlSchemaIR` structure and freezes itself at the end of its own - * constructor. - * - * `tables` holds `PostgresTableSchemaNode` instances which carry their own RLS - * policies. `children()` returns the tables directly — the table instances - * ARE the diff-tree nodes. - * - * Nothing RLS-specific leaks into the sql-family layer. - */ -export class PostgresSchemaIR extends SqlSchemaIRNode implements DiffableNode { - readonly nodeTarget: SqlSchemaTarget = 'postgres'; - readonly tables: Readonly>; - declare readonly annotations?: SqlAnnotations; - readonly pgSchemaName: string; - readonly pgVersion: string; - readonly roles: readonly PostgresRole[]; - readonly existingSchemas: readonly string[]; - readonly nativeEnumTypeNames: readonly string[]; - - constructor(input: PostgresSchemaIRInput) { - super(); - this.tables = Object.freeze( - Object.fromEntries( - Object.entries(input.tables).map(([key, t]) => [ - key, - t instanceof PostgresTableSchemaNode ? t : new PostgresTableSchemaNode(t), - ]), - ), - ); - this.pgSchemaName = input.pgSchemaName; - this.pgVersion = input.pgVersion; - this.roles = Object.freeze([...input.roles]); - this.existingSchemas = Object.freeze([...input.existingSchemas]); - this.nativeEnumTypeNames = Object.freeze([...input.nativeEnumTypeNames]); - // Populate the annotations.pg bag with only the subset the family layer - // reads (nativeEnumTypeNames for PSL inference, existingSchemas for - // namespace presence checks). - this.annotations = { - pg: { - schema: input.pgSchemaName, - ...(input.nativeEnumTypeNames.length > 0 && { - nativeEnumTypeNames: input.nativeEnumTypeNames, - }), - ...(input.existingSchemas.length > 0 && { - existingSchemas: input.existingSchemas, - }), - }, - }; - freezeNode(this); - } - - get id(): string { - return this.pgSchemaName; - } - - get rlsPolicies(): readonly PostgresPolicySchemaNode[] { - return Object.values(this.tables).flatMap((t) => t.policies); - } - - isEqualTo(_other: DiffableNode): boolean { - return true; - } - - children(): readonly DiffableNode[] { - return Object.values(this.tables); - } -} - -/** - * Structural guard for `PostgresSchemaIR`, narrowing on the `nodeTarget` - * discriminant rather than `instanceof`. `nodeTarget` is an enumerable own field - * (a plain class-field initializer), so it survives the `{ ...schema, tables }` - * spread the multi-space verify path (`projectSchemaToSpace`) produces — that - * projected object is not a class instance but retains every enumerable own - * property. The family-level `kind = 'sql-schema-ir'` discriminator is unusable - * here: it is shared by every SQL schema-IR node and is non-enumerable (dropped - * by the spread). - */ -export function isPostgresSchemaIR(schema: SqlSchemaIR): schema is PostgresSchemaIR { - return schema.nodeTarget === 'postgres'; -} - -export function assertPostgresSchemaIR(schema: SqlSchemaIR): asserts schema is PostgresSchemaIR { - if (!isPostgresSchemaIR(schema)) { - throw new Error( - `planPostgresSchemaDiff: expected a PostgresSchemaIR but got nodeTarget=${String(schema.nodeTarget ?? typeof schema)}`, - ); - } -} - -/** - * Returns `schema` as-is when it is a real `PostgresSchemaIR` instance, or - * reconstructs one when `projectSchemaToSpace` has spread the class into a - * plain object (losing prototype methods). - */ -export function ensurePostgresSchemaIR(schema: PostgresSchemaIR): PostgresSchemaIR { - if (schema instanceof PostgresSchemaIR) return schema; - return new PostgresSchemaIR( - blindCast< - PostgresSchemaIRInput, - 'spread objects from projectSchemaToSpace preserve all own-enumerable fields' - >(schema), - ); -} diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts index 69d7209e39..55112114eb 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts @@ -11,10 +11,13 @@ import { type SqlTableIRInput, SqlUniqueIR, } from '@prisma-next/sql-schema-ir/types'; -import type { PostgresPolicySchemaNode } from './postgres-policy-schema-node'; +import { + PostgresPolicySchemaNode, + type PostgresPolicySchemaNodeInput, +} from './postgres-policy-schema-node'; export interface PostgresTableSchemaNodeInput extends SqlTableIRInput { - readonly policies?: readonly PostgresPolicySchemaNode[]; + readonly policies?: readonly (PostgresPolicySchemaNode | PostgresPolicySchemaNodeInput)[]; } /** @@ -74,7 +77,13 @@ export class PostgresTableSchemaNode extends SqlSchemaIRNode implements Diffable ), ); } - this.policies = Object.freeze([...(input.policies ?? [])]); + // Reconstruct policy nodes from plain objects: `projectSchemaToSpace` + // spreads the tree into plain objects before a consumer `ensure`s the root. + this.policies = Object.freeze( + (input.policies ?? []).map((p) => + p instanceof PostgresPolicySchemaNode ? p : new PostgresPolicySchemaNode(p), + ), + ); freezeNode(this); } diff --git a/packages/3-targets/3-targets/postgres/src/exports/types.ts b/packages/3-targets/3-targets/postgres/src/exports/types.ts index db3aefd36c..d7bf17a40b 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/types.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/types.ts @@ -29,13 +29,6 @@ export { PostgresRoleSchemaNode, type PostgresRoleSchemaNodeInput, } from '../core/schema-ir/postgres-role-schema-node'; -export { - assertPostgresSchemaIR, - ensurePostgresSchemaIR, - isPostgresSchemaIR, - PostgresSchemaIR, - type PostgresSchemaIRInput, -} from '../core/schema-ir/postgres-schema-ir'; export { PostgresTableSchemaNode, type PostgresTableSchemaNodeInput, diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index fac8b6661a..84542a4727 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -38,7 +38,7 @@ import type { SqlForeignKeyIR, SqlIndexIR, SqlReferentialAction, - SqlSchemaIR, + SqlSchemaIRNode, SqlUniqueIR, } from '@prisma-next/sql-schema-ir/types'; import { @@ -66,8 +66,6 @@ import { } from '@prisma-next/target-postgres/planner'; import { escapeLiteral, quoteIdentifier } from '@prisma-next/target-postgres/sql-utils'; import { - ensurePostgresSchemaIR, - isPostgresSchemaIR, PostgresDatabaseSchemaNode, PostgresNamespaceSchemaNode, PostgresPolicySchemaNode, @@ -131,13 +129,9 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { collectSchemaDiffIssues( contract: Contract, - schema: SqlSchemaIR, + schema: SqlSchemaIRNode, ): readonly SchemaDiffIssue[] { - if (!isPostgresSchemaIR(schema)) { - throw new Error( - `Postgres schema diff requires a PostgresSchemaIR; got ${(schema as { constructor?: { name?: string } }).constructor?.name ?? typeof schema}`, - ); - } + PostgresDatabaseSchemaNode.assert(schema); const expected = contractToPostgresDatabaseSchemaNode( blindCast< PostgresContract, @@ -145,12 +139,12 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { >(contract), { annotationNamespace: 'pg' }, ); - const actual = ensurePostgresSchemaIR(schema); + const actual = PostgresDatabaseSchemaNode.ensure(schema); const issues = diffPostgresSchema(expected, actual); - const ownedSchemaNames = new Set([ - ...expected.rlsPolicies.map((p) => p.namespaceId), - ...expected.existingSchemas, - ]); + const expectedPolicyNamespaces = Object.values(expected.namespaces).flatMap((ns) => + Object.values(ns.tables).flatMap((t) => t.policies.map((p) => p.namespaceId)), + ); + const ownedSchemaNames = new Set([...expectedPolicyNamespaces, ...expected.existingSchemas]); return filterIssuesByOwnership(issues, ownedSchemaNames); } From 9096215c5cc7698e7e8f606dc949b6fcb89b7f47 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 19:07:22 +0200 Subject: [PATCH 10/49] =?UTF-8?q?wip(migration):=20unit=206=20=E2=80=94=20?= =?UTF-8?q?SQLite=20+=20aggregate=20consumers=20walk=20the=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite planner/runner resolve the single flat namespace from the introspected node (sqliteFlatSchema). The aggregate projectSchemaToSpace + the verifier-s orphan detection learn the namespaced tree shape: they prune / enumerate tables inside each namespace node rather than a flat tables record, keeping per-space isolation without flattening namespaces. New project-schema-to-space tests cover the namespaced-tree branch. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/aggregate/project-schema-to-space.ts | 55 ++++++++++++++++++- .../migration/src/aggregate/verifier.ts | 37 ++++++++++++- .../aggregate/project-schema-to-space.test.ts | 43 +++++++++++++++ .../sqlite/src/core/migrations/planner.ts | 16 +++++- .../sqlite/src/core/migrations/runner.ts | 8 ++- 5 files changed, 150 insertions(+), 9 deletions(-) diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts b/packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts index aac3e0615e..f4d6516138 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts @@ -65,7 +65,23 @@ export function projectSchemaToSpace( const ownedByOthers = collectOwnedNames(member, otherMembers); if (ownedByOthers.size === 0) return schema; - const schemaObj = schema as { readonly tables?: unknown; readonly collections?: unknown }; + const schemaObj = schema as { + readonly tables?: unknown; + readonly collections?: unknown; + readonly namespaces?: unknown; + }; + + // A namespaced schema tree (the Postgres `PostgresDatabaseSchemaNode` root) + // groups tables under per-schema namespace nodes rather than a flat `tables` + // record. Prune each namespace's tables in place, so per-space isolation + // holds without flattening namespaces into one (collision-prone) record. + if ( + typeof schemaObj.namespaces === 'object' && + schemaObj.namespaces !== null && + !Array.isArray(schemaObj.namespaces) + ) { + return pruneNamespaceTables(schemaObj, ownedByOthers); + } if ( typeof schemaObj.tables === 'object' && @@ -104,6 +120,43 @@ function collectOwnedNames( return owned; } +/** + * Prunes other-space tables from every namespace node of a schema tree root, + * returning a new root with pruned namespaces. The namespace nodes are spread + * into plain objects (losing their class prototype), mirroring the flat + * `pruneRecord` path — downstream consumers duck-type the result, and the + * `…SchemaNode.ensure()` guards reconstruct a node from the spread shape when + * a structure-aware consumer needs one. + */ +function pruneNamespaceTables( + schemaObj: { readonly namespaces?: unknown }, + ownedByOthers: ReadonlySet, +): unknown { + const namespaces = schemaObj.namespaces as Record; + let removed = false; + const prunedNamespaces: Record = {}; + for (const [namespaceId, namespaceNode] of Object.entries(namespaces)) { + if ( + typeof namespaceNode === 'object' && + namespaceNode !== null && + typeof (namespaceNode as { readonly tables?: unknown }).tables === 'object' && + (namespaceNode as { readonly tables?: unknown }).tables !== null + ) { + const prunedNode = pruneRecord( + namespaceNode as { readonly tables?: unknown }, + 'tables', + ownedByOthers, + ); + if (prunedNode !== namespaceNode) removed = true; + prunedNamespaces[namespaceId] = prunedNode; + } else { + prunedNamespaces[namespaceId] = namespaceNode; + } + } + if (!removed) return schemaObj; + return { ...schemaObj, namespaces: prunedNamespaces }; +} + function pruneRecord( schemaObj: { readonly tables?: unknown; readonly collections?: unknown }, field: 'tables' | 'collections', diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts b/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts index 30f454c182..4a716d5866 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts @@ -205,8 +205,8 @@ function detectOrphanElements( members: ReadonlyArray, ): readonly OrphanElement[] { if (typeof schemaIntrospection !== 'object' || schemaIntrospection === null) return []; - const liveTables = (schemaIntrospection as { readonly tables?: unknown }).tables; - if (typeof liveTables !== 'object' || liveTables === null) return []; + const liveTableNames = collectLiveTableNames(schemaIntrospection); + if (liveTableNames.length === 0) return []; const claimedTables = new Set(); for (const member of members) { @@ -217,7 +217,7 @@ function detectOrphanElements( } const orphans: OrphanElement[] = []; - for (const tableName of Object.keys(liveTables as Record)) { + for (const tableName of liveTableNames) { if (!claimedTables.has(tableName)) { orphans.push({ kind: 'table', name: tableName }); } @@ -225,3 +225,34 @@ function detectOrphanElements( orphans.sort((a, b) => a.name.localeCompare(b.name)); return orphans; } + +/** + * Bare names of every live table in the introspected schema. Duck-typed: + * a flat schema (SQLite) exposes a `tables` record; a namespaced schema tree + * (the Postgres `PostgresDatabaseSchemaNode` root) groups tables under + * per-schema namespace nodes, so the names are gathered across namespaces. + * Any other shape yields none (the {@link projectSchemaToSpace} fall-through). + */ +function collectLiveTableNames(schemaIntrospection: object): readonly string[] { + const schema = schemaIntrospection as { + readonly tables?: unknown; + readonly namespaces?: unknown; + }; + if (isRecord(schema.namespaces)) { + const names: string[] = []; + for (const namespaceNode of Object.values(schema.namespaces)) { + if (isRecord(namespaceNode) && isRecord(namespaceNode['tables'])) { + names.push(...Object.keys(namespaceNode['tables'])); + } + } + return names; + } + if (isRecord(schema.tables)) { + return Object.keys(schema.tables); + } + return []; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts index bea2121d91..b55ba5e338 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts @@ -182,6 +182,49 @@ describe('projectSchemaToSpace', () => { expect(projected.meta).toBe(schema.meta); }); + it('prunes other-member tables within each namespace of a namespaced schema tree', () => { + // A Postgres `PostgresDatabaseSchemaNode` root groups tables under + // per-schema namespace nodes (`namespaces[…].tables`) rather than a flat + // `tables` record. The projector prunes inside each namespace. + const schema = { + nodeTarget: 'postgres', + nodeKind: 'postgres-database', + namespaces: { + public: { + schemaName: 'public', + tables: { + app_user: { name: 'app_user' }, + ext_owned: { name: 'ext_owned' }, + }, + }, + auth: { + schemaName: 'auth', + tables: { ext_session: { name: 'ext_session' } }, + }, + }, + }; + const member = memberWithTables('app', { app_user: {} }); + const others = [memberWithTables('ext', { ext_owned: {}, ext_session: {} })]; + + const projected = projectSchemaToSpace(schema, member, others) as { + readonly namespaces: Record }>; + }; + + expect(Object.keys(projected.namespaces['public']!.tables)).toEqual(['app_user']); + expect(Object.keys(projected.namespaces['auth']!.tables)).toEqual([]); + }); + + it('returns a namespaced schema tree unchanged when no other-member tables are present', () => { + const schema = { + nodeKind: 'postgres-database', + namespaces: { public: { schemaName: 'public', tables: { app_user: {} } } }, + }; + const member = memberWithTables('app', { app_user: {} }); + const others = [memberWithTables('ext', { ext_owned: {} })]; + + expect(projectSchemaToSpace(schema, member, others)).toBe(schema); + }); + it('removes other-member collections from a Mongo-shaped introspected schema (array form)', () => { // Mongo's introspected `MongoSchemaIR` exposes // `collections: ReadonlyArray<{name, ...}>` rather than a record. diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 0f9ae1e56f..3db9b72f53 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -11,13 +11,14 @@ import { plannerFailure, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; -import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; +import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { MigrationPlanner, MigrationScaffoldContext, SchemaIssue, } from '@prisma-next/framework-components/control'; +import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { parseSqliteDefault } from '../default-normalizer'; import { normalizeSqliteNativeType } from '../native-type-normalizer'; import { planIssues } from './issue-planner'; @@ -117,7 +118,7 @@ export class SqliteMigrationPlanner fromContract: options.fromContract, codecHooks, storageTypes, - schema: options.schema, + schema: sqliteFlatSchema(options.schema), policy: options.policy, frameworkComponents: options.frameworkComponents, strategies: sqlitePlannerStrategies, @@ -183,7 +184,7 @@ export class SqliteMigrationPlanner const strict = allowed.includes('widening') || allowed.includes('destructive'); const verifyResult = verifySqlSchema({ contract: options.contract, - schema: options.schema, + schema: sqliteFlatSchema(options.schema), strict, typeMetadataRegistry: new Map(), frameworkComponents: options.frameworkComponents, @@ -193,3 +194,12 @@ export class SqliteMigrationPlanner return verifyResult.schema.issues; } } + +/** + * SQLite has a single, flat schema — its introspected node IS the per-schema + * `SqlSchemaIR`. `namespaceSchemaNodes` returns that sole node; SQLite never + * carries the multi-namespace tree the Postgres target builds. + */ +function sqliteFlatSchema(schema: SqlSchemaIRNode): SqlSchemaIR { + return namespaceSchemaNodes(schema)[0] ?? { tables: {} }; +} diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts index eac20f3ff9..0c06420062 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts @@ -12,7 +12,7 @@ import type { SqlMigrationRunnerSuccessValue, } from '@prisma-next/family-sql/control'; import { runnerFailure, runnerSuccess } from '@prisma-next/family-sql/control'; -import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; +import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import type { MigrationRunnerResult } from '@prisma-next/framework-components/control'; import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; import type { SqlControlDriverInstance, SqlStorage } from '@prisma-next/sql-contract/types'; @@ -92,10 +92,14 @@ class SqliteMigrationRunner implements SqlMigrationRunner Date: Mon, 29 Jun 2026 19:07:49 +0200 Subject: [PATCH 11/49] =?UTF-8?q?wip(postgres):=20unit=206=20=E2=80=94=20r?= =?UTF-8?q?ebuild=20consumer=20tests=20for=20the=20schema-node=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixtures build PostgresDatabaseSchemaNode roots (namespace nodes holding table nodes holding policy nodes) instead of the deleted flat PostgresSchemaIR; introspection tests narrow the result with PostgresDatabaseSchemaNode.assert and navigate namespaces[…].tables / .roles; policy diff paths gain the namespace segment. Covers diff-postgres-schema, rls-planner, verify-postgres-namespaces, the 6-adapters planner/introspection suites, pgvector planner suites, and the family.introspect / referential-actions integration tests. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/migrations/planner.behavior.test.ts | 158 +++--- .../planner.contract-to-schema-ir.test.ts | 55 +- .../migrations/planner.storage-types.test.ts | 17 +- .../migrations/diff-postgres-schema.test.ts | 470 ++++++++++-------- .../test/migrations/rls-planner.test.ts | 68 ++- .../verify-postgres-namespaces.test.ts | 17 +- ...y-column-introspection.integration.test.ts | 21 +- .../migrations/fixtures/runner-fixtures.ts | 13 +- .../index-introspection.integration.test.ts | 31 +- .../planner.authoring-surface.test.ts | 39 +- .../planner.codec-field-event.test.ts | 18 +- .../planner.cross-space-fk-ddl.test.ts | 18 +- .../test/migrations/planner.fk-config.test.ts | 37 +- .../migrations/planner.integration.test.ts | 4 +- ...planner.reconciliation.integration.test.ts | 4 +- .../migrations/planner.reconciliation.test.ts | 108 ++-- .../planner.referential-actions.test.ts | 18 +- .../planner.semantic-satisfaction.test.ts | 139 +++--- .../rls-collect-extension-issues.test.ts | 48 +- .../rls-introspection.integration.test.ts | 19 +- .../rls-lifecycle-e2e.integration.test.ts | 4 +- .../rls-migration-plan.integration.test.ts | 20 +- .../postgres-control-policy-planner.test.ts | 57 ++- .../family.introspect.integration.test.ts | 31 +- .../test/family.introspect.test.ts | 32 +- .../referential-actions.integration.test.ts | 14 +- 26 files changed, 856 insertions(+), 604 deletions(-) diff --git a/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts index ec94913347..265007b4a9 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts @@ -19,11 +19,15 @@ import { type SqlStorageInput, type StorageTable, } from '@prisma-next/sql-contract/types'; -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner'; import { buildBuiltinIdentityValue } from '@prisma-next/target-postgres/planner-identity-values'; import type { PostgresPlanTargetDetails } from '@prisma-next/target-postgres/planner-target-details'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + PostgresTableSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import pgvectorDescriptor from '../../src/exports/control'; @@ -35,26 +39,30 @@ describe('PostgresMigrationPlanner - subset/superset/conflict handling', () => { const contract = createTestContract(); it('returns empty plan when schema already satisfies contract (superset)', () => { - const schema = new PostgresSchemaIR({ - tables: { - user: buildUserTableSchema(), - post: buildPostTableSchema(), - extra: { - name: 'extra', - columns: { - id: { name: 'id', nativeType: 'uuid', nullable: false }, + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + user: buildUserTableSchema(), + post: buildPostTableSchema(), + extra: new PostgresTableSchemaNode({ + name: 'extra', + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + foreignKeys: [], + indexes: [], + }), }, - primaryKey: { columns: ['id'] }, - uniques: [], - foreignKeys: [], - indexes: [], - }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', - pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], + pgVersion: '', }); const result = planner.plan({ @@ -73,24 +81,28 @@ describe('PostgresMigrationPlanner - subset/superset/conflict handling', () => { }); it('plans additive operations for subset schema (missing column/index/fk)', async () => { - const schema = new PostgresSchemaIR({ - tables: { - user: { - name: 'user', - columns: { - id: { name: 'id', nativeType: 'uuid', nullable: false }, + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + user: new PostgresTableSchemaNode({ + name: 'user', + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + foreignKeys: [], + indexes: [], + }), }, - primaryKey: { columns: ['id'] }, - uniques: [], - foreignKeys: [], - indexes: [], - }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', - pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], + pgVersion: '', }); const result = planner.plan({ @@ -118,25 +130,29 @@ describe('PostgresMigrationPlanner - subset/superset/conflict handling', () => { }); it('fails with conflicts when schema has incompatible column types', () => { - const schema = new PostgresSchemaIR({ - tables: { - user: { - name: 'user', - columns: { - id: { name: 'id', nativeType: 'uuid', nullable: false }, - email: { name: 'email', nativeType: 'uuid', nullable: false }, + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + user: new PostgresTableSchemaNode({ + name: 'user', + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false }, + email: { name: 'email', nativeType: 'uuid', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + foreignKeys: [], + indexes: [], + }), }, - primaryKey: { columns: ['id'] }, - uniques: [], - foreignKeys: [], - indexes: [], - }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', - pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], + pgVersion: '', }); const result = planner.plan({ @@ -368,13 +384,13 @@ describe('NOT NULL column without default uses temporary default', () => { indexes: [], foreignKeys: [], }, - { + new PostgresTableSchemaNode({ name: 'user', columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } }, uniques: [], foreignKeys: [], indexes: [], - }, + }), ); const addCol = await getRequiredOperation(operationsPromise, 'column.user.slug'); @@ -455,7 +471,7 @@ describe('NOT NULL column without default uses temporary default', () => { }, }, extraSchemaTables: { - org: { + org: new PostgresTableSchemaNode({ name: 'org', columns: { id: { name: 'id', nativeType: 'uuid', nullable: false }, @@ -464,7 +480,7 @@ describe('NOT NULL column without default uses temporary default', () => { uniques: [], foreignKeys: [], indexes: [], - }, + }), }, }, ); @@ -631,8 +647,8 @@ function createTestContract( }; } -function buildUserTableSchema(): SqlSchemaIR['tables'][string] { - return { +function buildUserTableSchema(): PostgresTableSchemaNode { + return new PostgresTableSchemaNode({ name: 'user', columns: { id: { name: 'id', nativeType: 'uuid', nullable: false }, @@ -642,7 +658,7 @@ function buildUserTableSchema(): SqlSchemaIR['tables'][string] { uniques: [{ columns: ['email'], name: 'user_email_key' }], foreignKeys: [], indexes: [{ columns: ['email'], name: 'user_email_idx', unique: false }], - }; + }); } /** @@ -709,12 +725,12 @@ function createPlannerControlHookComponent( async function planUserTableOperations( userTable: StorageTable, - schemaUserTable: SqlSchemaIR['tables'][string], + schemaUserTable: PostgresTableSchemaNode, options?: { frameworkComponents?: ReadonlyArray>; extraStorageTypes?: Contract['storage']['types']; extraContractTables?: Record; - extraSchemaTables?: SqlSchemaIR['tables']; + extraSchemaTables?: Record; }, ) { const planner = createPostgresMigrationPlanner(testAdapter); @@ -734,16 +750,20 @@ async function planUserTableOperations( ...(options?.extraStorageTypes ? { types: options.extraStorageTypes } : {}), }, }); - const schema = new PostgresSchemaIR({ - tables: { - ...(options?.extraSchemaTables ?? {}), - user: schemaUserTable, + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + ...(options?.extraSchemaTables ?? {}), + user: schemaUserTable, + }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', - pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], + pgVersion: '', }); const result = planner.plan({ contract, @@ -771,19 +791,19 @@ async function getRequiredOperation( return operation; } -function buildUserTableSchemaWithoutEmail(): SqlSchemaIR['tables'][string] { - return { +function buildUserTableSchemaWithoutEmail(): PostgresTableSchemaNode { + return new PostgresTableSchemaNode({ name: 'user', columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } }, primaryKey: { columns: ['id'] }, uniques: [], foreignKeys: [], indexes: [], - }; + }); } -function buildPostTableSchema(): SqlSchemaIR['tables'][string] { - return { +function buildPostTableSchema(): PostgresTableSchemaNode { + return new PostgresTableSchemaNode({ name: 'post', columns: { id: { name: 'id', nativeType: 'uuid', nullable: false }, @@ -801,5 +821,5 @@ function buildPostTableSchema(): SqlSchemaIR['tables'][string] { }, ], indexes: [{ columns: ['userId'], name: 'post_userId_idx', unique: false }], - }; + }); } diff --git a/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts index 435384ae37..68bbfdc7ad 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts @@ -26,7 +26,12 @@ import { import { postgresRenderDefault } from '@prisma-next/target-postgres/control'; import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner'; import type { PostgresPlanTargetDetails } from '@prisma-next/target-postgres/planner-target-details'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + PostgresTableSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import pgvectorDescriptor from '../../src/exports/control'; @@ -92,23 +97,42 @@ function createTestContract( } // Carry the contract's enum native-type names in `nativeEnumTypeNames` (the -// `PostgresSchemaIR` field that records which enum types already exist — the -// signal the enum `planTypeOperations` hook reads to decide whether to emit a -// `CREATE TYPE`). +// `PostgresNamespaceSchemaNode` field that records which enum types already +// exist — the signal the enum `planTypeOperations` hook reads to decide +// whether to emit a `CREATE TYPE`). function contractToSchemaIR( contract: Contract | null, options?: Omit[1], 'annotationNamespace'>, -): PostgresSchemaIR { +): PostgresDatabaseSchemaNode { const sqlIr = contractToSchemaIRImpl(contract, { annotationNamespace: 'pg', ...options }); const nativeEnumTypeNames = contract === null ? [] : Object.values(contract.storage.types ?? {}).map((t) => t.nativeType); - return new PostgresSchemaIR({ - tables: sqlIr.tables, - pgSchemaName: 'public', - pgVersion: '', + const tables = Object.fromEntries( + Object.entries(sqlIr.tables).map(([name, t]) => [ + name, + new PostgresTableSchemaNode({ + name: t.name, + columns: t.columns, + foreignKeys: t.foreignKeys, + uniques: t.uniques, + indexes: t.indexes, + ...(t.primaryKey !== undefined ? { primaryKey: t.primaryKey } : {}), + ...(t.annotations !== undefined ? { annotations: t.annotations } : {}), + ...(t.checks !== undefined ? { checks: t.checks } : {}), + }), + ]), + ); + return new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables, + nativeEnumTypeNames, + }), + }, roles: [], existingSchemas: [], - nativeEnumTypeNames, + pgVersion: '', }); } @@ -695,10 +719,13 @@ function createAdapterHooksComponent(): TargetBoundComponentDescriptor<'sql', st const values = typeInstance.typeParams?.['values'] as string[] | undefined; if (!values || values.length === 0) return { operations: [] }; - // The "enum already exists" signal lives in `nativeEnumTypeNames` on a - // `PostgresSchemaIR` (the production-shaped field a real hook reads). - const existingEnumTypes = - schema instanceof PostgresSchemaIR ? schema.nativeEnumTypeNames : []; + // The "enum already exists" signal lives in `nativeEnumTypeNames` on the + // per-schema `PostgresNamespaceSchemaNode`. The strategy layer hands the + // hook that namespace node (the per-schema `SqlSchemaIR` shape), so read + // the field directly off it. + const existingEnumTypes = PostgresNamespaceSchemaNode.is(schema) + ? schema.nativeEnumTypeNames + : []; if (existingEnumTypes.includes(typeInstance.nativeType)) { return { operations: [] }; diff --git a/packages/3-extensions/pgvector/test/migrations/planner.storage-types.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.storage-types.test.ts index 47a1ce5498..5c20805d5a 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.storage-types.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.storage-types.test.ts @@ -12,7 +12,8 @@ import { SqlStorage } from '@prisma-next/sql-contract/types'; import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner'; import type { PostgresPlanTargetDetails } from '@prisma-next/target-postgres/planner-target-details'; import { - PostgresSchemaIR, + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, PostgresUnboundSchema, postgresCreateNamespace, } from '@prisma-next/target-postgres/types'; @@ -23,13 +24,17 @@ import pgvectorDescriptor from '../../src/exports/control'; const testAdapter = new PostgresControlAdapter(createPostgresBuiltinCodecLookup()); -const emptySchema = new PostgresSchemaIR({ - tables: {}, - pgSchemaName: 'public', - pgVersion: '', +const emptySchema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: {}, + nativeEnumTypeNames: [], + }), + }, roles: [], existingSchemas: [], - nativeEnumTypeNames: [], + pgVersion: '', }); describe('PostgresMigrationPlanner - storage types', () => { diff --git a/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts index bca307b7f6..b1029d51a0 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts @@ -3,14 +3,16 @@ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; -import { contractToPostgresSchemaIR } from '../../src/core/migrations/contract-to-postgres-schema-ir'; +import { contractToPostgresDatabaseSchemaNode } from '../../src/core/migrations/contract-to-postgres-database-schema-node'; import { diffPostgresSchema, filterIssuesByOwnership, } from '../../src/core/migrations/diff-postgres-schema'; -import { isPostgresRlsPolicy, PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; +import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { PostgresSchema } from '../../src/core/postgres-schema'; -import { PostgresSchemaIR } from '../../src/core/schema-ir/postgres-schema-ir'; +import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; +import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; +import { PostgresPolicySchemaNode } from '../../src/core/schema-ir/postgres-policy-schema-node'; import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; const TABLE_NAME = 'profiles'; @@ -33,6 +35,33 @@ function makePolicy( }); } +function policyNode(policy: PostgresRlsPolicy): PostgresPolicySchemaNode { + return new PostgresPolicySchemaNode({ + name: policy.name, + prefix: policy.prefix, + tableName: policy.tableName, + namespaceId: policy.namespaceId, + operation: policy.operation, + roles: [...policy.roles], + ...(policy.using !== undefined ? { using: policy.using } : {}), + ...(policy.withCheck !== undefined ? { withCheck: policy.withCheck } : {}), + permissive: policy.permissive, + }); +} + +/** Wraps one namespace node (keyed by its schema name) into a database root. */ +function rootOf( + namespace: PostgresNamespaceSchemaNode, + existingSchemas: readonly string[], +): PostgresDatabaseSchemaNode { + return new PostgresDatabaseSchemaNode({ + namespaces: { [namespace.schemaName]: namespace }, + roles: [], + existingSchemas: [...existingSchemas], + pgVersion: 'unknown', + }); +} + function makeContract(policies: readonly PostgresRlsPolicy[]): Contract { const policyEntries: Record = {}; for (const p of policies) { @@ -73,14 +102,15 @@ function makeContract(policies: readonly PostgresRlsPolicy[]): Contract(); +function makeSchema(actualPolicies: readonly PostgresRlsPolicy[]): PostgresDatabaseSchemaNode { + const policiesByTable = new Map(); for (const p of actualPolicies) { const list = policiesByTable.get(p.tableName) ?? []; - list.push(p); + list.push(policyNode(p)); policiesByTable.set(p.tableName, list); } @@ -111,24 +141,35 @@ function makeSchema(actualPolicies: readonly PostgresRlsPolicy[]): PostgresSchem } } - return new PostgresSchemaIR({ - tables, - pgSchemaName: 'public', - pgVersion: 'unknown', - roles: [], - existingSchemas: ['public'], - nativeEnumTypeNames: [], - }); + return rootOf( + new PostgresNamespaceSchemaNode({ + schemaName: SCHEMA_NAME, + tables, + nativeEnumTypeNames: [], + }), + [SCHEMA_NAME], + ); +} + +function expectedFor(contract: Contract): PostgresDatabaseSchemaNode { + return contractToPostgresDatabaseSchemaNode( + contract as Parameters[0], + { annotationNamespace: 'pg' }, + ); +} + +function ownedSchemaNamesOf(expected: PostgresDatabaseSchemaNode): Set { + const policyNamespaces = Object.values(expected.namespaces).flatMap((ns) => + Object.values(ns.tables).flatMap((t) => t.policies.map((p) => p.namespaceId)), + ); + return new Set([...policyNamespaces, ...expected.existingSchemas]); } describe('diffPostgresSchema', () => { it('emits missing outcome when a contract policy is absent from the DB', () => { const policy = makePolicy('read_own_profiles_a1b2c3d4'); const contract = makeContract([policy]); - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, - ); + const expected = expectedFor(contract); const actual = makeSchema([]); const issues = diffPostgresSchema(expected, actual); @@ -141,10 +182,7 @@ describe('diffPostgresSchema', () => { it('emits extra outcome when a DB policy is absent from the contract', () => { const actualPolicy = makePolicy('read_own_profiles_deadbeef'); const contract = makeContract([]); - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, - ); + const expected = expectedFor(contract); const actual = makeSchema([actualPolicy]); const issues = diffPostgresSchema(expected, actual); @@ -157,10 +195,7 @@ describe('diffPostgresSchema', () => { it('emits no issues when contract and DB policy sets match exactly', () => { const policy = makePolicy('read_own_profiles_a1b2c3d4'); const contract = makeContract([policy]); - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, - ); + const expected = expectedFor(contract); const actual = makeSchema([ new PostgresRlsPolicy({ name: 'read_own_profiles_a1b2c3d4', @@ -183,10 +218,7 @@ describe('diffPostgresSchema', () => { const newPolicy = makePolicy('read_own_profiles_11111111'); const oldPolicy = makePolicy('read_own_profiles_00000000'); const contract = makeContract([newPolicy]); - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, - ); + const expected = expectedFor(contract); const actual = makeSchema([oldPolicy]); const issues = diffPostgresSchema(expected, actual); @@ -201,10 +233,7 @@ describe('diffPostgresSchema', () => { const contractPolicy = makePolicy('rp_a1b2c3d4'); const actualPolicy = makePolicy('rp_deadbeef'); const contract = makeContract([contractPolicy]); - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, - ); + const expected = expectedFor(contract); const actual = makeSchema([actualPolicy]); const issues = diffPostgresSchema(expected, actual); @@ -217,10 +246,7 @@ describe('diffPostgresSchema', () => { it('returns empty when contract has no policies and DB has no policies', () => { const contract = makeContract([]); - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, - ); + const expected = expectedFor(contract); const actual = makeSchema([]); const issues = diffPostgresSchema(expected, actual); @@ -231,10 +257,7 @@ describe('diffPostgresSchema', () => { it('emits extra for a DB policy on a table not in the contract (strict drop)', () => { const outsidePolicy = makePolicy('some_policy_aaaabbbb', 'other_table'); const contract = makeContract([]); - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, - ); + const expected = expectedFor(contract); const actual = makeSchema([outsidePolicy]); const issues = diffPostgresSchema(expected, actual); @@ -280,42 +303,45 @@ describe('diffPostgresSchema', () => { meta: {}, }; - const schemaWithBothNamespaces = new PostgresSchemaIR({ - tables: { - users: new PostgresTableSchemaNode({ - name: 'users', - columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } }, - foreignKeys: [], - uniques: [], - indexes: [], - policies: [authPolicy], + const schemaWithBothNamespaces = new PostgresDatabaseSchemaNode({ + namespaces: { + auth: new PostgresNamespaceSchemaNode({ + schemaName: 'auth', + tables: { + users: new PostgresTableSchemaNode({ + name: 'users', + columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [policyNode(authPolicy)], + }), + }, + nativeEnumTypeNames: [], }), - profile: new PostgresTableSchemaNode({ - name: 'profile', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - policies: [foreignPublicPolicy], + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + profile: new PostgresTableSchemaNode({ + name: 'profile', + columns: {}, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [policyNode(foreignPublicPolicy)], + }), + }, + nativeEnumTypeNames: [], }), }, - pgSchemaName: 'auth', - pgVersion: 'unknown', roles: [], existingSchemas: ['auth', 'public'], - nativeEnumTypeNames: [], + pgVersion: 'unknown', }); - const expected = contractToPostgresSchemaIR( - contractOwningOnlyAuth as Parameters[0], - { annotationNamespace: 'pg' }, - ); + const expected = expectedFor(contractOwningOnlyAuth); const rawIssues = diffPostgresSchema(expected, schemaWithBothNamespaces); - const ownedSchemaNames = new Set([ - ...expected.rlsPolicies.map((p) => p.namespaceId), - ...expected.existingSchemas, - ]); - const issues = filterIssuesByOwnership(rawIssues, ownedSchemaNames); + const issues = filterIssuesByOwnership(rawIssues, ownedSchemaNamesOf(expected)); expect(issues).toHaveLength(0); }); @@ -357,36 +383,43 @@ describe('diffPostgresSchema', () => { meta: {}, }; - const schemaWithBothNamespaces = new PostgresSchemaIR({ - tables: { - users: new PostgresTableSchemaNode({ - name: 'users', - columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } }, - foreignKeys: [], - uniques: [], - indexes: [], - policies: [authPolicy], + const schemaWithBothNamespaces = new PostgresDatabaseSchemaNode({ + namespaces: { + auth: new PostgresNamespaceSchemaNode({ + schemaName: 'auth', + tables: { + users: new PostgresTableSchemaNode({ + name: 'users', + columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [policyNode(authPolicy)], + }), + }, + nativeEnumTypeNames: [], }), - profile: new PostgresTableSchemaNode({ - name: 'profile', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - policies: [foreignPublicPolicy], + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + profile: new PostgresTableSchemaNode({ + name: 'profile', + columns: {}, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [policyNode(foreignPublicPolicy)], + }), + }, + nativeEnumTypeNames: [], }), }, - pgSchemaName: 'auth', - pgVersion: 'unknown', roles: [], existingSchemas: ['auth', 'public'], - nativeEnumTypeNames: [], + pgVersion: 'unknown', }); - const expected = contractToPostgresSchemaIR( - contractOwningOnlyAuth as Parameters[0], - { annotationNamespace: 'pg' }, - ); + const expected = expectedFor(contractOwningOnlyAuth); const rawIssues = diffPostgresSchema(expected, schemaWithBothNamespaces); const extraIssues = rawIssues.filter((i) => i.outcome === 'extra'); expect(extraIssues.length).toBeGreaterThan(0); @@ -426,34 +459,27 @@ describe('diffPostgresSchema', () => { meta: {}, }; - const schema = new PostgresSchemaIR({ - tables: { - users: new PostgresTableSchemaNode({ - name: 'users', - columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } }, - foreignKeys: [], - uniques: [], - indexes: [], - policies: [ownedExtra], - }), - }, - pgSchemaName: 'auth', - pgVersion: 'unknown', - roles: [], - existingSchemas: ['auth'], - nativeEnumTypeNames: [], - }); - - const expected = contractToPostgresSchemaIR( - contractOwningAuth as Parameters[0], - { annotationNamespace: 'pg' }, + const schema = rootOf( + new PostgresNamespaceSchemaNode({ + schemaName: 'auth', + tables: { + users: new PostgresTableSchemaNode({ + name: 'users', + columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [policyNode(ownedExtra)], + }), + }, + nativeEnumTypeNames: [], + }), + ['auth'], ); + + const expected = expectedFor(contractOwningAuth); const rawIssues = diffPostgresSchema(expected, schema); - const ownedSchemaNames = new Set([ - ...expected.rlsPolicies.map((p) => p.namespaceId), - ...expected.existingSchemas, - ]); - const issues = filterIssuesByOwnership(rawIssues, ownedSchemaNames); + const issues = filterIssuesByOwnership(rawIssues, ownedSchemaNamesOf(expected)); expect(issues).toHaveLength(1); expect(issues[0]).toMatchObject({ outcome: 'extra' }); @@ -512,42 +538,39 @@ describe('diffPostgresSchema', () => { meta: {}, }; - const schema = new PostgresSchemaIR({ - tables: { - profiles: new PostgresTableSchemaNode({ - name: 'profiles', - columns: { - id: { name: 'id', nativeType: 'int4', nullable: false }, - user_id: { name: 'user_id', nativeType: 'int4', nullable: false }, - }, - foreignKeys: [], - uniques: [], - indexes: [], - policies: [policyOnProfiles], - }), - orders: new PostgresTableSchemaNode({ - name: 'orders', - columns: { - id: { name: 'id', nativeType: 'int4', nullable: false }, - user_id: { name: 'user_id', nativeType: 'int4', nullable: false }, - }, - foreignKeys: [], - uniques: [], - indexes: [], - policies: [policyOnOrders], - }), - }, - pgSchemaName: SCHEMA_NAME, - pgVersion: 'unknown', - roles: [], - existingSchemas: [SCHEMA_NAME], - nativeEnumTypeNames: [], - }); - - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, + const schema = rootOf( + new PostgresNamespaceSchemaNode({ + schemaName: SCHEMA_NAME, + tables: { + profiles: new PostgresTableSchemaNode({ + name: 'profiles', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + user_id: { name: 'user_id', nativeType: 'int4', nullable: false }, + }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [policyNode(policyOnProfiles)], + }), + orders: new PostgresTableSchemaNode({ + name: 'orders', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + user_id: { name: 'user_id', nativeType: 'int4', nullable: false }, + }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [policyNode(policyOnOrders)], + }), + }, + nativeEnumTypeNames: [], + }), + [SCHEMA_NAME], ); + + const expected = expectedFor(contract); expect(() => diffPostgresSchema(expected, schema)).not.toThrow(); const issues = diffPostgresSchema(expected, schema); expect(issues).toHaveLength(0); @@ -598,26 +621,46 @@ describe('diffPostgresSchema', () => { meta: {}, }; - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, + const expected = expectedFor(contract); + // Actual database root carries the `public` namespace with its tables but no + // policies — so only the two policy nodes are missing (the namespace and + // tables match by id). + const actual = rootOf( + new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + profiles: new PostgresTableSchemaNode({ + name: 'profiles', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [], + }), + orders: new PostgresTableSchemaNode({ + name: 'orders', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [], + }), + }, + nativeEnumTypeNames: [], + }), + ['public'], ); - const actual = new PostgresSchemaIR({ - tables: {}, - pgSchemaName: 'public', - pgVersion: 'unknown', - roles: [], - existingSchemas: ['public'], - nativeEnumTypeNames: [], - }); const issues = diffPostgresSchema(expected, actual); - expect(issues.every((i) => isPostgresRlsPolicy(i.expected ?? i.actual))).toBe(true); + expect(issues.every((i) => PostgresPolicySchemaNode.is(i.expected ?? i.actual ?? actual))).toBe( + true, + ); expect(issues).toHaveLength(2); + // Path is [ 'database', schemaName, tableName, policyName ]. const paths = issues.map((i) => i.path); - expect(paths.some((p) => p[1] === 'profiles' && p[2] === 'read_own_a1b2c3d4')).toBe(true); - expect(paths.some((p) => p[1] === 'orders' && p[2] === 'read_own_a1b2c3d4')).toBe(true); + expect(paths.some((p) => p[2] === 'profiles' && p[3] === 'read_own_a1b2c3d4')).toBe(true); + expect(paths.some((p) => p[2] === 'orders' && p[3] === 'read_own_a1b2c3d4')).toBe(true); }); it('multi-schema normalization: unbound contract policy pairs with public introspected policy (zero issues)', () => { @@ -674,28 +717,25 @@ describe('diffPostgresSchema', () => { permissive: true, }); - const actual = new PostgresSchemaIR({ - tables: { - profiles: new PostgresTableSchemaNode({ - name: 'profiles', - columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, - foreignKeys: [], - uniques: [], - indexes: [], - policies: [introspectedPolicy], - }), - }, - pgSchemaName: 'public', - pgVersion: 'unknown', - roles: [], - existingSchemas: ['public'], - nativeEnumTypeNames: [], - }); - - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, + const actual = rootOf( + new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + profiles: new PostgresTableSchemaNode({ + name: 'profiles', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [policyNode(introspectedPolicy)], + }), + }, + nativeEnumTypeNames: [], + }), + ['public'], ); + + const expected = expectedFor(contract); const issues = diffPostgresSchema(expected, actual); expect(issues).toHaveLength(0); @@ -755,34 +795,27 @@ describe('diffPostgresSchema', () => { permissive: true, }); - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, + const expected = expectedFor(contract); + const actual = rootOf( + new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + orders: new PostgresTableSchemaNode({ + name: 'orders', + columns: {}, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [policyNode(unownedExtra)], + }), + }, + nativeEnumTypeNames: [], + }), + ['public', 'other_schema'], ); - const actual = new PostgresSchemaIR({ - tables: { - orders: new PostgresTableSchemaNode({ - name: 'orders', - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - policies: [unownedExtra], - }), - }, - pgSchemaName: 'public', - pgVersion: 'unknown', - roles: [], - existingSchemas: ['public', 'other_schema'], - nativeEnumTypeNames: [], - }); const rawIssues = diffPostgresSchema(expected, actual); - const ownedSchemaNames = new Set([ - ...expected.rlsPolicies.map((p) => p.namespaceId), - ...expected.existingSchemas, - ]); - const issues = filterIssuesByOwnership(rawIssues, ownedSchemaNames); + const issues = filterIssuesByOwnership(rawIssues, ownedSchemaNamesOf(expected)); expect(issues).toHaveLength(1); expect(issues[0]).toMatchObject({ outcome: 'missing' }); @@ -792,10 +825,7 @@ describe('diffPostgresSchema', () => { it('policy issues carry a human-readable message', () => { const policy = makePolicy('read_own_profiles_a1b2c3d4'); const contract = makeContract([policy]); - const expected = contractToPostgresSchemaIR( - contract as Parameters[0], - { annotationNamespace: 'pg' }, - ); + const expected = expectedFor(contract); const actual = makeSchema([]); const issues = diffPostgresSchema(expected, actual); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts index 0dabadf098..00aafa3fa2 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts @@ -22,7 +22,9 @@ import { describe, expect, it } from 'vitest'; import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { PostgresSchema } from '../../src/core/postgres-schema'; -import { PostgresSchemaIR } from '../../src/core/schema-ir/postgres-schema-ir'; +import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; +import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; +import { PostgresPolicySchemaNode } from '../../src/core/schema-ir/postgres-policy-schema-node'; import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; import { PostgresCreatePolicy } from '../../src/exports/ddl'; @@ -112,36 +114,52 @@ function buildContractWith(policies: readonly PostgresRlsPolicy[]): Contract }); }); -// `migration plan` derives a contract-backed PostgresSchemaIR (the -// `contractToSchema` projection) and emits CREATE POLICY through the same diff -// pipeline as the live paths. A non-PostgresSchemaIR schema is rejected; the -// `migration plan` e2e proves the emission end-to-end. +// `migration plan` derives a contract-backed PostgresDatabaseSchemaNode (the +// `contractToPostgresDatabaseSchemaNode` projection) and emits CREATE POLICY +// through the same diff pipeline as the live paths. A non-database-root schema +// is rejected; the `migration plan` e2e proves the emission end-to-end. diff --git a/packages/3-targets/3-targets/postgres/test/migrations/verify-postgres-namespaces.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/verify-postgres-namespaces.test.ts index 99db82f952..db46a752bd 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/verify-postgres-namespaces.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/verify-postgres-namespaces.test.ts @@ -1,12 +1,12 @@ import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage, type SqlStorageInput } from '@prisma-next/sql-contract/types'; -import { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import { SqlSchemaIR, type SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { verifyPostgresNamespacePresence } from '../../src/core/migrations/verify-postgres-namespaces'; import { PostgresSchema, PostgresUnboundSchema } from '../../src/core/postgres-schema'; -import { PostgresSchemaIR } from '../../src/core/schema-ir/postgres-schema-ir'; +import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; function makeContract( namespaceIds: readonly string[], @@ -40,17 +40,18 @@ function makeContract( }; } -function makeSchema(existingSchemas?: readonly string[]): SqlSchemaIR { +function makeSchema(existingSchemas?: readonly string[]): SqlSchemaIRNode { + // `existingSchemas` is database-level, on the root node. A bare flat schema + // (no root) exercises the `['public']` default — proving the consumer reads + // `existingSchemas` from the database root (CF-1), not a per-namespace node. if (existingSchemas === undefined) { return new SqlSchemaIR({ tables: {} }); } - return new PostgresSchemaIR({ - tables: {}, - pgSchemaName: 'public', - pgVersion: 'unknown', + return new PostgresDatabaseSchemaNode({ + namespaces: {}, roles: [], existingSchemas, - nativeEnumTypeNames: [], + pgVersion: 'unknown', }); } diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/array-column-introspection.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/array-column-introspection.integration.test.ts index 39696a0c09..d1313a8e47 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/array-column-introspection.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/array-column-introspection.integration.test.ts @@ -5,6 +5,7 @@ * introspection, and asserts the produced `SqlColumnIR` carries * `many: true` with the element codec's native type. */ +import { PostgresDatabaseSchemaNode } from '@prisma-next/target-postgres/types'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { createDriver, @@ -42,8 +43,9 @@ describe.sequential('array column introspection', () => { it('text[] column → nativeType:text + many:true', { timeout: testTimeout }, async () => { await driver!.query('CREATE TABLE arr_test (id int4 PRIMARY KEY, tags text[] NOT NULL)'); - const schema = await familyInstance.introspect({ driver: driver! }); - const col = schema.tables['arr_test']?.columns['tags']; + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['arr_test']?.columns['tags']; expect(col).toBeDefined(); expect(col?.nativeType).toBe('text'); expect(col?.many).toBe(true); @@ -53,8 +55,9 @@ describe.sequential('array column introspection', () => { it('int4[] column → nativeType:int4 + many:true', { timeout: testTimeout }, async () => { await driver!.query('CREATE TABLE arr_test (id int4 PRIMARY KEY, scores integer[] NOT NULL)'); - const schema = await familyInstance.introspect({ driver: driver! }); - const col = schema.tables['arr_test']?.columns['scores']; + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['arr_test']?.columns['scores']; expect(col).toBeDefined(); expect(col?.nativeType).toBe('int4'); expect(col?.many).toBe(true); @@ -63,8 +66,9 @@ describe.sequential('array column introspection', () => { it('nullable text[] column → many:true + nullable:true', { timeout: testTimeout }, async () => { await driver!.query('CREATE TABLE arr_test (id int4 PRIMARY KEY, labels text[])'); - const schema = await familyInstance.introspect({ driver: driver! }); - const col = schema.tables['arr_test']?.columns['labels']; + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['arr_test']?.columns['labels']; expect(col).toBeDefined(); expect(col?.many).toBe(true); expect(col?.nullable).toBe(true); @@ -73,8 +77,9 @@ describe.sequential('array column introspection', () => { it('scalar text column carries no many property', { timeout: testTimeout }, async () => { await driver!.query('CREATE TABLE arr_test (id int4 PRIMARY KEY, name text NOT NULL)'); - const schema = await familyInstance.introspect({ driver: driver! }); - const col = schema.tables['arr_test']?.columns['name']; + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['arr_test']?.columns['name']; expect(col).toBeDefined(); expect(col?.many).toBeUndefined(); expect(col?.nativeType).toBe('text'); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/fixtures/runner-fixtures.ts b/packages/3-targets/6-adapters/postgres/test/migrations/fixtures/runner-fixtures.ts index 7eea7df942..8dce54cd14 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/fixtures/runner-fixtures.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/fixtures/runner-fixtures.ts @@ -21,7 +21,10 @@ import { buildControlTableBootstrapQueries } from '@prisma-next/target-postgres/ import postgresTargetDescriptor from '@prisma-next/target-postgres/control'; import type { PostgresDdlNode } from '@prisma-next/target-postgres/ddl'; import type { PostgresPlanTargetDetails } from '@prisma-next/target-postgres/planner-target-details'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { + PostgresDatabaseSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { applicationDomainOf, createDevDatabase, timeouts } from '@prisma-next/test-utils'; import { createPostgresBuiltinCodecLookup } from '../../../src/core/codec-lookup'; import { PostgresControlAdapter } from '../../../src/core/control-adapter'; @@ -61,13 +64,11 @@ export const contract: Contract = { meta: {}, }; -export const emptySchema = new PostgresSchemaIR({ - tables: {}, - pgSchemaName: 'public', - pgVersion: 'unknown', +export const emptySchema = new PostgresDatabaseSchemaNode({ + namespaces: {}, roles: [], existingSchemas: ['public'], - nativeEnumTypeNames: [], + pgVersion: 'unknown', }); const controlStack = createControlStack({ diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/index-introspection.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/index-introspection.integration.test.ts index 372720e255..6b1a69bdfa 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/index-introspection.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/index-introspection.integration.test.ts @@ -7,6 +7,7 @@ * the same columns — forcing a spurious DROP+CREATE on every plan even * when the live index already matches the contract. */ +import { PostgresDatabaseSchemaNode } from '@prisma-next/target-postgres/types'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { createDriver, @@ -47,8 +48,10 @@ describe.sequential('Postgres index introspection — type and options', () => { await driver!.query('CREATE TABLE doc (id int PRIMARY KEY, body text NOT NULL)'); await driver!.query('CREATE INDEX doc_body_idx ON doc (body)'); - const schema = await familyInstance.introspect({ driver: driver! }); - const indexes = schema.tables['doc']?.indexes ?? []; + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const ns = result.namespaces['public']!; + const indexes = ns.tables['doc']?.indexes ?? []; const idx = indexes.find((i) => i.name === 'doc_body_idx'); expect(idx).toBeDefined(); expect(idx?.type).toBeUndefined(); @@ -59,8 +62,10 @@ describe.sequential('Postgres index introspection — type and options', () => { await driver!.query('CREATE TABLE doc (id int PRIMARY KEY, tags jsonb NOT NULL)'); await driver!.query('CREATE INDEX doc_tags_gin_idx ON doc USING gin (tags)'); - const schema = await familyInstance.introspect({ driver: driver! }); - const idx = schema.tables['doc']?.indexes.find((i) => i.name === 'doc_tags_gin_idx'); + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const ns = result.namespaces['public']!; + const idx = ns.tables['doc']?.indexes.find((i) => i.name === 'doc_tags_gin_idx'); expect(idx).toBeDefined(); expect(idx?.type).toBe('gin'); expect(idx?.options).toBeUndefined(); @@ -72,8 +77,10 @@ describe.sequential('Postgres index introspection — type and options', () => { await driver!.query('CREATE TABLE doc (id int PRIMARY KEY, body text NOT NULL)'); await driver!.query('CREATE INDEX doc_body_idx ON doc (body) WITH (fillfactor = 70)'); - const schema = await familyInstance.introspect({ driver: driver! }); - const idx = schema.tables['doc']?.indexes.find((i) => i.name === 'doc_body_idx'); + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const ns = result.namespaces['public']!; + const idx = ns.tables['doc']?.indexes.find((i) => i.name === 'doc_body_idx'); expect(idx).toBeDefined(); // btree is the Postgres default → type is dropped to undefined expect(idx?.type).toBeUndefined(); @@ -90,8 +97,10 @@ describe.sequential('Postgres index introspection — type and options', () => { 'CREATE INDEX doc_tags_gin_idx ON doc USING gin (tags) WITH (fastupdate = false)', ); - const schema = await familyInstance.introspect({ driver: driver! }); - const idx = schema.tables['doc']?.indexes.find((i) => i.name === 'doc_tags_gin_idx'); + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const ns = result.namespaces['public']!; + const idx = ns.tables['doc']?.indexes.find((i) => i.name === 'doc_tags_gin_idx'); expect(idx).toBeDefined(); expect(idx?.type).toBe('gin'); expect(idx?.options).toEqual({ fastupdate: 'false' }); @@ -118,8 +127,10 @@ describe.sequential('Postgres index introspection — type and options', () => { 'CREATE INDEX sync_run_lookup_idx ON sync_run (source, entity, started_at)', ); - const schema = await familyInstance.introspect({ driver: driver! }); - const idx = schema.tables['sync_run']?.indexes.find((i) => i.name === 'sync_run_lookup_idx'); + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const ns = result.namespaces['public']!; + const idx = ns.tables['sync_run']?.indexes.find((i) => i.name === 'sync_run_lookup_idx'); expect(idx).toBeDefined(); expect(idx?.columns).toEqual(['source', 'entity', 'started_at']); }); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.authoring-surface.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.authoring-surface.test.ts index 5b1fc9dfbb..429b224851 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.authoring-surface.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.authoring-surface.test.ts @@ -1,9 +1,4 @@ import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types'; -import { - contractToSchemaIR as contractToSchemaIRImpl, - extractCodecControlHooks, - type NativeTypeExpander, -} from '@prisma-next/family-sql/control'; import { APP_SPACE_ID, type MigrationPlanner, @@ -11,20 +6,14 @@ import { } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage } from '@prisma-next/sql-contract/types'; -import postgresTargetDescriptor, { - postgresRenderDefault, -} from '@prisma-next/target-postgres/control'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import postgresTargetDescriptor from '@prisma-next/target-postgres/control'; +import { + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; -import postgresAdapterDescriptor from '../../src/exports/control'; - -const adapterCodecHooks = extractCodecControlHooks([postgresAdapterDescriptor]); -const expandParameterizedNativeType: NativeTypeExpander = (input) => { - if (!input.codecId) return input.nativeType; - const hooks = adapterCodecHooks.get(input.codecId); - return hooks?.expandNativeType?.(input) ?? input.nativeType; -}; function createEmptyContract(): Contract { return { @@ -60,17 +49,17 @@ describe('PostgresMigrationPlanner authoring surface', () => { it('emits a migration scaffold carrying the destination storage hash', () => { const planner = makeFrameworkPlanner(); const contract = createEmptyContract(); - const fromSchemaIR = new PostgresSchemaIR({ - tables: contractToSchemaIRImpl(null, { - annotationNamespace: 'pg', - expandNativeType: expandParameterizedNativeType, - renderDefault: postgresRenderDefault, - }).tables, - pgSchemaName: 'public', + const fromSchemaIR = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: {}, + nativeEnumTypeNames: [], + }), + }, pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); const fromContract: Contract = { diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.codec-field-event.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.codec-field-event.test.ts index 702c215e10..3da863d4e9 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.codec-field-event.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.codec-field-event.test.ts @@ -6,20 +6,28 @@ import { APP_SPACE_ID, type OpFactoryCall } from '@prisma-next/framework-compone import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage, type StorageColumn, type StorageTable } from '@prisma-next/sql-contract/types'; import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { expectNarrowedType } from '@prisma-next/test-utils/typed-expectations'; import { describe, expect, it } from 'vitest'; import { createPostgresBuiltinCodecLookup } from '../../src/core/codec-lookup'; import { PostgresControlAdapter } from '../../src/core/control-adapter'; -const emptySchema = new PostgresSchemaIR({ - tables: {}, - pgSchemaName: 'public', +const emptySchema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: {}, + nativeEnumTypeNames: [], + }), + }, pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); const testAdapter = new PostgresControlAdapter(createPostgresBuiltinCodecLookup()); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.cross-space-fk-ddl.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.cross-space-fk-ddl.test.ts index 5a0f23bf32..1024627b9b 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.cross-space-fk-ddl.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.cross-space-fk-ddl.test.ts @@ -18,7 +18,11 @@ import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage } from '@prisma-next/sql-contract/types'; import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createPostgresBuiltinCodecLookup } from '../../src/core/codec-lookup'; @@ -26,13 +30,17 @@ import { PostgresControlAdapter } from '../../src/core/control-adapter'; const testAdapter = new PostgresControlAdapter(createPostgresBuiltinCodecLookup()); -const emptySchema = new PostgresSchemaIR({ - tables: {}, - pgSchemaName: 'public', +const emptySchema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: {}, + nativeEnumTypeNames: [], + }), + }, pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); /** diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.fk-config.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.fk-config.test.ts index efc554bc78..16c4e28b2c 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.fk-config.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.fk-config.test.ts @@ -1,10 +1,18 @@ import { asNamespaceId, type Contract, coreHash, profileHash } from '@prisma-next/contract/types'; -import { contractToSchemaIR, INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; +import { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage } from '@prisma-next/sql-contract/types'; -import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { + contractToPostgresDatabaseSchemaNode, + createPostgresMigrationPlanner, +} from '@prisma-next/target-postgres/planner'; +import { + type PostgresContract, + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createPostgresBuiltinCodecLookup } from '../../src/core/codec-lookup'; @@ -75,13 +83,17 @@ function createFkTestContract(fkConfig: { }; } -const emptySchema = new PostgresSchemaIR({ - tables: {}, - pgSchemaName: 'public', +const emptySchema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: {}, + nativeEnumTypeNames: [], + }), + }, pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); const MIGRATION_PLAN_POLICY = { @@ -230,13 +242,8 @@ describe('PostgresMigrationPlanner - per-FK config combinations', () => { storageHash: coreHash('sha256:to'), includeStateColumn: true, }); - const schema = new PostgresSchemaIR({ - tables: contractToSchemaIR(fromContract, { annotationNamespace: 'pg' }).tables, - pgSchemaName: 'public', - pgVersion: '', - roles: [], - existingSchemas: [], - nativeEnumTypeNames: [], + const schema = contractToPostgresDatabaseSchemaNode(fromContract, { + annotationNamespace: 'pg', }); const result = planner.plan({ @@ -267,7 +274,7 @@ describe('PostgresMigrationPlanner - per-FK config combinations', () => { function createWorkflowStateContract(options: { storageHash: ReturnType; includeStateColumn: boolean; -}): Contract { +}): PostgresContract { const workflowStateColumns = { workflow_id: { nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false }, team_id: { nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false }, diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.integration.test.ts index 1f2e4e284e..7dc9296059 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.integration.test.ts @@ -3,7 +3,7 @@ import { type SqlMigrationPlanOperation, } from '@prisma-next/family-sql/control'; import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { contract, @@ -194,7 +194,7 @@ describe.sequential('PostgresMigrationPlanner - integration (existing schemas)', }); }); -async function introspectSchema(driver: PostgresControlDriver): Promise { +async function introspectSchema(driver: PostgresControlDriver): Promise { return familyInstance.introspect({ driver, contract: contract, diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts index ad9d305f56..5fff059cc2 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts @@ -6,7 +6,7 @@ import { } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage, type StorageTable } from '@prisma-next/sql-contract/types'; -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { postgresCreateNamespace } from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -100,7 +100,7 @@ async function applyBaseline( } } -async function introspectSchema(driver: PostgresControlDriver): Promise { +async function introspectSchema(driver: PostgresControlDriver): Promise { return familyInstance.introspect({ driver }); } diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.test.ts index 5ed6033282..58c373cd64 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.test.ts @@ -6,7 +6,12 @@ import { import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage } from '@prisma-next/sql-contract/types'; import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + PostgresTableSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createPostgresBuiltinCodecLookup } from '../../src/core/codec-lookup'; @@ -39,26 +44,31 @@ describe('PostgresMigrationPlanner - reconciliation planning', () => { }, }); - const schema = new PostgresSchemaIR({ - tables: { - user: { - name: 'user', - columns: { - id: { name: 'id', nativeType: 'uuid', nullable: false }, - email: { name: 'email', nativeType: 'text', nullable: false }, - legacyEmail: { name: 'legacyEmail', nativeType: 'text', nullable: true }, + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + user: new PostgresTableSchemaNode({ + name: 'user', + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false }, + email: { name: 'email', nativeType: 'text', nullable: false }, + legacyEmail: { name: 'legacyEmail', nativeType: 'text', nullable: true }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + foreignKeys: [], + indexes: [], + policies: [], + }), }, - primaryKey: { columns: ['id'] }, - uniques: [], - foreignKeys: [], - indexes: [], - }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); const result = planner.plan({ @@ -98,25 +108,30 @@ describe('PostgresMigrationPlanner - reconciliation planning', () => { }, }); - const schema = new PostgresSchemaIR({ - tables: { - user: { - name: 'user', - columns: { - id: { name: 'id', nativeType: 'uuid', nullable: false }, - email: { name: 'email', nativeType: 'text', nullable: false }, + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + user: new PostgresTableSchemaNode({ + name: 'user', + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false }, + email: { name: 'email', nativeType: 'text', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + foreignKeys: [], + indexes: [], + policies: [], + }), }, - primaryKey: { columns: ['id'] }, - uniques: [], - foreignKeys: [], - indexes: [], - }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); const result = planner.plan({ @@ -155,25 +170,30 @@ describe('PostgresMigrationPlanner - reconciliation planning', () => { }, }); - const schema = new PostgresSchemaIR({ - tables: { - user: { - name: 'user', - columns: { - id: { name: 'id', nativeType: 'uuid', nullable: false }, - legacyEmail: { name: 'legacyEmail', nativeType: 'text', nullable: true }, + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + user: new PostgresTableSchemaNode({ + name: 'user', + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false }, + legacyEmail: { name: 'legacyEmail', nativeType: 'text', nullable: true }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + foreignKeys: [], + indexes: [], + policies: [], + }), }, - primaryKey: { columns: ['id'] }, - uniques: [], - foreignKeys: [], - indexes: [], - }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); const result = planner.plan({ diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.referential-actions.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.referential-actions.test.ts index 990e828889..a21bab5f6d 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.referential-actions.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.referential-actions.test.ts @@ -11,7 +11,11 @@ import { SqlStorage, } from '@prisma-next/sql-contract/types'; import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createPostgresBuiltinCodecLookup } from '../../src/core/codec-lookup'; @@ -81,13 +85,17 @@ function createRefActionContract( }; } -const emptySchema = new PostgresSchemaIR({ - tables: {}, - pgSchemaName: 'public', +const emptySchema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: {}, + nativeEnumTypeNames: [], + }), + }, pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); async function planAndGetFkSql( diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.semantic-satisfaction.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.semantic-satisfaction.test.ts index 4e20991504..fe894db91b 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.semantic-satisfaction.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.semantic-satisfaction.test.ts @@ -13,7 +13,12 @@ import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage, type StorageTableInput } from '@prisma-next/sql-contract/types'; import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + PostgresTableSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createPostgresBuiltinCodecLookup } from '../../src/core/codec-lookup'; @@ -39,25 +44,30 @@ describe('PostgresMigrationPlanner - semantic satisfaction', () => { }, }); - const schema = new PostgresSchemaIR({ - tables: { - user: { - name: 'user', - columns: { - id: { name: 'id', nativeType: 'uuid', nullable: false }, - email: { name: 'email', nativeType: 'text', nullable: false }, + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + user: new PostgresTableSchemaNode({ + name: 'user', + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false }, + email: { name: 'email', nativeType: 'text', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + foreignKeys: [], + indexes: [{ columns: ['email'], unique: true, name: 'user_email_idx' }], + policies: [], + }), }, - primaryKey: { columns: ['id'] }, - uniques: [], - foreignKeys: [], - indexes: [{ columns: ['email'], unique: true, name: 'user_email_idx' }], - }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); const result = planner.plan({ @@ -92,25 +102,30 @@ describe('PostgresMigrationPlanner - semantic satisfaction', () => { }, }); - const schema = new PostgresSchemaIR({ - tables: { - user: { - name: 'user', - columns: { - id: { name: 'id', nativeType: 'uuid', nullable: false }, - email: { name: 'email', nativeType: 'text', nullable: false }, + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + user: new PostgresTableSchemaNode({ + name: 'user', + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false }, + email: { name: 'email', nativeType: 'text', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + foreignKeys: [], + indexes: [{ columns: ['email'], unique: true, name: 'user_email_idx' }], + policies: [], + }), }, - primaryKey: { columns: ['id'] }, - uniques: [], - foreignKeys: [], - indexes: [{ columns: ['email'], unique: true, name: 'user_email_idx' }], - }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); const result = planner.plan({ @@ -143,25 +158,30 @@ describe('PostgresMigrationPlanner - semantic satisfaction', () => { }, }); - const schema = new PostgresSchemaIR({ - tables: { - user: { - name: 'user', - columns: { - id: { name: 'id', nativeType: 'uuid', nullable: false }, - email: { name: 'email', nativeType: 'text', nullable: false }, + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + user: new PostgresTableSchemaNode({ + name: 'user', + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false }, + email: { name: 'email', nativeType: 'text', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [{ columns: ['email'], name: 'user_email_key' }], + foreignKeys: [], + indexes: [], + policies: [], + }), }, - primaryKey: { columns: ['id'] }, - uniques: [{ columns: ['email'], name: 'user_email_key' }], - foreignKeys: [], - indexes: [], - }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); const result = planner.plan({ @@ -196,25 +216,30 @@ describe('PostgresMigrationPlanner - semantic satisfaction', () => { }, }); - const schema = new PostgresSchemaIR({ - tables: { - user: { - name: 'user', - columns: { - id: { name: 'id', nativeType: 'uuid', nullable: false }, - email: { name: 'email', nativeType: 'text', nullable: false }, + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + user: new PostgresTableSchemaNode({ + name: 'user', + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false }, + email: { name: 'email', nativeType: 'text', nullable: false }, + }, + primaryKey: { columns: ['id'], name: 'user_pkey' }, + uniques: [{ columns: ['email'], name: 'user_email_key' }], + foreignKeys: [], + indexes: [{ columns: ['email'], unique: false, name: 'user_email_idx' }], + policies: [], + }), }, - primaryKey: { columns: ['id'], name: 'user_pkey' }, - uniques: [{ columns: ['email'], name: 'user_email_key' }], - foreignKeys: [], - indexes: [{ columns: ['email'], unique: false, name: 'user_email_idx' }], - }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], }); const result = planner.plan({ diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts index 34727094d9..c5675c55a4 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts @@ -6,10 +6,12 @@ import { normalizePredicate, } from '@prisma-next/target-postgres/rls-canonicalize'; import { + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + PostgresPolicySchemaNode, PostgresRlsPolicy, PostgresSchema, - PostgresSchemaIR, - PostgresTableIR, + PostgresTableSchemaNode, } from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; @@ -52,23 +54,41 @@ function externalPolicy(): PostgresRlsPolicy { }); } -function schemaWithPolicies(policies: PostgresRlsPolicy[]): PostgresSchemaIR { - return new PostgresSchemaIR({ - tables: { - [TABLE_NAME]: new PostgresTableIR({ - name: TABLE_NAME, - columns: {}, - foreignKeys: [], - uniques: [], - indexes: [], - rlsPolicies: policies, +function toPolicyNode(p: PostgresRlsPolicy): PostgresPolicySchemaNode { + return new PostgresPolicySchemaNode({ + name: p.name, + prefix: p.prefix, + tableName: p.tableName, + namespaceId: p.namespaceId, + operation: p.operation, + roles: [...p.roles], + ...(p.using !== undefined ? { using: p.using } : {}), + ...(p.withCheck !== undefined ? { withCheck: p.withCheck } : {}), + permissive: p.permissive, + }); +} + +function schemaWithPolicies(policies: PostgresRlsPolicy[]): PostgresDatabaseSchemaNode { + return new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + [TABLE_NAME]: new PostgresTableSchemaNode({ + name: TABLE_NAME, + columns: {}, + foreignKeys: [], + uniques: [], + indexes: [], + policies: policies.map(toPolicyNode), + }), + }, + nativeEnumTypeNames: [], }), }, - pgSchemaName: 'public', pgVersion: 'unknown', roles: [], existingSchemas: ['public'], - nativeEnumTypeNames: [], }); } diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-introspection.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-introspection.integration.test.ts index 2b6f3e9f49..17c48d36bb 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-introspection.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-introspection.integration.test.ts @@ -2,7 +2,10 @@ import { computeContentHash, normalizePredicate, } from '@prisma-next/target-postgres/rls-canonicalize'; -import { isPostgresSchemaIR, PostgresRlsPolicy } from '@prisma-next/target-postgres/types'; +import { + PostgresDatabaseSchemaNode, + PostgresPolicySchemaNode, +} from '@prisma-next/target-postgres/types'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { createDriver, @@ -57,11 +60,11 @@ describe.sequential('RLS introspection', () => { ); const schema = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(schema); - expect(isPostgresSchemaIR(schema)).toBe(true); - if (!isPostgresSchemaIR(schema)) return; - - const { rlsPolicies } = schema; + const rlsPolicies = Object.values(schema.namespaces['public']!.tables).flatMap( + (t) => t.policies, + ); expect(rlsPolicies).toBeDefined(); expect(Array.isArray(rlsPolicies)).toBe(true); @@ -69,7 +72,7 @@ describe.sequential('RLS introspection', () => { const policy = rlsPolicies.find((p) => p.tableName === 'posts'); expect(policy).toBeDefined(); - expect(policy).toBeInstanceOf(PostgresRlsPolicy); + expect(policy).toBeInstanceOf(PostgresPolicySchemaNode); // Introspect reads policyname verbatim from pg_policies — no hash recompute. expect(policy!.name).toBe(wireName); @@ -85,9 +88,7 @@ describe.sequential('RLS introspection', () => { timeout: testTimeout, }, async () => { const schema = await familyInstance.introspect({ driver: driver! }); - - expect(isPostgresSchemaIR(schema)).toBe(true); - if (!isPostgresSchemaIR(schema)) return; + PostgresDatabaseSchemaNode.assert(schema); const { roles } = schema; diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts index 0b63ad2cd4..7723ad25a6 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts @@ -9,7 +9,7 @@ import { buildSymbolTable } from '@prisma-next/psl-parser'; import { parse } from '@prisma-next/psl-parser/syntax'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { interpretPslDocumentToSqlContract } from '@prisma-next/sql-contract-psl'; -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { isPostgresSchema, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createPostgresBuiltinCodecLookup } from '../../src/core/codec-lookup'; @@ -145,7 +145,7 @@ const ALLOW_DESTRUCTIVE: MigrationOperationPolicy = { async function applyContract( driver: PostgresControlDriver, contract: Contract, - schema: SqlSchemaIR, + schema: SqlSchemaIRNode, policy: MigrationOperationPolicy = INIT_ADDITIVE_POLICY, ): Promise { const planner = postgresTargetDescriptor.createPlanner(controlAdapter); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts index 7397e343b0..14f0c30a6f 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts @@ -8,7 +8,10 @@ import { buildSymbolTable } from '@prisma-next/psl-parser'; import { parse } from '@prisma-next/psl-parser/syntax'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { interpretPslDocumentToSqlContract } from '@prisma-next/sql-contract-psl'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { + PostgresDatabaseSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { describe, expect, it } from 'vitest'; import { createPostgresBuiltinCodecLookup } from '../../src/core/codec-lookup'; import { createPostgresScalarTypeDescriptors } from '../../src/core/control-mutation-defaults'; @@ -87,7 +90,7 @@ function buildPslContract() { } describe('migration plan emits RLS (offline, no live database)', () => { - it('derives a PostgresSchemaIR from the contract and plans CREATE POLICY + ENABLE RLS', async () => { + it('derives a PostgresDatabaseSchemaNode from the contract and plans CREATE POLICY + ENABLE RLS', async () => { const result = buildPslContract(); expect(result.ok).toBe(true); if (!result.ok) return; @@ -95,15 +98,18 @@ describe('migration plan emits RLS (offline, no live database)', () => { const contract = result.value as Contract; // The initial `migration plan` derives the "from" schema from a null - // contract (no prior state) — an empty PostgresSchemaIR. The differ then - // reports the contract's policy as missing → CREATE POLICY. + // contract (no prior state) — an empty PostgresDatabaseSchemaNode. The differ + // then reports the contract's policy as missing → CREATE POLICY. const fromSchema = postgresTargetDescriptor.migrations.contractToSchema( null, frameworkComponents, ); - expect(fromSchema).toBeInstanceOf(PostgresSchemaIR); - if (!(fromSchema instanceof PostgresSchemaIR)) return; - expect(fromSchema.rlsPolicies).toEqual([]); + PostgresDatabaseSchemaNode.assert(fromSchema); + expect(fromSchema).toBeInstanceOf(PostgresDatabaseSchemaNode); + const allPolicies = Object.values(fromSchema.namespaces).flatMap((ns) => + Object.values(ns.tables).flatMap((t) => t.policies), + ); + expect(allPolicies).toEqual([]); const planner = postgresTargetDescriptor.createPlanner(controlAdapter); const planResult = planner.plan({ diff --git a/test/integration/test/cross-package/postgres-control-policy-planner.test.ts b/test/integration/test/cross-package/postgres-control-policy-planner.test.ts index 25f9db5cf6..f13eebec86 100644 --- a/test/integration/test/cross-package/postgres-control-policy-planner.test.ts +++ b/test/integration/test/cross-package/postgres-control-policy-planner.test.ts @@ -8,7 +8,12 @@ import type { MigrationOperationPolicy } from '@prisma-next/framework-components import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage, type StorageTableInput } from '@prisma-next/sql-contract/types'; import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner'; -import { PostgresSchemaIR, postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { + PostgresDatabaseSchemaNode, + PostgresNamespaceSchemaNode, + PostgresTableSchemaNode, + postgresCreateNamespace, +} from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; @@ -47,38 +52,46 @@ const RECONCILIATION_POLICY: MigrationOperationPolicy = { const testAdapter = new PostgresControlAdapter(createPostgresBuiltinCodecLookup()); const planner = createPostgresMigrationPlanner(testAdapter); -const emptySchema = new PostgresSchemaIR({ - tables: {}, - pgSchemaName: 'public', - pgVersion: '', +const emptySchema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: {}, + nativeEnumTypeNames: [], + }), + }, roles: [], existingSchemas: [], - nativeEnumTypeNames: [], + pgVersion: '', }); function liveSchemaWithUsers( columns: Record, -): PostgresSchemaIR { - return new PostgresSchemaIR({ - tables: { - users: { - name: 'users', - columns, - primaryKey: { columns: ['id'] }, - uniques: [], - foreignKeys: [], - indexes: [], - }, +): PostgresDatabaseSchemaNode { + return new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + users: new PostgresTableSchemaNode({ + name: 'users', + columns, + primaryKey: { columns: ['id'] }, + uniques: [], + foreignKeys: [], + indexes: [], + }), + }, + nativeEnumTypeNames: [], + }), }, - pgSchemaName: 'public', - pgVersion: '', roles: [], existingSchemas: [], - nativeEnumTypeNames: [], + pgVersion: '', }); } -async function planAgainst(contract: Contract, schema: PostgresSchemaIR) { +async function planAgainst(contract: Contract, schema: PostgresDatabaseSchemaNode) { const result = planner.plan({ contract, schema, @@ -255,7 +268,7 @@ describe('PostgresMigrationPlanner.plan control-policy partitioning', async () = // but never be modified in place. The same diff under `managed` emits the // add-column. describe('PostgresMigrationPlanner.plan tolerated vs managed add-column', async () => { - const liveSchemaWithUsersIdOnly: PostgresSchemaIR = liveSchemaWithUsers({ + const liveSchemaWithUsersIdOnly: PostgresDatabaseSchemaNode = liveSchemaWithUsers({ id: { name: 'id', nativeType: 'text', nullable: false }, }); diff --git a/test/integration/test/family.introspect.integration.test.ts b/test/integration/test/family.introspect.integration.test.ts index d22be32824..609313cb4d 100644 --- a/test/integration/test/family.introspect.integration.test.ts +++ b/test/integration/test/family.introspect.integration.test.ts @@ -3,6 +3,7 @@ import postgresDriver from '@prisma-next/driver-postgres/control'; import sql from '@prisma-next/family-sql/control'; import { createControlStack } from '@prisma-next/framework-components/control'; import postgres from '@prisma-next/target-postgres/control'; +import { PostgresDatabaseSchemaNode } from '@prisma-next/target-postgres/types'; import { createDevDatabase, type DevDatabase, timeouts, withClient } from '@prisma-next/test-utils'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -76,9 +77,11 @@ describe('family instance introspect', () => { const schemaIR = await familyInstance.introspect({ driver, }); + PostgresDatabaseSchemaNode.assert(schemaIR); + const ns = schemaIR.namespaces['public']!; expect(schemaIR).toBeDefined(); - expect(schemaIR.tables).toBeDefined(); + expect(ns.tables).toBeDefined(); } finally { await driver.close(); } @@ -108,8 +111,10 @@ describe('family instance introspect', () => { const schemaIR = await familyInstance.introspect({ driver, }); + PostgresDatabaseSchemaNode.assert(schemaIR); + const ns = schemaIR.namespaces['public']!; - const userTable = schemaIR.tables['user']; + const userTable = ns.tables['user']; expect(userTable).toBeDefined(); if (!userTable) { throw new Error('user table not found'); @@ -172,8 +177,10 @@ describe('family instance introspect', () => { const schemaIR = await familyInstance.introspect({ driver, }); + PostgresDatabaseSchemaNode.assert(schemaIR); + const ns = schemaIR.namespaces['public']!; - const userTable = schemaIR.tables['user']; + const userTable = ns.tables['user']; expect(userTable).toBeDefined(); if (!userTable) { throw new Error('user table not found'); @@ -209,8 +216,10 @@ describe('family instance introspect', () => { const schemaIR = await familyInstance.introspect({ driver, }); + PostgresDatabaseSchemaNode.assert(schemaIR); + const ns = schemaIR.namespaces['public']!; - const userTable = schemaIR.tables['user']; + const userTable = ns.tables['user']; expect(userTable).toBeDefined(); if (!userTable) { throw new Error('user table not found'); @@ -249,8 +258,10 @@ describe('family instance introspect', () => { const schemaIR = await familyInstance.introspect({ driver, }); + PostgresDatabaseSchemaNode.assert(schemaIR); + const ns = schemaIR.namespaces['public']!; - const postTable = schemaIR.tables['post']; + const postTable = ns.tables['post']; expect(postTable).toBeDefined(); if (!postTable) { throw new Error('post table not found'); @@ -298,8 +309,10 @@ describe('family instance introspect', () => { const schemaIR = await familyInstance.introspect({ driver, }); + PostgresDatabaseSchemaNode.assert(schemaIR); + const ns = schemaIR.namespaces['public']!; - const postTable = schemaIR.tables['post']; + const postTable = ns.tables['post']; expect(postTable).toBeDefined(); if (!postTable) { throw new Error('post table not found'); @@ -338,9 +351,11 @@ describe('family instance introspect', () => { const schemaIR = await familyInstance.introspect({ driver, }); + PostgresDatabaseSchemaNode.assert(schemaIR); + const ns = schemaIR.namespaces['public']!; - expect(schemaIR.annotations).toBeDefined(); - expect(schemaIR.annotations?.['pg']).toBeDefined(); + expect(ns.annotations).toBeDefined(); + expect(ns.annotations?.['pg']).toBeDefined(); } finally { await driver.close(); } diff --git a/test/integration/test/family.introspect.test.ts b/test/integration/test/family.introspect.test.ts index 42161c4b8c..0bcb13b06b 100644 --- a/test/integration/test/family.introspect.test.ts +++ b/test/integration/test/family.introspect.test.ts @@ -4,19 +4,17 @@ import sql from '@prisma-next/family-sql/control'; import { createControlStack } from '@prisma-next/framework-components/control'; import type { SqlControlDriverInstance } from '@prisma-next/sql-contract/types'; import postgres from '@prisma-next/target-postgres/control'; +import { PostgresDatabaseSchemaNode } from '@prisma-next/target-postgres/types'; import { createDevDatabase, type DevDatabase, timeouts, withClient } from '@prisma-next/test-utils'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -// Type for schemaIR returned by introspect -type SchemaIR = Awaited['introspect']>>; - /** - * Helper to run introspection and pass schemaIR to callback. + * Helper to run introspection and pass the narrowed database root to callback. * Handles driver lifecycle (create + close) automatically. */ async function withIntrospection( connectionString: string, - fn: (schemaIR: SchemaIR) => T | Promise, + fn: (schemaIR: PostgresDatabaseSchemaNode) => T | Promise, ): Promise { const driver = await postgresDriver.create(connectionString); try { @@ -31,6 +29,7 @@ async function withIntrospection( ); const schemaIR = await familyInstance.introspect({ driver }); + PostgresDatabaseSchemaNode.assert(schemaIR); return await fn(schemaIR); } finally { await driver.close(); @@ -89,8 +88,9 @@ describe('family instance introspect', () => { 'returns schema IR with tables and columns', async () => { await withIntrospection(connectionString!, (schemaIR) => { + const ns = schemaIR.namespaces['public']!; expect(schemaIR).toBeDefined(); - expect(schemaIR.tables).toBeDefined(); + expect(ns.tables).toBeDefined(); }); }, timeouts.spinUpPpgDev, @@ -100,7 +100,8 @@ describe('family instance introspect', () => { 'includes user table with correct columns', async () => { await withIntrospection(connectionString!, (schemaIR) => { - const userTable = schemaIR.tables['user']!; + const ns = schemaIR.namespaces['public']!; + const userTable = ns.tables['user']!; expect(userTable.name).toBe('user'); expect(userTable.columns).toBeDefined(); @@ -127,7 +128,8 @@ describe('family instance introspect', () => { 'includes primary key for user table', async () => { await withIntrospection(connectionString!, (schemaIR) => { - const userTable = schemaIR.tables['user']!; + const ns = schemaIR.namespaces['public']!; + const userTable = ns.tables['user']!; expect(userTable.primaryKey).toBeDefined(); expect(userTable.primaryKey?.columns).toEqual(['id']); }); @@ -139,7 +141,8 @@ describe('family instance introspect', () => { 'includes unique constraint for user email', async () => { await withIntrospection(connectionString!, (schemaIR) => { - const userTable = schemaIR.tables['user']!; + const ns = schemaIR.namespaces['public']!; + const userTable = ns.tables['user']!; expect(userTable.uniques).toBeDefined(); expect(userTable.uniques.length).toBeGreaterThan(0); const emailUnique = userTable.uniques.find((uq) => uq.name === 'user_email_unique'); @@ -154,7 +157,8 @@ describe('family instance introspect', () => { 'includes post table with foreign key to user', async () => { await withIntrospection(connectionString!, (schemaIR) => { - const postTable = schemaIR.tables['post']!; + const ns = schemaIR.namespaces['public']!; + const postTable = ns.tables['post']!; expect(postTable.name).toBe('post'); expect(postTable.columns['id']).toBeDefined(); expect(postTable.columns['userId']).toBeDefined(); @@ -175,7 +179,8 @@ describe('family instance introspect', () => { 'includes indexes for post table', async () => { await withIntrospection(connectionString!, (schemaIR) => { - const postTable = schemaIR.tables['post']!; + const ns = schemaIR.namespaces['public']!; + const postTable = ns.tables['post']!; expect(postTable.indexes).toBeDefined(); expect(postTable.indexes.length).toBeGreaterThan(0); const userIdIndex = postTable.indexes.find((idx) => idx.name === 'post_userId_idx'); @@ -190,8 +195,9 @@ describe('family instance introspect', () => { 'includes Postgres annotations', async () => { await withIntrospection(connectionString!, (schemaIR) => { - expect(schemaIR.annotations).toBeDefined(); - expect(schemaIR.annotations?.['pg']).toBeDefined(); + const ns = schemaIR.namespaces['public']!; + expect(ns.annotations).toBeDefined(); + expect(ns.annotations?.['pg']).toBeDefined(); }); }, timeouts.spinUpPpgDev, diff --git a/test/integration/test/referential-actions.integration.test.ts b/test/integration/test/referential-actions.integration.test.ts index 2deef15ab1..db708865fa 100644 --- a/test/integration/test/referential-actions.integration.test.ts +++ b/test/integration/test/referential-actions.integration.test.ts @@ -9,6 +9,7 @@ import { defineContract, field, model } from '@prisma-next/postgres/contract-bui import type { SqlStorage } from '@prisma-next/sql-contract/types'; import postgres from '@prisma-next/target-postgres/control'; import { PostgresContractSerializer } from '@prisma-next/target-postgres/runtime'; +import { PostgresDatabaseSchemaNode } from '@prisma-next/target-postgres/types'; import { createDevDatabase, timeouts, withClient } from '@prisma-next/test-utils'; import type { Client } from 'pg'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -81,8 +82,10 @@ describe('referential actions integration', () => { ); const schemaIR = await familyInstance.introspect({ driver }); + PostgresDatabaseSchemaNode.assert(schemaIR); + const ns = schemaIR.namespaces['public']!; - const postTable = schemaIR.tables['post']; + const postTable = ns.tables['post']; expect(postTable).toBeDefined(); expect(postTable?.foreignKeys).toHaveLength(1); @@ -116,8 +119,10 @@ describe('referential actions integration', () => { ); const schemaIR = await familyInstance.introspect({ driver }); + PostgresDatabaseSchemaNode.assert(schemaIR); + const ns = schemaIR.namespaces['public']!; - const commentTable = schemaIR.tables['comment']; + const commentTable = ns.tables['comment']; expect(commentTable).toBeDefined(); expect(commentTable?.foreignKeys).toHaveLength(1); @@ -165,7 +170,10 @@ describe('referential actions integration', () => { ); const schemaIR = await familyInstance.introspect({ driver }); - const fk = schemaIR.tables['post']?.foreignKeys[0]; + PostgresDatabaseSchemaNode.assert(schemaIR); + const ns = schemaIR.namespaces['public']!; + + const fk = ns.tables['post']?.foreignKeys[0]; expect(fk).toBeDefined(); expect(fk!.onDelete).toBeUndefined(); expect(fk!.onUpdate).toBeUndefined(); From c4d939bb137155b85bc7f4265df44b99344a0ff5 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 19:19:09 +0200 Subject: [PATCH 12/49] =?UTF-8?q?wip(migration):=20unit=206=20=E2=80=94=20?= =?UTF-8?q?F3=20remove=20bare=20casts=20in=20aggregate=20consumers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pruneNamespaceTables narrows with a local isRecord type predicate instead of four bare `as` casts, mirroring the predicate already used in verifier.ts. Net casts return below the pre-unit-6 baseline (lint:casts delta=-2). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/aggregate/project-schema-to-space.ts | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts b/packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts index f4d6516138..31cf016ce4 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts @@ -132,21 +132,12 @@ function pruneNamespaceTables( schemaObj: { readonly namespaces?: unknown }, ownedByOthers: ReadonlySet, ): unknown { - const namespaces = schemaObj.namespaces as Record; + if (!isRecord(schemaObj.namespaces)) return schemaObj; let removed = false; const prunedNamespaces: Record = {}; - for (const [namespaceId, namespaceNode] of Object.entries(namespaces)) { - if ( - typeof namespaceNode === 'object' && - namespaceNode !== null && - typeof (namespaceNode as { readonly tables?: unknown }).tables === 'object' && - (namespaceNode as { readonly tables?: unknown }).tables !== null - ) { - const prunedNode = pruneRecord( - namespaceNode as { readonly tables?: unknown }, - 'tables', - ownedByOthers, - ); + for (const [namespaceId, namespaceNode] of Object.entries(schemaObj.namespaces)) { + if (isRecord(namespaceNode) && isRecord(namespaceNode['tables'])) { + const prunedNode = pruneRecord(namespaceNode, 'tables', ownedByOthers); if (prunedNode !== namespaceNode) removed = true; prunedNamespaces[namespaceId] = prunedNode; } else { @@ -157,6 +148,10 @@ function pruneNamespaceTables( return { ...schemaObj, namespaces: prunedNamespaces }; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function pruneRecord( schemaObj: { readonly tables?: unknown; readonly collections?: unknown }, field: 'tables' | 'collections', From 1e0f67683b576bde2c72e9a16ecc2a034f99cdbb Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 29 Jun 2026 19:49:45 +0200 Subject: [PATCH 13/49] fix(sql-family): verify pairs each contract namespace to its actual node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit-6 relational verify ran verifySqlSchema once per actual namespace node but passed the WHOLE contract each time, so a multi-schema database reported every contract table as missing_table from the N-1 namespaces it does not belong to. Replace verifyContractAgainstNamespaceNodes with a per-namespace pairing: resolve each contract namespace to its live DDL schema (via the target-s expected-tree projection) and check that namespace-s tables against the matching actual node. verifySqlSchema gains an optional restrictToNamespaceIds so the full contract is still consulted for value-set / control-policy resolution while only the paired namespace-s tables are checked. Single-schema and SQLite are one pairing — byte-identical to before; multi-schema is correct. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../9-family/src/core/control-instance.ts | 161 ++++++++++++++---- .../core/schema-verify/verify-sql-schema.ts | 16 ++ 2 files changed, 148 insertions(+), 29 deletions(-) diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index 2ca29c78a0..8fb9e9965e 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -694,13 +694,20 @@ export function createSqlFamilyInstance( }): VerifyDatabaseSchemaResult { const contract = deserializeWithTargetSerializer(options.contract) as Contract; const controlAdapter = getControlAdapter(); - // The relational verify walks one per-schema namespace node at a time - // (never a merged flat schema — that would collide same-named tables - // across schemas). A flat schema (SQLite) is its own single namespace. - const namespaceNodes = namespaceSchemaNodes(options.schema); - const sqlResult = verifyContractAgainstNamespaceNodes({ + // The relational verify pairs each contract namespace to the actual + // namespace node holding the same DDL schema, then checks that + // namespace's contract tables against that node — never the whole + // contract against one node (which would report every table in other + // schemas as missing) and never a merged flat schema (which would collide + // same-named tables across schemas). The expected tree the target builds + // resolves each contract namespace to its DDL schema; the actual tree is + // keyed the same way, so the pairing is by identity. Single-schema (and + // SQLite's flat schema) is one pairing — byte-identical to before. + const sqlResult = verifyContractPerNamespacePairing({ contract, - namespaceNodes, + actualSchema: options.schema, + buildExpectedSchema: (scopedContract) => + buildTargetSchema(target, scopedContract, options.frameworkComponents), strict: options.strict, typeMetadataRegistry, frameworkComponents: options.frameworkComponents, @@ -1047,25 +1054,95 @@ export function createSqlFamilyInstance( } /** - * Runs the relational verify against the introspected namespace nodes — one - * `verifySqlSchema` pass per node, the nodes never merged so same-named tables - * in different schemas can't collide. - * - * Single-schema (one node, the common case) is one pass of the whole contract - * against the sole namespace node — byte-identical to the pre-tree flat verify. - * SQLite (a flat schema, its own single namespace) is likewise one pass. + * Builds the target's expected schema tree from a contract via the descriptor's + * `migrations.contractToSchema` hook (Postgres → a namespaced tree root; SQLite + * → a flat schema). Read off `target`, like the other target-owned hooks. + */ +function buildTargetSchema( + target: TargetDescriptor<'sql', string>, + contract: Contract, + frameworkComponents: ReadonlyArray>, +): SqlSchemaIRNode { + const hook = blindCast< + { + readonly migrations?: { + readonly contractToSchema?: ( + contract: Contract | null, + frameworkComponents?: ReadonlyArray>, + ) => unknown; + }; + }, + 'reading the target descriptor migrations.contractToSchema hook' + >(target).migrations?.contractToSchema; + if (!hook) { + throw new Error( + 'SQL family verifySchema requires the target to expose migrations.contractToSchema', + ); + } + return blindCast( + hook(contract, frameworkComponents), + ); +} + +/** + * Reads a namespace node's DDL schema name. Namespaced target nodes (Postgres + * `PostgresNamespaceSchemaNode`) carry `schemaName`; a flat schema (SQLite) has + * none and pairs by position as the sole namespace. + */ +function namespaceSchemaName(node: SqlSchemaIR): string | undefined { + return blindCast< + { readonly schemaName?: string }, + 'reading the optional namespace schemaName off a per-schema node' + >(node).schemaName; +} + +/** + * Returns a shallow copy of `contract` exposing only the one named namespace — + * used solely to resolve that namespace's live DDL schema via the target's + * expected-tree projection (never for verification, so global value-sets it may + * reference are not consulted). + */ +function scopeContractToNamespace( + contract: Contract, + namespaceId: string, +): Contract { + const namespace = contract.storage.namespaces[namespaceId]; + const scopedNamespaces = namespace === undefined ? {} : { [namespaceId]: namespace }; + return blindCast< + Contract, + 'narrowing storage.namespaces to one entry; the rest of the contract is preserved' + >({ + ...contract, + storage: blindCast< + SqlStorage, + 'shallow storage copy with a single-namespace map; other storage fields are preserved' + >({ + ...contract.storage, + namespaces: scopedNamespaces, + }), + }); +} + +/** + * Verifies a contract against an introspected schema by pairing each contract + * namespace to the actual namespace node holding the same DDL schema, then + * running the relational `verifySqlSchema` for that pairing — restricted to that + * one namespace's tables, against the matching actual node. The contract table + * under `auth` is only ever looked up in the `auth` actual node, so a + * multi-schema database no longer reports tables in other schemas as missing. * - * Genuine multi-schema (more than one node) is the open CF-2 item: the expected - * (contract) side still routes through the flat `contractToSchemaIR`, so it is - * not yet scoped per namespace. Pairing each contract namespace to its live - * node needs target DDL-schema resolution, which the family layer can't do - * generically. Until that lands, each node is verified against the whole - * contract; tables a node doesn't own surface as `missing_table`. This is - * flagged, not worked around with a collision-prone flat merge. + * The full contract is passed every time (cross-namespace value-set / control + * policy resolution depends on it); `restrictToNamespaceIds` scopes only which + * tables are checked. Each contract namespace's DDL schema is read from a + * single-namespace expected projection and selects the matching actual node. + * Empty contract namespaces verify nothing and are skipped. Single-schema (one + * namespace) and SQLite's flat schema are one pairing — byte-identical to the + * prior verify. */ -function verifyContractAgainstNamespaceNodes(options: { +function verifyContractPerNamespacePairing(options: { readonly contract: Contract; - readonly namespaceNodes: readonly SqlSchemaIR[]; + readonly actualSchema: SqlSchemaIRNode; + readonly buildExpectedSchema: (contract: Contract) => SqlSchemaIRNode; readonly strict: boolean; readonly typeMetadataRegistry: ReadonlyMap; readonly frameworkComponents: ReadonlyArray>; @@ -1080,13 +1157,39 @@ function verifyContractAgainstNamespaceNodes(options: { ...ifDefined('normalizeDefault', options.normalizeDefault), ...ifDefined('normalizeNativeType', options.normalizeNativeType), }; - const [first, ...rest] = options.namespaceNodes; - const firstResult = verifySqlSchema({ ...baseOptions, schema: first ?? { tables: {} } }); - if (rest.length === 0) return firstResult; - return rest.reduce( - (acc, node) => mergeVerifyResults(acc, verifySqlSchema({ ...baseOptions, schema: node })), - firstResult, - ); + + const actualNodes = namespaceSchemaNodes(options.actualSchema); + const actualByName = new Map(); + for (const node of actualNodes) { + const name = namespaceSchemaName(node); + if (name !== undefined) actualByName.set(name, node); + } + // A flat actual schema (SQLite) has no named namespaces — it is the sole node. + const soleFlatActual = actualByName.size === 0 ? actualNodes[0] : undefined; + const emptyNamespace: SqlSchemaIR = { tables: {} }; + + let combined: VerifyDatabaseSchemaResult | undefined; + for (const namespaceId of Object.keys(options.contract.storage.namespaces)) { + const namespace = options.contract.storage.namespaces[namespaceId]; + if (!namespace || Object.keys(namespace.entries.table ?? {}).length === 0) continue; + + const ddlSchema = namespaceSchemaNodes( + options.buildExpectedSchema(scopeContractToNamespace(options.contract, namespaceId)), + ) + .map(namespaceSchemaName) + .find((name) => name !== undefined); + const actualNode = + (ddlSchema !== undefined ? actualByName.get(ddlSchema) : soleFlatActual) ?? emptyNamespace; + + const result = verifySqlSchema({ + ...baseOptions, + schema: actualNode, + restrictToNamespaceIds: new Set([namespaceId]), + }); + combined = combined === undefined ? result : mergeVerifyResults(combined, result); + } + + return combined ?? verifySqlSchema({ ...baseOptions, schema: emptyNamespace }); } /** diff --git a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts index 5899142d00..99ee50cd79 100644 --- a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts +++ b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts @@ -119,6 +119,15 @@ export interface VerifySqlSchemaOptions { * with contract native types (e.g., Postgres 'varchar' → 'character varying'). */ readonly normalizeNativeType?: NativeTypeNormalizer; + /** + * When set, only the contract tables in these namespace ids are checked + * against `schema` (the matching actual namespace node). The full contract is + * still consulted for cross-namespace value-set / control-policy resolution. + * Used by the multi-schema verify, which pairs each contract namespace to its + * own actual node. Absent ⇒ all contract namespaces are checked against the + * single (flat) `schema` — the single-schema / SQLite path. + */ + readonly restrictToNamespaceIds?: ReadonlySet; } /** @@ -158,6 +167,7 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase storageTypes, ...ifDefined('normalizeDefault', normalizeDefault), ...ifDefined('normalizeNativeType', normalizeNativeType), + ...ifDefined('restrictToNamespaceIds', options.restrictToNamespaceIds), }); validateFrameworkComponentsForExtensions(contract, options.frameworkComponents); @@ -305,6 +315,7 @@ function verifySchemaTables(options: { storageTypes: Readonly>; normalizeDefault?: DefaultNormalizer; normalizeNativeType?: NativeTypeNormalizer; + restrictToNamespaceIds?: ReadonlySet; }): { issues: SchemaIssue[]; rootChildren: SchemaVerificationNode[] } { const { contract, @@ -315,6 +326,7 @@ function verifySchemaTables(options: { storageTypes, normalizeDefault, normalizeNativeType, + restrictToNamespaceIds, } = options; const contractDefaultControl = contract.defaultControlPolicy; const issues: SchemaIssue[] = []; @@ -325,6 +337,10 @@ function verifySchemaTables(options: { ); for (const namespaceId of namespaceIds) { + // When the caller pairs each contract namespace to its own actual node, it + // restricts the table check to that namespace; the full contract is still + // consulted for value-set / control-policy resolution. + if (restrictToNamespaceIds !== undefined && !restrictToNamespaceIds.has(namespaceId)) continue; const ns = contract.storage.namespaces[namespaceId]; if (!ns) continue; for (const [tableName, contractTableRaw] of Object.entries(ns.entries.table ?? {})) { From 03271fe595aeb0b423d81fa09406e72cf262feb8 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 30 Jun 2026 11:02:57 +0200 Subject: [PATCH 14/49] refactor(sql-family): one shared per-namespace-paired schema diff for planner + verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The planner and the family schema verify must run the same diffing operation. Extract the per-namespace pairing into a single shared verifySqlSchemaTree in @prisma-next/family-sql/schema-verify (the relational verifySqlSchema paired by namespace identity to the actual nodes). Verify now calls it and rejects when the issues are non-empty — it owns no pairing/scoping logic of its own. The planner collectSchemaIssues calls the same function instead of its old whole-contract-per-node loop, so the planner picks up the multi-schema fix too (its flat loop had the same latent false-missing bug). Behaviour-neutral: single-schema is one pairing, byte-identical; multi-schema is correct. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../9-family/src/core/control-instance.ts | 159 +----------------- .../core/schema-verify/verify-sql-schema.ts | 138 +++++++++++++++ .../9-family/src/exports/schema-verify.ts | 6 +- .../postgres/src/core/migrations/planner.ts | 60 +++---- 4 files changed, 175 insertions(+), 188 deletions(-) diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index 8fb9e9965e..fb8bd46b77 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -44,7 +44,7 @@ import type { SqlExecuteRequest, } from '@prisma-next/sql-relational-core/ast'; import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming'; -import type { SqlSchemaIR, SqlSchemaIRNode, SqlTableIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIRNode, SqlTableIR } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { SqlControlAdapter } from './control-adapter'; @@ -55,8 +55,7 @@ import type { } from './migrations/types'; import { sqlOperationsToPreview } from './operation-preview'; import { sqlSchemaIrToPslAst } from './psl-contract-infer/sql-schema-ir-to-psl-ast'; -import type { DefaultNormalizer, NativeTypeNormalizer } from './schema-verify/verify-sql-schema'; -import { namespaceSchemaNodes, verifySqlSchema } from './schema-verify/verify-sql-schema'; +import { namespaceSchemaNodes, verifySqlSchemaTree } from './schema-verify/verify-sql-schema'; import { collectSupportedCodecTypeIds } from './verify'; function extractCodecTypeIdsFromContract(contract: unknown): readonly string[] { @@ -694,16 +693,13 @@ export function createSqlFamilyInstance( }): VerifyDatabaseSchemaResult { const contract = deserializeWithTargetSerializer(options.contract) as Contract; const controlAdapter = getControlAdapter(); - // The relational verify pairs each contract namespace to the actual - // namespace node holding the same DDL schema, then checks that - // namespace's contract tables against that node — never the whole - // contract against one node (which would report every table in other - // schemas as missing) and never a merged flat schema (which would collide - // same-named tables across schemas). The expected tree the target builds - // resolves each contract namespace to its DDL schema; the actual tree is - // keyed the same way, so the pairing is by identity. Single-schema (and - // SQLite's flat schema) is one pairing — byte-identical to before. - const sqlResult = verifyContractPerNamespacePairing({ + // Verify owns no namespace pairing of its own: it runs the same shared + // per-namespace-paired relational verify the migration planner runs + // (`verifySqlSchemaTree`) and rejects when the combined issues are + // non-empty. The target's `contractToSchema` projection resolves each + // contract namespace to its DDL schema, matched by identity to the actual + // tree's namespaces. + const sqlResult = verifySqlSchemaTree({ contract, actualSchema: options.schema, buildExpectedSchema: (scopedContract) => @@ -1084,143 +1080,6 @@ function buildTargetSchema( ); } -/** - * Reads a namespace node's DDL schema name. Namespaced target nodes (Postgres - * `PostgresNamespaceSchemaNode`) carry `schemaName`; a flat schema (SQLite) has - * none and pairs by position as the sole namespace. - */ -function namespaceSchemaName(node: SqlSchemaIR): string | undefined { - return blindCast< - { readonly schemaName?: string }, - 'reading the optional namespace schemaName off a per-schema node' - >(node).schemaName; -} - -/** - * Returns a shallow copy of `contract` exposing only the one named namespace — - * used solely to resolve that namespace's live DDL schema via the target's - * expected-tree projection (never for verification, so global value-sets it may - * reference are not consulted). - */ -function scopeContractToNamespace( - contract: Contract, - namespaceId: string, -): Contract { - const namespace = contract.storage.namespaces[namespaceId]; - const scopedNamespaces = namespace === undefined ? {} : { [namespaceId]: namespace }; - return blindCast< - Contract, - 'narrowing storage.namespaces to one entry; the rest of the contract is preserved' - >({ - ...contract, - storage: blindCast< - SqlStorage, - 'shallow storage copy with a single-namespace map; other storage fields are preserved' - >({ - ...contract.storage, - namespaces: scopedNamespaces, - }), - }); -} - -/** - * Verifies a contract against an introspected schema by pairing each contract - * namespace to the actual namespace node holding the same DDL schema, then - * running the relational `verifySqlSchema` for that pairing — restricted to that - * one namespace's tables, against the matching actual node. The contract table - * under `auth` is only ever looked up in the `auth` actual node, so a - * multi-schema database no longer reports tables in other schemas as missing. - * - * The full contract is passed every time (cross-namespace value-set / control - * policy resolution depends on it); `restrictToNamespaceIds` scopes only which - * tables are checked. Each contract namespace's DDL schema is read from a - * single-namespace expected projection and selects the matching actual node. - * Empty contract namespaces verify nothing and are skipped. Single-schema (one - * namespace) and SQLite's flat schema are one pairing — byte-identical to the - * prior verify. - */ -function verifyContractPerNamespacePairing(options: { - readonly contract: Contract; - readonly actualSchema: SqlSchemaIRNode; - readonly buildExpectedSchema: (contract: Contract) => SqlSchemaIRNode; - readonly strict: boolean; - readonly typeMetadataRegistry: ReadonlyMap; - readonly frameworkComponents: ReadonlyArray>; - readonly normalizeDefault?: DefaultNormalizer; - readonly normalizeNativeType?: NativeTypeNormalizer; -}): VerifyDatabaseSchemaResult { - const baseOptions = { - contract: options.contract, - strict: options.strict, - typeMetadataRegistry: options.typeMetadataRegistry, - frameworkComponents: options.frameworkComponents, - ...ifDefined('normalizeDefault', options.normalizeDefault), - ...ifDefined('normalizeNativeType', options.normalizeNativeType), - }; - - const actualNodes = namespaceSchemaNodes(options.actualSchema); - const actualByName = new Map(); - for (const node of actualNodes) { - const name = namespaceSchemaName(node); - if (name !== undefined) actualByName.set(name, node); - } - // A flat actual schema (SQLite) has no named namespaces — it is the sole node. - const soleFlatActual = actualByName.size === 0 ? actualNodes[0] : undefined; - const emptyNamespace: SqlSchemaIR = { tables: {} }; - - let combined: VerifyDatabaseSchemaResult | undefined; - for (const namespaceId of Object.keys(options.contract.storage.namespaces)) { - const namespace = options.contract.storage.namespaces[namespaceId]; - if (!namespace || Object.keys(namespace.entries.table ?? {}).length === 0) continue; - - const ddlSchema = namespaceSchemaNodes( - options.buildExpectedSchema(scopeContractToNamespace(options.contract, namespaceId)), - ) - .map(namespaceSchemaName) - .find((name) => name !== undefined); - const actualNode = - (ddlSchema !== undefined ? actualByName.get(ddlSchema) : soleFlatActual) ?? emptyNamespace; - - const result = verifySqlSchema({ - ...baseOptions, - schema: actualNode, - restrictToNamespaceIds: new Set([namespaceId]), - }); - combined = combined === undefined ? result : mergeVerifyResults(combined, result); - } - - return combined ?? verifySqlSchema({ ...baseOptions, schema: emptyNamespace }); -} - -/** - * Combines two `VerifyDatabaseSchemaResult`s by concatenating issues and - * summing counts. Used to fold the per-namespace verify passes of a - * multi-schema database (CF-2) into one result. The `root` verification node - * of the first pass is retained — multi-schema verify-tree shaping is part of - * the open CF-2 work, not this slice. - */ -function mergeVerifyResults( - a: VerifyDatabaseSchemaResult, - b: VerifyDatabaseSchemaResult, -): VerifyDatabaseSchemaResult { - return { - ...a, - ok: a.ok && b.ok, - ...ifDefined('code', a.code ?? b.code), - schema: { - ...a.schema, - issues: [...a.schema.issues, ...b.schema.issues], - schemaDiffIssues: [...a.schema.schemaDiffIssues, ...b.schema.schemaDiffIssues], - counts: { - pass: a.schema.counts.pass + b.schema.counts.pass, - warn: a.schema.counts.warn + b.schema.counts.warn, - fail: a.schema.counts.fail + b.schema.counts.fail, - totalNodes: a.schema.counts.totalNodes + b.schema.counts.totalNodes, - }, - }, - }; -} - /** * Filters schema diff issues (from `collectSchemaDiffIssues`) through the * contract's `defaultControlPolicy`. Issues whose outcome maps to a suppressed diff --git a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts index 99ee50cd79..e2940b413a 100644 --- a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts +++ b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts @@ -1358,3 +1358,141 @@ function formatLiteralValue(value: unknown): string { } return JSON.stringify(value); } + +/** + * Reads a namespace node's DDL schema name. Namespaced target nodes (Postgres + * `PostgresNamespaceSchemaNode`) carry `schemaName`; a flat schema (SQLite) has + * none and pairs by position as the sole namespace. + */ +function namespaceSchemaName(node: SqlSchemaIR): string | undefined { + return blindCast< + { readonly schemaName?: string }, + 'reading the optional namespace schemaName off a per-schema node' + >(node).schemaName; +} + +/** + * Returns a shallow copy of `contract` exposing only the one named namespace — + * used solely to resolve that namespace's live DDL schema via the target's + * expected-tree projection (never for verification, so global value-sets it may + * reference are not consulted). + */ +function scopeContractToNamespace( + contract: Contract, + namespaceId: string, +): Contract { + const namespace = contract.storage.namespaces[namespaceId]; + const scopedNamespaces = namespace === undefined ? {} : { [namespaceId]: namespace }; + return blindCast< + Contract, + 'narrowing storage.namespaces to one entry; the rest of the contract is preserved' + >({ + ...contract, + storage: blindCast< + SqlStorage, + 'shallow storage copy with a single-namespace map; other storage fields are preserved' + >({ + ...contract.storage, + namespaces: scopedNamespaces, + }), + }); +} + +/** + * Combines two `VerifyDatabaseSchemaResult`s by concatenating issues and summing + * counts — used to fold the per-namespace pairings of a multi-schema database + * into one result. The verification-tree `root` of the first pairing is + * retained (multi-schema verify-tree shaping is future work). + */ +function mergeVerifyResults( + a: VerifyDatabaseSchemaResult, + b: VerifyDatabaseSchemaResult, +): VerifyDatabaseSchemaResult { + return { + ...a, + ok: a.ok && b.ok, + ...ifDefined('code', a.code ?? b.code), + schema: { + ...a.schema, + issues: [...a.schema.issues, ...b.schema.issues], + schemaDiffIssues: [...a.schema.schemaDiffIssues, ...b.schema.schemaDiffIssues], + counts: { + pass: a.schema.counts.pass + b.schema.counts.pass, + warn: a.schema.counts.warn + b.schema.counts.warn, + fail: a.schema.counts.fail + b.schema.counts.fail, + totalNodes: a.schema.counts.totalNodes + b.schema.counts.totalNodes, + }, + }, + }; +} + +/** + * The single per-namespace-paired relational verify shared by the migration + * planner and the family schema verify — there is exactly one such operation. + * + * Each contract namespace is paired to the introspected namespace node holding + * the same DDL schema, then `verifySqlSchema` checks that namespace's tables + * against the matching actual node (a contract table under `auth` is only ever + * looked up in the `auth` actual node, so a multi-schema database no longer + * reports tables in other schemas as missing). The full contract is passed every + * time — `restrictToNamespaceIds` scopes only which tables are checked, so + * cross-namespace value-set / control-policy resolution is unaffected. + * + * The DDL schema of each contract namespace is read from a single-namespace + * expected projection (`buildExpectedSchema`), which both callers already build + * the same way. Empty contract namespaces verify nothing and are skipped. + * Single-schema (one namespace) and SQLite's flat schema are one pairing — + * byte-identical to the prior per-node verify. + */ +export function verifySqlSchemaTree(options: { + readonly contract: Contract; + readonly actualSchema: SqlSchemaIRNode; + readonly buildExpectedSchema: (contract: Contract) => SqlSchemaIRNode; + readonly strict: boolean; + readonly typeMetadataRegistry: ReadonlyMap; + readonly frameworkComponents: ReadonlyArray>; + readonly normalizeDefault?: DefaultNormalizer; + readonly normalizeNativeType?: NativeTypeNormalizer; +}): VerifyDatabaseSchemaResult { + const baseOptions = { + contract: options.contract, + strict: options.strict, + typeMetadataRegistry: options.typeMetadataRegistry, + frameworkComponents: options.frameworkComponents, + ...ifDefined('normalizeDefault', options.normalizeDefault), + ...ifDefined('normalizeNativeType', options.normalizeNativeType), + }; + + const actualNodes = namespaceSchemaNodes(options.actualSchema); + const actualByName = new Map(); + for (const node of actualNodes) { + const name = namespaceSchemaName(node); + if (name !== undefined) actualByName.set(name, node); + } + // A flat actual schema (SQLite) has no named namespaces — it is the sole node. + const soleFlatActual = actualByName.size === 0 ? actualNodes[0] : undefined; + const emptyNamespace: SqlSchemaIR = { tables: {} }; + + let combined: VerifyDatabaseSchemaResult | undefined; + for (const namespaceId of Object.keys(options.contract.storage.namespaces)) { + const namespace = options.contract.storage.namespaces[namespaceId]; + if (!namespace || Object.keys(namespace.entries.table ?? {}).length === 0) continue; + + const ddlSchema = namespaceSchemaNodes( + options.buildExpectedSchema(scopeContractToNamespace(options.contract, namespaceId)), + ) + .map(namespaceSchemaName) + .find((name) => name !== undefined); + const actualNode = + (ddlSchema !== undefined ? actualByName.get(ddlSchema) : soleFlatActual) ?? emptyNamespace; + + const result = verifySqlSchema({ + ...baseOptions, + schema: actualNode, + restrictToNamespaceIds: new Set([namespaceId]), + }); + combined = combined === undefined ? result : mergeVerifyResults(combined, result); + } + + return combined ?? verifySqlSchema({ ...baseOptions, schema: emptyNamespace }); +} diff --git a/packages/2-sql/9-family/src/exports/schema-verify.ts b/packages/2-sql/9-family/src/exports/schema-verify.ts index ed312e7d2e..aee664c311 100644 --- a/packages/2-sql/9-family/src/exports/schema-verify.ts +++ b/packages/2-sql/9-family/src/exports/schema-verify.ts @@ -15,4 +15,8 @@ export type { NativeTypeNormalizer, VerifySqlSchemaOptions, } from '../core/schema-verify/verify-sql-schema'; -export { namespaceSchemaNodes, verifySqlSchema } from '../core/schema-verify/verify-sql-schema'; +export { + namespaceSchemaNodes, + verifySqlSchema, + verifySqlSchemaTree, +} from '../core/schema-verify/verify-sql-schema'; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 0c9d2c48bf..cba72fdba1 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -13,7 +13,7 @@ import { plannerFailure, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; -import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; +import { namespaceSchemaNodes, verifySqlSchemaTree } from '@prisma-next/family-sql/schema-verify'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { MigrationPlanner, @@ -61,10 +61,6 @@ type PlannerOptionsWithComponents = SqlMigrationPlannerPlanOptions & { readonly frameworkComponents: PlannerFrameworkComponents; }; -type VerifySqlSchemaOptionsWithComponents = Parameters[0] & { - readonly frameworkComponents: PlannerFrameworkComponents; -}; - export function createPostgresMigrationPlanner( lowerer: ExecuteRequestLowerer, ): PostgresMigrationPlanner { @@ -353,27 +349,28 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // destructive) must inspect extras to reconcile strict equality. const allowed = options.policy.allowedOperationClasses; const strict = allowed.includes('widening') || allowed.includes('destructive'); - // The relational verify walks one per-schema namespace node at a time, - // never a flat merge of every namespace — a merge would silently collide - // same-named tables across schemas (the dual-representation flatten the - // tree restructure removed). Single-schema (the common case) is the sole - // namespace node, byte-identical to the pre-tree flat verify; multi-schema - // verify-side contract scoping is CF-2. A fresh database (empty root, no - // namespaces) still runs one pass against an empty schema so the contract's - // tables surface as `missing_table` — the same as the pre-tree empty - // flat schema. - const verifyIssues = relationalSchemaNodes(options.schema).flatMap((namespaceNode) => { - const verifyOptions: VerifySqlSchemaOptionsWithComponents = { - contract: options.contract, - schema: namespaceNode, - strict, - typeMetadataRegistry: new Map(), - frameworkComponents: options.frameworkComponents, - normalizeDefault: parsePostgresDefault, - normalizeNativeType: normalizeSchemaNativeType, - }; - return verifySqlSchema(verifyOptions).schema.issues; - }); + // The relational verify pairs each contract namespace to its live actual + // node — the SAME shared `verifySqlSchemaTree` the family schema verify + // runs, so the planner and verify diff identically. A fresh database (empty + // root) pairs every contract table to an empty node, surfacing them as + // `missing_table` so the planner emits the CREATE TABLEs. + const verifyIssues = verifySqlSchemaTree({ + contract: options.contract, + actualSchema: options.schema, + buildExpectedSchema: (scopedContract) => + contractToPostgresDatabaseSchemaNode( + blindCast< + PostgresContract | null, + 'collectSchemaIssues is only called with a postgres contract' + >(scopedContract), + { annotationNamespace: 'pg' }, + ), + strict, + typeMetadataRegistry: new Map(), + frameworkComponents: options.frameworkComponents, + normalizeDefault: parsePostgresDefault, + normalizeNativeType: normalizeSchemaNativeType, + }).schema.issues; // Schema presence is a Postgres-specific concern (no equivalent in // SQLite / Mongo), so the issue emission lives in the target layer // rather than in the family verifier. Stitch it in here so a single @@ -393,17 +390,6 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr } } -/** - * The per-schema namespace nodes the relational verify runs against, one pass - * each. A fresh database (empty root, no namespaces) yields a single empty - * schema so the contract's tables surface as `missing_table` — the pre-tree - * empty flat schema behaviour. - */ -function relationalSchemaNodes(schema: SqlSchemaIRNode): readonly SqlSchemaIR[] { - const namespaceNodes = namespaceSchemaNodes(schema); - return namespaceNodes.length > 0 ? namespaceNodes : [{ tables: {} }]; -} - /** * Selects the per-schema namespace node the relational strategy layer probes * for live-table existence. Prefers the node matching the planner's resolved From b3fe123946f07dfc70efb73f056ad149e98da438 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 30 Jun 2026 15:43:58 +0200 Subject: [PATCH 15/49] refactor(postgres): one diffDatabaseSchema for planner + verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration planner and the schema verify now run ONE combined database-schema diff instead of each composing the relational + policy strategies themselves. `diffPostgresDatabaseSchema(...)` (new `diff-database-schema.ts`, Postgres target) composes, once each: the per-namespace-paired relational diff (`verifySqlSchemaTree` → table/column/constraint `SchemaIssue`s + the verification-tree root/counts) and the RLS policy diff (`diffPostgresSchema` → ownership-filtered `SchemaDiffIssue`s). It returns a `VerifyDatabaseSchemaResult` whose `schema` carries both shapes — exactly the existing verify-result schema shape, so nothing downstream changes. The two issue shapes stay separate (the relational findings are stringly `SchemaIssue`s, the policy findings carry the live policy nodes the planner needs to build ops); merging them onto one type is the follow-on relational port, not here. The control adapter exposes it as the `diffDatabaseSchema` seam (replacing `collectSchemaDiffIssues`). The family verify calls `controlAdapter.diffDatabaseSchema` and rejects on non-empty — composing no diff itself (SQLite, which has no structural diff, falls back to the relational diff alone). The planner calls the same `diffPostgresDatabaseSchema` and maps `schema.issues` (+ planner-only namespace presence) to DDL ops and `schema.schemaDiffIssues` to RLS ops — no re-diff. Behaviour-neutral: planner ops byte-identical, verify accept/reject and result shape unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../9-family/src/core/control-adapter.ts | 27 +++-- .../9-family/src/core/control-instance.ts | 63 +++++----- .../core/migrations/diff-database-schema.ts | 100 ++++++++++++++++ .../postgres/src/core/migrations/planner.ts | 108 ++++++++---------- .../3-targets/postgres/src/exports/planner.ts | 1 + .../postgres/src/core/control-adapter.ts | 45 ++++---- .../rls-collect-extension-issues.test.ts | 25 ++-- 7 files changed, 240 insertions(+), 129 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts diff --git a/packages/2-sql/9-family/src/core/control-adapter.ts b/packages/2-sql/9-family/src/core/control-adapter.ts index eb5f0d426c..77d896a6a1 100644 --- a/packages/2-sql/9-family/src/core/control-adapter.ts +++ b/packages/2-sql/9-family/src/core/control-adapter.ts @@ -3,10 +3,11 @@ import type { ContractMarkerRecord, LedgerEntryRecord, } from '@prisma-next/contract/types'; +import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { ControlAdapterInstance, ControlStack, - SchemaDiffIssue, + VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; import type { SqlControlDriverInstance, SqlStorage } from '@prisma-next/sql-contract/types'; import type { @@ -199,16 +200,22 @@ export interface SqlControlAdapter readonly normalizeNativeType?: NativeTypeNormalizer; /** - * Optional hook for collecting target-specific diff issues during schema - * verification. Called after the relational SQL verification pass; returns - * generic `SchemaDiffIssue[]` (coordinate + outcome + message — no target - * naming) that are folded into `VerifyDatabaseSchemaResult.schema.schemaDiffIssues` - * and counted as failures when non-empty. + * The single combined database-schema diff the migration planner and the + * schema verify both run for this target — the relational diff (table / + * column / constraint findings as `SchemaIssue[]`, with the verification-tree + * `root`/`counts`) plus the target's structural diff (e.g. Postgres RLS policy + * presence as `SchemaDiffIssue[]`), each computed once. Returns a + * `VerifyDatabaseSchemaResult` whose `schema` carries both shapes — exactly the + * existing verify-result schema shape. Optional: targets without a structural + * diff (SQLite) omit it, and the family verify runs the relational diff alone. */ - collectSchemaDiffIssues?( - contract: Contract, - schema: SqlSchemaIRNode, - ): readonly SchemaDiffIssue[]; + diffDatabaseSchema?(input: { + readonly contract: Contract; + readonly schema: SqlSchemaIRNode; + readonly strict: boolean; + readonly typeMetadataRegistry: ReadonlyMap; + readonly frameworkComponents: ReadonlyArray>; + }): VerifyDatabaseSchemaResult; /** * Ordered DDL queries that bootstrap marker/ledger control tables for migration diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index fb8bd46b77..2d8c4e5302 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -693,33 +693,44 @@ export function createSqlFamilyInstance( }): VerifyDatabaseSchemaResult { const contract = deserializeWithTargetSerializer(options.contract) as Contract; const controlAdapter = getControlAdapter(); - // Verify owns no namespace pairing of its own: it runs the same shared - // per-namespace-paired relational verify the migration planner runs - // (`verifySqlSchemaTree`) and rejects when the combined issues are - // non-empty. The target's `contractToSchema` projection resolves each - // contract namespace to its DDL schema, matched by identity to the actual - // tree's namespaces. - const sqlResult = verifySqlSchemaTree({ - contract, - actualSchema: options.schema, - buildExpectedSchema: (scopedContract) => - buildTargetSchema(target, scopedContract, options.frameworkComponents), - strict: options.strict, - typeMetadataRegistry, - frameworkComponents: options.frameworkComponents, - ...ifDefined('normalizeDefault', controlAdapter.normalizeDefault), - ...ifDefined('normalizeNativeType', controlAdapter.normalizeNativeType), - }); - // The target's RLS diff machinery ensures and walks the tree root, so it - // receives the node unchanged (not the per-namespace relational view). - const rawSchemaDiffIssues = - controlAdapter.collectSchemaDiffIssues?.(contract, options.schema) ?? []; + // Verify runs the one combined database-schema diff the migration planner + // runs — `controlAdapter.diffDatabaseSchema` (relational columns + target + // structural diff, per-namespace-paired) — and rejects when the result is + // non-empty. Verify composes no strategies itself. A target without a + // structural diff (SQLite) has no `diffDatabaseSchema`; fall back to the + // relational diff alone (its only schema diff). + const sqlResult = controlAdapter.diffDatabaseSchema + ? controlAdapter.diffDatabaseSchema({ + contract, + schema: options.schema, + strict: options.strict, + typeMetadataRegistry, + frameworkComponents: options.frameworkComponents, + }) + : verifySqlSchemaTree({ + contract, + actualSchema: options.schema, + buildExpectedSchema: (scopedContract) => + buildTargetSchema(target, scopedContract, options.frameworkComponents), + strict: options.strict, + typeMetadataRegistry, + frameworkComponents: options.frameworkComponents, + ...ifDefined('normalizeDefault', controlAdapter.normalizeDefault), + ...ifDefined('normalizeNativeType', controlAdapter.normalizeNativeType), + }); + // Control-policy suppression of the structural (e.g. RLS policy) diff + // issues is a verify-side post-step — the combined diff returns them + // ownership-filtered, and a suppressed control policy drops them here. const schemaDiffIssues = filterSchemaDiffIssues( - rawSchemaDiffIssues, + sqlResult.schema.schemaDiffIssues, contract.defaultControlPolicy, ); - if (schemaDiffIssues.length === 0) return sqlResult; - const totalFails = sqlResult.schema.counts.fail + schemaDiffIssues.length; + const relationalFails = sqlResult.schema.counts.fail; + if (schemaDiffIssues.length === 0) { + if (schemaDiffIssues === sqlResult.schema.schemaDiffIssues) return sqlResult; + return { ...sqlResult, schema: { ...sqlResult.schema, schemaDiffIssues } }; + } + const totalFails = relationalFails + schemaDiffIssues.length; return { ...sqlResult, ok: false, @@ -1081,8 +1092,8 @@ function buildTargetSchema( } /** - * Filters schema diff issues (from `collectSchemaDiffIssues`) through the - * contract's `defaultControlPolicy`. Issues whose outcome maps to a suppressed + * Filters the structural schema-diff issues (from `diffDatabaseSchema`) through + * the contract's `defaultControlPolicy`. Issues whose outcome maps to a suppressed * category under the effective policy are removed. This mirrors the control-policy * filtering applied by `verifySqlSchema` for table/column-level findings. * diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts new file mode 100644 index 0000000000..49dfefd5dd --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts @@ -0,0 +1,100 @@ +import type { Contract } from '@prisma-next/contract/types'; +import { verifySqlSchemaTree } from '@prisma-next/family-sql/schema-verify'; +import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; +import type { VerifyDatabaseSchemaResult } from '@prisma-next/framework-components/control'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; +import { blindCast } from '@prisma-next/utils/casts'; +import { parsePostgresDefault } from '../default-normalizer'; +import { normalizeSchemaNativeType } from '../native-type-normalizer'; +import type { PostgresContract } from '../postgres-schema'; +import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import { contractToPostgresDatabaseSchemaNode } from './contract-to-postgres-database-schema-node'; +import { diffPostgresSchema, filterIssuesByOwnership } from './diff-postgres-schema'; + +/** + * The single combined database-schema diff of two schema-IR trees — the one + * operation the migration planner and the family schema verify both run. It + * composes, once each: + * + * - the per-namespace-paired relational diff (`verifySqlSchemaTree`) → table / + * column / constraint findings as framework `SchemaIssue`s (with the + * verification-tree `root` and pass/warn/fail counts); + * - the policy diff (`diffPostgresSchema` over the two trees) → RLS policy + * presence as `SchemaDiffIssue`s, ownership-filtered to the contract's owned + * schemas. + * + * The return is a `VerifyDatabaseSchemaResult` whose `schema` carries both shapes + * (`issues` + `schemaDiffIssues`) plus the relational `root`/`counts` the CLI + * renders — i.e. exactly the existing verify-result schema shape, so nothing + * downstream changes. The two issue shapes stay separate (the relational + * findings are stringly `SchemaIssue`s; the policy findings carry the live + * policy nodes the planner needs to build ops); merging them onto one type is + * the follow-on relational port, not here. + * + * Namespace presence (`missing_schema` → `CREATE SCHEMA`) is intentionally NOT + * composed here: it is a planner-only op-generation concern (verify rejects on + * the relational `missing_table` a missing schema already produces), so the + * planner stitches it in around this diff. Control-policy suppression of the + * policy issues is likewise a per-consumer post-step (verify filters the issues; + * the planner filters the calls). + */ +export function diffPostgresDatabaseSchema(input: { + readonly contract: Contract; + readonly actualSchema: SqlSchemaIRNode; + readonly strict: boolean; + readonly typeMetadataRegistry: ReadonlyMap; + readonly frameworkComponents: ReadonlyArray>; +}): VerifyDatabaseSchemaResult { + const postgresContract = blindCast< + PostgresContract, + 'diffPostgresDatabaseSchema is only called with a postgres contract' + >(input.contract); + + // Relational diff: per-namespace-paired so a multi-schema database checks each + // contract namespace against its own actual node. + const relational = verifySqlSchemaTree({ + contract: input.contract, + actualSchema: input.actualSchema, + buildExpectedSchema: (scopedContract) => + contractToPostgresDatabaseSchemaNode( + blindCast< + PostgresContract | null, + 'the relational pairing projects a scoped postgres contract' + >(scopedContract), + { annotationNamespace: 'pg' }, + ), + strict: input.strict, + typeMetadataRegistry: input.typeMetadataRegistry, + frameworkComponents: input.frameworkComponents, + normalizeDefault: parsePostgresDefault, + normalizeNativeType: normalizeSchemaNativeType, + }); + + // Policy diff: the generic node differ over the expected/actual policy trees, + // ownership-filtered to the schemas the contract owns (so unowned-namespace + // policies are not reported as extras). The actual schema is always the + // Postgres database root in production — assert it, matching the prior + // `collectSchemaDiffIssues` / `planPostgresSchemaDiff` behaviour. + PostgresDatabaseSchemaNode.assert(input.actualSchema); + const expected = contractToPostgresDatabaseSchemaNode(postgresContract, { + annotationNamespace: 'pg', + }); + const actual = PostgresDatabaseSchemaNode.ensure(input.actualSchema); + const schemaDiffIssues = filterIssuesByOwnership( + diffPostgresSchema(expected, actual), + ownedSchemaNames(expected), + ); + + return { + ...relational, + schema: { ...relational.schema, schemaDiffIssues }, + }; +} + +function ownedSchemaNames(expected: PostgresDatabaseSchemaNode): ReadonlySet { + const policyNamespaces = Object.values(expected.namespaces).flatMap((ns) => + Object.values(ns.tables).flatMap((t) => t.policies.map((p) => p.namespaceId)), + ); + return new Set([...policyNamespaces, ...expected.existingSchemas]); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index cba72fdba1..beb6056321 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -13,33 +13,30 @@ import { plannerFailure, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; -import { namespaceSchemaNodes, verifySqlSchemaTree } from '@prisma-next/family-sql/schema-verify'; +import { namespaceSchemaNodes } from '@prisma-next/family-sql/schema-verify'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { MigrationPlanner, MigrationPlanWithAuthoringSurface, MigrationScaffoldContext, + SchemaDiffIssue, SchemaIssue, } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; -import { parsePostgresDefault } from '../default-normalizer'; -import { normalizeSchemaNativeType } from '../native-type-normalizer'; import { PostgresRlsPolicy } from '../postgres-rls-policy'; -import type { PostgresContract } from '../postgres-schema'; import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; import { resolveDdlSchemaForNamespaceStorage } from '../schema-ir/postgres-schema-ir-annotations'; -import { contractToPostgresDatabaseSchemaNode } from './contract-to-postgres-database-schema-node'; import { formatPostgresControlPolicySubjectLabel, resolvePostgresCallControlPolicySubject, resolvePostgresIssueControlPolicySubject, resolvePostgresIssueCreationFactoryName, } from './control-policy'; -import { diffPostgresSchema, filterIssuesByOwnership } from './diff-postgres-schema'; +import { diffPostgresDatabaseSchema } from './diff-database-schema'; import { planIssues } from './issue-planner'; import type { PostgresOpFactoryCall } from './op-factory-call'; import { @@ -161,7 +158,21 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return policyResult; } - const schemaIssues = this.collectSchemaIssues(options); + // One combined database-schema diff drives the whole plan: the relational + // findings (+ namespace presence) become structural DDL via `planIssues`, + // the policy findings become RLS ops via `planPostgresSchemaDiff`. Verify + // runs the same `diffPostgresDatabaseSchema` and rejects on non-empty. + PostgresDatabaseSchemaNode.assert(options.schema); + const databaseDiff = diffPostgresDatabaseSchema({ + contract: options.contract, + actualSchema: options.schema, + strict: + options.policy.allowedOperationClasses.includes('widening') || + options.policy.allowedOperationClasses.includes('destructive'), + typeMetadataRegistry: new Map(), + frameworkComponents: options.frameworkComponents, + }); + const schemaIssues = this.collectSchemaIssues(options, databaseDiff.schema.issues); const codecHooks = extractCodecControlHooks(options.frameworkComponents); const storageTypes = options.contract.storage.types ?? {}; // The strategy layer reads the live schema by bare table name for @@ -211,7 +222,10 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return plannerFailure(result.failure); } - const schemaDiffCalls = this.planPostgresSchemaDiff(options); + const schemaDiffCalls = this.planPostgresSchemaDiff( + options, + databaseDiff.schema.schemaDiffIssues, + ); const schemaDiffPartition = partitionCallsByControlPolicy({ calls: schemaDiffCalls, contract: options.contract, @@ -269,24 +283,16 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr }); } + /** + * Maps the RLS policy presence findings of the shared + * `diffPostgresDatabaseSchema` (already ownership-filtered) into + * `ENABLE RLS` / `CREATE POLICY` / `DROP POLICY` ops. It no longer re-diffs — + * it consumes the `schemaDiffIssues` of the one combined diff. + */ private planPostgresSchemaDiff( options: PlannerOptionsWithComponents, + filteredDiffIssues: readonly SchemaDiffIssue[], ): readonly PostgresOpFactoryCall[] { - PostgresDatabaseSchemaNode.assert(options.schema); - const expected = contractToPostgresDatabaseSchemaNode( - blindCast( - options.contract, - ), - { annotationNamespace: 'pg' }, - ); - const actual = PostgresDatabaseSchemaNode.ensure(options.schema); - const rawIssues = diffPostgresSchema(expected, actual); - const expectedPolicyNamespaces = Object.values(expected.namespaces).flatMap((ns) => - Object.values(ns.tables).flatMap((t) => t.policies.map((p) => p.namespaceId)), - ); - const ownedSchemaNames = new Set([...expectedPolicyNamespaces, ...expected.existingSchemas]); - const filteredDiffIssues = filterIssuesByOwnership(rawIssues, ownedSchemaNames); - const allowsDestructive = options.policy.allowedOperationClasses.includes('destructive'); const calls: PostgresOpFactoryCall[] = []; const seenEnableTables = new Set(); @@ -343,50 +349,30 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return null; } - private collectSchemaIssues(options: PlannerOptionsWithComponents): readonly SchemaIssue[] { - // `db init` uses additive-only policy and intentionally ignores extra - // schema objects. Any reconciliation-capable policy (widening or - // destructive) must inspect extras to reconcile strict equality. - const allowed = options.policy.allowedOperationClasses; - const strict = allowed.includes('widening') || allowed.includes('destructive'); - // The relational verify pairs each contract namespace to its live actual - // node — the SAME shared `verifySqlSchemaTree` the family schema verify - // runs, so the planner and verify diff identically. A fresh database (empty - // root) pairs every contract table to an empty node, surfacing them as - // `missing_table` so the planner emits the CREATE TABLEs. - const verifyIssues = verifySqlSchemaTree({ - contract: options.contract, - actualSchema: options.schema, - buildExpectedSchema: (scopedContract) => - contractToPostgresDatabaseSchemaNode( - blindCast< - PostgresContract | null, - 'collectSchemaIssues is only called with a postgres contract' - >(scopedContract), - { annotationNamespace: 'pg' }, - ), - strict, - typeMetadataRegistry: new Map(), - frameworkComponents: options.frameworkComponents, - normalizeDefault: parsePostgresDefault, - normalizeNativeType: normalizeSchemaNativeType, - }).schema.issues; - // Schema presence is a Postgres-specific concern (no equivalent in - // SQLite / Mongo), so the issue emission lives in the target layer - // rather than in the family verifier. Stitch it in here so a single - // `SchemaIssue[]` flows through `planIssues` and the planner emits - // CREATE SCHEMA in the dep bucket before any CreateTableCall. - // It reads `existingSchemas` off the database root (CF-1), so it takes the - // whole tree, not a per-namespace node. - // Schema drift is handled separately via diffPostgresSchema → planPostgresSchemaDiff. + /** + * The structural issue list `planIssues` consumes: the relational findings + * from the shared `diffPostgresDatabaseSchema` plus namespace presence. + * + * Schema presence (`missing_schema` → `CREATE SCHEMA`) is a planner-only + * op-generation concern, so it is stitched in here rather than inside the + * shared diff — verify never needs it (a missing schema already surfaces as + * `missing_table` in the relational findings). It reads `existingSchemas` off + * the database root (CF-1) so it takes the whole tree. Policy drift is handled + * separately via `planPostgresSchemaDiff` from the same shared diff's + * `schemaDiffIssues`. + */ + private collectSchemaIssues( + options: PlannerOptionsWithComponents, + relationalIssues: readonly SchemaIssue[], + ): readonly SchemaIssue[] { const namespaceIssues = verifyPostgresNamespacePresence({ contract: options.contract, schema: options.schema, }); if (namespaceIssues.length === 0) { - return verifyIssues; + return relationalIssues; } - return [...namespaceIssues, ...verifyIssues]; + return [...namespaceIssues, ...relationalIssues]; } } diff --git a/packages/3-targets/3-targets/postgres/src/exports/planner.ts b/packages/3-targets/3-targets/postgres/src/exports/planner.ts index 7be195dda6..08e04c0671 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/planner.ts @@ -1,4 +1,5 @@ export { contractToPostgresDatabaseSchemaNode } from '../core/migrations/contract-to-postgres-database-schema-node'; +export { diffPostgresDatabaseSchema } from '../core/migrations/diff-database-schema'; export { diffPostgresSchema, filterIssuesByOwnership, diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index 84542a4727..22521145be 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -11,7 +11,11 @@ import { import type { SqlControlAdapter } from '@prisma-next/family-sql/control-adapter'; import { parseContractMarkerRow } from '@prisma-next/family-sql/verify'; import type { CodecLookup, CodecRegistry } from '@prisma-next/framework-components/codec'; -import { APP_SPACE_ID, type SchemaDiffIssue } from '@prisma-next/framework-components/control'; +import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; +import { + APP_SPACE_ID, + type VerifyDatabaseSchemaResult, +} from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { ledgerOriginFromStored } from '@prisma-next/migration-tools/ledger-origin'; import { REFERENTIAL_ACTION_SQL } from '@prisma-next/sql-contract/referential-action-sql'; @@ -59,11 +63,7 @@ import type { } from '@prisma-next/target-postgres/ddl'; import { parsePostgresDefault } from '@prisma-next/target-postgres/default-normalizer'; import { normalizeSchemaNativeType } from '@prisma-next/target-postgres/native-type-normalizer'; -import { - contractToPostgresDatabaseSchemaNode, - diffPostgresSchema, - filterIssuesByOwnership, -} from '@prisma-next/target-postgres/planner'; +import { diffPostgresDatabaseSchema } from '@prisma-next/target-postgres/planner'; import { escapeLiteral, quoteIdentifier } from '@prisma-next/target-postgres/sql-utils'; import { PostgresDatabaseSchemaNode, @@ -127,25 +127,20 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { */ readonly normalizeNativeType = normalizeSchemaNativeType; - collectSchemaDiffIssues( - contract: Contract, - schema: SqlSchemaIRNode, - ): readonly SchemaDiffIssue[] { - PostgresDatabaseSchemaNode.assert(schema); - const expected = contractToPostgresDatabaseSchemaNode( - blindCast< - PostgresContract, - 'collectSchemaDiffIssues is only called with a postgres contract' - >(contract), - { annotationNamespace: 'pg' }, - ); - const actual = PostgresDatabaseSchemaNode.ensure(schema); - const issues = diffPostgresSchema(expected, actual); - const expectedPolicyNamespaces = Object.values(expected.namespaces).flatMap((ns) => - Object.values(ns.tables).flatMap((t) => t.policies.map((p) => p.namespaceId)), - ); - const ownedSchemaNames = new Set([...expectedPolicyNamespaces, ...expected.existingSchemas]); - return filterIssuesByOwnership(issues, ownedSchemaNames); + diffDatabaseSchema(input: { + readonly contract: Contract; + readonly schema: SqlSchemaIRNode; + readonly strict: boolean; + readonly typeMetadataRegistry: ReadonlyMap; + readonly frameworkComponents: ReadonlyArray>; + }): VerifyDatabaseSchemaResult { + return diffPostgresDatabaseSchema({ + contract: input.contract, + actualSchema: input.schema, + strict: input.strict, + typeMetadataRegistry: input.typeMetadataRegistry, + frameworkComponents: input.frameworkComponents, + }); } bootstrapControlTableQueries(): readonly DdlNode[] { diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts index c5675c55a4..331c945160 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts @@ -145,9 +145,23 @@ function contractWithPolicy(): Contract { }; } -describe('collectSchemaDiffIssues — RLS drift detection', () => { +/** + * Runs the combined database-schema diff and returns only the policy + * (`schemaDiffIssues`) findings — the RLS drift these tests assert on. + */ +function policyDiffIssues(contract: Contract, schema: PostgresDatabaseSchemaNode) { + return controlAdapter.diffDatabaseSchema!({ + contract, + schema, + strict: false, + typeMetadataRegistry: new Map(), + frameworkComponents: [], + }).schema.schemaDiffIssues; +} + +describe('diffDatabaseSchema — RLS drift detection', () => { it('no contract policy + Prisma-managed DB policy → one extra diff issue', () => { - const issues = controlAdapter.collectSchemaDiffIssues!( + const issues = policyDiffIssues( emptyContractNoPolicies(), schemaWithPolicies([managedPolicy()]), ); @@ -158,7 +172,7 @@ describe('collectSchemaDiffIssues — RLS drift detection', () => { }); it('no contract policy + external DB policy → one extra diff issue', () => { - const issues = controlAdapter.collectSchemaDiffIssues!( + const issues = policyDiffIssues( emptyContractNoPolicies(), schemaWithPolicies([externalPolicy()]), ); @@ -169,10 +183,7 @@ describe('collectSchemaDiffIssues — RLS drift detection', () => { }); it('matching contract + DB policy → no issues', () => { - const issues = controlAdapter.collectSchemaDiffIssues!( - contractWithPolicy(), - schemaWithPolicies([managedPolicy()]), - ); + const issues = policyDiffIssues(contractWithPolicy(), schemaWithPolicies([managedPolicy()])); expect(issues).toHaveLength(0); }); From 722d8738726d5c51b575b693eddd46a741891780 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 30 Jun 2026 16:10:29 +0200 Subject: [PATCH 16/49] =?UTF-8?q?feat(postgres):=20unit=207=20=E2=80=94=20?= =?UTF-8?q?database=E2=86=92PSL=20inference=20moves=20to=20the=20target=20?= =?UTF-8?q?(R7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Database→PSL inference is target logic (it owns the dialect type/default maps and walks its own schema tree), so it moves off the SQL family onto the Postgres target descriptor — clearing the layering violation and the last expected-red. - The Postgres target descriptor gains `inferPslContract(tree)`, beside `contractSerializer`. New `core/psl-infer/infer-psl-contract.ts` (`inferPostgresPslContract`) walks the `PostgresDatabaseSchemaNode` tree and owns the Postgres maps (`postgres-type-map` / `postgres-default-mapping`, moved from the family). The enum diagnostic now reads each namespace node-s `nativeEnumTypeNames`; tables are gathered across namespaces into the model set and emitted as the single `__unspecified__` bucket — byte-identical PSL. - The shape-neutral leaf transforms (name transforms, relation inference, `mapDefault`, raw-default parser, the `PslTypeMap`/printer-config types) stay in the family and are exported from the new `@prisma-next/family-sql/psl-infer` entrypoint, which the target imports. - The family instance `inferPslContract` delegates to `target.inferPslContract` (read off the descriptor like `contractSerializer`); absent ⇒ a clear error (Mongo). The flat `sqlSchemaIrToPslAst` + `buildPslDocumentAst` are deleted. - The inference tests move to the target (`test/psl-infer/`), retargeted to `inferPostgresPslContract` via tree fixtures; their inline PSL snapshots are unchanged (byte-identity). The neutral-utility tests stay in the family. No SQL-family file imports the Postgres maps; `contract infer` output is byte-identical; the full repo typecheck and `fixtures:check` are green. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- packages/2-sql/9-family/package.json | 1 + .../9-family/src/core/control-instance.ts | 15 +- .../9-family/src/core/migrations/types.ts | 11 ++ .../core/psl-contract-infer/printer-config.ts | 7 +- .../2-sql/9-family/src/exports/psl-infer.ts | 36 ++++ .../default-mapping.test.ts | 22 ++- packages/2-sql/9-family/tsdown.config.ts | 1 + .../3-targets/3-targets/postgres/package.json | 3 +- .../src/core/psl-infer/infer-psl-contract.ts} | 70 +++++--- .../psl-infer}/postgres-default-mapping.ts | 2 +- .../src/core/psl-infer}/postgres-type-map.ts | 2 +- .../3-targets/postgres/src/exports/control.ts | 6 + .../postgres/test/psl-infer/fixtures.ts | 55 +++++++ .../psl-infer/infer-psl-contract.test.ts} | 4 +- .../test/psl-infer}/postgres-type-map.test.ts | 2 +- .../print-psl/print-psl.core.test.ts | 7 +- .../print-psl.defaults-and-types.test.ts | 7 +- .../print-psl/print-psl.enums.test.ts | 4 +- .../print-psl.naming-and-constraints.test.ts | 7 +- .../print-psl/print-psl.relations.test.ts | 7 +- pnpm-lock.yaml | 154 ++++++++---------- 21 files changed, 267 insertions(+), 156 deletions(-) create mode 100644 packages/2-sql/9-family/src/exports/psl-infer.ts rename packages/{2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts => 3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts} (91%) rename packages/{2-sql/9-family/src/core/psl-contract-infer => 3-targets/3-targets/postgres/src/core/psl-infer}/postgres-default-mapping.ts (85%) rename packages/{2-sql/9-family/src/core/psl-contract-infer => 3-targets/3-targets/postgres/src/core/psl-infer}/postgres-type-map.ts (99%) create mode 100644 packages/3-targets/3-targets/postgres/test/psl-infer/fixtures.ts rename packages/{2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts => 3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts} (98%) rename packages/{2-sql/9-family/test/psl-contract-infer => 3-targets/3-targets/postgres/test/psl-infer}/postgres-type-map.test.ts (99%) rename packages/{2-sql/9-family/test/psl-contract-infer => 3-targets/3-targets/postgres/test/psl-infer}/print-psl/print-psl.core.test.ts (94%) rename packages/{2-sql/9-family/test/psl-contract-infer => 3-targets/3-targets/postgres/test/psl-infer}/print-psl/print-psl.defaults-and-types.test.ts (98%) rename packages/{2-sql/9-family/test/psl-contract-infer => 3-targets/3-targets/postgres/test/psl-infer}/print-psl/print-psl.enums.test.ts (93%) rename packages/{2-sql/9-family/test/psl-contract-infer => 3-targets/3-targets/postgres/test/psl-infer}/print-psl/print-psl.naming-and-constraints.test.ts (97%) rename packages/{2-sql/9-family/test/psl-contract-infer => 3-targets/3-targets/postgres/test/psl-infer}/print-psl/print-psl.relations.test.ts (98%) diff --git a/packages/2-sql/9-family/package.json b/packages/2-sql/9-family/package.json index b174919908..8b847aa548 100644 --- a/packages/2-sql/9-family/package.json +++ b/packages/2-sql/9-family/package.json @@ -59,6 +59,7 @@ "./ir": "./dist/ir.mjs", "./migration": "./dist/migration.mjs", "./pack": "./dist/pack.mjs", + "./psl-infer": "./dist/psl-infer.mjs", "./runtime": "./dist/runtime.mjs", "./schema-verify": "./dist/schema-verify.mjs", "./verify": "./dist/verify.mjs", diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index 2d8c4e5302..ba5856c9d2 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -54,7 +54,6 @@ import type { SqlControlExtensionDescriptor, } from './migrations/types'; import { sqlOperationsToPreview } from './operation-preview'; -import { sqlSchemaIrToPslAst } from './psl-contract-infer/sql-schema-ir-to-psl-ast'; import { namespaceSchemaNodes, verifySqlSchemaTree } from './schema-verify/verify-sql-schema'; import { collectSupportedCodecTypeIds } from './verify'; @@ -535,6 +534,13 @@ export function createSqlFamilyInstance( }; } ).contractSerializer; + // Database→PSL inference is target logic (it owns the dialect type/default + // maps and walks its own schema tree), so it is read off the descriptor like + // `contractSerializer`. Absent for targets without `contract infer` (Mongo). + const targetInferPslContract = blindCast< + { readonly inferPslContract?: (schema: SqlSchemaIRNode) => PslDocumentAst }, + 'reading the optional target-descriptor inferPslContract hook' + >(target).inferPslContract; const deserializeWithTargetSerializer = (contractOrJson: unknown): Contract => { const serializer = targetSerializer ?? new SqlContractSerializer(); const json = @@ -911,7 +917,12 @@ export function createSqlFamilyInstance( }, inferPslContract(schemaIR: SqlSchemaIRNode): PslDocumentAst { - return sqlSchemaIrToPslAst(schemaIR); + if (!targetInferPslContract) { + throw new Error( + `Target "${target.targetId}" does not support contract infer (no inferPslContract on its descriptor).`, + ); + } + return targetInferPslContract(schemaIR); }, lowerAst( diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index a309f0f791..e41a0e4a7a 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -21,6 +21,7 @@ import type { SchemaIssue, SchemaVerifier, } from '@prisma-next/framework-components/control'; +import type { PslDocumentAst } from '@prisma-next/framework-components/psl-ast'; import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; import type { SqlControlDriverInstance, @@ -482,6 +483,16 @@ export interface SqlControlTargetDescriptor< * the base, the target-specific dispatch on the subclass. */ readonly schemaVerifier: SchemaVerifier; + /** + * Database→PSL inference: walks the target's introspected schema tree into a + * `PslDocumentAst` for `contract infer`. Target logic (it owns the dialect + * type/default maps), so it lives on the descriptor beside `contractSerializer` + * — not in the family. Optional: targets that do not support `contract infer` + * (Mongo) omit it, and the family instance throws when it is absent. The param + * is the family-base node so the interface stays target-agnostic; the impl + * narrows to its own tree root. + */ + readonly inferPslContract?: (schema: SqlSchemaIRNode) => PslDocumentAst; createPlanner(adapter: SqlControlAdapter): SqlMigrationPlanner; createRunner(family: SqlControlFamilyInstance): SqlMigrationRunner; } diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts index 44c3d56b10..0c1903570b 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts @@ -2,9 +2,10 @@ import type { ColumnDefault } from '@prisma-next/contract/types'; import type { DefaultMappingOptions } from './default-mapping'; /** - * Internal printer-shaped configuration, used by the SQL family's - * `sqlSchemaIrToPslAst` helper (M2). The framework-level psl-printer no longer - * exposes these — they're consumed only inside the SQL family. + * Printer-shaped configuration for database→PSL inference. These shape-neutral + * types are exported from the SQL family (`@prisma-next/family-sql/psl-infer`) + * and consumed by the target that owns the dialect maps and walks its own + * schema tree (the Postgres target's `inferPostgresPslContract`). */ export type PslNativeTypeAttribute = { diff --git a/packages/2-sql/9-family/src/exports/psl-infer.ts b/packages/2-sql/9-family/src/exports/psl-infer.ts new file mode 100644 index 0000000000..6ee9f254c0 --- /dev/null +++ b/packages/2-sql/9-family/src/exports/psl-infer.ts @@ -0,0 +1,36 @@ +/** + * Shape-neutral database→PSL inference utilities. + * + * These leaf transforms (name normalization, relation inference, generic + * default mapping, the printer-config types, raw-default parsing) carry no + * dialect knowledge, so they live in the SQL family and are imported by the + * target that owns the dialect maps and walks its own schema tree (Postgres). + * The framework owns `PslDocumentAst` + `printPsl`; the target owns the + * Postgres type/default maps. + */ + +export type { + DefaultMappingOptions, + DefaultMappingResult, +} from '../core/psl-contract-infer/default-mapping'; +export { mapDefault } from '../core/psl-contract-infer/default-mapping'; +export { + deriveBackRelationFieldName, + deriveRelationFieldName, + pluralize, + toEnumName, + toFieldName, + toModelName, + toNamedTypeName, +} from '../core/psl-contract-infer/name-transforms'; +export type { + EnumInfo, + PslNativeTypeAttribute, + PslPrinterOptions, + PslTypeMap, + PslTypeResolution, + RelationField, +} from '../core/psl-contract-infer/printer-config'; +export { parseRawDefault } from '../core/psl-contract-infer/raw-default-parser'; +export type { InferredRelations } from '../core/psl-contract-infer/relation-inference'; +export { inferRelations } from '../core/psl-contract-infer/relation-inference'; diff --git a/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts index 82a45f69bb..1a89871f45 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { mapDefault } from '../../src/core/psl-contract-infer/default-mapping'; -import { createPostgresDefaultMapping } from '../../src/core/psl-contract-infer/postgres-default-mapping'; +import { + type DefaultMappingOptions, + mapDefault, +} from '../../src/core/psl-contract-infer/default-mapping'; + +// Inline dialect-mapping fixture (the Postgres maps now live in the target); +// these cases exercise the neutral `mapDefault` with an injected mapping. +const injectedMapping: DefaultMappingOptions = { + functionAttributes: { 'gen_random_uuid()': '@default(dbgenerated("gen_random_uuid()"))' }, + fallbackFunctionAttribute: (expression) => `@default(dbgenerated(${JSON.stringify(expression)}))`, +}; describe('mapDefault', () => { it('maps autoincrement()', () => { @@ -17,19 +26,14 @@ describe('mapDefault', () => { it('maps gen_random_uuid() when Postgres mapping is injected', () => { expect( - mapDefault( - { kind: 'function', expression: 'gen_random_uuid()' }, - createPostgresDefaultMapping(), - ), + mapDefault({ kind: 'function', expression: 'gen_random_uuid()' }, injectedMapping), ).toEqual({ attribute: '@default(dbgenerated("gen_random_uuid()"))', }); }); it('maps unmapped Postgres defaults to dbgenerated when Postgres mapping is injected', () => { - expect( - mapDefault({ kind: 'function', expression: "'{}'::jsonb" }, createPostgresDefaultMapping()), - ).toEqual({ + expect(mapDefault({ kind: 'function', expression: "'{}'::jsonb" }, injectedMapping)).toEqual({ attribute: `@default(dbgenerated(${JSON.stringify("'{}'::jsonb")}))`, }); }); diff --git a/packages/2-sql/9-family/tsdown.config.ts b/packages/2-sql/9-family/tsdown.config.ts index 908af46c9d..2baa969844 100644 --- a/packages/2-sql/9-family/tsdown.config.ts +++ b/packages/2-sql/9-family/tsdown.config.ts @@ -10,5 +10,6 @@ export default defineConfig({ 'src/exports/runtime.ts', 'src/exports/verify.ts', 'src/exports/schema-verify.ts', + 'src/exports/psl-infer.ts', ], }); diff --git a/packages/3-targets/3-targets/postgres/package.json b/packages/3-targets/3-targets/postgres/package.json index 86b7a94364..03b46053ae 100644 --- a/packages/3-targets/3-targets/postgres/package.json +++ b/packages/3-targets/3-targets/postgres/package.json @@ -22,12 +22,12 @@ "@prisma-next/family-sql": "workspace:0.14.0", "@prisma-next/framework-components": "workspace:0.14.0", "@prisma-next/migration-tools": "workspace:0.14.0", - "@prisma-next/ts-render": "workspace:0.14.0", "@prisma-next/sql-contract": "workspace:0.14.0", "@prisma-next/sql-errors": "workspace:0.14.0", "@prisma-next/sql-operations": "workspace:0.14.0", "@prisma-next/sql-relational-core": "workspace:0.14.0", "@prisma-next/sql-schema-ir": "workspace:0.14.0", + "@prisma-next/ts-render": "workspace:0.14.0", "@prisma-next/utils": "workspace:0.14.0", "@standard-schema/spec": "^1.1.0", "arktype": "^2.2.0", @@ -35,6 +35,7 @@ }, "devDependencies": { "@prisma-next/psl-parser": "workspace:0.14.0", + "@prisma-next/psl-printer": "workspace:*", "@prisma-next/sql-contract-psl": "workspace:0.14.0", "@prisma-next/test-utils": "workspace:0.14.0", "@prisma-next/tsconfig": "workspace:0.14.0", diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts similarity index 91% rename from packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts index 96a417e159..b87d46e789 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts @@ -1,4 +1,19 @@ import type { ColumnDefault } from '@prisma-next/contract/types'; +import type { + DefaultMappingOptions, + PslNativeTypeAttribute, + PslPrinterOptions, + PslTypeMap, + RelationField, +} from '@prisma-next/family-sql/psl-infer'; +import { + inferRelations, + mapDefault, + parseRawDefault, + toFieldName, + toModelName, + toNamedTypeName, +} from '@prisma-next/family-sql/psl-infer'; import type { PslAttribute, PslAttributeArgument, @@ -17,19 +32,9 @@ import { UNSPECIFIED_PSL_NAMESPACE_ID, } from '@prisma-next/framework-components/psl-ast'; import type { SqlColumnIR, SqlSchemaIR, SqlTableIR } from '@prisma-next/sql-schema-ir/types'; -import type { DefaultMappingOptions } from './default-mapping'; -import { mapDefault } from './default-mapping'; -import { toFieldName, toModelName, toNamedTypeName } from './name-transforms'; +import type { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; import { createPostgresDefaultMapping } from './postgres-default-mapping'; -import { createPostgresTypeMap, extractEnumInfo } from './postgres-type-map'; -import type { - PslNativeTypeAttribute, - PslPrinterOptions, - PslTypeMap, - RelationField, -} from './printer-config'; -import { parseRawDefault } from './raw-default-parser'; -import { inferRelations } from './relation-inference'; +import { createPostgresTypeMap } from './postgres-type-map'; const SYNTHETIC_SPAN: PslSpan = { start: { offset: 0, line: 1, column: 1 }, @@ -72,16 +77,28 @@ type TopLevelNameResult = { }; /** - * Converts a SQL schema IR into a PSL AST suitable for `printPsl`. + * Infers a PSL AST (for `printPsl`) from an introspected Postgres schema tree. + * + * Target-owned inference: it walks the `PostgresDatabaseSchemaNode` tree and + * owns the Postgres dialect knowledge — the native type map and default map. + * Relation inference, name transforms, generic default mapping, and raw-default + * parsing are shape-neutral utilities imported from the SQL family. * - * This function owns all SQL-specific concerns: native type mapping (Postgres), - * relation inference from foreign keys, enum extraction, and raw default parsing. - * The output is a fully-formed `PslDocumentAst` with synthetic spans. + * This slice emits relational-only PSL, byte-identical to the prior flat + * inference: the tree's tables (across its namespaces — `contract infer` + * introspects a single live namespace) are gathered into the model set and + * emitted as one `UNSPECIFIED_PSL_NAMESPACE_ID` bucket. Top-level entities + * (policies/roles → PSL extension blocks) are a later slice. */ -export function sqlSchemaIrToPslAst(schemaIR: SqlSchemaIR): PslDocumentAst { - const enumInfo = extractEnumInfo(schemaIR.annotations); - if (enumInfo.typeNames.size > 0) { - const names = [...enumInfo.typeNames].join(', '); +export function inferPostgresPslContract(tree: PostgresDatabaseSchemaNode): PslDocumentAst { + const namespaces = Object.values(tree.namespaces); + + // Native Postgres enums (CREATE TYPE … AS ENUM) are not adoptable by + // `contract infer`; throw an actionable diagnostic naming the type(s). Enum + // names live on each namespace node's `nativeEnumTypeNames`. + const enumTypeNames = [...new Set(namespaces.flatMap((ns) => ns.nativeEnumTypeNames))]; + if (enumTypeNames.length > 0) { + const names = enumTypeNames.join(', '); throw new Error( `contract infer: the database contains native Postgres enum type(s): ${names}. ` + 'Native Postgres enums (CREATE TYPE … AS ENUM) are not adoptable by contract infer. ' + @@ -90,6 +107,19 @@ export function sqlSchemaIrToPslAst(schemaIR: SqlSchemaIR): PslDocumentAst { 'surface generates the required check automatically.', ); } + + // Gather the tree's tables into the flat `{ tables }` the relational document + // builder walks. This replaces the old flat `.tables` iteration; it does not + // reintroduce a stored flat schema — it is a read-only projection for PSL + // emission over a single introspected namespace. + const tables: Record = {}; + for (const namespace of namespaces) { + for (const [tableName, table] of Object.entries(namespace.tables)) { + tables[tableName] = table; + } + } + const schemaIR: SqlSchemaIR = { tables }; + const options: PslPrinterOptions = { typeMap: createPostgresTypeMap(new Set()), defaultMapping: createPostgresDefaultMapping(), diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/postgres-default-mapping.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts similarity index 85% rename from packages/2-sql/9-family/src/core/psl-contract-infer/postgres-default-mapping.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts index f80559e398..d971c64658 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/postgres-default-mapping.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts @@ -1,4 +1,4 @@ -import type { DefaultMappingOptions } from './default-mapping'; +import type { DefaultMappingOptions } from '@prisma-next/family-sql/psl-infer'; const POSTGRES_FUNCTION_ATTRIBUTES: Readonly> = { 'gen_random_uuid()': '@default(dbgenerated("gen_random_uuid()"))', diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/postgres-type-map.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts similarity index 99% rename from packages/2-sql/9-family/src/core/psl-contract-infer/postgres-type-map.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts index be678e7414..0bbdcef939 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/postgres-type-map.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts @@ -3,7 +3,7 @@ import type { PslNativeTypeAttribute, PslTypeMap, PslTypeResolution, -} from './printer-config'; +} from '@prisma-next/family-sql/psl-infer'; const POSTGRES_TO_PSL: Record = { text: 'String', diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index 85213be411..3a8d045247 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -19,6 +19,8 @@ import { createPostgresMigrationRunner } from '../core/migrations/runner'; import { PostgresContractSerializer } from '../core/postgres-contract-serializer'; import type { PostgresContract } from '../core/postgres-schema'; import { PostgresSchemaVerifier } from '../core/postgres-schema-verifier'; +import { inferPostgresPslContract } from '../core/psl-infer/infer-psl-contract'; +import { PostgresDatabaseSchemaNode } from '../core/schema-ir/postgres-database-schema-node'; function buildNativeTypeExpander( frameworkComponents?: ReadonlyArray>, @@ -52,6 +54,10 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP ...postgresTargetDescriptorMeta, contractSerializer: new PostgresContractSerializer(), schemaVerifier: new PostgresSchemaVerifier(), + inferPslContract(schema) { + PostgresDatabaseSchemaNode.assert(schema); + return inferPostgresPslContract(PostgresDatabaseSchemaNode.ensure(schema)); + }, migrations: { createPlanner(adapter: SqlControlAdapter<'postgres'>) { return createPostgresMigrationPlanner(adapter); diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/fixtures.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/fixtures.ts new file mode 100644 index 0000000000..628f3ffe27 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/fixtures.ts @@ -0,0 +1,55 @@ +import type { PslDocumentAst } from '@prisma-next/framework-components/psl-ast'; +import { printPsl } from '@prisma-next/psl-printer'; +import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import { inferPostgresPslContract } from '../../src/core/psl-infer/infer-psl-contract'; +import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; +import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; +import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; + +/** + * Wraps a flat `{ tables, annotations? }` introspection fixture into the + * `PostgresDatabaseSchemaNode` tree the target's inference walks. The old flat + * `annotations.pg.nativeEnumTypeNames` becomes the namespace's typed + * `nativeEnumTypeNames`. All fixture tables live under the single `public` + * namespace (`contract infer` introspects one live schema), so the inferred PSL + * is byte-identical to the prior flat inference. + */ +export function treeFromFlat(schemaIR: SqlSchemaIR): PostgresDatabaseSchemaNode { + const nativeEnumTypeNames = readNativeEnumTypeNames(schemaIR.annotations); + const tables: Record = {}; + for (const [name, table] of Object.entries(schemaIR.tables)) { + tables[name] = new PostgresTableSchemaNode(table); + } + return new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables, + nativeEnumTypeNames, + }), + }, + roles: [], + existingSchemas: ['public'], + pgVersion: '', + }); +} + +/** Infers and prints PSL from a flat introspection fixture. */ +export function printPslFromFlat(schemaIR: SqlSchemaIR): string { + return printPsl(inferPostgresPslContract(treeFromFlat(schemaIR))); +} + +/** Infers a PSL AST from a flat introspection fixture. */ +export function inferPslAstFromFlat(schemaIR: SqlSchemaIR): PslDocumentAst { + return inferPostgresPslContract(treeFromFlat(schemaIR)); +} + +function readNativeEnumTypeNames(annotations: SqlSchemaIR['annotations']): readonly string[] { + const pg = annotations?.['pg']; + const names = + pg && typeof pg === 'object' + ? (pg as Record)['nativeEnumTypeNames'] + : undefined; + if (!Array.isArray(names)) return []; + return names.filter((name): name is string => typeof name === 'string'); +} diff --git a/packages/2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts similarity index 98% rename from packages/2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts index 7ee01ded3e..f878c86307 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts @@ -2,7 +2,7 @@ import { flatPslModels } from '@prisma-next/framework-components/psl-ast'; import { printPsl } from '@prisma-next/psl-printer'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; -import { sqlSchemaIrToPslAst } from '../../src/core/psl-contract-infer/sql-schema-ir-to-psl-ast'; +import { inferPslAstFromFlat as sqlSchemaIrToPslAst } from './fixtures'; function ir(partial: Partial & Pick): SqlSchemaIR { return { @@ -10,7 +10,7 @@ function ir(partial: Partial & Pick): SqlSch }; } -describe('sqlSchemaIrToPslAst', () => { +describe('inferPostgresPslContract', () => { it('produces a model for a single table with PK and unique', () => { const schemaIR = ir({ tables: { diff --git a/packages/2-sql/9-family/test/psl-contract-infer/postgres-type-map.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/postgres-type-map.test.ts similarity index 99% rename from packages/2-sql/9-family/test/psl-contract-infer/postgres-type-map.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/postgres-type-map.test.ts index b7cb3afc49..425f3261c0 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/postgres-type-map.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/postgres-type-map.test.ts @@ -3,7 +3,7 @@ import { createPostgresTypeMap, extractEnumDefinitions, extractEnumTypeNames, -} from '../../src/core/psl-contract-infer/postgres-type-map'; +} from '../../src/core/psl-infer/postgres-type-map'; describe('createPostgresTypeMap', () => { const typeMap = createPostgresTypeMap(); diff --git a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.core.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.core.test.ts similarity index 94% rename from packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.core.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.core.test.ts index 6fd438c664..8deada842d 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.core.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.core.test.ts @@ -1,11 +1,6 @@ -import { printPsl } from '@prisma-next/psl-printer'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; -import { sqlSchemaIrToPslAst } from '../../../src/core/psl-contract-infer/sql-schema-ir-to-psl-ast'; - -function printPslFromSql(schemaIR: SqlSchemaIR): string { - return printPsl(sqlSchemaIrToPslAst(schemaIR)); -} +import { printPslFromFlat as printPslFromSql } from '../fixtures'; describe('printPsl', () => { it('empty schema', () => { diff --git a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts similarity index 98% rename from packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts index fe86535aaf..d999457929 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts @@ -1,11 +1,6 @@ -import { printPsl } from '@prisma-next/psl-printer'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; -import { sqlSchemaIrToPslAst } from '../../../src/core/psl-contract-infer/sql-schema-ir-to-psl-ast'; - -function printPslFromSql(schemaIR: SqlSchemaIR): string { - return printPsl(sqlSchemaIrToPslAst(schemaIR)); -} +import { printPslFromFlat as printPslFromSql } from '../fixtures'; describe('printPsl', () => { it('schema with defaults (autoincrement, now, boolean, string, number)', () => { diff --git a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.enums.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts similarity index 93% rename from packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.enums.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts index 3b6600c76f..bda5918b4d 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.enums.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts @@ -1,8 +1,8 @@ import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; -import { sqlSchemaIrToPslAst } from '../../../src/core/psl-contract-infer/sql-schema-ir-to-psl-ast'; +import { inferPslAstFromFlat as sqlSchemaIrToPslAst } from '../fixtures'; -describe('sqlSchemaIrToPslAst — native enum diagnostic', () => { +describe('inferPostgresPslContract — native enum diagnostic', () => { it('throws when the schema contains a nativeEnumTypeNames annotation', () => { const schemaIR: SqlSchemaIR = { tables: { diff --git a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.naming-and-constraints.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.naming-and-constraints.test.ts similarity index 97% rename from packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.naming-and-constraints.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.naming-and-constraints.test.ts index 22dc1269dd..958bdda75e 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.naming-and-constraints.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.naming-and-constraints.test.ts @@ -1,11 +1,6 @@ -import { printPsl } from '@prisma-next/psl-printer'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; -import { sqlSchemaIrToPslAst } from '../../../src/core/psl-contract-infer/sql-schema-ir-to-psl-ast'; - -function printPslFromSql(schemaIR: SqlSchemaIR): string { - return printPsl(sqlSchemaIrToPslAst(schemaIR)); -} +import { printPslFromFlat as printPslFromSql } from '../fixtures'; describe('printPsl', () => { it('escapes inferred relation field names that would start with a digit', () => { diff --git a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.relations.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts similarity index 98% rename from packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.relations.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts index 47249702a6..68f009ab73 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.relations.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts @@ -1,11 +1,6 @@ -import { printPsl } from '@prisma-next/psl-printer'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; -import { sqlSchemaIrToPslAst } from '../../../src/core/psl-contract-infer/sql-schema-ir-to-psl-ast'; - -function printPslFromSql(schemaIR: SqlSchemaIR): string { - return printPsl(sqlSchemaIrToPslAst(schemaIR)); -} +import { printPslFromFlat as printPslFromSql } from '../fixtures'; describe('printPsl', () => { it('schema with 1:N relation', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4806b5122..7faed56d8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1403,7 +1403,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/0-foundation/utils: devDependencies: @@ -1421,7 +1421,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/1-core/config: dependencies: @@ -1455,7 +1455,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/1-core/errors: dependencies: @@ -1480,7 +1480,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/1-core/framework-components: dependencies: @@ -1517,7 +1517,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/1-core/operations: devDependencies: @@ -1538,7 +1538,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/1-core/ts-render: devDependencies: @@ -1556,7 +1556,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/2-authoring/contract: dependencies: @@ -1578,7 +1578,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/2-authoring/ids: dependencies: @@ -1609,7 +1609,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/2-authoring/psl-parser: dependencies: @@ -1634,7 +1634,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/2-authoring/psl-printer: dependencies: @@ -1662,7 +1662,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/3-tooling/cli: dependencies: @@ -1995,7 +1995,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/1-framework/3-tooling/prisma-next: dependencies: @@ -2151,7 +2151,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/1-foundation/mongo-contract: dependencies: @@ -2191,7 +2191,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/1-foundation/mongo-value: dependencies: @@ -2213,7 +2213,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/2-authoring/contract-psl: dependencies: @@ -2259,7 +2259,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/2-authoring/contract-ts: dependencies: @@ -2302,7 +2302,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/3-tooling/emitter: dependencies: @@ -2336,7 +2336,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/3-tooling/mongo-schema-ir: dependencies: @@ -2370,7 +2370,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/4-query/query-ast: dependencies: @@ -2407,7 +2407,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/5-query-builders/orm: dependencies: @@ -2468,7 +2468,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/5-query-builders/query-builder: dependencies: @@ -2502,7 +2502,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/6-transport/mongo-lowering: dependencies: @@ -2533,7 +2533,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/6-transport/mongo-wire: dependencies: @@ -2561,7 +2561,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/7-runtime: dependencies: @@ -2631,7 +2631,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-mongo-family/9-family: dependencies: @@ -2698,7 +2698,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/1-core/contract: dependencies: @@ -2732,7 +2732,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/1-core/errors: devDependencies: @@ -2753,7 +2753,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/1-core/operations: dependencies: @@ -2787,7 +2787,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/1-core/schema-ir: dependencies: @@ -2815,7 +2815,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/2-authoring/contract-psl: dependencies: @@ -2867,7 +2867,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/2-authoring/contract-ts: dependencies: @@ -2922,7 +2922,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/3-tooling/emitter: dependencies: @@ -2959,7 +2959,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/4-lanes/query-builder: devDependencies: @@ -2986,7 +2986,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/4-lanes/relational-core: dependencies: @@ -3038,7 +3038,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/4-lanes/sql-builder: dependencies: @@ -3090,7 +3090,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/5-runtime: dependencies: @@ -3145,7 +3145,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/2-sql/9-family: dependencies: @@ -3221,7 +3221,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-extensions/arktype-json: dependencies: @@ -3270,7 +3270,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-extensions/middleware-cache: dependencies: @@ -3295,7 +3295,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-extensions/mongo: dependencies: @@ -3371,7 +3371,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-extensions/paradedb: dependencies: @@ -3438,7 +3438,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-extensions/pgvector: dependencies: @@ -3514,7 +3514,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-extensions/postgis: dependencies: @@ -3590,7 +3590,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-extensions/postgres: dependencies: @@ -3672,7 +3672,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-extensions/sql-orm-client: dependencies: @@ -3748,7 +3748,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-extensions/sqlite: dependencies: @@ -3821,7 +3821,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-extensions/supabase: dependencies: @@ -3921,7 +3921,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-mongo-target/1-mongo-target: dependencies: @@ -4012,7 +4012,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-mongo-target/2-mongo-adapter: dependencies: @@ -4100,7 +4100,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-mongo-target/3-mongo-driver: dependencies: @@ -4143,7 +4143,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-targets/3-targets/postgres: dependencies: @@ -4199,6 +4199,9 @@ importers: '@prisma-next/psl-parser': specifier: workspace:0.14.0 version: link:../../../1-framework/2-authoring/psl-parser + '@prisma-next/psl-printer': + specifier: workspace:* + version: link:../../../1-framework/2-authoring/psl-printer '@prisma-next/sql-contract-psl': specifier: workspace:0.14.0 version: link:../../../2-sql/2-authoring/contract-psl @@ -4219,7 +4222,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-targets/3-targets/sqlite: dependencies: @@ -4286,7 +4289,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-targets/6-adapters/postgres: dependencies: @@ -4371,7 +4374,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-targets/6-adapters/sqlite: dependencies: @@ -4453,7 +4456,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-targets/7-drivers/postgres: dependencies: @@ -4517,7 +4520,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages/3-targets/7-drivers/sqlite: dependencies: @@ -4563,7 +4566,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) test/e2e/framework: dependencies: @@ -4859,7 +4862,7 @@ importers: version: vite@8.0.9(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) test/integration/test/fixtures/cli/cli-e2e-test-app: dependencies: @@ -5034,7 +5037,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) packages: @@ -13019,7 +13022,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) + vitest: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) '@vitest/expect@4.1.8': dependencies: @@ -15875,36 +15878,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)): - dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.2 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.9.1 - '@vitest/coverage-v8': 4.1.6(vitest@4.1.8) - jsdom: 29.1.1(@noble/hashes@2.2.0) - transitivePeerDependencies: - - msw - - vitest@4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6(vitest@4.1.8))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)): + vitest@4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.8.4)) From a834a4046dd7b5465917431473240cd6169dad7b Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 30 Jun 2026 16:39:08 +0200 Subject: [PATCH 17/49] fix(postgres): runner post-apply verify uses the shared per-namespace diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration runner held a third, private copy of the whole-contract-against- each-namespace-node verify loop that the diffDatabaseSchema unification missed — the same multi-schema false-missing bug already fixed in the family verify and the planner. For an auth.user + public.note contract the runner-s post-apply verify reported public.note missing because it checked the full contract against the auth namespace node, failing db init. The slice-DoD e2e gate caught it (multi-namespace-runtime). The runner now delegates to the family `verifySchema`, the single shared per-namespace-paired diff (diffDatabaseSchema / verifySqlSchemaTree) the CLI verify and planner already use — no private per-namespace loop remains. Also update the control-api integration test: introspect() returns the PostgresDatabaseSchemaNode tree (namespaces), not the old flat { tables }; the assertion now checks the real tree shape. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../postgres/src/core/migrations/runner.ts | 55 +++++++++---------- test/integration/test/control-api.test.ts | 9 ++- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts index 618b4d372f..873d522265 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts @@ -12,7 +12,6 @@ import type { SqlMigrationRunnerSuccessValue, } from '@prisma-next/family-sql/control'; import { runnerFailure, runnerSuccess } from '@prisma-next/family-sql/control'; -import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import type { MigrationRunnerResult } from '@prisma-next/framework-components/control'; import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; @@ -23,8 +22,6 @@ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { Result } from '@prisma-next/utils/result'; import { notOk, ok, okVoid } from '@prisma-next/utils/result'; -import { parsePostgresDefault } from '../default-normalizer'; -import { normalizeSchemaNativeType } from '../native-type-normalizer'; import type { PostgresPlanTargetDetails } from './planner-target-details'; interface ApplyPlanSuccessValue { @@ -124,38 +121,38 @@ class PostgresMigrationRunner implements SqlMigrationRunner { expect(result).toBeDefined(); expect(typeof result).toBe('object'); - // The result should be a schema IR with tables + // `introspect()` returns the target's schema-IR node — for Postgres + // the `PostgresDatabaseSchemaNode` tree root: namespaces keyed by + // DDL schema, each carrying a `tables` record (always at least the + // live `public` schema). expect(result).toMatchObject({ - tables: expect.anything(), + namespaces: { + public: { schemaName: 'public', tables: expect.anything() }, + }, }); } finally { await client.close(); From a43576bd1dd45bbcabcefca8bccae5a8a7bb54b0 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 30 Jun 2026 18:35:41 +0200 Subject: [PATCH 18/49] fix(postgres): project expected tree per-namespace, no whole-contract flatten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expected-side projection called `contractToSchemaIR` once on the whole contract, collapsing every namespace into one bare-keyed `Record` that throws on a cross-namespace table-name collision. So a contract with `public.thing` + `auth.thing` could not be projected — the residual flatten the tree restructure was meant to remove. New family helper `contractNamespaceToSchemaIR(storage, namespaceId, options)` converts only one namespace-s tables, keyed within that namespace, passing the full storage so cross-namespace type/value-set/enum resolution is unaffected (and cross-namespace FKs survive — `convertForeignKey` builds purely from `fk.target`). The Postgres projection now calls it per namespace instead of flattening. New test proves same-named cross-schema tables project into distinct namespace nodes without throwing. `verifySqlSchemaTree`-s `restrictToNamespaceIds` stays — it scopes the relational `verifySqlSchema`-s contract-table iteration to one namespace while keeping the full contract available for cross-namespace value-set resolution; the projection fix is on the diff-s expected tree, not what `verifySqlSchema` consumes. Behaviour-neutral: planner ops byte-identical, multi-schema + e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../core/migrations/contract-to-schema-ir.ts | 48 +++++++++++++++++++ .../2-sql/9-family/src/exports/control.ts | 1 + ...ntract-to-postgres-database-schema-node.ts | 11 +++-- ...t-to-postgres-database-schema-node.test.ts | 44 +++++++++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts index d599053d5a..41a8134374 100644 --- a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts +++ b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts @@ -360,6 +360,54 @@ export interface ContractToSchemaIROptions { * * Returns an empty schema IR when `contract` is `null` (new project). */ +/** + * Converts the tables of a single namespace into a `SqlSchemaIR`, keyed by + * table name within that namespace. Unlike {@link contractToSchemaIR}, which + * flattens every namespace's tables into one bare-keyed record (and throws on a + * cross-namespace name collision), this scopes the table iteration to one + * namespace so the same table name can exist in two schemas. + * + * The full `storage` is still passed to `convertTable`, so value-set / enum / + * type resolution that legitimately spans namespaces is unaffected. Foreign + * keys are built purely from the FK descriptor (`fk.target`), so cross-namespace + * FKs survive per-namespace conversion. The `annotations` block (storage-type + * derived) is omitted here — the per-namespace tree consumer reads only the + * per-table fields. + */ +export function contractNamespaceToSchemaIR( + storage: SqlStorage, + namespaceId: string, + options: ContractToSchemaIROptions, +): SqlSchemaIR { + if (options.annotationNamespace.length === 0) { + throw new Error('annotationNamespace must be a non-empty string'); + } + const namespace = storage.namespaces[namespaceId]; + if (!namespace) { + return { tables: {} }; + } + const storageTypes: ResolvedStorageTypes = { + ...((storage.types ?? {}) as ResolvedStorageTypes), + }; + const tables: Record = {}; + for (const [tableName, tableDefRaw] of Object.entries(namespace.entries.table ?? {})) { + if (!isStorageTable(tableDefRaw)) { + throw new Error( + `contractNamespaceToSchemaIR: expected StorageTable at namespaces.${namespaceId}.entries.table.${tableName}`, + ); + } + tables[tableName] = convertTable( + tableName, + tableDefRaw, + storageTypes, + options.expandNativeType, + options.renderDefault, + storage, + ); + } + return { tables }; +} + export function contractToSchemaIR( contract: Contract | null, options: ContractToSchemaIROptions, diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 412e0687bf..7820b7fb51 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -22,6 +22,7 @@ export type { } from '../core/migrations/contract-to-schema-ir'; // Contract → SchemaIR conversion for offline migration planning export { + contractNamespaceToSchemaIR, contractToSchemaIR, detectDestructiveChanges, resolveValueSetValues, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts index e2f13648db..3497151c5c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts @@ -1,5 +1,5 @@ import type { ContractToSchemaIROptions } from '@prisma-next/family-sql/control'; -import { contractToSchemaIR } from '@prisma-next/family-sql/control'; +import { contractNamespaceToSchemaIR } from '@prisma-next/family-sql/control'; import { ifDefined } from '@prisma-next/utils/defined'; import type { PostgresRlsPolicy } from '../postgres-rls-policy'; import type { PostgresContract } from '../postgres-schema'; @@ -51,8 +51,6 @@ export function contractToPostgresDatabaseSchemaNode( }); } - const sqlIr = contractToSchemaIR(contract, options); - const namespaces: Record = {}; const roles: PostgresRoleSchemaNode[] = []; const ownedSchemas: string[] = []; @@ -62,6 +60,11 @@ export function contractToPostgresDatabaseSchemaNode( const ddlSchema = resolveDdlSchemaForNamespaceStorage(contract.storage, ns.id); ownedSchemas.push(ddlSchema); + // Convert only THIS namespace's tables (passing the full storage for + // type/value-set/enum resolution that spans namespaces), so the same table + // name can exist in two schemas without colliding in a bare-keyed record. + const sqlTables = contractNamespaceToSchemaIR(contract.storage, ns.id, options).tables; + const policiesByTable = new Map(); for (const policy of Object.values(ns.policy)) { const list = policiesByTable.get(policy.tableName) ?? []; @@ -71,7 +74,7 @@ export function contractToPostgresDatabaseSchemaNode( const tables: Record = {}; for (const tableName of Object.keys(ns.table)) { - const sqlTable = sqlIr.tables[tableName]; + const sqlTable = sqlTables[tableName]; if (sqlTable === undefined) continue; tables[tableName] = new PostgresTableSchemaNode({ name: sqlTable.name, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts index 7c1f6b39c1..9c83c28a52 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts @@ -136,6 +136,50 @@ describe('contractToPostgresDatabaseSchemaNode', () => { expect(root.existingSchemas).toEqual([]); }); + it('projects same-named tables in different schemas into their own namespace nodes', () => { + const thingTable = () => + new StorageTable({ + columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }); + const contract: PostgresContract = { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('sha256:same-name-cross-schema'), + storage: new SqlStorage({ + storageHash: coreHash('sha256:same-name-cross-schema'), + namespaces: { + public: new PostgresSchema({ + id: 'public', + entries: { table: { thing: thingTable() } }, + }), + auth: new PostgresSchema({ + id: 'auth', + entries: { table: { thing: thingTable() } }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensionPacks: {}, + meta: {}, + }; + + const root = contractToPostgresDatabaseSchemaNode(contract, projectionOptions); + + expect(Object.keys(root.namespaces).sort()).toEqual(['auth', 'public']); + expect(Object.keys(root.namespaces['public']!.tables)).toEqual(['thing']); + expect(Object.keys(root.namespaces['auth']!.tables)).toEqual(['thing']); + // The two same-named tables are distinct nodes in distinct namespaces. + expect(root.namespaces['public']!.tables['thing']).not.toBe( + root.namespaces['auth']!.tables['thing'], + ); + }); + it('throws when a policy references a table absent from its namespace', () => { const orphan = new PostgresRlsPolicy({ name: 'read_orphan_deadbeef', From 91248f69d7f7733c8e45dd604c94075698604ad3 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 30 Jun 2026 18:54:34 +0200 Subject: [PATCH 19/49] docs(postgres-rls): mark slice 2 in review (#894); record landed decisions + D1 follow-on Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/postgres-rls/plan.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/projects/postgres-rls/plan.md b/projects/postgres-rls/plan.md index 46a28c5cc7..976de89ec8 100644 --- a/projects/postgres-rls/plan.md +++ b/projects/postgres-rls/plan.md @@ -10,7 +10,7 @@ Each slice is named for what a developer can **rely on** when it merges; every D | --- | --- | --- | --- | --- | | 1 | `select-policies-dependable` | A SELECT policy is dependable end-to-end — create / edit-replaces / remove, drift fails `db verify`, proven in the Supabase example app. | ✅ merged | [TML-2868](https://linear.app/prisma-company/issue/TML-2868) · [#771](https://github.com/prisma/prisma-next/pull/771) | | 1.5 | `entity-kind-migration-seam` | Foundational: both diff sides are derived schema IRs, so `migration plan` emits RLS like every other command. | ✅ merged | [TML-2931](https://linear.app/prisma-company/issue/TML-2931) · [#868](https://github.com/prisma/prisma-next/pull/868) | -| 2 | `schema-node-tree-restructure` | Foundational: a real `database → namespace → table → policy` node tree; inference moves to the Postgres target. Behavior-neutral. | 🚧 in progress | new ticket (TBD) | +| 2 | `schema-node-tree-restructure` | Foundational: a real `database → namespace → table → policy` node tree; inference moves to the Postgres target. Behavior-neutral. | ✅ in review | [#894](https://github.com/prisma/prisma-next/pull/894) · ticket TBD | | 3 | `explicit-rls-control` | `@@rls` enablement, policy rename, per-table `managed`/`external` grading. | ⬜ | [TML-2869](https://linear.app/prisma-company/issue/TML-2869) | | 4 | `migration-support-for-roles` | A policy referencing a missing role fails verify (policy→role edge; dependency-graph seed). | ⬜ | new ticket (TBD) | | 5 | `support-all-rls-policy-types` | INSERT / UPDATE / DELETE / ALL policies, same lifecycle as SELECT. | ⬜ | [TML-2870](https://linear.app/prisma-company/issue/TML-2870) | @@ -18,10 +18,12 @@ Each slice is named for what a developer can **rely on** when it merges; every D ## Not-yet-done slices -### 2 — `schema-node-tree-restructure` (in progress) +### 2 — `schema-node-tree-restructure` (in review — [#894](https://github.com/prisma/prisma-next/pull/894)) Retire the conflated `PostgresSchemaIR` (it was a tree node, a schema, and the root at once). New single-purpose tree: **`PostgresDatabaseSchemaNode`** (root; holds roles) → **`PostgresNamespaceSchemaNode`** → **`PostgresTableSchemaNode`** → **`PostgresPolicySchemaNode`** / **`PostgresRoleSchemaNode`** leaves. Diff nodes are split from the authored Contract-IR entities (`PostgresRlsPolicy` / `PostgresRole` stay as the serialized entities). `introspect()` returns the root as a node; consumers `ensure` the target type and walk. Database→PSL inference moves onto the Postgres target (fixing a SQL-family layering violation). **No behavior change.** Spec + design: [`slices/schema-node-tree-restructure/`](slices/schema-node-tree-restructure/). +**Landed:** verify, the planner, and the migration runner share one `diffDatabaseSchema` (returning `{ issues, schemaDiffIssues }` — the two issue types stay distinct until follow-on A); the expected-side projection builds per-namespace, so same-named tables across schemas (`public.thing` + `auth.thing`) now project instead of throwing; inference moved to the Postgres target descriptor. Residual: **D1** (PSL inference still gathers the tree to a flat document for today's single-namespace `contract infer` — tree-walk lands with the namespaced-PSL slice). + ### 3 — `explicit-rls-control` - **`@@rls`** marks a model RLS-controlled independent of any policy → drives ENABLE/DISABLE. Removing the last policy leaves RLS **on** (deny-all, fail-closed); DISABLE only on marker removal. A policy on an unmarked model is an authoring error. First real table-attribute diff. @@ -48,7 +50,7 @@ Top-level Postgres policy helpers taking the model handle (not a model-builder m ## Out of scope / follow-on projects - Role-ref **authoring** validation — roles are platform-provided; policies only reference them by name (slice 4 checks existence in the DB, nothing more). -- **A** — port the legacy relational verifier onto the generic differ. **B** — dependency-aware planner ordering (slice 4's edges seed it). **C** — a generic project-from-contract / project-from-database registration surface, once a second node type needs the shared shape. +- **A** — port the legacy relational verifier onto the generic differ (merges the two issue types `SchemaIssue` + `SchemaDiffIssue` into one). **B** — dependency-aware planner ordering (slice 4's edges seed it). **C** — a generic project-from-contract / project-from-database registration surface, once a second node type needs the shared shape. **D1** — walk the schema-node tree in PSL inference instead of gathering it to a flat document (lands with the namespaced-PSL / top-level-entities inference slice). ## Linear From a4913356c1b63e567e505c2947c475da77054173 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 1 Jul 2026 10:12:18 +0200 Subject: [PATCH 20/49] =?UTF-8?q?docs(postgres-rls):=20D1=20residual=20?= =?UTF-8?q?=E2=86=92=20TML-2958=20(drop=20invented=20namespaced-PSL=20slic?= =?UTF-8?q?e;=20note=20the=20fail-loud=20guard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/postgres-rls/plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/postgres-rls/plan.md b/projects/postgres-rls/plan.md index 976de89ec8..9825aa9c18 100644 --- a/projects/postgres-rls/plan.md +++ b/projects/postgres-rls/plan.md @@ -22,7 +22,7 @@ Each slice is named for what a developer can **rely on** when it merges; every D Retire the conflated `PostgresSchemaIR` (it was a tree node, a schema, and the root at once). New single-purpose tree: **`PostgresDatabaseSchemaNode`** (root; holds roles) → **`PostgresNamespaceSchemaNode`** → **`PostgresTableSchemaNode`** → **`PostgresPolicySchemaNode`** / **`PostgresRoleSchemaNode`** leaves. Diff nodes are split from the authored Contract-IR entities (`PostgresRlsPolicy` / `PostgresRole` stay as the serialized entities). `introspect()` returns the root as a node; consumers `ensure` the target type and walk. Database→PSL inference moves onto the Postgres target (fixing a SQL-family layering violation). **No behavior change.** Spec + design: [`slices/schema-node-tree-restructure/`](slices/schema-node-tree-restructure/). -**Landed:** verify, the planner, and the migration runner share one `diffDatabaseSchema` (returning `{ issues, schemaDiffIssues }` — the two issue types stay distinct until follow-on A); the expected-side projection builds per-namespace, so same-named tables across schemas (`public.thing` + `auth.thing`) now project instead of throwing; inference moved to the Postgres target descriptor. Residual: **D1** (PSL inference still gathers the tree to a flat document for today's single-namespace `contract infer` — tree-walk lands with the namespaced-PSL slice). +**Landed:** verify, the planner, and the migration runner share one `diffDatabaseSchema` (returning `{ issues, schemaDiffIssues }` — the two issue types stay distinct until follow-on A); the expected-side projection builds per-namespace, so same-named tables across schemas (`public.thing` + `auth.thing`) now project instead of throwing; inference moved to the Postgres target descriptor. Residual: **D1** (PSL inference still gathers the tree to a flat document for today's single-namespace `contract infer`; a fail-loud throw guards the same-name collision — tree-walk tracked in [TML-2958](https://linear.app/prisma-company/issue/TML-2958)). ### 3 — `explicit-rls-control` @@ -50,7 +50,7 @@ Top-level Postgres policy helpers taking the model handle (not a model-builder m ## Out of scope / follow-on projects - Role-ref **authoring** validation — roles are platform-provided; policies only reference them by name (slice 4 checks existence in the DB, nothing more). -- **A** — port the legacy relational verifier onto the generic differ (merges the two issue types `SchemaIssue` + `SchemaDiffIssue` into one). **B** — dependency-aware planner ordering (slice 4's edges seed it). **C** — a generic project-from-contract / project-from-database registration surface, once a second node type needs the shared shape. **D1** — walk the schema-node tree in PSL inference instead of gathering it to a flat document (lands with the namespaced-PSL / top-level-entities inference slice). +- **A** — port the legacy relational verifier onto the generic differ (merges the two issue types `SchemaIssue` + `SchemaDiffIssue` into one). **B** — dependency-aware planner ordering (slice 4's edges seed it). **C** — a generic project-from-contract / project-from-database registration surface, once a second node type needs the shared shape. **D1** ([TML-2958](https://linear.app/prisma-company/issue/TML-2958)) — walk the schema-node tree in PSL inference instead of gathering it to a flat document; a fail-loud throw guards the same-name collision until then. (No planned slice owns this — it is not the RLS slices 3–6.) ## Linear From e12408fbff3a3e7277ad457a51dc4cb8911de76e Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 1 Jul 2026 10:12:52 +0200 Subject: [PATCH 21/49] fix(postgres): PSL inference throws on cross-schema table-name collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inference gather flattens the tree-s tables into one bucket for the single-namespace `__unspecified__` PSL output. It did a silent last-wins overwrite if two namespaces held the same table name — unreachable today (`contract infer` introspects a single namespace) but a new silent-lossy path on this branch. It now throws an actionable error naming the table, mirroring the `contractToSchemaIR` duplicate-name throw: same-named cross-schema tables are not yet supported for `contract infer` (multi-namespace `namespace { … }` output is a later slice). New test proves the tree with two same-named tables throws instead of dropping one. Behaviour-neutral: unreachable on all single-namespace paths — psl-infer suite, contract-infer e2e, and fixtures:check stay green. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/psl-infer/infer-psl-contract.ts | 13 +++++++ .../test/psl-infer/infer-psl-contract.test.ts | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts index b87d46e789..8301c45e8a 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts @@ -112,9 +112,22 @@ export function inferPostgresPslContract(tree: PostgresDatabaseSchemaNode): PslD // builder walks. This replaces the old flat `.tables` iteration; it does not // reintroduce a stored flat schema — it is a read-only projection for PSL // emission over a single introspected namespace. + // + // Multi-namespace PSL inference (per-namespace `namespace { … }` output) is not + // supported yet — `contract infer` introspects a single namespace and emits one + // flat `__unspecified__` bucket. Until the per-namespace tree-walk lands, a + // same-named table in two schemas has no unambiguous single-bucket model, so + // throw rather than silently dropping one. const tables: Record = {}; for (const namespace of namespaces) { for (const [tableName, table] of Object.entries(namespace.tables)) { + if (tables[tableName] !== undefined) { + throw new Error( + `contract infer: duplicate table name "${tableName}" across schemas is not yet supported ` + + '(single-namespace PSL inference emits one flat bucket; multi-namespace `namespace { … }` ' + + 'output is a later slice).', + ); + } tables[tableName] = table; } } diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts index f878c86307..c90e69409d 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts @@ -2,6 +2,10 @@ import { flatPslModels } from '@prisma-next/framework-components/psl-ast'; import { printPsl } from '@prisma-next/psl-printer'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; +import { inferPostgresPslContract } from '../../src/core/psl-infer/infer-psl-contract'; +import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; +import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; +import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; import { inferPslAstFromFlat as sqlSchemaIrToPslAst } from './fixtures'; function ir(partial: Partial & Pick): SqlSchemaIR { @@ -264,4 +268,35 @@ describe('inferPostgresPslContract', () => { " `); }); + + it('throws on same-named tables in different schemas (single-namespace stopgap)', () => { + const thingNode = (schemaName: string) => + new PostgresNamespaceSchemaNode({ + schemaName, + tables: { + thing: new PostgresTableSchemaNode({ + name: 'thing', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [], + }), + }, + nativeEnumTypeNames: [], + }); + const tree = new PostgresDatabaseSchemaNode({ + namespaces: { public: thingNode('public'), auth: thingNode('auth') }, + roles: [], + existingSchemas: ['public', 'auth'], + pgVersion: '', + }); + + // The same table name in two schemas has no unambiguous single-bucket model: + // throw rather than silently dropping one namespace's table. + expect(() => inferPostgresPslContract(tree)).toThrow( + /duplicate table name "thing" across schemas is not yet supported/i, + ); + }); }); From b696c2170bddad84297e7052be1b2bc3c32d2b72 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 1 Jul 2026 15:08:02 +0200 Subject: [PATCH 22/49] refactor(sql): move database-schema diff to a required target-descriptor operation The combined database-schema diff was an optional method on the SQL control adapter. Move it to the SQL target descriptor and make it required: schema comparison is target logic (dialect type/default maps, schema-tree walk), not adapter I/O, so it belongs on the descriptor alongside contractSerializer and inferPslContract. - Relocate the relational diffing code from core/schema-verify/ to core/diff/ (verify-sql-schema.ts -> sql-schema-diff.ts, plus the sibling files) with no logic changes. The family entrypoint moves from ./schema-verify to ./diff. - Add diffDatabaseSchema to SqlControlTargetDescriptor as a required operation. Postgres provides relational + policy diffing (diffPostgresDatabaseSchema); SQLite provides relational-only diffing via a new diffSqliteDatabaseSchema. - The family verifier (verifySchema) becomes a thin consumer: it introspects the actual schema, calls target.diffDatabaseSchema, applies control-policy suppression as a post-step, and fails when a surviving issue remains. It no longer composes any diffing itself and has no SQLite fallback branch. - Drop the now-dead diffDatabaseSchema method and orphaned imports from the Postgres control adapter; update importers and test fixtures accordingly. Behavior-neutral: planner op-assertions are unchanged and pass. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- packages/2-sql/9-family/package.json | 2 +- .../9-family/src/core/control-adapter.ts | 30 +------ .../9-family/src/core/control-instance.ts | 85 ++++++------------- .../control-verify-emit.ts | 0 .../sql-schema-diff.ts} | 0 .../verifier-disposition.ts | 0 .../{schema-verify => diff}/verify-helpers.ts | 0 .../9-family/src/core/migrations/types.ts | 22 +++++ packages/2-sql/9-family/src/exports/diff.ts | 22 +++++ .../9-family/src/exports/schema-verify.ts | 22 ----- ...stance.descriptor-self-consistency.test.ts | 5 +- .../test/cross-contract-validation.test.ts | 5 +- .../9-family/test/operation-preview.test.ts | 1 + .../9-family/test/schema-verify.basic.test.ts | 2 +- .../schema-verify.check-constraints.test.ts | 6 +- .../test/schema-verify.constraints.test.ts | 2 +- .../test/schema-verify.control-policy.test.ts | 2 +- .../test/schema-verify.defaults.test.ts | 4 +- .../9-family/test/schema-verify.helpers.ts | 33 +++++++ .../schema-verify.referential-actions.test.ts | 2 +- ...chema-verify.semantic-satisfaction.test.ts | 2 +- .../test/schema-verify.storage-types.test.ts | 2 +- .../test/schema-verify.strict.test.ts | 2 +- .../2-sql/9-family/test/schema-view.test.ts | 9 +- .../test/verifier-disposition.test.ts | 2 +- packages/2-sql/9-family/tsdown.config.ts | 2 +- .../core/migrations/diff-database-schema.ts | 2 +- .../src/core/migrations/issue-planner.ts | 2 +- .../postgres/src/core/migrations/planner.ts | 2 +- .../3-targets/postgres/src/exports/control.ts | 10 +++ .../sqlite/src/core/control-target.ts | 34 ++++---- .../core/migrations/diff-database-schema.ts | 53 ++++++++++++ .../sqlite/src/core/migrations/planner.ts | 2 +- .../sqlite/src/core/migrations/runner.ts | 2 +- .../postgres/src/core/control-adapter.ts | 32 +------ .../rls-collect-extension-issues.test.ts | 6 +- .../test/sqlite/migrations/harness.ts | 2 +- 37 files changed, 229 insertions(+), 182 deletions(-) rename packages/2-sql/9-family/src/core/{schema-verify => diff}/control-verify-emit.ts (100%) rename packages/2-sql/9-family/src/core/{schema-verify/verify-sql-schema.ts => diff/sql-schema-diff.ts} (100%) rename packages/2-sql/9-family/src/core/{schema-verify => diff}/verifier-disposition.ts (100%) rename packages/2-sql/9-family/src/core/{schema-verify => diff}/verify-helpers.ts (100%) create mode 100644 packages/2-sql/9-family/src/exports/diff.ts delete mode 100644 packages/2-sql/9-family/src/exports/schema-verify.ts create mode 100644 packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts diff --git a/packages/2-sql/9-family/package.json b/packages/2-sql/9-family/package.json index 8b847aa548..c72b1506d2 100644 --- a/packages/2-sql/9-family/package.json +++ b/packages/2-sql/9-family/package.json @@ -56,12 +56,12 @@ "exports": { "./control": "./dist/control.mjs", "./control-adapter": "./dist/control-adapter.mjs", + "./diff": "./dist/diff.mjs", "./ir": "./dist/ir.mjs", "./migration": "./dist/migration.mjs", "./pack": "./dist/pack.mjs", "./psl-infer": "./dist/psl-infer.mjs", "./runtime": "./dist/runtime.mjs", - "./schema-verify": "./dist/schema-verify.mjs", "./verify": "./dist/verify.mjs", "./package.json": "./package.json" }, diff --git a/packages/2-sql/9-family/src/core/control-adapter.ts b/packages/2-sql/9-family/src/core/control-adapter.ts index 77d896a6a1..69514c20da 100644 --- a/packages/2-sql/9-family/src/core/control-adapter.ts +++ b/packages/2-sql/9-family/src/core/control-adapter.ts @@ -1,15 +1,9 @@ -import type { - Contract, - ContractMarkerRecord, - LedgerEntryRecord, -} from '@prisma-next/contract/types'; -import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; +import type { ContractMarkerRecord, LedgerEntryRecord } from '@prisma-next/contract/types'; import type { ControlAdapterInstance, ControlStack, - VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; -import type { SqlControlDriverInstance, SqlStorage } from '@prisma-next/sql-contract/types'; +import type { SqlControlDriverInstance } from '@prisma-next/sql-contract/types'; import type { AnyQueryAst, DdlNode, @@ -18,7 +12,7 @@ import type { SqlExecuteRequest, } from '@prisma-next/sql-relational-core/ast'; import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; -import type { DefaultNormalizer, NativeTypeNormalizer } from './schema-verify/verify-sql-schema'; +import type { DefaultNormalizer, NativeTypeNormalizer } from './diff/sql-schema-diff'; /** * Structural interface for anything that can lower a SQL/DDL AST node to a @@ -199,24 +193,6 @@ export interface SqlControlAdapter */ readonly normalizeNativeType?: NativeTypeNormalizer; - /** - * The single combined database-schema diff the migration planner and the - * schema verify both run for this target — the relational diff (table / - * column / constraint findings as `SchemaIssue[]`, with the verification-tree - * `root`/`counts`) plus the target's structural diff (e.g. Postgres RLS policy - * presence as `SchemaDiffIssue[]`), each computed once. Returns a - * `VerifyDatabaseSchemaResult` whose `schema` carries both shapes — exactly the - * existing verify-result schema shape. Optional: targets without a structural - * diff (SQLite) omit it, and the family verify runs the relational diff alone. - */ - diffDatabaseSchema?(input: { - readonly contract: Contract; - readonly schema: SqlSchemaIRNode; - readonly strict: boolean; - readonly typeMetadataRegistry: ReadonlyMap; - readonly frameworkComponents: ReadonlyArray>; - }): VerifyDatabaseSchemaResult; - /** * Ordered DDL queries that bootstrap marker/ledger control tables for migration * runners. Postgres includes `CREATE SCHEMA`; SQLite does not. diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index ba5856c9d2..dba97a0b33 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -48,13 +48,14 @@ import type { SqlSchemaIRNode, SqlTableIR } from '@prisma-next/sql-schema-ir/typ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { SqlControlAdapter } from './control-adapter'; +import { namespaceSchemaNodes } from './diff/sql-schema-diff'; import { SqlContractSerializer } from './ir/sql-contract-serializer'; import type { + DiffDatabaseSchemaInput, SqlControlAdapterDescriptor, SqlControlExtensionDescriptor, } from './migrations/types'; import { sqlOperationsToPreview } from './operation-preview'; -import { namespaceSchemaNodes, verifySqlSchemaTree } from './schema-verify/verify-sql-schema'; import { collectSupportedCodecTypeIds } from './verify'; function extractCodecTypeIdsFromContract(contract: unknown): readonly string[] { @@ -541,6 +542,20 @@ export function createSqlFamilyInstance( { readonly inferPslContract?: (schema: SqlSchemaIRNode) => PslDocumentAst }, 'reading the optional target-descriptor inferPslContract hook' >(target).inferPslContract; + // The combined database-schema diff is a required target-descriptor operation: + // every SQL target provides it. Read it off the descriptor like the other + // target-owned hooks. + const diffDatabaseSchema = blindCast< + { + readonly diffDatabaseSchema?: (input: DiffDatabaseSchemaInput) => VerifyDatabaseSchemaResult; + }, + 'reading the required target-descriptor diffDatabaseSchema hook' + >(target).diffDatabaseSchema; + if (!diffDatabaseSchema) { + throw new Error( + `SQL target "${target.targetId}" is missing the required diffDatabaseSchema descriptor operation`, + ); + } const deserializeWithTargetSerializer = (contractOrJson: unknown): Contract => { const serializer = targetSerializer ?? new SqlContractSerializer(); const json = @@ -698,32 +713,17 @@ export function createSqlFamilyInstance( readonly frameworkComponents: ReadonlyArray>; }): VerifyDatabaseSchemaResult { const contract = deserializeWithTargetSerializer(options.contract) as Contract; - const controlAdapter = getControlAdapter(); - // Verify runs the one combined database-schema diff the migration planner - // runs — `controlAdapter.diffDatabaseSchema` (relational columns + target - // structural diff, per-namespace-paired) — and rejects when the result is - // non-empty. Verify composes no strategies itself. A target without a - // structural diff (SQLite) has no `diffDatabaseSchema`; fall back to the - // relational diff alone (its only schema diff). - const sqlResult = controlAdapter.diffDatabaseSchema - ? controlAdapter.diffDatabaseSchema({ - contract, - schema: options.schema, - strict: options.strict, - typeMetadataRegistry, - frameworkComponents: options.frameworkComponents, - }) - : verifySqlSchemaTree({ - contract, - actualSchema: options.schema, - buildExpectedSchema: (scopedContract) => - buildTargetSchema(target, scopedContract, options.frameworkComponents), - strict: options.strict, - typeMetadataRegistry, - frameworkComponents: options.frameworkComponents, - ...ifDefined('normalizeDefault', controlAdapter.normalizeDefault), - ...ifDefined('normalizeNativeType', controlAdapter.normalizeNativeType), - }); + // Verify is a thin consumer of the target's black-box diff: it introspects + // the actual schema (already in `options.schema`), calls the target's + // required `diffDatabaseSchema`, and rejects when a surviving issue is a + // failure. It composes no diffing itself and is blind to how the diff works. + const sqlResult = diffDatabaseSchema({ + contract, + schema: options.schema, + strict: options.strict, + typeMetadataRegistry, + frameworkComponents: options.frameworkComponents, + }); // Control-policy suppression of the structural (e.g. RLS policy) diff // issues is a verify-side post-step — the combined diff returns them // ownership-filtered, and a suppressed control policy drops them here. @@ -1071,37 +1071,6 @@ export function createSqlFamilyInstance( }; } -/** - * Builds the target's expected schema tree from a contract via the descriptor's - * `migrations.contractToSchema` hook (Postgres → a namespaced tree root; SQLite - * → a flat schema). Read off `target`, like the other target-owned hooks. - */ -function buildTargetSchema( - target: TargetDescriptor<'sql', string>, - contract: Contract, - frameworkComponents: ReadonlyArray>, -): SqlSchemaIRNode { - const hook = blindCast< - { - readonly migrations?: { - readonly contractToSchema?: ( - contract: Contract | null, - frameworkComponents?: ReadonlyArray>, - ) => unknown; - }; - }, - 'reading the target descriptor migrations.contractToSchema hook' - >(target).migrations?.contractToSchema; - if (!hook) { - throw new Error( - 'SQL family verifySchema requires the target to expose migrations.contractToSchema', - ); - } - return blindCast( - hook(contract, frameworkComponents), - ); -} - /** * Filters the structural schema-diff issues (from `diffDatabaseSchema`) through * the contract's `defaultControlPolicy`. Issues whose outcome maps to a suppressed diff --git a/packages/2-sql/9-family/src/core/schema-verify/control-verify-emit.ts b/packages/2-sql/9-family/src/core/diff/control-verify-emit.ts similarity index 100% rename from packages/2-sql/9-family/src/core/schema-verify/control-verify-emit.ts rename to packages/2-sql/9-family/src/core/diff/control-verify-emit.ts diff --git a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts b/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts similarity index 100% rename from packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts rename to packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts diff --git a/packages/2-sql/9-family/src/core/schema-verify/verifier-disposition.ts b/packages/2-sql/9-family/src/core/diff/verifier-disposition.ts similarity index 100% rename from packages/2-sql/9-family/src/core/schema-verify/verifier-disposition.ts rename to packages/2-sql/9-family/src/core/diff/verifier-disposition.ts diff --git a/packages/2-sql/9-family/src/core/schema-verify/verify-helpers.ts b/packages/2-sql/9-family/src/core/diff/verify-helpers.ts similarity index 100% rename from packages/2-sql/9-family/src/core/schema-verify/verify-helpers.ts rename to packages/2-sql/9-family/src/core/diff/verify-helpers.ts diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index e41a0e4a7a..63f47bf5e8 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -20,6 +20,7 @@ import type { OpFactoryCall, SchemaIssue, SchemaVerifier, + VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; import type { PslDocumentAst } from '@prisma-next/framework-components/psl-ast'; import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; @@ -493,10 +494,31 @@ export interface SqlControlTargetDescriptor< * narrows to its own tree root. */ readonly inferPslContract?: (schema: SqlSchemaIRNode) => PslDocumentAst; + /** + * The single combined database-schema diff of two derived representations — + * the target's black-box comparison. Every SQL target provides it (Postgres + * returns relational + policy issues; SQLite returns relational only). It is + * schema logic on the target, not database I/O, so it lives here rather than + * on the control adapter. How it computes the two issue sets is private. + */ + readonly diffDatabaseSchema: (input: DiffDatabaseSchemaInput) => VerifyDatabaseSchemaResult; createPlanner(adapter: SqlControlAdapter): SqlMigrationPlanner; createRunner(family: SqlControlFamilyInstance): SqlMigrationRunner; } +/** + * Inputs to a target descriptor's {@link SqlControlTargetDescriptor.diffDatabaseSchema}: + * the contract (the expected side derives from it), the introspected actual + * schema node, and the resolution context the relational diff needs. + */ +export interface DiffDatabaseSchemaInput { + readonly contract: Contract; + readonly schema: SqlSchemaIRNode; + readonly strict: boolean; + readonly typeMetadataRegistry: ReadonlyMap; + readonly frameworkComponents: ReadonlyArray>; +} + export interface CreateSqlMigrationPlanOptions { readonly targetId: string; /** diff --git a/packages/2-sql/9-family/src/exports/diff.ts b/packages/2-sql/9-family/src/exports/diff.ts new file mode 100644 index 0000000000..9be8c4e68c --- /dev/null +++ b/packages/2-sql/9-family/src/exports/diff.ts @@ -0,0 +1,22 @@ +/** + * SQL relational schema-diff exports. + * + * The shared relational diff that each SQL target descriptor's + * `diffDatabaseSchema` composes (Postgres adds its structural policy diff on + * top; SQLite is relational only). Pure — no database connection required. + */ + +export type { + NativeTypeNormalizer, + VerifySqlSchemaOptions, +} from '../core/diff/sql-schema-diff'; +export { + namespaceSchemaNodes, + verifySqlSchema, + verifySqlSchemaTree, +} from '../core/diff/sql-schema-diff'; +export { + arraysEqual, + isIndexSatisfied, + isUniqueConstraintSatisfied, +} from '../core/diff/verify-helpers'; diff --git a/packages/2-sql/9-family/src/exports/schema-verify.ts b/packages/2-sql/9-family/src/exports/schema-verify.ts deleted file mode 100644 index aee664c311..0000000000 --- a/packages/2-sql/9-family/src/exports/schema-verify.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Pure schema verification exports. - * - * This module exports the pure schema verification function that can be used - * without a database connection. It's suitable for migration planning and - * other tools that need to compare schema states. - */ - -export { - arraysEqual, - isIndexSatisfied, - isUniqueConstraintSatisfied, -} from '../core/schema-verify/verify-helpers'; -export type { - NativeTypeNormalizer, - VerifySqlSchemaOptions, -} from '../core/schema-verify/verify-sql-schema'; -export { - namespaceSchemaNodes, - verifySqlSchema, - verifySqlSchemaTree, -} from '../core/schema-verify/verify-sql-schema'; diff --git a/packages/2-sql/9-family/test/control-instance.descriptor-self-consistency.test.ts b/packages/2-sql/9-family/test/control-instance.descriptor-self-consistency.test.ts index 766f68ec7e..f59644245d 100644 --- a/packages/2-sql/9-family/test/control-instance.descriptor-self-consistency.test.ts +++ b/packages/2-sql/9-family/test/control-instance.descriptor-self-consistency.test.ts @@ -4,6 +4,7 @@ import type { ContractSpace, ControlFamilyDescriptor, ControlStack, + ControlTargetDescriptor, } from '@prisma-next/framework-components/control'; import { createControlStack } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; @@ -15,6 +16,7 @@ import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; import { createSqlFamilyInstance } from '../src/core/control-instance'; import type { SqlControlExtensionDescriptor } from '../src/core/migrations/types'; +import { stubTargetDiffDatabaseSchema } from './schema-verify.helpers'; const TARGET = 'postgres' as const; const TARGET_FAMILY = 'sql' as const; @@ -126,8 +128,9 @@ function makeStack( deserializeContract: (json) => json as never, serializeContract: (contract) => contract as never, }, + diffDatabaseSchema: stubTargetDiffDatabaseSchema, create: () => ({ familyId: 'sql', targetId: 'postgres' }), - }, + } as ControlTargetDescriptor<'sql', 'postgres'>, adapter: { kind: 'adapter', id: 'postgres', diff --git a/packages/2-sql/9-family/test/cross-contract-validation.test.ts b/packages/2-sql/9-family/test/cross-contract-validation.test.ts index 355e0d947b..05cac37acc 100644 --- a/packages/2-sql/9-family/test/cross-contract-validation.test.ts +++ b/packages/2-sql/9-family/test/cross-contract-validation.test.ts @@ -4,6 +4,7 @@ import type { ContractSpace, ControlFamilyDescriptor, ControlStack, + ControlTargetDescriptor, } from '@prisma-next/framework-components/control'; import { createControlStack } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; @@ -14,6 +15,7 @@ import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; import { createSqlFamilyInstance } from '../src/core/control-instance'; import type { SqlControlExtensionDescriptor } from '../src/core/migrations/types'; +import { stubTargetDiffDatabaseSchema } from './schema-verify.helpers'; const TARGET = 'postgres' as const; const TARGET_FAMILY = 'sql' as const; @@ -170,8 +172,9 @@ function makeStack( deserializeContract: (json) => json as never, serializeContract: (contract) => contract as never, }, + diffDatabaseSchema: stubTargetDiffDatabaseSchema, create: () => ({ familyId: 'sql', targetId: 'postgres' }), - }, + } as ControlTargetDescriptor<'sql', 'postgres'>, adapter: { kind: 'adapter', id: 'postgres', diff --git a/packages/2-sql/9-family/test/operation-preview.test.ts b/packages/2-sql/9-family/test/operation-preview.test.ts index 1dc474aec9..3634746960 100644 --- a/packages/2-sql/9-family/test/operation-preview.test.ts +++ b/packages/2-sql/9-family/test/operation-preview.test.ts @@ -99,6 +99,7 @@ describe('SqlControlFamilyInstance OperationPreviewCapable', () => { familyId: 'sql', kind: 'target', types: { storage: [] }, + diffDatabaseSchema: () => ({}), }, adapter: { targetId: 'postgres', diff --git a/packages/2-sql/9-family/test/schema-verify.basic.test.ts b/packages/2-sql/9-family/test/schema-verify.basic.test.ts index 156ff46c94..ba97f2ac54 100644 --- a/packages/2-sql/9-family/test/schema-verify.basic.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.basic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; +import { verifySqlSchema } from '../src/core/diff/sql-schema-diff'; import { createContractTable, createMockPostgresComponent, diff --git a/packages/2-sql/9-family/test/schema-verify.check-constraints.test.ts b/packages/2-sql/9-family/test/schema-verify.check-constraints.test.ts index acb0d42677..480e4b5beb 100644 --- a/packages/2-sql/9-family/test/schema-verify.check-constraints.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.check-constraints.test.ts @@ -12,9 +12,9 @@ import { SqlCheckConstraintIR } from '@prisma-next/sql-schema-ir/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; -import { classifySqlVerifierIssueKind } from '../src/core/schema-verify/verifier-disposition'; -import { verifyCheckConstraints } from '../src/core/schema-verify/verify-helpers'; -import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; +import { verifySqlSchema } from '../src/core/diff/sql-schema-diff'; +import { classifySqlVerifierIssueKind } from '../src/core/diff/verifier-disposition'; +import { verifyCheckConstraints } from '../src/core/diff/verify-helpers'; import { createSchemaTable, createTestSchemaIR, diff --git a/packages/2-sql/9-family/test/schema-verify.constraints.test.ts b/packages/2-sql/9-family/test/schema-verify.constraints.test.ts index dc62f41167..f47564a646 100644 --- a/packages/2-sql/9-family/test/schema-verify.constraints.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.constraints.test.ts @@ -1,6 +1,6 @@ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { describe, expect, it } from 'vitest'; -import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; +import { verifySqlSchema } from '../src/core/diff/sql-schema-diff'; import { createContractTable, createSchemaTable, diff --git a/packages/2-sql/9-family/test/schema-verify.control-policy.test.ts b/packages/2-sql/9-family/test/schema-verify.control-policy.test.ts index 6ea4f7d320..d0d9deec0f 100644 --- a/packages/2-sql/9-family/test/schema-verify.control-policy.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.control-policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { filterSchemaDiffIssues } from '../src/core/control-instance'; -import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; +import { verifySqlSchema } from '../src/core/diff/sql-schema-diff'; import { createContractTable, createMockPostgresComponent, diff --git a/packages/2-sql/9-family/test/schema-verify.defaults.test.ts b/packages/2-sql/9-family/test/schema-verify.defaults.test.ts index 5d1a28c7a9..0d4d782fac 100644 --- a/packages/2-sql/9-family/test/schema-verify.defaults.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.defaults.test.ts @@ -1,8 +1,8 @@ import { type ColumnDefault, type Contract, executionHash } from '@prisma-next/contract/types'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { describe, expect, it } from 'vitest'; -import type { DefaultNormalizer } from '../src/core/schema-verify/verify-sql-schema'; -import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; +import type { DefaultNormalizer } from '../src/core/diff/sql-schema-diff'; +import { verifySqlSchema } from '../src/core/diff/sql-schema-diff'; import { createContractTable, createSchemaTable, diff --git a/packages/2-sql/9-family/test/schema-verify.helpers.ts b/packages/2-sql/9-family/test/schema-verify.helpers.ts index 4aa9da2cad..044bb0bda7 100644 --- a/packages/2-sql/9-family/test/schema-verify.helpers.ts +++ b/packages/2-sql/9-family/test/schema-verify.helpers.ts @@ -11,6 +11,7 @@ import { type StorageHashBase, } from '@prisma-next/contract/types'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; +import type { VerifyDatabaseSchemaResult } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { applyFkDefaults, @@ -302,3 +303,35 @@ export function createMockPostgresComponent(): TargetBoundComponentDescriptor<'s }, } as TargetBoundComponentDescriptor<'sql', 'postgres'>; } + +/** + * A no-op `diffDatabaseSchema` for target-descriptor stubs in tests that + * construct a family instance but never call `verifySchema`. Every SQL target + * descriptor must provide `diffDatabaseSchema`; this satisfies that requirement + * for construction-only tests. + */ +export function stubTargetDiffDatabaseSchema(): VerifyDatabaseSchemaResult { + return { + ok: true, + summary: 'stub', + contract: { storageHash: 'stub' }, + target: { expected: 'postgres' }, + schema: { + issues: [], + schemaDiffIssues: [], + root: { + status: 'pass', + kind: 'database', + name: 'root', + contractPath: '', + code: '', + message: '', + expected: undefined, + actual: undefined, + children: [], + }, + counts: { pass: 0, warn: 0, fail: 0, totalNodes: 0 }, + }, + timings: { total: 0 }, + }; +} diff --git a/packages/2-sql/9-family/test/schema-verify.referential-actions.test.ts b/packages/2-sql/9-family/test/schema-verify.referential-actions.test.ts index fdbafc200e..a018937745 100644 --- a/packages/2-sql/9-family/test/schema-verify.referential-actions.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.referential-actions.test.ts @@ -1,6 +1,6 @@ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { describe, expect, it } from 'vitest'; -import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; +import { verifySqlSchema } from '../src/core/diff/sql-schema-diff'; import { createContractTable, createSchemaTable, diff --git a/packages/2-sql/9-family/test/schema-verify.semantic-satisfaction.test.ts b/packages/2-sql/9-family/test/schema-verify.semantic-satisfaction.test.ts index 91257a462f..d22bf1ef9a 100644 --- a/packages/2-sql/9-family/test/schema-verify.semantic-satisfaction.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.semantic-satisfaction.test.ts @@ -9,7 +9,7 @@ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { describe, expect, it } from 'vitest'; -import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; +import { verifySqlSchema } from '../src/core/diff/sql-schema-diff'; import { createContractTable, createSchemaTable, diff --git a/packages/2-sql/9-family/test/schema-verify.storage-types.test.ts b/packages/2-sql/9-family/test/schema-verify.storage-types.test.ts index f2d853de96..40b66ec7cf 100644 --- a/packages/2-sql/9-family/test/schema-verify.storage-types.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.storage-types.test.ts @@ -1,6 +1,6 @@ import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import { describe, expect, it } from 'vitest'; -import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; +import { verifySqlSchema } from '../src/core/diff/sql-schema-diff'; import type { CodecControlHooks } from '../src/exports/control'; import { createTestContract, diff --git a/packages/2-sql/9-family/test/schema-verify.strict.test.ts b/packages/2-sql/9-family/test/schema-verify.strict.test.ts index c96a5d9d30..a519e239ce 100644 --- a/packages/2-sql/9-family/test/schema-verify.strict.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.strict.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; +import { verifySqlSchema } from '../src/core/diff/sql-schema-diff'; import { createContractTable, createSchemaTable, diff --git a/packages/2-sql/9-family/test/schema-view.test.ts b/packages/2-sql/9-family/test/schema-view.test.ts index ffa5ad4b29..13124b6f3e 100644 --- a/packages/2-sql/9-family/test/schema-view.test.ts +++ b/packages/2-sql/9-family/test/schema-view.test.ts @@ -1,8 +1,12 @@ -import type { ControlFamilyDescriptor } from '@prisma-next/framework-components/control'; +import type { + ControlFamilyDescriptor, + ControlTargetDescriptor, +} from '@prisma-next/framework-components/control'; import { createControlStack } from '@prisma-next/framework-components/control'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; import { createSqlFamilyInstance } from '../src/core/control-instance'; +import { stubTargetDiffDatabaseSchema } from './schema-verify.helpers'; function createMockStack() { return createControlStack({ @@ -36,8 +40,9 @@ function createMockStack() { deserializeContract: (json) => json as never, serializeContract: (contract) => contract as never, }, + diffDatabaseSchema: stubTargetDiffDatabaseSchema, create: () => ({ familyId: 'sql', targetId: 'postgres' }), - }, + } as ControlTargetDescriptor<'sql', 'postgres'>, adapter: { kind: 'adapter', id: 'postgres', diff --git a/packages/2-sql/9-family/test/verifier-disposition.test.ts b/packages/2-sql/9-family/test/verifier-disposition.test.ts index 325ffc7417..110caf21cd 100644 --- a/packages/2-sql/9-family/test/verifier-disposition.test.ts +++ b/packages/2-sql/9-family/test/verifier-disposition.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { classifySqlVerifierIssueKind, verifierDisposition, -} from '../src/core/schema-verify/verifier-disposition'; +} from '../src/core/diff/verifier-disposition'; describe('classifySqlVerifierIssueKind', () => { it('classifies the extra nested element (column)', () => { diff --git a/packages/2-sql/9-family/tsdown.config.ts b/packages/2-sql/9-family/tsdown.config.ts index 2baa969844..476e76a542 100644 --- a/packages/2-sql/9-family/tsdown.config.ts +++ b/packages/2-sql/9-family/tsdown.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ 'src/exports/pack.ts', 'src/exports/runtime.ts', 'src/exports/verify.ts', - 'src/exports/schema-verify.ts', + 'src/exports/diff.ts', 'src/exports/psl-infer.ts', ], }); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts index 49dfefd5dd..f2131fca46 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts @@ -1,5 +1,5 @@ import type { Contract } from '@prisma-next/contract/types'; -import { verifySqlSchemaTree } from '@prisma-next/family-sql/schema-verify'; +import { verifySqlSchemaTree } from '@prisma-next/family-sql/diff'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { VerifyDatabaseSchemaResult } from '@prisma-next/framework-components/control'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 7404386c2b..cafd63402b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts @@ -15,7 +15,7 @@ import type { SqlPlannerConflict, SqlPlannerConflictLocation, } from '@prisma-next/family-sql/control'; -import { arraysEqual } from '@prisma-next/family-sql/schema-verify'; +import { arraysEqual } from '@prisma-next/family-sql/diff'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { SchemaIssue } from '@prisma-next/framework-components/control'; import type { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index beb6056321..4812587c9c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -13,7 +13,7 @@ import { plannerFailure, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; -import { namespaceSchemaNodes } from '@prisma-next/family-sql/schema-verify'; +import { namespaceSchemaNodes } from '@prisma-next/family-sql/diff'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { MigrationPlanner, diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index 3a8d045247..d1e7aa3829 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -12,6 +12,7 @@ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { postgresTargetDescriptorMeta } from '../core/descriptor-meta'; import { contractToPostgresDatabaseSchemaNode } from '../core/migrations/contract-to-postgres-database-schema-node'; +import { diffPostgresDatabaseSchema } from '../core/migrations/diff-database-schema'; import { createPostgresMigrationPlanner } from '../core/migrations/planner'; import { renderDefaultLiteral } from '../core/migrations/planner-ddl-builders'; import type { PostgresPlanTargetDetails } from '../core/migrations/planner-target-details'; @@ -58,6 +59,15 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP PostgresDatabaseSchemaNode.assert(schema); return inferPostgresPslContract(PostgresDatabaseSchemaNode.ensure(schema)); }, + diffDatabaseSchema(input) { + return diffPostgresDatabaseSchema({ + contract: input.contract, + actualSchema: input.schema, + strict: input.strict, + typeMetadataRegistry: input.typeMetadataRegistry, + frameworkComponents: input.frameworkComponents, + }); + }, migrations: { createPlanner(adapter: SqlControlAdapter<'postgres'>) { return createPostgresMigrationPlanner(adapter); diff --git a/packages/3-targets/3-targets/sqlite/src/core/control-target.ts b/packages/3-targets/3-targets/sqlite/src/core/control-target.ts index 3b65ac24a9..c13a4254ce 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/control-target.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/control-target.ts @@ -1,16 +1,18 @@ -import type { ColumnDefault, Contract } from '@prisma-next/contract/types'; +import type { Contract } from '@prisma-next/contract/types'; import type { SqlControlTargetDescriptor } from '@prisma-next/family-sql/control'; -import { contractToSchemaIR } from '@prisma-next/family-sql/control'; import type { SqlControlAdapter } from '@prisma-next/family-sql/control-adapter'; import type { ControlTargetInstance, MigrationPlanner, MigrationRunner, } from '@prisma-next/framework-components/control'; -import { SqlStorage, type StorageColumn } from '@prisma-next/sql-contract/types'; +import { SqlStorage } from '@prisma-next/sql-contract/types'; import { sqliteTargetDescriptorMeta } from './descriptor-meta'; +import { + diffSqliteDatabaseSchema, + sqliteContractToSchema, +} from './migrations/diff-database-schema'; import { createSqliteMigrationPlanner } from './migrations/planner'; -import { renderDefaultLiteral } from './migrations/planner-ddl-builders'; import type { SqlitePlanTargetDetails } from './migrations/planner-target-details'; import { createSqliteMigrationRunner } from './migrations/runner'; import { SqliteContractSerializer } from './sqlite-contract-serializer'; @@ -20,21 +22,20 @@ function isSqlContract(contract: Contract | null): contract is Contract = { ...sqliteTargetDescriptorMeta, contractSerializer: new SqliteContractSerializer(), schemaVerifier: new SqliteSchemaVerifier(), + diffDatabaseSchema(input) { + return diffSqliteDatabaseSchema({ + contract: input.contract, + actualSchema: input.schema, + strict: input.strict, + typeMetadataRegistry: input.typeMetadataRegistry, + frameworkComponents: input.frameworkComponents, + }); + }, migrations: { createPlanner(adapter: SqlControlAdapter<'sqlite'>): MigrationPlanner<'sql', 'sqlite'> { return createSqliteMigrationPlanner(adapter); @@ -55,10 +56,7 @@ const sqliteControlTargetDescriptor: SqlControlTargetDescriptor<'sqlite', Sqlite 'sqliteControlTargetDescriptor.contractToSchema received a non-SQL contract; expected Contract', ); } - return contractToSchemaIR(contract, { - annotationNamespace: 'sqlite', - renderDefault: sqliteRenderDefault, - }); + return sqliteContractToSchema(contract); }, }, create(): ControlTargetInstance<'sql', 'sqlite'> { diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts new file mode 100644 index 0000000000..cea08f6dba --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts @@ -0,0 +1,53 @@ +import type { ColumnDefault, Contract } from '@prisma-next/contract/types'; +import { contractToSchemaIR } from '@prisma-next/family-sql/control'; +import { verifySqlSchemaTree } from '@prisma-next/family-sql/diff'; +import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; +import type { VerifyDatabaseSchemaResult } from '@prisma-next/framework-components/control'; +import type { SqlStorage, StorageColumn } from '@prisma-next/sql-contract/types'; +import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; +import { parseSqliteDefault } from '../default-normalizer'; +import { normalizeSqliteNativeType } from '../native-type-normalizer'; +import { renderDefaultLiteral } from './planner-ddl-builders'; + +/** Renders a column default for the SQLite dialect. */ +export function sqliteRenderDefault(def: ColumnDefault, _column: StorageColumn): string { + if (def.kind === 'function') { + if (def.expression === 'now()') { + return "datetime('now')"; + } + return def.expression; + } + return renderDefaultLiteral(def.value); +} + +/** The SQLite expected-side projection: contract → flat relational schema IR. */ +export function sqliteContractToSchema(contract: Contract | null): SqlSchemaIR { + return contractToSchemaIR(contract, { + annotationNamespace: 'sqlite', + renderDefault: sqliteRenderDefault, + }); +} + +/** + * The SQLite combined database-schema diff — relational only. SQLite has a + * single flat schema and no structural (policy) diff, so it runs the shared + * per-schema relational diff and returns no `schemaDiffIssues`. + */ +export function diffSqliteDatabaseSchema(input: { + readonly contract: Contract; + readonly actualSchema: SqlSchemaIRNode; + readonly strict: boolean; + readonly typeMetadataRegistry: ReadonlyMap; + readonly frameworkComponents: ReadonlyArray>; +}): VerifyDatabaseSchemaResult { + return verifySqlSchemaTree({ + contract: input.contract, + actualSchema: input.actualSchema, + buildExpectedSchema: sqliteContractToSchema, + strict: input.strict, + typeMetadataRegistry: input.typeMetadataRegistry, + frameworkComponents: input.frameworkComponents, + normalizeDefault: parseSqliteDefault, + normalizeNativeType: normalizeSqliteNativeType, + }); +} diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 3db9b72f53..aab70950d7 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -11,7 +11,7 @@ import { plannerFailure, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; -import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; +import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/diff'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { MigrationPlanner, diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts index 0c06420062..f21145a7ba 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts @@ -12,7 +12,7 @@ import type { SqlMigrationRunnerSuccessValue, } from '@prisma-next/family-sql/control'; import { runnerFailure, runnerSuccess } from '@prisma-next/family-sql/control'; -import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; +import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/diff'; import type { MigrationRunnerResult } from '@prisma-next/framework-components/control'; import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; import type { SqlControlDriverInstance, SqlStorage } from '@prisma-next/sql-contract/types'; diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index 22521145be..9130ccccfc 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -1,8 +1,4 @@ -import type { - Contract, - ContractMarkerRecord, - LedgerEntryRecord, -} from '@prisma-next/contract/types'; +import type { ContractMarkerRecord, LedgerEntryRecord } from '@prisma-next/contract/types'; import { parseMarkerRowSafely, rethrowMarkerReadError, @@ -11,15 +7,11 @@ import { import type { SqlControlAdapter } from '@prisma-next/family-sql/control-adapter'; import { parseContractMarkerRow } from '@prisma-next/family-sql/verify'; import type { CodecLookup, CodecRegistry } from '@prisma-next/framework-components/codec'; -import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; -import { - APP_SPACE_ID, - type VerifyDatabaseSchemaResult, -} from '@prisma-next/framework-components/control'; +import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { ledgerOriginFromStored } from '@prisma-next/migration-tools/ledger-origin'; import { REFERENTIAL_ACTION_SQL } from '@prisma-next/sql-contract/referential-action-sql'; -import type { SqlControlDriverInstance, SqlStorage } from '@prisma-next/sql-contract/types'; +import type { SqlControlDriverInstance } from '@prisma-next/sql-contract/types'; import type { AnyQueryAst, CodecRef, @@ -42,7 +34,6 @@ import type { SqlForeignKeyIR, SqlIndexIR, SqlReferentialAction, - SqlSchemaIRNode, SqlUniqueIR, } from '@prisma-next/sql-schema-ir/types'; import { @@ -63,7 +54,6 @@ import type { } from '@prisma-next/target-postgres/ddl'; import { parsePostgresDefault } from '@prisma-next/target-postgres/default-normalizer'; import { normalizeSchemaNativeType } from '@prisma-next/target-postgres/native-type-normalizer'; -import { diffPostgresDatabaseSchema } from '@prisma-next/target-postgres/planner'; import { escapeLiteral, quoteIdentifier } from '@prisma-next/target-postgres/sql-utils'; import { PostgresDatabaseSchemaNode, @@ -127,22 +117,6 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { */ readonly normalizeNativeType = normalizeSchemaNativeType; - diffDatabaseSchema(input: { - readonly contract: Contract; - readonly schema: SqlSchemaIRNode; - readonly strict: boolean; - readonly typeMetadataRegistry: ReadonlyMap; - readonly frameworkComponents: ReadonlyArray>; - }): VerifyDatabaseSchemaResult { - return diffPostgresDatabaseSchema({ - contract: input.contract, - actualSchema: input.schema, - strict: input.strict, - typeMetadataRegistry: input.typeMetadataRegistry, - frameworkComponents: input.frameworkComponents, - }); - } - bootstrapControlTableQueries(): readonly DdlNode[] { return buildControlTableBootstrapQueries(); } diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts index 331c945160..2c1f8a0fa3 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts @@ -1,6 +1,7 @@ import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; +import { diffPostgresDatabaseSchema } from '@prisma-next/target-postgres/planner'; import { computeContentHash, normalizePredicate, @@ -15,7 +16,6 @@ import { } from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; -import { controlAdapter } from './fixtures/runner-fixtures'; const TABLE_NAME = 'items'; const USING = '(owner_id = current_user_id())'; @@ -150,9 +150,9 @@ function contractWithPolicy(): Contract { * (`schemaDiffIssues`) findings — the RLS drift these tests assert on. */ function policyDiffIssues(contract: Contract, schema: PostgresDatabaseSchemaNode) { - return controlAdapter.diffDatabaseSchema!({ + return diffPostgresDatabaseSchema({ contract, - schema, + actualSchema: schema, strict: false, typeMetadataRegistry: new Map(), frameworkComponents: [], diff --git a/test/e2e/framework/test/sqlite/migrations/harness.ts b/test/e2e/framework/test/sqlite/migrations/harness.ts index 02c3a7c27f..4d1de94f22 100644 --- a/test/e2e/framework/test/sqlite/migrations/harness.ts +++ b/test/e2e/framework/test/sqlite/migrations/harness.ts @@ -10,8 +10,8 @@ import sqliteAdapterDescriptor, { import type { Contract } from '@prisma-next/contract/types'; import sqliteDriverDescriptor from '@prisma-next/driver-sqlite/control'; import sqlFamilyDescriptor, { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; +import { verifySqlSchema } from '@prisma-next/family-sql/diff'; import sqlFamilyPack from '@prisma-next/family-sql/pack'; -import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import { APP_SPACE_ID, createControlStack, From 349b1e4272a8ebb71528442d62cf1ebf0de0eae0 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 1 Jul 2026 15:25:17 +0200 Subject: [PATCH 23/49] refactor(sql): decouple the schema view and SQLite verify from the diff flatten helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema view and SQLite reached into namespaceSchemaNodes — a diff-internal flatten helper — to walk the schema IR. Decouple both so the helper is used only inside the diff module. - toSchemaView now walks the schema-IR tree own structure (root -> namespaces -> tables) directly instead of calling namespaceSchemaNodes, and drops that import from control-instance.ts. A multi-namespace test locks the flatten output. - SQLite post-apply verify (runner) and the planner schema-issue collection now go through diffSqliteDatabaseSchema (added in the prior unit) rather than namespaceSchemaNodes(x)[0] ?? { tables: {} } + a direct verifySqlSchema call. The duplicated empty-schema fallback is gone; the planner narrows the flat SQLite node directly when building ops. Behavior-neutral: schema-view output and SQLite db verify verdicts are unchanged; the SQLite planner op-assertions are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../9-family/src/core/control-instance.ts | 30 +++++++++++----- .../2-sql/9-family/test/schema-view.test.ts | 34 ++++++++++++++++++- .../sqlite/src/core/migrations/planner.ts | 21 ++++++------ .../sqlite/src/core/migrations/runner.ts | 16 ++------- 4 files changed, 68 insertions(+), 33 deletions(-) diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index dba97a0b33..1ade30bad1 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -48,7 +48,6 @@ import type { SqlSchemaIRNode, SqlTableIR } from '@prisma-next/sql-schema-ir/typ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { SqlControlAdapter } from './control-adapter'; -import { namespaceSchemaNodes } from './diff/sql-schema-diff'; import { SqlContractSerializer } from './ir/sql-contract-serializer'; import type { DiffDatabaseSchemaInput, @@ -945,13 +944,28 @@ export function createSqlFamilyInstance( }, toSchemaView(schema: SqlSchemaIRNode): CoreSchemaView { - // Walk root → namespaces → tables into one flat list of table nodes. The - // single-schema common case (one namespace node) renders the same - // table-level view as today — no synthetic namespace level. A flat schema - // (SQLite) is its own single namespace. - const tableEntries: ReadonlyArray<[string, SqlTableIR]> = namespaceSchemaNodes( - schema, - ).flatMap((namespace) => Object.entries(namespace.tables)); + // Walk the schema-IR tree's own structure (root → namespaces → tables) + // into one flat list of table nodes. A root that exposes a `namespaces` + // record (Postgres) contributes each namespace's tables; a flat root + // (SQLite) is its own single namespace. The single-schema common case + // renders the same table-level view as today — no synthetic namespace + // level. + const root = blindCast< + { + readonly namespaces?: Readonly< + Record }> + >; + readonly tables?: Record; + }, + 'structural read of the schema-IR tree own namespaces/tables records' + >(schema); + const tableRecords: ReadonlyArray> = + root.namespaces !== undefined + ? Object.values(root.namespaces).map((namespace) => namespace.tables) + : [root.tables ?? {}]; + const tableEntries: ReadonlyArray<[string, SqlTableIR]> = tableRecords.flatMap((tables) => + Object.entries(tables), + ); const tableNodes: readonly SchemaTreeNode[] = tableEntries.map( ([tableName, table]: [string, SqlTableIR]) => { const children: SchemaTreeNode[] = []; diff --git a/packages/2-sql/9-family/test/schema-view.test.ts b/packages/2-sql/9-family/test/schema-view.test.ts index 13124b6f3e..ef15d79b5d 100644 --- a/packages/2-sql/9-family/test/schema-view.test.ts +++ b/packages/2-sql/9-family/test/schema-view.test.ts @@ -3,7 +3,7 @@ import type { ControlTargetDescriptor, } from '@prisma-next/framework-components/control'; import { createControlStack } from '@prisma-next/framework-components/control'; -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIR, SqlSchemaIRNode, SqlTableIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; import { createSqlFamilyInstance } from '../src/core/control-instance'; import { stubTargetDiffDatabaseSchema } from './schema-verify.helpers'; @@ -111,4 +111,36 @@ describe('SqlFamilyInstance.toSchemaView', () => { default: "'draft'::text", }); }); + + it('flattens tables from every namespace of a multi-namespace root', () => { + const familyInstance = createSqlFamilyInstance(createMockStack()); + + const namespaceTable = (columnName: string): SqlTableIR => ({ + name: 'ignored', + columns: { + [columnName]: { name: columnName, nativeType: 'int4', nullable: false }, + }, + foreignKeys: [], + uniques: [], + indexes: [], + }); + + const schema = { + namespaces: { + public: { schemaName: 'public', tables: { User: namespaceTable('id') } }, + audit: { schemaName: 'audit', tables: { Log: namespaceTable('event') } }, + }, + } as unknown as SqlSchemaIRNode; + + const view = familyInstance.toSchemaView(schema); + const tableIds = view.root.children?.map((n) => n.id) ?? []; + expect(tableIds).toContain('table-User'); + expect(tableIds).toContain('table-Log'); + + const userColumn = view.root.children + ?.find((n) => n.id === 'table-User') + ?.children?.find((n) => n.id === 'columns-User') + ?.children?.find((n) => n.id === 'column-User-id'); + expect(userColumn?.label).toBe('id: int4 (not nullable)'); + }); }); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index aab70950d7..7105be77d6 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -11,7 +11,6 @@ import { plannerFailure, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; -import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/diff'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { MigrationPlanner, @@ -19,8 +18,8 @@ import type { SchemaIssue, } from '@prisma-next/framework-components/control'; import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; -import { parseSqliteDefault } from '../default-normalizer'; -import { normalizeSqliteNativeType } from '../native-type-normalizer'; +import { blindCast } from '@prisma-next/utils/casts'; +import { diffSqliteDatabaseSchema } from './diff-database-schema'; import { planIssues } from './issue-planner'; import { type SqliteMigrationDestinationInfo, @@ -182,24 +181,24 @@ export class SqliteMigrationPlanner private collectSchemaIssues(options: SqlMigrationPlannerPlanOptions): readonly SchemaIssue[] { const allowed = options.policy.allowedOperationClasses; const strict = allowed.includes('widening') || allowed.includes('destructive'); - const verifyResult = verifySqlSchema({ + const verifyResult = diffSqliteDatabaseSchema({ contract: options.contract, - schema: sqliteFlatSchema(options.schema), + actualSchema: options.schema, strict, typeMetadataRegistry: new Map(), frameworkComponents: options.frameworkComponents, - normalizeDefault: parseSqliteDefault, - normalizeNativeType: normalizeSqliteNativeType, }); return verifyResult.schema.issues; } } /** - * SQLite has a single, flat schema — its introspected node IS the per-schema - * `SqlSchemaIR`. `namespaceSchemaNodes` returns that sole node; SQLite never - * carries the multi-namespace tree the Postgres target builds. + * SQLite has a single, flat schema — its introspected node IS a per-schema + * `SqlSchemaIR`, never the multi-namespace tree the Postgres target builds. The + * planner consumes that flat shape directly when building ops. */ function sqliteFlatSchema(schema: SqlSchemaIRNode): SqlSchemaIR { - return namespaceSchemaNodes(schema)[0] ?? { tables: {} }; + return blindCast( + schema, + ); } diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts index f21145a7ba..600ac44318 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts @@ -12,7 +12,6 @@ import type { SqlMigrationRunnerSuccessValue, } from '@prisma-next/family-sql/control'; import { runnerFailure, runnerSuccess } from '@prisma-next/family-sql/control'; -import { namespaceSchemaNodes, verifySqlSchema } from '@prisma-next/family-sql/diff'; import type { MigrationRunnerResult } from '@prisma-next/framework-components/control'; import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; import type { SqlControlDriverInstance, SqlStorage } from '@prisma-next/sql-contract/types'; @@ -22,8 +21,7 @@ import { ifDefined } from '@prisma-next/utils/defined'; import type { Result } from '@prisma-next/utils/result'; import { notOk, ok, okVoid } from '@prisma-next/utils/result'; import { MARKER_TABLE_NAME } from '../control-tables'; -import { parseSqliteDefault } from '../default-normalizer'; -import { normalizeSqliteNativeType } from '../native-type-normalizer'; +import { diffSqliteDatabaseSchema } from './diff-database-schema'; import type { SqlitePlanTargetDetails } from './planner-target-details'; export function createSqliteMigrationRunner( @@ -96,20 +94,12 @@ class SqliteMigrationRunner implements SqlMigrationRunner Date: Wed, 1 Jul 2026 15:30:30 +0200 Subject: [PATCH 24/49] refactor(postgres): planner reads its own database tree, not the diff flatten helper relationalNamespaceNode was the last consumer of namespaceSchemaNodes outside the diff module. The Postgres planner always holds a PostgresDatabaseSchemaNode, so it does not need the generic flatten helper: ensure the node and read its .namespaces directly to select the namespace by schemaName. namespaceSchemaNodes is now used only inside the diff module (and re-exported from exports/diff). Behavior-neutral: planner ops byte-identical (planner/rls-planner op-assertions unmodified and passing). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../postgres/src/core/migrations/planner.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 4812587c9c..9a888d2791 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -13,7 +13,6 @@ import { plannerFailure, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; -import { namespaceSchemaNodes } from '@prisma-next/family-sql/diff'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { MigrationPlanner, @@ -23,7 +22,7 @@ import type { SchemaIssue, } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; -import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { PostgresRlsPolicy } from '../postgres-rls-policy'; @@ -387,15 +386,11 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr * by bare name, so only one namespace's tables can be probed at a time. */ function relationalNamespaceNode( - schema: SqlSchemaIRNode, + schema: PostgresDatabaseSchemaNode, schemaName: string, ): SqlSchemaIR | undefined { - const namespaceNodes = namespaceSchemaNodes(schema); - const byName = namespaceNodes.find( - (node) => - blindCast<{ readonly schemaName?: string }, 'reading the namespace node schema name'>(node) - .schemaName === schemaName, - ); + const namespaceNodes = Object.values(PostgresDatabaseSchemaNode.ensure(schema).namespaces); + const byName = namespaceNodes.find((node) => node.schemaName === schemaName); return byName ?? namespaceNodes[0]; } From 92a4a6b876f985e03c85bd08ab59c4ef21e96428 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 1 Jul 2026 16:06:35 +0200 Subject: [PATCH 25/49] refactor(postgres): schema-diff nodes carry a unique nodeKind; guards discriminate on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five Postgres schema-diff nodes discriminated their type inconsistently: some guards took `unknown`, some `DiffableNode`, all used `instanceof`, and only the database root carried a `nodeKind`. `instanceof` also fails after the `projectSchemaToSpace` spread flattens the tree into plain objects. - Add a shared `PostgresSchemaNodeKind` identifier map (schema-node-kinds.ts) and give each of the five nodes a unique enumerable `nodeKind` (database / namespace / table / policy / role). The base `SqlSchemaIRNode` declares the optional field so guards can read it. - Rewrite every `.is`/`.assert` guard to take `SqlSchemaIRNode` and return `node.nodeKind === PostgresSchemaNodeKind.X` — no `instanceof`. The enumerable field survives the spread, so all five guards behave uniformly. (The `.ensure` real-vs-spread reconstruction check and the constructor input-normalization keep their `instanceof` — those detect representation, not node type.) - Drop `nodeTarget` and the `SqlSchemaTarget = "sql" | "postgres"` family type: it hard-coded a target id in a SQL-family type. `nodeKind` now subsumes node discrimination (it distinguished database from namespace, both of which carried `nodeTarget = "postgres"`), so nothing reads `nodeTarget` anymore. - Make `isEqualTo` an identity check: namespace/table nodes equal iff their ids match; columns are not compared. Behaviour-neutral — the differ only compares nodes paired by id, so identity always held. Guard results and diff presence are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../aggregate/project-schema-to-space.test.ts | 1 - .../1-core/schema-ir/src/exports/types.ts | 1 - .../schema-ir/src/ir/sql-schema-ir-node.ts | 9 ++++ .../1-core/schema-ir/src/ir/sql-schema-ir.ts | 16 ------ packages/2-sql/1-core/schema-ir/src/types.ts | 2 +- .../core/migrations/diff-postgres-schema.ts | 34 ++++++++---- .../postgres/src/core/migrations/planner.ts | 39 +++++++++----- .../postgres-database-schema-node.ts | 52 +++++++------------ .../postgres-namespace-schema-node.ts | 22 ++++---- .../schema-ir/postgres-policy-schema-node.ts | 21 +++++--- .../schema-ir/postgres-role-schema-node.ts | 17 ++++-- .../schema-ir/postgres-table-schema-node.ts | 13 +++-- .../src/core/schema-ir/schema-node-kinds.ts | 17 ++++++ ...t-to-postgres-database-schema-node.test.ts | 3 +- .../migrations/diff-postgres-schema.test.ts | 9 ++-- .../postgres-database-schema-node.test.ts | 27 +++++----- .../postgres-namespace-schema-node.test.ts | 12 +++-- .../test/postgres-table-schema-node.test.ts | 8 +-- .../rls-migration-plan.integration.test.ts | 3 +- 19 files changed, 173 insertions(+), 133 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts index b55ba5e338..14f52f91d9 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts @@ -187,7 +187,6 @@ describe('projectSchemaToSpace', () => { // per-schema namespace nodes (`namespaces[…].tables`) rather than a flat // `tables` record. The projector prunes inside each namespace. const schema = { - nodeTarget: 'postgres', nodeKind: 'postgres-database', namespaces: { public: { diff --git a/packages/2-sql/1-core/schema-ir/src/exports/types.ts b/packages/2-sql/1-core/schema-ir/src/exports/types.ts index 6a37320b73..0ccb58e573 100644 --- a/packages/2-sql/1-core/schema-ir/src/exports/types.ts +++ b/packages/2-sql/1-core/schema-ir/src/exports/types.ts @@ -7,7 +7,6 @@ export type { SqlIndexIRInput, SqlReferentialAction, SqlSchemaIRInput, - SqlSchemaTarget, SqlTableIRInput, SqlTypeMetadata, SqlTypeMetadataRegistry, diff --git a/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir-node.ts b/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir-node.ts index 8ff07ce522..745a4fe92c 100644 --- a/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir-node.ts +++ b/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir-node.ts @@ -22,6 +22,15 @@ import { IRNodeBase } from '@prisma-next/framework-components/ir'; export abstract class SqlSchemaIRNode extends IRNodeBase { readonly kind?: string; + /** + * Enumerable discriminant identifying which node this is (database / + * namespace / table / policy / role). Target concretions set a unique value; + * the `.is`/`.assert`/`.ensure` guards compare against it. Unlike `kind`, it + * is enumerable, so it survives the `projectSchemaToSpace` spread that + * flattens the tree into plain objects. + */ + readonly nodeKind?: string; + constructor() { super(); Object.defineProperty(this, 'kind', { diff --git a/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts b/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts index e19a385305..564db69989 100644 --- a/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts +++ b/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts @@ -3,16 +3,6 @@ import type { SqlAnnotations } from './sql-column-ir'; import { SqlSchemaIRNode } from './sql-schema-ir-node'; import { SqlTableIR, type SqlTableIRInput } from './sql-table-ir'; -/** - * Target discriminant for a schema-root IR node. `'sql'` is the - * target-agnostic generic root (SQLite uses it directly); target-specific - * concretions set their own id (e.g. Postgres → `'postgres'`). It is an - * enumerable own field so it survives a `{ ...schema }` spread (unlike the - * non-enumerable, shared `kind` discriminator), which the multi-space verify - * path relies on. - */ -export type SqlSchemaTarget = 'sql' | 'postgres'; - export interface SqlSchemaIRInput { readonly tables: Record; readonly annotations?: SqlAnnotations; @@ -29,12 +19,6 @@ export interface SqlSchemaIRInput { * was a plain-data literal or already-constructed class instances. */ export class SqlSchemaIR extends SqlSchemaIRNode { - // Optional on the type so plain-data `SqlSchemaIR`-shaped literals (common in - // tests and the contract-derived schema) still satisfy it without restating - // the field; instances always carry the concrete `'sql'`. - // `PostgresDatabaseSchemaNode.is` reads it — an absent value is correctly not - // Postgres. - readonly nodeTarget?: SqlSchemaTarget = 'sql'; readonly tables: Readonly>; declare readonly annotations?: SqlAnnotations; diff --git a/packages/2-sql/1-core/schema-ir/src/types.ts b/packages/2-sql/1-core/schema-ir/src/types.ts index 7d87da3c97..af2075e7ef 100644 --- a/packages/2-sql/1-core/schema-ir/src/types.ts +++ b/packages/2-sql/1-core/schema-ir/src/types.ts @@ -25,7 +25,7 @@ export { type SqlReferentialAction, } from './ir/sql-foreign-key-ir'; export { SqlIndexIR, type SqlIndexIRInput } from './ir/sql-index-ir'; -export { SqlSchemaIR, type SqlSchemaIRInput, type SqlSchemaTarget } from './ir/sql-schema-ir'; +export { SqlSchemaIR, type SqlSchemaIRInput } from './ir/sql-schema-ir'; export { SqlSchemaIRNode } from './ir/sql-schema-ir-node'; export { SqlTableIR, type SqlTableIRInput } from './ir/sql-table-ir'; export { SqlUniqueIR, type SqlUniqueIRInput } from './ir/sql-unique-ir'; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts index 7c1479d648..e7ccdbed5c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts @@ -1,8 +1,19 @@ -import type { SchemaDiffIssue } from '@prisma-next/framework-components/control'; +import type { DiffableNode, SchemaDiffIssue } from '@prisma-next/framework-components/control'; import { diffSchemas } from '@prisma-next/framework-components/control'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; +import { blindCast } from '@prisma-next/utils/casts'; import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; +// Every node in a diff issue produced from Postgres schema trees is a +// `SqlSchemaIRNode`; the framework types it as the narrower `DiffableNode`. +function asSchemaNode(node: DiffableNode): SqlSchemaIRNode { + return blindCast< + SqlSchemaIRNode, + 'diff issues over Postgres schema trees carry SqlSchemaIRNode nodes' + >(node); +} + // Renders a display-only reference string for the diff message. If policy // rendering grows, route it through the adapter's SQL renderer so the message // can't diverge from the emitted policy SQL. @@ -33,11 +44,13 @@ export function diffPostgresSchema( return issues .filter((i) => { const node = i.expected ?? i.actual; - return node !== undefined && PostgresPolicySchemaNode.is(node); + return node !== undefined && PostgresPolicySchemaNode.is(asSchemaNode(node)); }) .map((i) => { - const policy = i.expected ?? i.actual; - if (policy === undefined || !PostgresPolicySchemaNode.is(policy)) return i; + const node = i.expected ?? i.actual; + if (node === undefined) return i; + const policy = asSchemaNode(node); + if (!PostgresPolicySchemaNode.is(policy)) return i; return { ...i, message: `${i.outcome}: ${renderPostgresPolicyReference(policy)}` }; }); } @@ -51,11 +64,10 @@ export function filterIssuesByOwnership( issues: readonly SchemaDiffIssue[], ownedSchemaNames: ReadonlySet, ): readonly SchemaDiffIssue[] { - return issues.filter( - (i) => - i.outcome !== 'extra' || - (i.actual !== undefined && - PostgresPolicySchemaNode.is(i.actual) && - ownedSchemaNames.has(i.actual.namespaceId)), - ); + return issues.filter((i) => { + if (i.outcome !== 'extra') return true; + if (i.actual === undefined) return false; + const policy = asSchemaNode(i.actual); + return PostgresPolicySchemaNode.is(policy) && ownedSchemaNames.has(policy.namespaceId); + }); } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 9a888d2791..cae717a13e 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -15,6 +15,7 @@ import { import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { + DiffableNode, MigrationPlanner, MigrationPlanWithAuthoringSurface, MigrationScaffoldContext, @@ -22,7 +23,7 @@ import type { SchemaIssue, } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { PostgresRlsPolicy } from '../postgres-rls-policy'; @@ -301,34 +302,34 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // encodes the body hash, so two policies sharing a local key (same name) // are always equal and isEqualTo never returns false. if (issue.outcome === 'missing') { - PostgresPolicySchemaNode.assert(issue.expected); - // issue.expected.namespaceId is the DDL schema name (resolved during projection); + const expected = asSchemaNode(issue.expected); + PostgresPolicySchemaNode.assert(expected); + // expected.namespaceId is the DDL schema name (resolved during projection); // this re-resolution is a no-op as long as PostgresSchema.ddlSchemaName() returns this.id. const schemaForTable = resolveDdlSchemaForNamespaceStorage( options.contract.storage, - issue.expected.namespaceId, + expected.namespaceId, ); - const tableKey = `${schemaForTable}.${issue.expected.tableName}`; + const tableKey = `${schemaForTable}.${expected.tableName}`; if (!seenEnableTables.has(tableKey)) { seenEnableTables.add(tableKey); - calls.push(new EnableRowLevelSecurityCall(schemaForTable, issue.expected.tableName)); + calls.push(new EnableRowLevelSecurityCall(schemaForTable, expected.tableName)); } calls.push( new CreatePostgresRlsPolicyCall( schemaForTable, - issue.expected.tableName, - policyNodeToContractPolicy(issue.expected), + expected.tableName, + policyNodeToContractPolicy(expected), ), ); } else if (issue.outcome === 'extra' && allowsDestructive) { - PostgresPolicySchemaNode.assert(issue.actual); + const actual = asSchemaNode(issue.actual); + PostgresPolicySchemaNode.assert(actual); const schemaForTable = resolveDdlSchemaForNamespaceStorage( options.contract.storage, - issue.actual.namespaceId, - ); - calls.push( - new DropPostgresRlsPolicyCall(schemaForTable, issue.actual.tableName, issue.actual.name), + actual.namespaceId, ); + calls.push(new DropPostgresRlsPolicyCall(schemaForTable, actual.tableName, actual.name)); } } @@ -375,6 +376,18 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr } } +// Every node in a diff issue produced from Postgres schema trees is a +// `SqlSchemaIRNode`; the framework types issue nodes as the narrower +// `DiffableNode`. The `PostgresPolicySchemaNode` guards downcast from +// `SqlSchemaIRNode`, so bridge the framework type here. +function asSchemaNode(node: DiffableNode | undefined): SqlSchemaIRNode | undefined { + if (node === undefined) return undefined; + return blindCast< + SqlSchemaIRNode, + 'diff issues over Postgres schema trees carry SqlSchemaIRNode nodes' + >(node); +} + /** * Selects the per-schema namespace node the relational strategy layer probes * for live-table existence. Prefers the node matching the planner's resolved diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts index 5d10a9f731..7ebdb9124e 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts @@ -1,12 +1,13 @@ import type { DiffableNode } from '@prisma-next/framework-components/control'; import { freezeNode } from '@prisma-next/framework-components/ir'; -import { SqlSchemaIRNode, type SqlSchemaTarget } from '@prisma-next/sql-schema-ir/types'; +import { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; import { PostgresNamespaceSchemaNode, type PostgresNamespaceSchemaNodeInput, } from './postgres-namespace-schema-node'; import { PostgresRoleSchemaNode } from './postgres-role-schema-node'; +import { PostgresSchemaNodeKind } from './schema-node-kinds'; export interface PostgresDatabaseSchemaNodeInput { readonly namespaces: Readonly< @@ -21,19 +22,17 @@ export interface PostgresDatabaseSchemaNodeInput { * The real root of the Postgres schema-diff tree: one node per database. * * `id` is the fixed sentinel `'database'` — the root has no siblings and - * the value is never emitted into migration paths. `isEqualTo` is always - * true. `children()` returns namespace nodes only; roles are held on the - * root but NOT yielded (role diffing is a later slice, R4). + * the value is never emitted into migration paths. `isEqualTo` is identity + * (roots always share the `'database'` id). `children()` returns namespace + * nodes only; roles are held on the root but NOT yielded (role diffing is a + * later slice, R4). * - * `nodeTarget = 'postgres'` is an enumerable own field so it survives the - * `{ ...node }` spread that `projectSchemaToSpace` produces. `nodeKind` is - * a second enumerable discriminant that distinguishes the database root - * from `PostgresNamespaceSchemaNode` (which also carries `nodeTarget = - * 'postgres'`) after a spread. + * `nodeKind` is an enumerable own discriminant that identifies this node and + * distinguishes it from the other schema-diff nodes after the `{ ...node }` + * spread `projectSchemaToSpace` produces. */ export class PostgresDatabaseSchemaNode extends SqlSchemaIRNode implements DiffableNode { - readonly nodeTarget: SqlSchemaTarget = 'postgres'; - readonly nodeKind = 'postgres-database' as const; + override readonly nodeKind = PostgresSchemaNodeKind.database; readonly namespaces: Readonly>; readonly roles: readonly PostgresRoleSchemaNode[]; readonly existingSchemas: readonly string[]; @@ -66,36 +65,23 @@ export class PostgresDatabaseSchemaNode extends SqlSchemaIRNode implements Diffa return 'database'; } - isEqualTo(_other: DiffableNode): boolean { - return true; + isEqualTo(other: DiffableNode): boolean { + return this.id === other.id; } children(): readonly DiffableNode[] { return Object.values(this.namespaces); } - static is(node: unknown): node is PostgresDatabaseSchemaNode { - if (node instanceof PostgresDatabaseSchemaNode) return true; - if (typeof node !== 'object' || node === null) return false; - const n = blindCast< - Record, - 'narrowed to a non-null object; reading enumerable own discriminants that survive the projectSchemaToSpace spread' - >(node); - return n['nodeTarget'] === 'postgres' && n['nodeKind'] === 'postgres-database'; + static is(node: SqlSchemaIRNode): node is PostgresDatabaseSchemaNode { + return node.nodeKind === PostgresSchemaNodeKind.database; } - static assert(node: unknown): asserts node is PostgresDatabaseSchemaNode { + static assert(node: SqlSchemaIRNode): asserts node is PostgresDatabaseSchemaNode { if (!PostgresDatabaseSchemaNode.is(node)) { - const target = - typeof node === 'object' && node !== null - ? String( - blindCast< - Record, - 'narrowed to a non-null object; reading the nodeTarget discriminant for the error message' - >(node)['nodeTarget'] ?? typeof node, - ) - : typeof node; - throw new Error(`Expected a PostgresDatabaseSchemaNode but got nodeTarget=${target}`); + throw new Error( + `Expected a PostgresDatabaseSchemaNode but got nodeKind=${node.nodeKind ?? 'undefined'}`, + ); } } @@ -104,7 +90,7 @@ export class PostgresDatabaseSchemaNode extends SqlSchemaIRNode implements Diffa * `projectSchemaToSpace` has spread the class into a plain object (losing * prototype methods but preserving all own-enumerable fields). */ - static ensure(node: PostgresDatabaseSchemaNode): PostgresDatabaseSchemaNode { + static ensure(node: SqlSchemaIRNode): PostgresDatabaseSchemaNode { if (node instanceof PostgresDatabaseSchemaNode) return node; return new PostgresDatabaseSchemaNode( blindCast< diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts index 2cf1d8ecf3..6aaa4dad8f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts @@ -1,14 +1,11 @@ import type { DiffableNode } from '@prisma-next/framework-components/control'; import { freezeNode } from '@prisma-next/framework-components/ir'; -import { - type SqlAnnotations, - SqlSchemaIRNode, - type SqlSchemaTarget, -} from '@prisma-next/sql-schema-ir/types'; +import { type SqlAnnotations, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { PostgresTableSchemaNode, type PostgresTableSchemaNodeInput, } from './postgres-table-schema-node'; +import { PostgresSchemaNodeKind } from './schema-node-kinds'; export interface PostgresNamespaceSchemaNodeInput { readonly schemaName: string; @@ -22,8 +19,9 @@ export interface PostgresNamespaceSchemaNodeInput { * consumers (verifySqlSchema, the relational planner, toSchemaView) can * accept it unchanged in Unit 6. * - * `id` is the schema name. `isEqualTo` is always true — namespace-level - * attribute diffing is not needed yet. `children()` returns the table nodes. + * `id` is the schema name. `isEqualTo` is identity — two namespace nodes are + * equal iff their ids (schema names) match. `children()` returns the table + * nodes. * * The `annotations.pg` bag mirrors what `PostgresSchemaIR` carried for the * per-schema slot (`schema` + `nativeEnumTypeNames`). `existingSchemas` is @@ -32,7 +30,7 @@ export interface PostgresNamespaceSchemaNodeInput { * the annotations bag (TML-2936). */ export class PostgresNamespaceSchemaNode extends SqlSchemaIRNode implements DiffableNode { - readonly nodeTarget: SqlSchemaTarget = 'postgres'; + override readonly nodeKind = PostgresSchemaNodeKind.namespace; readonly schemaName: string; readonly tables: Readonly>; declare readonly annotations?: SqlAnnotations; @@ -67,15 +65,15 @@ export class PostgresNamespaceSchemaNode extends SqlSchemaIRNode implements Diff return this.schemaName; } - isEqualTo(_other: DiffableNode): boolean { - return true; + isEqualTo(other: DiffableNode): boolean { + return this.id === other.id; } children(): readonly DiffableNode[] { return Object.values(this.tables); } - static is(node: unknown): node is PostgresNamespaceSchemaNode { - return node instanceof PostgresNamespaceSchemaNode; + static is(node: SqlSchemaIRNode): node is PostgresNamespaceSchemaNode { + return node.nodeKind === PostgresSchemaNodeKind.namespace; } } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts index 64d7033024..a2eff02e77 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts @@ -1,7 +1,9 @@ import type { DiffableNode } from '@prisma-next/framework-components/control'; import { freezeNode } from '@prisma-next/framework-components/ir'; import { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; +import { blindCast } from '@prisma-next/utils/casts'; import type { RlsPolicyOperation } from '../postgres-rls-policy'; +import { PostgresSchemaNodeKind } from './schema-node-kinds'; export interface PostgresPolicySchemaNodeInput { /** Full wire name: `_<8hex>`. */ @@ -35,6 +37,7 @@ export interface PostgresPolicySchemaNodeInput { * bodies, because Postgres reprints them. */ export class PostgresPolicySchemaNode extends SqlSchemaIRNode implements DiffableNode { + override readonly nodeKind = PostgresSchemaNodeKind.policy; readonly name: string; readonly prefix: string; readonly tableName: string; @@ -68,22 +71,26 @@ export class PostgresPolicySchemaNode extends SqlSchemaIRNode implements Diffabl } isEqualTo(other: DiffableNode): boolean { - if (!PostgresPolicySchemaNode.is(other)) { + const node = blindCast< + SqlSchemaIRNode, + 'every diff-tree node the differ pairs is a SqlSchemaIRNode; the guard rejects non-policy kinds' + >(other); + if (!PostgresPolicySchemaNode.is(node)) { throw new Error( - `PostgresPolicySchemaNode.isEqualTo: expected a PostgresPolicySchemaNode, got ${other.constructor?.name ?? typeof other}`, + `PostgresPolicySchemaNode.isEqualTo: expected a PostgresPolicySchemaNode, got nodeKind=${node.nodeKind ?? 'undefined'}`, ); } - return this.name === other.name; + return this.id === node.id; } - static is(node: DiffableNode): node is PostgresPolicySchemaNode { - return node instanceof PostgresPolicySchemaNode; + static is(node: SqlSchemaIRNode): node is PostgresPolicySchemaNode { + return node.nodeKind === PostgresSchemaNodeKind.policy; } - static assert(node: DiffableNode | undefined): asserts node is PostgresPolicySchemaNode { + static assert(node: SqlSchemaIRNode | undefined): asserts node is PostgresPolicySchemaNode { if (node === undefined || !PostgresPolicySchemaNode.is(node)) { throw new Error( - `Expected a PostgresPolicySchemaNode, got ${node?.constructor?.name ?? typeof node}`, + `Expected a PostgresPolicySchemaNode, got nodeKind=${node?.nodeKind ?? 'undefined'}`, ); } } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts index 7e7d14eb03..04eacfd91f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts @@ -1,6 +1,8 @@ import type { DiffableNode } from '@prisma-next/framework-components/control'; import { freezeNode } from '@prisma-next/framework-components/ir'; import { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; +import { blindCast } from '@prisma-next/utils/casts'; +import { PostgresSchemaNodeKind } from './schema-node-kinds'; export interface PostgresRoleSchemaNodeInput { readonly name: string; @@ -22,6 +24,7 @@ export interface PostgresRoleSchemaNodeInput { * names — name-equality is role-equality for cluster-scoped objects. */ export class PostgresRoleSchemaNode extends SqlSchemaIRNode implements DiffableNode { + override readonly nodeKind = PostgresSchemaNodeKind.role; readonly name: string; readonly namespaceId: string; @@ -41,15 +44,19 @@ export class PostgresRoleSchemaNode extends SqlSchemaIRNode implements DiffableN } isEqualTo(other: DiffableNode): boolean { - if (!PostgresRoleSchemaNode.is(other)) { + const node = blindCast< + SqlSchemaIRNode, + 'every diff-tree node the differ pairs is a SqlSchemaIRNode; the guard rejects non-role kinds' + >(other); + if (!PostgresRoleSchemaNode.is(node)) { throw new Error( - `PostgresRoleSchemaNode.isEqualTo: expected a PostgresRoleSchemaNode, got ${other.constructor?.name ?? typeof other}`, + `PostgresRoleSchemaNode.isEqualTo: expected a PostgresRoleSchemaNode, got nodeKind=${node.nodeKind ?? 'undefined'}`, ); } - return this.name === other.name; + return this.id === node.id; } - static is(node: DiffableNode): node is PostgresRoleSchemaNode { - return node instanceof PostgresRoleSchemaNode; + static is(node: SqlSchemaIRNode): node is PostgresRoleSchemaNode { + return node.nodeKind === PostgresSchemaNodeKind.role; } } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts index 55112114eb..aafff81fdd 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts @@ -15,6 +15,7 @@ import { PostgresPolicySchemaNode, type PostgresPolicySchemaNodeInput, } from './postgres-policy-schema-node'; +import { PostgresSchemaNodeKind } from './schema-node-kinds'; export interface PostgresTableSchemaNodeInput extends SqlTableIRInput { readonly policies?: readonly (PostgresPolicySchemaNode | PostgresPolicySchemaNodeInput)[]; @@ -30,9 +31,11 @@ export interface PostgresTableSchemaNodeInput extends SqlTableIRInput { * subclass field initialisation. * * `id` is the table name. `children()` returns the policy nodes on this table. - * `isEqualTo` is always true — table-level attributes are not diffed yet. + * `isEqualTo` is identity — two table nodes are equal iff their ids (names) + * match. Columns are not compared here; they become child nodes later. */ export class PostgresTableSchemaNode extends SqlSchemaIRNode implements DiffableNode { + override readonly nodeKind = PostgresSchemaNodeKind.table; readonly name: string; readonly columns: Readonly>; readonly foreignKeys: ReadonlyArray; @@ -91,15 +94,15 @@ export class PostgresTableSchemaNode extends SqlSchemaIRNode implements Diffable return this.name; } - isEqualTo(_other: DiffableNode): boolean { - return true; + isEqualTo(other: DiffableNode): boolean { + return this.id === other.id; } children(): readonly DiffableNode[] { return this.policies; } - static is(node: DiffableNode): node is PostgresTableSchemaNode { - return node instanceof PostgresTableSchemaNode; + static is(node: SqlSchemaIRNode): node is PostgresTableSchemaNode { + return node.nodeKind === PostgresSchemaNodeKind.table; } } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts new file mode 100644 index 0000000000..c34a1e30b4 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts @@ -0,0 +1,17 @@ +/** + * The `nodeKind` discriminant for each Postgres schema-diff node. Each node + * carries a unique value; the `.is`/`.assert`/`.ensure` guards compare against + * these identifiers rather than spelling the string inline. The field is an + * enumerable own property, so it survives the `projectSchemaToSpace` spread that + * flattens the tree into plain objects. + */ +export const PostgresSchemaNodeKind = { + database: 'postgres-database', + namespace: 'postgres-namespace', + table: 'postgres-table', + policy: 'postgres-policy', + role: 'postgres-role', +} as const; + +export type PostgresSchemaNodeKind = + (typeof PostgresSchemaNodeKind)[keyof typeof PostgresSchemaNodeKind]; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts index 9c83c28a52..bd8308ee7d 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts @@ -1,5 +1,6 @@ import { coreHash, profileHash } from '@prisma-next/contract/types'; import { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { contractToPostgresDatabaseSchemaNode } from '../../src/core/migrations/contract-to-postgres-database-schema-node'; @@ -124,7 +125,7 @@ describe('contractToPostgresDatabaseSchemaNode', () => { ); expect(root.roles).toContainEqual(expect.objectContaining({ name: 'app_user' })); for (const child of root.children()) { - expect(PostgresNamespaceSchemaNode.is(child)).toBe(true); + expect(PostgresNamespaceSchemaNode.is(child as SqlSchemaIRNode)).toBe(true); } }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts index b1029d51a0..d0c5289599 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts @@ -1,6 +1,7 @@ import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { contractToPostgresDatabaseSchemaNode } from '../../src/core/migrations/contract-to-postgres-database-schema-node'; @@ -653,9 +654,11 @@ describe('diffPostgresSchema', () => { const issues = diffPostgresSchema(expected, actual); - expect(issues.every((i) => PostgresPolicySchemaNode.is(i.expected ?? i.actual ?? actual))).toBe( - true, - ); + expect( + issues.every((i) => + PostgresPolicySchemaNode.is((i.expected ?? i.actual ?? actual) as SqlSchemaIRNode), + ), + ).toBe(true); expect(issues).toHaveLength(2); // Path is [ 'database', schemaName, tableName, policyName ]. const paths = issues.map((i) => i.path); diff --git a/packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts index b9ecf9410a..1dee5ebb83 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts @@ -1,4 +1,5 @@ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; import { PostgresDatabaseSchemaNode, @@ -47,7 +48,7 @@ describe('PostgresDatabaseSchemaNode', () => { expect(node.id).toBe('database'); }); - it('isEqualTo always returns true', () => { + it('isEqualTo matches by id (roots always share the "database" id)', () => { const a = new PostgresDatabaseSchemaNode(baseInput); const b = new PostgresDatabaseSchemaNode({ ...baseInput, pgVersion: '16.0' }); expect(a.isEqualTo(b)).toBe(true); @@ -62,7 +63,7 @@ describe('PostgresDatabaseSchemaNode', () => { const node = new PostgresDatabaseSchemaNode(baseInput); const children = node.children(); for (const child of children) { - expect(PostgresRoleSchemaNode.is(child)).toBe(false); + expect(PostgresRoleSchemaNode.is(child as SqlSchemaIRNode)).toBe(false); } }); @@ -92,9 +93,9 @@ describe('PostgresDatabaseSchemaNode', () => { expect(Object.isFrozen(node)).toBe(true); }); - it('nodeTarget discriminant is "postgres"', () => { + it('nodeKind discriminant is "postgres-database"', () => { const node = new PostgresDatabaseSchemaNode(baseInput); - expect(node.nodeTarget).toBe('postgres'); + expect(node.nodeKind).toBe('postgres-database'); }); describe('PostgresDatabaseSchemaNode.is', () => { @@ -103,22 +104,18 @@ describe('PostgresDatabaseSchemaNode', () => { expect(PostgresDatabaseSchemaNode.is(node)).toBe(true); }); - it('returns true for a spread plain object that retains nodeTarget', () => { + it('returns true for a spread plain object that retains nodeKind', () => { const node = new PostgresDatabaseSchemaNode(baseInput); const spread = { ...node }; - expect(PostgresDatabaseSchemaNode.is(spread as unknown as PostgresDatabaseSchemaNode)).toBe( - true, - ); + expect(PostgresDatabaseSchemaNode.is(spread as unknown as SqlSchemaIRNode)).toBe(true); }); it('returns false for a PostgresNamespaceSchemaNode', () => { - expect(PostgresDatabaseSchemaNode.is(nsPublic as unknown as PostgresDatabaseSchemaNode)).toBe( - false, - ); + expect(PostgresDatabaseSchemaNode.is(nsPublic as unknown as SqlSchemaIRNode)).toBe(false); }); - it('returns false for an object without nodeTarget', () => { - const bare = { id: 'database' } as unknown as PostgresDatabaseSchemaNode; + it('returns false for an object without nodeKind', () => { + const bare = { id: 'database' } as unknown as SqlSchemaIRNode; expect(PostgresDatabaseSchemaNode.is(bare)).toBe(false); }); }); @@ -129,8 +126,8 @@ describe('PostgresDatabaseSchemaNode', () => { expect(() => PostgresDatabaseSchemaNode.assert(node)).not.toThrow(); }); - it('throws for an object with wrong nodeTarget', () => { - const bad = { nodeTarget: 'sql' } as unknown as PostgresDatabaseSchemaNode; + it('throws for an object with wrong nodeKind', () => { + const bad = { nodeKind: 'postgres-namespace' } as unknown as SqlSchemaIRNode; expect(() => PostgresDatabaseSchemaNode.assert(bad)).toThrow(); }); }); diff --git a/packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts index 24116d722a..40e241e0c8 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts @@ -1,4 +1,4 @@ -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, expectTypeOf, it, test } from 'vitest'; import { PostgresNamespaceSchemaNode } from '../src/core/schema-ir/postgres-namespace-schema-node'; import { PostgresPolicySchemaNode } from '../src/core/schema-ir/postgres-policy-schema-node'; @@ -46,10 +46,12 @@ describe('PostgresNamespaceSchemaNode', () => { expect(node.id).toBe('public'); }); - it('isEqualTo always returns true', () => { + it('isEqualTo matches by id (schema name)', () => { const a = new PostgresNamespaceSchemaNode(baseInput); - const b = new PostgresNamespaceSchemaNode({ ...baseInput, schemaName: 'other' }); - expect(a.isEqualTo(b)).toBe(true); + const same = new PostgresNamespaceSchemaNode({ ...baseInput, nativeEnumTypeNames: [] }); + const other = new PostgresNamespaceSchemaNode({ ...baseInput, schemaName: 'other' }); + expect(a.isEqualTo(same)).toBe(true); + expect(a.isEqualTo(other)).toBe(false); }); it('children() returns table nodes', () => { @@ -70,7 +72,7 @@ describe('PostgresNamespaceSchemaNode', () => { const node = new PostgresNamespaceSchemaNode(baseInput); const children = node.children(); for (const child of children) { - expect(PostgresRoleSchemaNode.is(child)).toBe(false); + expect(PostgresRoleSchemaNode.is(child as SqlSchemaIRNode)).toBe(false); } }); diff --git a/packages/3-targets/3-targets/postgres/test/postgres-table-schema-node.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-table-schema-node.test.ts index b05d00dc9d..41b6dab26f 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-table-schema-node.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-table-schema-node.test.ts @@ -41,10 +41,12 @@ describe('PostgresTableSchemaNode', () => { expect(table.id).toBe('orders'); }); - it('isEqualTo always returns true', () => { + it('isEqualTo matches by id (name), ignoring columns and policies', () => { const a = new PostgresTableSchemaNode({ ...tableInput, policies: [basePolicy] }); - const b = new PostgresTableSchemaNode({ ...tableInput, policies: [] }); - expect(a.isEqualTo(b)).toBe(true); + const same = new PostgresTableSchemaNode({ ...tableInput, policies: [] }); + const other = new PostgresTableSchemaNode({ ...tableInput, name: 'other', policies: [] }); + expect(a.isEqualTo(same)).toBe(true); + expect(a.isEqualTo(other)).toBe(false); }); it('children() returns its policies', () => { diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts index 14f0c30a6f..e3395ecef8 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts @@ -8,6 +8,7 @@ import { buildSymbolTable } from '@prisma-next/psl-parser'; import { parse } from '@prisma-next/psl-parser/syntax'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { interpretPslDocumentToSqlContract } from '@prisma-next/sql-contract-psl'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { PostgresDatabaseSchemaNode, postgresCreateNamespace, @@ -103,7 +104,7 @@ describe('migration plan emits RLS (offline, no live database)', () => { const fromSchema = postgresTargetDescriptor.migrations.contractToSchema( null, frameworkComponents, - ); + ) as SqlSchemaIRNode; PostgresDatabaseSchemaNode.assert(fromSchema); expect(fromSchema).toBeInstanceOf(PostgresDatabaseSchemaNode); const allPolicies = Object.values(fromSchema.namespaces).flatMap((ns) => From 7489e4fce020a5b2295775da66619789f75c6335 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 1 Jul 2026 16:43:11 +0200 Subject: [PATCH 26/49] refactor(migration): aggregate verifier/planner take family schema-shape callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework aggregate layer duck-typed introspected storage shapes: projectSchemaToSpace branched on .namespaces/.tables/.collections and named PostgresDatabaseSchemaNode; the verifier collectLiveTableNames/detectOrphanElements did the same. That is an inverted dependency — framework code knowing target storage shapes. Move the shape knowledge to the families, exactly as verifySchemaForMember already does for verification: - Add two caller-supplied callbacks. projectSchemaToMember(schema, ownedByOtherNames) prunes the live schema to a member slice; listSchemaEntityNames(schema) lists its entity names for orphan detection. The framework computes the owned names from contract storage (target-agnostic elementCoordinates) and calls the callbacks; it inspects no schema shape and names no target type. - The families provide the callbacks. New diff/schema-shape.ts (SQL, walks the flat/namespaced SqlSchemaIRNode) and schema-shape.ts (Mongo, walks collections), exposed as projectSchemaToMember/listSchemaEntityNames on each ControlFamilyInstance. - The CLI wires them at db-verify/db-run, the same layer that wires verifySchemaForMember. The Mongo target runner passes its own callback to the now-callback-driven projectSchemaToSpace. Behaviour-neutral: per-space projection, orphan detection, and aggregate verify/plan results are unchanged. Shape-branching tests move from the framework to the SQL and Mongo family packages; the framework tests now prove delegation. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/control-instances.ts | 17 + .../cli/src/control-api/operations/db-run.ts | 8 + .../src/control-api/operations/db-verify.ts | 15 + .../3-tooling/cli/test/config-types.test.ts | 2 + .../migration/src/aggregate/planner-types.ts | 6 + .../migration/src/aggregate/planner.ts | 2 + .../src/aggregate/project-schema-to-space.ts | 203 ++---------- .../src/aggregate/strategies/synth.ts | 4 +- .../migration/src/aggregate/verifier.ts | 77 +++-- .../migration/src/exports/aggregate.ts | 7 +- .../migration/test/aggregate/planner.test.ts | 19 ++ .../aggregate/project-schema-to-space.test.ts | 303 +++++------------- .../test/aggregate/strategies/synth.test.ts | 13 + .../migration/test/aggregate/verifier.test.ts | 42 ++- .../test/deletable-node-modules.test.ts | 7 + .../9-family/src/core/control-instance.ts | 18 +- .../9-family/src/core/schema-shape.ts | 96 ++++++ .../9-family/src/exports/control.ts | 4 + .../9-family/test/schema-shape.test.ts | 74 +++++ .../9-family/src/core/control-instance.ts | 26 ++ .../9-family/src/core/diff/schema-shape.ts | 104 ++++++ packages/2-sql/9-family/src/exports/diff.ts | 4 + .../2-sql/9-family/test/schema-shape.test.ts | 108 +++++++ .../1-mongo-target/src/core/control-target.ts | 12 +- 24 files changed, 733 insertions(+), 438 deletions(-) create mode 100644 packages/2-mongo-family/9-family/src/core/schema-shape.ts create mode 100644 packages/2-mongo-family/9-family/test/schema-shape.test.ts create mode 100644 packages/2-sql/9-family/src/core/diff/schema-shape.ts create mode 100644 packages/2-sql/9-family/test/schema-shape.test.ts diff --git a/packages/1-framework/1-core/framework-components/src/control/control-instances.ts b/packages/1-framework/1-core/framework-components/src/control/control-instances.ts index 5f39520f67..7ff6887287 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-instances.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-instances.ts @@ -56,6 +56,23 @@ export interface ControlFamilyInstance readonly frameworkComponents: ReadonlyArray>; }): VerifyDatabaseSchemaResult; + /** + * Prunes the introspected live schema to the slice claimed by one aggregate + * member, given the entity names owned by the other members. The framework + * touches no storage shape: the aggregate verifier/planner call this so the + * per-space verify doesn't see sibling members' entities as extras. Only the + * family knows its introspected shape (SQL's flat/namespaced `SqlSchemaIRNode`, + * Mongo's `collections`). + */ + projectSchemaToMember(schema: TSchemaIR, ownedByOtherNames: ReadonlySet): TSchemaIR; + + /** + * Lists the bare names of every top-level entity in the introspected live + * schema. Used by the aggregate verifier's orphan detection. Family-provided + * for the same reason as {@link projectSchemaToMember}. + */ + listSchemaEntityNames(schema: TSchemaIR): readonly string[]; + sign(options: { readonly driver: ControlDriverInstance; readonly contract: unknown; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts index b9b7cc5d3b..6daae010bd 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts @@ -185,6 +185,14 @@ export async function executeRun + familyInstance.projectSchemaToMember( + blindCast< + never, + 'family TSchemaIR is opaque to the CLI; schema is passed straight through' + >(schema), + ownedByOtherNames, + ), }); if (!planResult.ok) { onProgress?.({ action, kind: 'spanEnd', spanId: SPAN_IDS.plan, outcome: 'error' }); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts index 45e496857e..f0c778413f 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts @@ -116,6 +116,21 @@ export async function executeDbVerify + familyInstance.projectSchemaToMember( + blindCast< + never, + 'family TSchemaIR is opaque to the CLI; schema is passed straight through' + >(schema), + ownedByOtherNames, + ), + listEntityNames: (schema) => + familyInstance.listSchemaEntityNames( + blindCast< + never, + 'family TSchemaIR is opaque to the CLI; schema is passed straight through' + >(schema), + ), }); return finaliseVerifyResult({ verifyResult, aggregate, skipMarker, onProgress }); } diff --git a/packages/1-framework/3-tooling/cli/test/config-types.test.ts b/packages/1-framework/3-tooling/cli/test/config-types.test.ts index c8f1d9d4d8..36d848296a 100644 --- a/packages/1-framework/3-tooling/cli/test/config-types.test.ts +++ b/packages/1-framework/3-tooling/cli/test/config-types.test.ts @@ -62,6 +62,8 @@ describe('defineConfig', () => { }, timings: { total: 0 }, }), + projectSchemaToMember: (schema: unknown) => schema, + listSchemaEntityNames: () => [], sign: async () => ({ ok: true, summary: 'test', diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts b/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts index 0b6fbfa1ff..dcee5de17f 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts @@ -12,6 +12,7 @@ import type { import type { Result } from '@prisma-next/utils/result'; import type { PathDecision } from '../migration-graph'; import type { ContractMarkerRecordLike } from './marker-types'; +import type { ProjectSchemaToMember } from './project-schema-to-space'; import type { ContractSpaceAggregate } from './types'; /** @@ -81,6 +82,11 @@ export interface PlannerInput>; readonly callerPolicy: CallerPolicy; readonly operationPolicy: MigrationOperationPolicy; + /** + * Family-provided callback that prunes the live schema to a member's slice. + * Threaded into the synth strategy; the planner never touches the shape. + */ + readonly projectSchemaToMember: ProjectSchemaToMember; } /** diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts b/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts index c16aa2490f..3ba65f0f2c 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts @@ -81,6 +81,7 @@ export async function planMigration` on - * contracts and the introspected schema mirrors the same record shape. - * Pruning iterates the record entries. - * - Mongo exposes `storage.collections: Record` on - * contracts; the introspected `MongoSchemaIR` exposes - * `collections: ReadonlyArray<{name: string, ...}>`. Pruning iterates - * the array on the schema side and the record's keys on the - * other-member side. - * - * Schemas of unrecognised shape are returned unchanged. The function - * never imports family classes (`SqlSchemaIR`, `MongoSchemaIR`); the - * projected schema is a plain object — `{...schema, tables: pruned}` or - * `{...schema, collections: pruned}` — that downstream consumers - * duck-type. A future family with a different storage shape gets the - * schema returned unchanged rather than blowing up the aggregate - * planner. - * - * Record-shape detection guards against arrays (`!Array.isArray`) so - * an unrecognised array-shaped value falls through unchanged rather - * than being pruned by numeric keys. + * Prunes the introspected live schema down to the slice a single member + * claims, given the entity names owned by the other members. Family-provided: + * the framework never touches the storage shape, so only the family knows how + * to walk its own introspected schema. + * + * The returned schema is the same value with every top-level entity owned by + * another member removed. Entities claimed by no member flow through unchanged, + * so the per-space verify sees them as orphans (extras in strict mode). */ -export function projectSchemaToSpace( +export type ProjectSchemaToMember = ( schema: unknown, - member: ContractSpaceMember, - otherMembers: ReadonlyArray, -): unknown { - if (typeof schema !== 'object' || schema === null) return schema; + ownedByOtherNames: ReadonlySet, +) => unknown; - const ownedByOthers = collectOwnedNames(member, otherMembers); - if (ownedByOthers.size === 0) return schema; - - const schemaObj = schema as { - readonly tables?: unknown; - readonly collections?: unknown; - readonly namespaces?: unknown; - }; - - // A namespaced schema tree (the Postgres `PostgresDatabaseSchemaNode` root) - // groups tables under per-schema namespace nodes rather than a flat `tables` - // record. Prune each namespace's tables in place, so per-space isolation - // holds without flattening namespaces into one (collision-prone) record. - if ( - typeof schemaObj.namespaces === 'object' && - schemaObj.namespaces !== null && - !Array.isArray(schemaObj.namespaces) - ) { - return pruneNamespaceTables(schemaObj, ownedByOthers); - } - - if ( - typeof schemaObj.tables === 'object' && - schemaObj.tables !== null && - !Array.isArray(schemaObj.tables) - ) { - return pruneRecord(schemaObj, 'tables', ownedByOthers); - } - - if (Array.isArray(schemaObj.collections)) { - return pruneCollectionsArray(schemaObj, ownedByOthers); - } - - if ( - typeof schemaObj.collections === 'object' && - schemaObj.collections !== null && - !Array.isArray(schemaObj.collections) - ) { - return pruneRecord(schemaObj, 'collections', ownedByOthers); - } - - return schema; -} +/** + * Lists the bare names of every top-level entity in the introspected live + * schema. Family-provided, for the same reason as {@link ProjectSchemaToMember}: + * only the family knows how its introspected schema is shaped. + */ +export type ListSchemaEntityNames = (schema: unknown) => readonly string[]; -function collectOwnedNames( +/** + * The entity names claimed by every member of the aggregate **other than** + * `member`. Target-agnostic: reads the contract-side storage IR through the + * framework's {@link elementCoordinates}, never the introspected schema shape. + */ +export function collectOwnedNames( member: ContractSpaceMember, otherMembers: ReadonlyArray, ): Set { @@ -121,73 +43,18 @@ function collectOwnedNames( } /** - * Prunes other-space tables from every namespace node of a schema tree root, - * returning a new root with pruned namespaces. The namespace nodes are spread - * into plain objects (losing their class prototype), mirroring the flat - * `pruneRecord` path — downstream consumers duck-type the result, and the - * `…SchemaNode.ensure()` guards reconstruct a node from the spread shape when - * a structure-aware consumer needs one. + * Projects the live schema to `member`'s slice by collecting the names owned by + * the other members ({@link collectOwnedNames}) and handing them to the + * family-provided {@link ProjectSchemaToMember} callback. When nothing is owned + * by others, the schema is returned unchanged without invoking the callback. */ -function pruneNamespaceTables( - schemaObj: { readonly namespaces?: unknown }, - ownedByOthers: ReadonlySet, -): unknown { - if (!isRecord(schemaObj.namespaces)) return schemaObj; - let removed = false; - const prunedNamespaces: Record = {}; - for (const [namespaceId, namespaceNode] of Object.entries(schemaObj.namespaces)) { - if (isRecord(namespaceNode) && isRecord(namespaceNode['tables'])) { - const prunedNode = pruneRecord(namespaceNode, 'tables', ownedByOthers); - if (prunedNode !== namespaceNode) removed = true; - prunedNamespaces[namespaceId] = prunedNode; - } else { - prunedNamespaces[namespaceId] = namespaceNode; - } - } - if (!removed) return schemaObj; - return { ...schemaObj, namespaces: prunedNamespaces }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function pruneRecord( - schemaObj: { readonly tables?: unknown; readonly collections?: unknown }, - field: 'tables' | 'collections', - ownedByOthers: ReadonlySet, -): unknown { - const source = schemaObj[field] as Record; - let removed = false; - const pruned: Record = {}; - for (const [name, value] of Object.entries(source)) { - if (ownedByOthers.has(name)) { - removed = true; - } else { - pruned[name] = value; - } - } - if (!removed) return schemaObj; - return { ...schemaObj, [field]: pruned }; -} - -function pruneCollectionsArray( - schemaObj: { readonly collections?: unknown }, - ownedByOthers: ReadonlySet, +export function projectSchemaToSpace( + schema: unknown, + member: ContractSpaceMember, + otherMembers: ReadonlyArray, + projectSchemaToMember: ProjectSchemaToMember, ): unknown { - const source = schemaObj.collections as ReadonlyArray; - let removed = false; - const pruned: unknown[] = []; - for (const entry of source) { - if (typeof entry === 'object' && entry !== null) { - const name = (entry as { readonly name?: unknown }).name; - if (typeof name === 'string' && ownedByOthers.has(name)) { - removed = true; - continue; - } - } - pruned.push(entry); - } - if (!removed) return schemaObj; - return { ...schemaObj, collections: pruned }; + const ownedByOthers = collectOwnedNames(member, otherMembers); + if (ownedByOthers.size === 0) return schema; + return projectSchemaToMember(schema, ownedByOthers); } diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts b/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts index 0f62eb6faa..aa15f206e9 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts @@ -10,7 +10,7 @@ import type { } from '@prisma-next/framework-components/control'; import type { ContractMarkerRecordLike } from '../marker-types'; import type { PerSpacePlan } from '../planner-types'; -import { projectSchemaToSpace } from '../project-schema-to-space'; +import { type ProjectSchemaToMember, projectSchemaToSpace } from '../project-schema-to-space'; import { buildSynthMigrationEdge } from '../synth-migration-edge'; import type { ContractSpaceMember } from '../types'; @@ -28,6 +28,7 @@ export interface SynthStrategyInputs; readonly frameworkComponents: ReadonlyArray>; readonly operationPolicy: MigrationOperationPolicy; + readonly projectSchemaToMember: ProjectSchemaToMember; } export type SynthStrategyOutcome = @@ -73,6 +74,7 @@ export async function synthStrategy { member: ContractSpaceMember, mode: 'strict' | 'lenient', ) => TSchemaResult; + /** + * Caller-supplied schema-shape callbacks. The framework touches no storage + * shape: `projectSchemaToMember` prunes the live schema to a member's slice, + * and `listEntityNames` enumerates the live entity names for orphan + * detection. The families provide both (each knows how its own introspected + * schema is shaped); the CLI wires them. + */ + readonly projectSchemaToMember: ProjectSchemaToMember; + readonly listEntityNames: ListSchemaEntityNames; } /** @@ -131,7 +144,15 @@ export function verifyMigration( function runVerifyMigration( input: VerifierInput, ): VerifierOutput { - const { aggregate, markersBySpaceId, schemaIntrospection, mode, verifySchemaForMember } = input; + const { + aggregate, + markersBySpaceId, + schemaIntrospection, + mode, + verifySchemaForMember, + projectSchemaToMember, + listEntityNames, + } = input; const allMembers: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; const memberSpaceIds = new Set(allMembers.map((m) => m.spaceId)); @@ -178,7 +199,12 @@ function runVerifyMigration( const schemaPerSpace = new Map(); for (const member of allMembers) { const others = allMembers.filter((m) => m.spaceId !== member.spaceId); - const projected = projectSchemaToSpace(schemaIntrospection, member, others); + const projected = projectSchemaToSpace( + schemaIntrospection, + member, + others, + projectSchemaToMember, + ); schemaPerSpace.set(member.spaceId, verifySchemaForMember(projected, member, mode)); } @@ -189,23 +215,23 @@ function runVerifyMigration( }, schemaCheck: { perSpace: schemaPerSpace, - orphanElements: detectOrphanElements(schemaIntrospection, allMembers), + orphanElements: detectOrphanElements(schemaIntrospection, allMembers, listEntityNames), }, }); } /** - * Live tables not claimed by any aggregate member. Duck-typed against - * the introspected schema's `tables` map; schemas whose shape doesn't - * match return an empty list (consistent with - * {@link projectSchemaToSpace}'s fall-through). + * Live entities not claimed by any aggregate member. The live entity names come + * from the family-provided {@link ListSchemaEntityNames} callback; the claimed + * names come from each member's contract storage via {@link elementCoordinates} + * (target-agnostic). The framework never inspects the schema shape. */ function detectOrphanElements( schemaIntrospection: unknown, members: ReadonlyArray, + listEntityNames: ListSchemaEntityNames, ): readonly OrphanElement[] { - if (typeof schemaIntrospection !== 'object' || schemaIntrospection === null) return []; - const liveTableNames = collectLiveTableNames(schemaIntrospection); + const liveTableNames = listEntityNames(schemaIntrospection); if (liveTableNames.length === 0) return []; const claimedTables = new Set(); @@ -225,34 +251,3 @@ function detectOrphanElements( orphans.sort((a, b) => a.name.localeCompare(b.name)); return orphans; } - -/** - * Bare names of every live table in the introspected schema. Duck-typed: - * a flat schema (SQLite) exposes a `tables` record; a namespaced schema tree - * (the Postgres `PostgresDatabaseSchemaNode` root) groups tables under - * per-schema namespace nodes, so the names are gathered across namespaces. - * Any other shape yields none (the {@link projectSchemaToSpace} fall-through). - */ -function collectLiveTableNames(schemaIntrospection: object): readonly string[] { - const schema = schemaIntrospection as { - readonly tables?: unknown; - readonly namespaces?: unknown; - }; - if (isRecord(schema.namespaces)) { - const names: string[] = []; - for (const namespaceNode of Object.values(schema.namespaces)) { - if (isRecord(namespaceNode) && isRecord(namespaceNode['tables'])) { - names.push(...Object.keys(namespaceNode['tables'])); - } - } - return names; - } - if (isRecord(schema.tables)) { - return Object.keys(schema.tables); - } - return []; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts index 0f65ddcb10..17a3b4a32f 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts @@ -23,7 +23,12 @@ export { type PlannerSuccess, planMigration, } from '../aggregate/planner'; -export { projectSchemaToSpace } from '../aggregate/project-schema-to-space'; +export { + collectOwnedNames, + type ListSchemaEntityNames, + type ProjectSchemaToMember, + projectSchemaToSpace, +} from '../aggregate/project-schema-to-space'; export { type GraphWalkOutcome, type GraphWalkStrategyInputs, diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts index cd17f74f45..099d1504e0 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts @@ -21,6 +21,19 @@ const POLICY: MigrationOperationPolicy = { allowedOperationClasses: ['additive', 'widening'], }; +// Flat-`tables` schema-shape pruning standing in for a family's callback. The +// planner is family-agnostic: it threads this into the synth strategy and never +// inspects the schema shape itself. +const STUB_PROJECT = (schema: unknown, ownedByOtherNames: ReadonlySet): unknown => { + const s = schema as { tables?: Record }; + if (typeof s !== 'object' || s === null || typeof s.tables !== 'object') return schema; + const pruned: Record = {}; + for (const [name, value] of Object.entries(s.tables)) { + if (!ownedByOtherNames.has(name)) pruned[name] = value; + } + return { ...s, tables: pruned }; +}; + function makeMember(args: { spaceId: string; contract?: Contract; @@ -108,6 +121,7 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, + projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(true); @@ -145,6 +159,7 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, + projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(true); @@ -183,6 +198,7 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, + projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(true); @@ -217,6 +233,7 @@ describe('planMigration', () => { // policy conflict. callerPolicy: { ignoreGraphFor: new Set(['app', 'cipherstash']) }, operationPolicy: POLICY, + projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(false); @@ -255,6 +272,7 @@ describe('planMigration', () => { // graph-walk can't satisfy its non-empty invariants. callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, + projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(false); @@ -285,6 +303,7 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, + projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(false); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts index 14f52f91d9..282e1afea6 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts @@ -2,7 +2,10 @@ import type { StorageBase } from '@prisma-next/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { createContract, createSqlContract } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; -import { projectSchemaToSpace } from '../../src/aggregate/project-schema-to-space'; +import { + collectOwnedNames, + projectSchemaToSpace, +} from '../../src/aggregate/project-schema-to-space'; import type { ContractSpaceMember } from '../../src/aggregate/types'; import { makeContractSpaceMember } from '../fixtures'; @@ -18,26 +21,13 @@ type MongoStorageLike = StorageBase & { }; /** - * Unit tests for the duck-typed schema projector used by the aggregate - * planner (synth strategy) and the aggregate verifier (schemaCheck) to - * project the introspected live schema down to the slice claimed by a - * single contract-space member. - * - * Semantics: - * - * - `unknown` for schema; structural fall-through when shape doesn't match. - * - Tables claimed by other members are stripped; everything else is - * identity-preserving. - * - * @see packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts + * `projectSchemaToSpace` is target-agnostic: it collects the entity names owned + * by the other members from their contract storage, then delegates the actual + * schema pruning to a family-provided callback. It never inspects the + * introspected schema shape (that lives in the SQL / Mongo family + * `schema-shape` modules and is tested there). */ describe('projectSchemaToSpace', () => { - /** - * Build a synthetic member with only the fields `projectSchemaToSpace` - * inspects (`spaceId`, `contract.storage.namespaces[…].entries.table`). The rest is filled - * with empty / sentinel values to satisfy the type without committing - * to a particular family. - */ function memberWithTables(spaceId: string, tables: Record): ContractSpaceMember { return makeContractSpaceMember({ spaceId, @@ -54,11 +44,6 @@ describe('projectSchemaToSpace', () => { }); } - /** - * Build a synthetic member whose contract storage is Mongo-shaped - * (`collections: Record`). This helper exercises the Mongo - * branch of other-member name collection. - */ function memberWithCollections( spaceId: string, collections: Record, @@ -81,224 +66,96 @@ describe('projectSchemaToSpace', () => { }); } - describe('duck-typing fall-through (returns input unchanged)', () => { - it('returns scalar schemas verbatim', () => { - const member = memberWithTables('app', {}); - expect(projectSchemaToSpace(null, member, [])).toBe(null); - expect(projectSchemaToSpace(undefined, member, [])).toBe(undefined); - expect(projectSchemaToSpace(42, member, [])).toBe(42); - expect(projectSchemaToSpace('schema', member, [])).toBe('schema'); - }); - - it('returns schemas without a `tables` field unchanged (identity-preserving)', () => { - const schema = { other: 'shape' }; - const member = memberWithTables('app', {}); - const others = [memberWithTables('ext', { x: {} })]; - expect(projectSchemaToSpace(schema, member, others)).toBe(schema); - }); - - it('returns schemas whose `tables` is not a plain object unchanged', () => { - const schema = { tables: 'not-an-object' }; - const member = memberWithTables('app', {}); - const others = [memberWithTables('ext', { x: {} })]; - expect(projectSchemaToSpace(schema, member, others)).toBe(schema); + // The callback the framework never implements; the test supplies a trivial + // one that records what it was handed, so we assert on the framework's + // target-agnostic behaviour rather than any storage shape. + const passthrough = (schema: unknown) => schema; + + describe('zero-cost path (does not invoke the callback)', () => { + it('returns the schema verbatim when the other-members list is empty', () => { + const schema = { tables: { user: {} } }; + const member = memberWithTables('app', { user: {} }); + let called = false; + const result = projectSchemaToSpace(schema, member, [], (s) => { + called = true; + return s; + }); + expect(result).toBe(schema); + expect(called).toBe(false); + }); + + it('returns the schema verbatim when other-members contains only the projection target', () => { + const schema = { tables: { user: {} } }; + const member = memberWithTables('app', { user: {} }); + let called = false; + projectSchemaToSpace(schema, member, [member], (s) => { + called = true; + return s; + }); + expect(called).toBe(false); }); }); - describe('zero-cost path (no other-space contracts)', () => { - it('returns the schema verbatim when other-members list is empty', () => { - const schema = { tables: { user: {}, post: {} } }; - const member = memberWithTables('app', { user: {}, post: {} }); - expect(projectSchemaToSpace(schema, member, [])).toBe(schema); - }); - - it('returns the schema verbatim when other-members list contains only the projection target', () => { - const schema = { tables: { user: {}, post: {} } }; - const member = memberWithTables('app', { user: {}, post: {} }); - expect(projectSchemaToSpace(schema, member, [member])).toBe(schema); - }); - }); - - describe('genuine projection', () => { - it('removes only tables claimed by other-space members', () => { - const schema = { - tables: { - app_user: { columns: { id: {} } }, - ext_audit_log: { columns: { id: {} } }, - ext_feature_flag: { columns: { id: {} } }, - }, - }; + describe('delegation', () => { + it('invokes the callback with the schema and the names owned by other members', () => { + const schema = { tables: { app_user: {} } }; const member = memberWithTables('app', { app_user: {} }); const others = [ - memberWithTables('audit', { ext_audit_log: { columns: {} } }), - memberWithTables('flags', { ext_feature_flag: { columns: {} } }), + memberWithTables('audit', { ext_audit_log: {} }), + memberWithTables('flags', { ext_feature_flag: {} }), ]; - - const projected = projectSchemaToSpace(schema, member, others) as { - readonly tables: Record; - }; - - expect(Object.keys(projected.tables).sort()).toEqual(['app_user']); - expect(projected.tables['app_user']).toBe(schema.tables['app_user']); - }); - - it('preserves orphan tables (live tables owned by no member) so the planner can flag them as extras', () => { - const orphanTable = { columns: { id: {} } }; - const schema = { - tables: { - app_user: { columns: { id: {} } }, - ext_owned: { columns: { id: {} } }, - orphan_table: orphanTable, - }, - }; - const member = memberWithTables('app', { app_user: {} }); - const others = [memberWithTables('ext', { ext_owned: { columns: {} } })]; - - const projected = projectSchemaToSpace(schema, member, others) as { - readonly tables: Record; - }; - - expect(Object.keys(projected.tables).sort()).toEqual(['app_user', 'orphan_table']); - expect(projected.tables['orphan_table']).toBe(orphanTable); - }); - - it('preserves non-`tables` storage fields on the schema object', () => { - const schema = { - tables: { ext_owned: {}, app_user: {} }, - views: { v1: {} }, - meta: { dialect: 'postgres' }, - }; + let seenSchema: unknown; + let seenNames: ReadonlySet | undefined; + projectSchemaToSpace(schema, member, others, (s, names) => { + seenSchema = s; + seenNames = names; + return s; + }); + expect(seenSchema).toBe(schema); + expect([...(seenNames ?? [])].sort()).toEqual(['ext_audit_log', 'ext_feature_flag']); + }); + + it('returns whatever the callback returns', () => { + const schema = { tables: { app_user: {}, ext_owned: {} } }; + const pruned = { tables: { app_user: {} } }; const member = memberWithTables('app', { app_user: {} }); const others = [memberWithTables('ext', { ext_owned: {} })]; - - const projected = projectSchemaToSpace(schema, member, others) as { - readonly tables: Record; - readonly views: Record; - readonly meta: { readonly dialect: string }; - }; - - expect(Object.keys(projected.tables)).toEqual(['app_user']); - expect(projected.views).toBe(schema.views); - expect(projected.meta).toBe(schema.meta); + const result = projectSchemaToSpace(schema, member, others, () => pruned); + expect(result).toBe(pruned); }); - it('prunes other-member tables within each namespace of a namespaced schema tree', () => { - // A Postgres `PostgresDatabaseSchemaNode` root groups tables under - // per-schema namespace nodes (`namespaces[…].tables`) rather than a flat - // `tables` record. The projector prunes inside each namespace. - const schema = { - nodeKind: 'postgres-database', - namespaces: { - public: { - schemaName: 'public', - tables: { - app_user: { name: 'app_user' }, - ext_owned: { name: 'ext_owned' }, - }, - }, - auth: { - schemaName: 'auth', - tables: { ext_session: { name: 'ext_session' } }, - }, - }, - }; + it('excludes the projection target itself when it appears in other-members', () => { + const schema = { tables: { app_user: {}, ext_owned: {} } }; const member = memberWithTables('app', { app_user: {} }); - const others = [memberWithTables('ext', { ext_owned: {}, ext_session: {} })]; - - const projected = projectSchemaToSpace(schema, member, others) as { - readonly namespaces: Record }>; - }; - - expect(Object.keys(projected.namespaces['public']!.tables)).toEqual(['app_user']); - expect(Object.keys(projected.namespaces['auth']!.tables)).toEqual([]); + const others = [member, memberWithTables('ext', { ext_owned: {} })]; + let seenNames: ReadonlySet | undefined; + projectSchemaToSpace(schema, member, others, (s, names) => { + seenNames = names; + return s; + }); + expect([...(seenNames ?? [])]).toEqual(['ext_owned']); }); + }); - it('returns a namespaced schema tree unchanged when no other-member tables are present', () => { - const schema = { - nodeKind: 'postgres-database', - namespaces: { public: { schemaName: 'public', tables: { app_user: {} } } }, - }; + describe('collectOwnedNames', () => { + it('collects table names from SQL-shaped other-member contracts', () => { const member = memberWithTables('app', { app_user: {} }); - const others = [memberWithTables('ext', { ext_owned: {} })]; - - expect(projectSchemaToSpace(schema, member, others)).toBe(schema); - }); - - it('removes other-member collections from a Mongo-shaped introspected schema (array form)', () => { - // Mongo's introspected `MongoSchemaIR` exposes - // `collections: ReadonlyArray<{name, ...}>` rather than a record. - // The projector duck-types the array shape on the schema side; - // other-members supply record-shaped Mongo contract storage. - const appColl = { name: 'users', indexes: [] }; - const extColl = { name: 'cipherstash_state', indexes: [] }; - const orphanColl = { name: 'legacy_audit', indexes: [] }; - const schema = { collections: [appColl, extColl, orphanColl] }; - - const member = memberWithCollections('app', { users: {} }); - const others = [memberWithCollections('cipherstash', { cipherstash_state: {} })]; - - const projected = projectSchemaToSpace(schema, member, others) as { - readonly collections: ReadonlyArray<{ readonly name: string }>; - }; - - expect(projected.collections.map((c) => c.name).sort()).toEqual(['legacy_audit', 'users']); - expect(projected.collections).not.toBe(schema.collections); - expect(projected.collections.find((c) => c.name === 'users')).toBe(appColl); - expect(projected.collections.find((c) => c.name === 'legacy_audit')).toBe(orphanColl); + const others = [memberWithTables('ext', { ext_a: {}, ext_b: {} })]; + expect([...collectOwnedNames(member, others)].sort()).toEqual(['ext_a', 'ext_b']); }); - it('returns the schema verbatim when no other-member collections are claimed', () => { - const schema = { collections: [{ name: 'users' }] }; + it('collects collection names from Mongo-shaped other-member contracts', () => { const member = memberWithCollections('app', { users: {} }); - expect(projectSchemaToSpace(schema, member, [member])).toBe(schema); + const others = [memberWithCollections('ext', { cipher_state: {} })]; + expect([...collectOwnedNames(member, others)]).toEqual(['cipher_state']); }); - it('preserves non-`collections` fields on a Mongo-shaped schema object', () => { - const schema = { - collections: [{ name: 'app_users' }, { name: 'ext_owned' }], - meta: { driverVersion: '6.0' }, - }; - const member = memberWithCollections('app', { app_users: {} }); - const others = [memberWithCollections('ext', { ext_owned: {} })]; - - const projected = projectSchemaToSpace(schema, member, others) as { - readonly collections: ReadonlyArray<{ readonly name: string }>; - readonly meta: { readonly driverVersion: string }; - }; - - expect(projected.collections.map((c) => c.name)).toEqual(['app_users']); - expect(projected.meta).toBe(schema.meta); - }); - - it('cross-shape: SQL-shaped schema with a Mongo-shaped other-member is returned unchanged', () => { - // The SQL schema only exposes `.tables`; a Mongo other-member only - // claims `.collections`. The projector must not strip SQL tables - // because of a Mongo claim, and there are no Mongo collections in - // the SQL schema to strip — net effect: identity. - const schema = { tables: { users: {}, posts: {} } }; - const member = memberWithTables('app', { users: {}, posts: {} }); - const others = [memberWithCollections('mongo-ext', { audit_log: {} })]; - - expect(projectSchemaToSpace(schema, member, others)).toBe(schema); - }); - - it('does not include the projection target itself when it appears in `otherMembers` (defensive)', () => { - const schema = { - tables: { - app_user: { columns: {} }, - ext_owned: { columns: {} }, - }, - }; - const member = memberWithTables('app', { app_user: {} }); - const others = [member, memberWithTables('ext', { ext_owned: {} })]; - - const projected = projectSchemaToSpace(schema, member, others) as { - readonly tables: Record; - }; - - // app_user is not stripped even though `member` claims it: the - // function filters by spaceId equality. - expect(Object.keys(projected.tables).sort()).toEqual(['app_user']); + it('returns an empty set when the only other member is the projection target', () => { + const member = memberWithTables('app', { user: {} }); + expect(collectOwnedNames(member, [member]).size).toBe(0); + // A callback-free projection with nothing owned returns the input. + const schema = { tables: { user: {} } }; + expect(projectSchemaToSpace(schema, member, [member], passthrough)).toBe(schema); }); }); }); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts index 768f72f3bc..a8e9ce45fc 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts @@ -17,6 +17,17 @@ const POLICY: MigrationOperationPolicy = { allowedOperationClasses: ['additive', 'widening'], }; +// Flat-`tables` schema-shape pruning standing in for a family's callback. +const STUB_PROJECT = (schema: unknown, ownedByOtherNames: ReadonlySet): unknown => { + const s = schema as { tables?: Record }; + if (typeof s !== 'object' || s === null || typeof s.tables !== 'object') return schema; + const pruned: Record = {}; + for (const [name, value] of Object.entries(s.tables)) { + if (!ownedByOtherNames.has(name)) pruned[name] = value; + } + return { ...s, tables: pruned }; +}; + const STUB_ADAPTER: ControlAdapterInstance<'sql', 'postgres'> = {} as unknown as ControlAdapterInstance<'sql', 'postgres'>; @@ -89,6 +100,7 @@ describe('synthStrategy', () => { migrations: stubMigrations, frameworkComponents: [], operationPolicy: POLICY, + projectSchemaToMember: STUB_PROJECT, }); expect(outcome.kind).toBe('ok'); @@ -143,6 +155,7 @@ describe('synthStrategy', () => { migrations: stubMigrations, frameworkComponents: [], operationPolicy: POLICY, + projectSchemaToMember: STUB_PROJECT, }); expect(outcome.kind).toBe('failure'); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts index bd1dbaa379..cf0c2e781c 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts @@ -58,6 +58,24 @@ const STUB_VERIFY = ( return { tablesSeen: Object.keys(schema.tables).sort() }; }; +// Flat-`tables` schema-shape callbacks standing in for a family's. The verifier +// is family-agnostic: it only calls these, never inspects the shape itself. +const STUB_PROJECT = (schema: unknown, ownedByOtherNames: ReadonlySet): unknown => { + const s = schema as { tables?: Record }; + if (typeof s !== 'object' || s === null || typeof s.tables !== 'object') return schema; + const pruned: Record = {}; + for (const [name, value] of Object.entries(s.tables)) { + if (!ownedByOtherNames.has(name)) pruned[name] = value; + } + return { ...s, tables: pruned }; +}; + +const STUB_LIST = (schema: unknown): readonly string[] => { + const s = schema as { tables?: Record }; + if (typeof s !== 'object' || s === null || typeof s.tables !== 'object') return []; + return Object.keys(s.tables); +}; + describe('verifyMigration', () => { describe('markerCheck', () => { it('reports `absent` when the member has no marker row', () => { @@ -70,6 +88,8 @@ describe('verifyMigration', () => { schemaIntrospection: { tables: {} }, mode: 'strict', verifySchemaForMember: STUB_VERIFY, + projectSchemaToMember: STUB_PROJECT, + listEntityNames: STUB_LIST, }); expect(result.ok).toBe(true); expect(result.assertOk().markerCheck.perSpace.get('app')).toEqual({ kind: 'absent' }); @@ -92,6 +112,8 @@ describe('verifyMigration', () => { schemaIntrospection: { tables: {} }, mode: 'strict', verifySchemaForMember: STUB_VERIFY, + projectSchemaToMember: STUB_PROJECT, + listEntityNames: STUB_LIST, }); expect(result.assertOk().markerCheck.perSpace.get('app')).toEqual({ kind: 'ok' }); }); @@ -109,6 +131,8 @@ describe('verifyMigration', () => { schemaIntrospection: { tables: {} }, mode: 'strict', verifySchemaForMember: STUB_VERIFY, + projectSchemaToMember: STUB_PROJECT, + listEntityNames: STUB_LIST, }); expect(result.assertOk().markerCheck.perSpace.get('app')).toEqual({ kind: 'hashMismatch', @@ -137,6 +161,8 @@ describe('verifyMigration', () => { schemaIntrospection: { tables: {} }, mode: 'strict', verifySchemaForMember: STUB_VERIFY, + projectSchemaToMember: STUB_PROJECT, + listEntityNames: STUB_LIST, }); expect(result.assertOk().markerCheck.perSpace.get('cipher')).toEqual({ kind: 'missingInvariants', @@ -159,6 +185,8 @@ describe('verifyMigration', () => { schemaIntrospection: { tables: {} }, mode: 'strict', verifySchemaForMember: STUB_VERIFY, + projectSchemaToMember: STUB_PROJECT, + listEntityNames: STUB_LIST, }); expect(result.assertOk().markerCheck.orphanMarkers.map((o) => o.spaceId)).toEqual([ 'cipher', @@ -198,6 +226,8 @@ describe('verifyMigration', () => { schemaIntrospection: liveSchema, mode: 'strict', verifySchemaForMember: STUB_VERIFY, + projectSchemaToMember: STUB_PROJECT, + listEntityNames: STUB_LIST, }); const schemaCheck = result.assertOk().schemaCheck; @@ -238,6 +268,8 @@ describe('verifyMigration', () => { // caller (db verify) decides whether to treat them as errors. mode: 'lenient', verifySchemaForMember: STUB_VERIFY, + projectSchemaToMember: STUB_PROJECT, + listEntityNames: STUB_LIST, }); expect(result.assertOk().schemaCheck.orphanElements).toEqual([ @@ -269,6 +301,8 @@ describe('verifyMigration', () => { }, mode: 'strict', verifySchemaForMember: STUB_VERIFY, + projectSchemaToMember: STUB_PROJECT, + listEntityNames: STUB_LIST, }); expect(result.assertOk().schemaCheck.orphanElements).toEqual([]); @@ -287,6 +321,8 @@ describe('verifyMigration', () => { verifySchemaForMember: () => { throw new Error('introspection broke'); }, + projectSchemaToMember: STUB_PROJECT, + listEntityNames: STUB_LIST, }); expect(result.ok).toBe(false); @@ -296,7 +332,7 @@ describe('verifyMigration', () => { }); }); - it('returns notOk(introspectionFailure) when projectSchemaToSpace throws via a malformed schema', () => { + it('returns notOk(introspectionFailure) when a shape callback throws via a malformed schema', () => { const aggregate = makeAggregate({ app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), }); @@ -313,6 +349,8 @@ describe('verifyMigration', () => { schemaIntrospection: exploding, mode: 'strict', verifySchemaForMember: STUB_VERIFY, + projectSchemaToMember: STUB_PROJECT, + listEntityNames: STUB_LIST, }); expect(result.ok).toBe(false); @@ -334,6 +372,8 @@ describe('verifyMigration', () => { observedMode = mode; return { tablesSeen: [] }; }, + projectSchemaToMember: STUB_PROJECT, + listEntityNames: STUB_LIST, }); expect(observedMode).toBe('lenient'); diff --git a/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts b/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts index 879c50e2f9..5118f09684 100644 --- a/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts +++ b/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts @@ -284,6 +284,13 @@ describe('aggregate pipeline (loader → planner → verifier) against deleted n schemaIntrospection: { tables: { user: { columns: {} }, test_box: { columns: {} } } }, mode: 'lenient', verifySchemaForMember: () => ({ ok: true }), + projectSchemaToMember: (schema) => schema, + listEntityNames: (schema) => { + const s = schema as { tables?: Record }; + return typeof s === 'object' && s !== null && typeof s.tables === 'object' + ? Object.keys(s.tables) + : []; + }, }); expect(verifyResult.ok).toBe(true); if (!verifyResult.ok) return; diff --git a/packages/2-mongo-family/9-family/src/core/control-instance.ts b/packages/2-mongo-family/9-family/src/core/control-instance.ts index 8f413be000..d0a11cc101 100644 --- a/packages/2-mongo-family/9-family/src/core/control-instance.ts +++ b/packages/2-mongo-family/9-family/src/core/control-instance.ts @@ -26,12 +26,14 @@ import { import { assertDescriptorSelfConsistency } from '@prisma-next/migration-tools/spaces'; import type { MongoContract } from '@prisma-next/mongo-contract'; import { mongoContractCanonicalizationHooks } from '@prisma-next/mongo-contract/canonicalization-hooks'; -import type { MongoSchemaIR } from '@prisma-next/mongo-schema-ir'; +import type { MongoSchemaCollection } from '@prisma-next/mongo-schema-ir'; +import { MongoSchemaIR } from '@prisma-next/mongo-schema-ir'; import { ifDefined } from '@prisma-next/utils/defined'; import type { MongoControlAdapter, MongoControlAdapterDescriptor } from './control-adapter'; import type { MongoControlExtensionDescriptor } from './control-types'; import { MongoContractSerializer } from './ir/mongo-contract-serializer'; import { mongoOperationsToPreview } from './operation-preview'; +import { mongoListSchemaEntityNames, mongoProjectSchemaToMember } from './schema-shape'; import { mongoSchemaToView } from './schema-to-view'; import { verifyMongoSchema } from './schema-verify/verify-mongo-schema'; @@ -280,6 +282,20 @@ export function createMongoFamilyInstance(controlStack: ControlStack): MongoCont }); }, + projectSchemaToMember( + schema: MongoSchemaIR, + ownedByOtherNames: ReadonlySet, + ): MongoSchemaIR { + const projected = mongoProjectSchemaToMember(schema, ownedByOtherNames) as { + readonly collections: ReadonlyArray; + }; + return new MongoSchemaIR(projected.collections); + }, + + listSchemaEntityNames(schema: MongoSchemaIR): readonly string[] { + return mongoListSchemaEntityNames(schema); + }, + async sign(options): Promise { const { driver, contract: rawContract, contractPath, configPath } = options; const startTime = Date.now(); diff --git a/packages/2-mongo-family/9-family/src/core/schema-shape.ts b/packages/2-mongo-family/9-family/src/core/schema-shape.ts new file mode 100644 index 0000000000..21f1aa6d3b --- /dev/null +++ b/packages/2-mongo-family/9-family/src/core/schema-shape.ts @@ -0,0 +1,96 @@ +/** + * Mongo-family schema-shape callbacks for the aggregate planner/verifier. + * + * The framework is unaware of any storage shape (ADR: framework layer purity); + * it hands the Mongo family its own introspected schema and asks two questions: + * "prune this to a member's slice" and "list its entity names". Mongo's + * introspected `MongoSchemaIR` exposes `collections` as an array of + * `{ name, ... }`; the callbacks walk that array. + */ + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Prunes collections owned by other members from a Mongo introspected schema. + * Returns a plain object (`{ ...schema, collections: prunedArray }`); the caller + * rewraps it into a `MongoSchemaIR` when the class accessors are needed. Returns + * the input unchanged when it is not an object or nothing was removed. + */ +export function mongoProjectSchemaToMember( + schema: unknown, + ownedByOtherNames: ReadonlySet, +): unknown { + if (!isRecord(schema)) return schema; + + if (Array.isArray(schema['collections'])) { + return pruneCollectionsArray(schema, ownedByOtherNames); + } + + if (isRecord(schema['collections'])) { + return pruneCollectionsRecord(schema, ownedByOtherNames); + } + + return schema; +} + +/** + * Bare names of every live collection in a Mongo introspected schema. + */ +export function mongoListSchemaEntityNames(schema: unknown): readonly string[] { + if (!isRecord(schema)) return []; + const collections = schema['collections']; + if (Array.isArray(collections)) { + const names: string[] = []; + for (const entry of collections) { + if (isRecord(entry) && typeof entry['name'] === 'string') { + names.push(entry['name']); + } + } + return names; + } + if (isRecord(collections)) { + return Object.keys(collections); + } + return []; +} + +function pruneCollectionsArray( + schema: Record, + ownedByOthers: ReadonlySet, +): unknown { + const source = schema['collections'] as ReadonlyArray; + let removed = false; + const pruned: unknown[] = []; + for (const entry of source) { + if (isRecord(entry)) { + const name = entry['name']; + if (typeof name === 'string' && ownedByOthers.has(name)) { + removed = true; + continue; + } + } + pruned.push(entry); + } + if (!removed) return schema; + return { ...schema, collections: pruned }; +} + +function pruneCollectionsRecord( + schema: Record, + ownedByOthers: ReadonlySet, +): unknown { + const source = schema['collections'] as Record; + let removed = false; + const pruned: Record = {}; + for (const [name, value] of Object.entries(source)) { + if (ownedByOthers.has(name)) { + removed = true; + } else { + pruned[name] = value; + } + } + if (!removed) return schema; + return { ...schema, collections: pruned }; +} diff --git a/packages/2-mongo-family/9-family/src/exports/control.ts b/packages/2-mongo-family/9-family/src/exports/control.ts index 8386ea3617..61e1128360 100644 --- a/packages/2-mongo-family/9-family/src/exports/control.ts +++ b/packages/2-mongo-family/9-family/src/exports/control.ts @@ -11,4 +11,8 @@ export { mongoOperationsToPreview, } from '../core/operation-preview'; export { diffMongoSchemas } from '../core/schema-diff'; +export { + mongoListSchemaEntityNames, + mongoProjectSchemaToMember, +} from '../core/schema-shape'; export { canonicalizeSchemasForVerification } from '../core/schema-verify/canonicalize-introspection'; diff --git a/packages/2-mongo-family/9-family/test/schema-shape.test.ts b/packages/2-mongo-family/9-family/test/schema-shape.test.ts new file mode 100644 index 0000000000..474263afa0 --- /dev/null +++ b/packages/2-mongo-family/9-family/test/schema-shape.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { mongoListSchemaEntityNames, mongoProjectSchemaToMember } from '../src/core/schema-shape'; + +/** + * The Mongo family owns the introspected-schema shape (the framework aggregate + * verifier/planner is shape-free). The introspected `MongoSchemaIR` exposes + * `collections` as an array of `{ name, ... }`. + */ +describe('mongoProjectSchemaToMember', () => { + it('removes other-member collections from the array form', () => { + const appColl = { name: 'users', indexes: [] }; + const extColl = { name: 'cipherstash_state', indexes: [] }; + const orphanColl = { name: 'legacy_audit', indexes: [] }; + const schema = { collections: [appColl, extColl, orphanColl] }; + + const projected = mongoProjectSchemaToMember(schema, new Set(['cipherstash_state'])) as { + readonly collections: ReadonlyArray<{ readonly name: string }>; + }; + + expect(projected.collections.map((c) => c.name).sort()).toEqual(['legacy_audit', 'users']); + expect(projected.collections).not.toBe(schema.collections); + expect(projected.collections.find((c) => c.name === 'users')).toBe(appColl); + expect(projected.collections.find((c) => c.name === 'legacy_audit')).toBe(orphanColl); + }); + + it('preserves non-`collections` fields', () => { + const schema = { + collections: [{ name: 'app_users' }, { name: 'ext_owned' }], + meta: { driverVersion: '6.0' }, + }; + const projected = mongoProjectSchemaToMember(schema, new Set(['ext_owned'])) as { + readonly collections: ReadonlyArray<{ readonly name: string }>; + readonly meta: unknown; + }; + expect(projected.collections.map((c) => c.name)).toEqual(['app_users']); + expect(projected.meta).toBe(schema.meta); + }); + + it('returns the schema verbatim when nothing is removed', () => { + const schema = { collections: [{ name: 'users' }] }; + expect(mongoProjectSchemaToMember(schema, new Set(['nope']))).toBe(schema); + }); + + it('prunes a record form of collections too', () => { + const schema = { collections: { users: {}, ext_owned: {} } }; + const projected = mongoProjectSchemaToMember(schema, new Set(['ext_owned'])) as { + readonly collections: Record; + }; + expect(Object.keys(projected.collections)).toEqual(['users']); + }); + + it('returns non-object schemas verbatim', () => { + expect(mongoProjectSchemaToMember(null, new Set(['x']))).toBe(null); + }); +}); + +describe('mongoListSchemaEntityNames', () => { + it('lists collection names from the array form', () => { + const schema = { collections: [{ name: 'a' }, { name: 'b' }] }; + expect([...mongoListSchemaEntityNames(schema)].sort()).toEqual(['a', 'b']); + }); + + it('lists collection names from the record form', () => { + expect([...mongoListSchemaEntityNames({ collections: { a: {}, b: {} } })].sort()).toEqual([ + 'a', + 'b', + ]); + }); + + it('returns none for an unrecognised shape', () => { + expect(mongoListSchemaEntityNames({ other: 'shape' })).toEqual([]); + expect(mongoListSchemaEntityNames(null)).toEqual([]); + }); +}); diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index 1ade30bad1..21a80c512b 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -48,6 +48,7 @@ import type { SqlSchemaIRNode, SqlTableIR } from '@prisma-next/sql-schema-ir/typ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { SqlControlAdapter } from './control-adapter'; +import { sqlListSchemaEntityNames, sqlProjectSchemaToMember } from './diff/schema-shape'; import { SqlContractSerializer } from './ir/sql-contract-serializer'; import type { DiffDatabaseSchemaInput, @@ -231,6 +232,19 @@ export interface SqlControlFamilyInstance readonly frameworkComponents: ReadonlyArray>; }): VerifyDatabaseSchemaResult; + /** + * Prunes the introspected SQL schema to a member's slice. Walks the family's + * flat/namespaced shape so the framework aggregate verifier/planner never has + * to. See the framework `ControlFamilyInstance.projectSchemaToMember`. + */ + projectSchemaToMember( + schema: SqlSchemaIRNode, + ownedByOtherNames: ReadonlySet, + ): SqlSchemaIRNode; + + /** Lists the live table names in the introspected SQL schema. */ + listSchemaEntityNames(schema: SqlSchemaIRNode): readonly string[]; + sign(options: { readonly driver: SqlControlDriverInstance; readonly contract: unknown; @@ -748,6 +762,18 @@ export function createSqlFamilyInstance( }, }; }, + projectSchemaToMember( + schema: SqlSchemaIRNode, + ownedByOtherNames: ReadonlySet, + ): SqlSchemaIRNode { + return blindCast< + SqlSchemaIRNode, + 'the SQL shape pruner returns the same SqlSchemaIRNode shape, spread into a plain object' + >(sqlProjectSchemaToMember(schema, ownedByOtherNames)); + }, + listSchemaEntityNames(schema: SqlSchemaIRNode): readonly string[] { + return sqlListSchemaEntityNames(schema); + }, async sign(options: { readonly driver: SqlControlDriverInstance; readonly contract: unknown; diff --git a/packages/2-sql/9-family/src/core/diff/schema-shape.ts b/packages/2-sql/9-family/src/core/diff/schema-shape.ts new file mode 100644 index 0000000000..69c27a0b3f --- /dev/null +++ b/packages/2-sql/9-family/src/core/diff/schema-shape.ts @@ -0,0 +1,104 @@ +/** + * SQL-family schema-shape callbacks for the aggregate planner/verifier. + * + * The framework is unaware of any storage shape (ADR: framework layer purity); + * it hands the SQL family its own introspected `SqlSchemaIRNode` and asks two + * questions: "prune this to a member's slice" and "list its entity names". Only + * the family knows whether the schema is a flat `tables` record (SQLite) or a + * namespaced tree (Postgres `PostgresDatabaseSchemaNode`), so it answers both. + */ + +import { blindCast } from '@prisma-next/utils/casts'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Prunes tables owned by other members from a SQL introspected schema. A + * namespaced tree (Postgres) prunes inside each namespace's `tables`; a flat + * schema (SQLite) prunes its top-level `tables`. The result is a plain object + * (`{ ...schema, tables|namespaces: pruned }`); structure-aware consumers + * `ensure` a typed node from it. Returns the input unchanged when it is not an + * object or nothing was removed. + */ +export function sqlProjectSchemaToMember( + schema: unknown, + ownedByOtherNames: ReadonlySet, +): unknown { + if (!isRecord(schema)) return schema; + + if (isRecord(schema['namespaces'])) { + return pruneNamespaceTables(schema, ownedByOtherNames); + } + + if (isRecord(schema['tables'])) { + return pruneRecord(schema, 'tables', ownedByOtherNames); + } + + return schema; +} + +/** + * Bare names of every live table in a SQL introspected schema: gathered across + * namespaces for a namespaced tree, or the top-level `tables` keys for a flat + * schema. Any other shape yields none. + */ +export function sqlListSchemaEntityNames(schema: unknown): readonly string[] { + if (!isRecord(schema)) return []; + if (isRecord(schema['namespaces'])) { + const names: string[] = []; + for (const namespaceNode of Object.values(schema['namespaces'])) { + if (isRecord(namespaceNode) && isRecord(namespaceNode['tables'])) { + names.push(...Object.keys(namespaceNode['tables'])); + } + } + return names; + } + if (isRecord(schema['tables'])) { + return Object.keys(schema['tables']); + } + return []; +} + +function pruneNamespaceTables( + schema: Record, + ownedByOthers: ReadonlySet, +): unknown { + const namespaces = schema['namespaces']; + if (!isRecord(namespaces)) return schema; + let removed = false; + const prunedNamespaces: Record = {}; + for (const [namespaceId, namespaceNode] of Object.entries(namespaces)) { + if (isRecord(namespaceNode) && isRecord(namespaceNode['tables'])) { + const prunedNode = pruneRecord(namespaceNode, 'tables', ownedByOthers); + if (prunedNode !== namespaceNode) removed = true; + prunedNamespaces[namespaceId] = prunedNode; + } else { + prunedNamespaces[namespaceId] = namespaceNode; + } + } + if (!removed) return schema; + return { ...schema, namespaces: prunedNamespaces }; +} + +function pruneRecord( + schema: Record, + field: 'tables', + ownedByOthers: ReadonlySet, +): unknown { + const source = blindCast, 'isRecord narrowed the field above'>( + schema[field], + ); + let removed = false; + const pruned: Record = {}; + for (const [name, value] of Object.entries(source)) { + if (ownedByOthers.has(name)) { + removed = true; + } else { + pruned[name] = value; + } + } + if (!removed) return schema; + return { ...schema, [field]: pruned }; +} diff --git a/packages/2-sql/9-family/src/exports/diff.ts b/packages/2-sql/9-family/src/exports/diff.ts index 9be8c4e68c..ea2c004038 100644 --- a/packages/2-sql/9-family/src/exports/diff.ts +++ b/packages/2-sql/9-family/src/exports/diff.ts @@ -6,6 +6,10 @@ * top; SQLite is relational only). Pure — no database connection required. */ +export { + sqlListSchemaEntityNames, + sqlProjectSchemaToMember, +} from '../core/diff/schema-shape'; export type { NativeTypeNormalizer, VerifySqlSchemaOptions, diff --git a/packages/2-sql/9-family/test/schema-shape.test.ts b/packages/2-sql/9-family/test/schema-shape.test.ts new file mode 100644 index 0000000000..744f5506de --- /dev/null +++ b/packages/2-sql/9-family/test/schema-shape.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { sqlListSchemaEntityNames, sqlProjectSchemaToMember } from '../src/core/diff/schema-shape'; + +/** + * The SQL family owns the introspected-schema shape (the framework aggregate + * verifier/planner is shape-free). These callbacks walk a flat `tables` record + * (SQLite) and a namespaced `namespaces[…].tables` tree (Postgres). + */ +describe('sqlProjectSchemaToMember', () => { + describe('flat schema', () => { + it('removes only tables owned by other members', () => { + const schema = { + tables: { + app_user: { columns: { id: {} } }, + ext_audit_log: { columns: { id: {} } }, + }, + }; + const projected = sqlProjectSchemaToMember(schema, new Set(['ext_audit_log'])) as { + readonly tables: Record; + }; + expect(Object.keys(projected.tables)).toEqual(['app_user']); + expect(projected.tables['app_user']).toBe(schema.tables['app_user']); + }); + + it('preserves orphan tables owned by no member', () => { + const orphan = { columns: {} }; + const schema = { tables: { app_user: {}, ext_owned: {}, orphan_table: orphan } }; + const projected = sqlProjectSchemaToMember(schema, new Set(['ext_owned'])) as { + readonly tables: Record; + }; + expect(Object.keys(projected.tables).sort()).toEqual(['app_user', 'orphan_table']); + expect(projected.tables['orphan_table']).toBe(orphan); + }); + + it('preserves non-`tables` fields', () => { + const schema = { tables: { ext_owned: {}, app_user: {} }, meta: { dialect: 'postgres' } }; + const projected = sqlProjectSchemaToMember(schema, new Set(['ext_owned'])) as { + readonly tables: Record; + readonly meta: unknown; + }; + expect(Object.keys(projected.tables)).toEqual(['app_user']); + expect(projected.meta).toBe(schema.meta); + }); + + it('returns the input unchanged when nothing is removed', () => { + const schema = { tables: { app_user: {} } }; + expect(sqlProjectSchemaToMember(schema, new Set(['nope']))).toBe(schema); + }); + }); + + describe('namespaced tree', () => { + it('prunes other-member tables within each namespace', () => { + const schema = { + nodeKind: 'postgres-database', + namespaces: { + public: { schemaName: 'public', tables: { app_user: {}, ext_owned: {} } }, + auth: { schemaName: 'auth', tables: { ext_session: {} } }, + }, + }; + const projected = sqlProjectSchemaToMember(schema, new Set(['ext_owned', 'ext_session'])) as { + readonly namespaces: Record }>; + }; + expect(Object.keys(projected.namespaces['public']!.tables)).toEqual(['app_user']); + expect(Object.keys(projected.namespaces['auth']!.tables)).toEqual([]); + }); + + it('returns the tree unchanged when nothing is removed', () => { + const schema = { + nodeKind: 'postgres-database', + namespaces: { public: { schemaName: 'public', tables: { app_user: {} } } }, + }; + expect(sqlProjectSchemaToMember(schema, new Set(['ext_owned']))).toBe(schema); + }); + }); + + describe('fall-through', () => { + it('returns non-object schemas verbatim', () => { + expect(sqlProjectSchemaToMember(null, new Set(['x']))).toBe(null); + expect(sqlProjectSchemaToMember(42, new Set(['x']))).toBe(42); + }); + + it('returns schemas without `tables`/`namespaces` unchanged', () => { + const schema = { other: 'shape' }; + expect(sqlProjectSchemaToMember(schema, new Set(['x']))).toBe(schema); + }); + }); +}); + +describe('sqlListSchemaEntityNames', () => { + it('lists top-level table names for a flat schema', () => { + expect([...sqlListSchemaEntityNames({ tables: { a: {}, b: {} } })].sort()).toEqual(['a', 'b']); + }); + + it('gathers table names across namespaces for a tree', () => { + const schema = { + namespaces: { + public: { tables: { app_user: {} } }, + auth: { tables: { session: {} } }, + }, + }; + expect([...sqlListSchemaEntityNames(schema)].sort()).toEqual(['app_user', 'session']); + }); + + it('returns none for an unrecognised shape', () => { + expect(sqlListSchemaEntityNames({ other: 'shape' })).toEqual([]); + expect(sqlListSchemaEntityNames(null)).toEqual([]); + }); +}); diff --git a/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts b/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts index 4e7a10346f..9acc64c79e 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts @@ -9,7 +9,10 @@ import type { MongoControlFamilyInstance, MongoControlTargetDescriptor, } from '@prisma-next/family-mongo/control'; -import { contractToMongoSchemaIR } from '@prisma-next/family-mongo/control'; +import { + contractToMongoSchemaIR, + mongoProjectSchemaToMember, +} from '@prisma-next/family-mongo/control'; import type { MongoControlAdapter } from '@prisma-next/family-mongo/control-adapter'; import type { MigrationRunner, @@ -121,7 +124,12 @@ export const mongoTargetDescriptor: MongoControlTargetDescriptor; }; return new MongoSchemaIR(projected.collections); From 3c3a96f121496515844bbe65b741109176d90bf7 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 1 Jul 2026 17:08:29 +0200 Subject: [PATCH 27/49] refactor(postgres): mechanical cleanups from the schema-node-tree review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behaviour-neutral cleanups closing the rework: - Replace the bespoke `throw new Error("expected StorageTable…")` in contractNamespaceToSchemaIR / contractToSchemaIR with an assertStorageTable assertion helper (per prefer-assertions-over-defensive-checks). - Drop the three `(storage.types ?? {}) as ResolvedStorageTypes` casts — the SqlStorage class already normalises `types` to the resolved shape, so the cast was redundant. - Move the DDL-schema-name resolver out of schema-ir/ (renamed to migrations/resolve-ddl-schema.ts) so schema-ir/ holds the node files, and stop populating the obsolete annotations.pg bag on PostgresNamespaceSchemaNode — the typed nativeEnumTypeNames field is the read model. - Make namespaceSchemaNodes module-private (nothing outside diff/ consumes it). - Convert the Mongo schema-shape bare casts to blindCast with reasons, matching the SQL sibling. - Trim verbose doc comments (printer-config, migrations/types) and improve test readability (toMatchObject for the array-column introspection asserts, ifDefined in the rls-collect fixture, typed-field asserts replacing the annotations-bag asserts). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../9-family/src/core/control-instance.ts | 8 ++++--- .../9-family/src/core/schema-shape.ts | 10 ++++++-- .../1-core/contract/src/exports/types.ts | 1 + .../1-core/contract/src/ir/storage-table.ts | 9 ++++++++ packages/2-sql/1-core/contract/src/types.ts | 7 +++++- .../9-family/src/core/diff/sql-schema-diff.ts | 16 +++++-------- .../core/migrations/contract-to-schema-ir.ts | 23 +++++-------------- .../9-family/src/core/migrations/types.ts | 17 +++++--------- .../core/psl-contract-infer/printer-config.ts | 7 +++--- packages/2-sql/9-family/src/exports/diff.ts | 6 +---- ...ntract-to-postgres-database-schema-node.ts | 2 +- .../postgres/src/core/migrations/planner.ts | 2 +- .../resolve-ddl-schema.ts} | 0 .../postgres-namespace-schema-node.ts | 20 +++------------- .../src/exports/schema-ir-annotations.ts | 2 +- .../postgres-namespace-schema-node.test.ts | 22 ++++-------------- ...y-column-introspection.integration.test.ts | 13 +++-------- .../rls-collect-extension-issues.test.ts | 5 ++-- .../family.introspect.integration.test.ts | 5 ++-- .../test/family.introspect.test.ts | 5 ++-- 20 files changed, 72 insertions(+), 108 deletions(-) rename packages/3-targets/3-targets/postgres/src/core/{schema-ir/postgres-schema-ir-annotations.ts => migrations/resolve-ddl-schema.ts} (100%) diff --git a/packages/2-mongo-family/9-family/src/core/control-instance.ts b/packages/2-mongo-family/9-family/src/core/control-instance.ts index d0a11cc101..7c2f905df0 100644 --- a/packages/2-mongo-family/9-family/src/core/control-instance.ts +++ b/packages/2-mongo-family/9-family/src/core/control-instance.ts @@ -28,6 +28,7 @@ import type { MongoContract } from '@prisma-next/mongo-contract'; import { mongoContractCanonicalizationHooks } from '@prisma-next/mongo-contract/canonicalization-hooks'; import type { MongoSchemaCollection } from '@prisma-next/mongo-schema-ir'; import { MongoSchemaIR } from '@prisma-next/mongo-schema-ir'; +import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { MongoControlAdapter, MongoControlAdapterDescriptor } from './control-adapter'; import type { MongoControlExtensionDescriptor } from './control-types'; @@ -286,9 +287,10 @@ export function createMongoFamilyInstance(controlStack: ControlStack): MongoCont schema: MongoSchemaIR, ownedByOtherNames: ReadonlySet, ): MongoSchemaIR { - const projected = mongoProjectSchemaToMember(schema, ownedByOtherNames) as { - readonly collections: ReadonlyArray; - }; + const projected = blindCast< + { readonly collections: ReadonlyArray }, + 'the Mongo shape pruner returns a { collections } object spread from MongoSchemaIR' + >(mongoProjectSchemaToMember(schema, ownedByOtherNames)); return new MongoSchemaIR(projected.collections); }, diff --git a/packages/2-mongo-family/9-family/src/core/schema-shape.ts b/packages/2-mongo-family/9-family/src/core/schema-shape.ts index 21f1aa6d3b..766f82b5dc 100644 --- a/packages/2-mongo-family/9-family/src/core/schema-shape.ts +++ b/packages/2-mongo-family/9-family/src/core/schema-shape.ts @@ -8,6 +8,8 @@ * `{ name, ... }`; the callbacks walk that array. */ +import { blindCast } from '@prisma-next/utils/casts'; + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -60,7 +62,9 @@ function pruneCollectionsArray( schema: Record, ownedByOthers: ReadonlySet, ): unknown { - const source = schema['collections'] as ReadonlyArray; + const source = blindCast, 'Array.isArray narrowed collections above'>( + schema['collections'], + ); let removed = false; const pruned: unknown[] = []; for (const entry of source) { @@ -81,7 +85,9 @@ function pruneCollectionsRecord( schema: Record, ownedByOthers: ReadonlySet, ): unknown { - const source = schema['collections'] as Record; + const source = blindCast, 'isRecord narrowed collections above'>( + schema['collections'], + ); let removed = false; const pruned: Record = {}; for (const [name, value] of Object.entries(source)) { 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 1f1bc88f0e..5271b0ee22 100644 --- a/packages/2-sql/1-core/contract/src/exports/types.ts +++ b/packages/2-sql/1-core/contract/src/exports/types.ts @@ -53,6 +53,7 @@ export type { } from '../types'; export { applyFkDefaults, + assertStorageTable, CheckConstraint, CODEC_INSTANCE_KIND, DEFAULT_FK_CONSTRAINT, diff --git a/packages/2-sql/1-core/contract/src/ir/storage-table.ts b/packages/2-sql/1-core/contract/src/ir/storage-table.ts index f5c0e5c572..361658c9f2 100644 --- a/packages/2-sql/1-core/contract/src/ir/storage-table.ts +++ b/packages/2-sql/1-core/contract/src/ir/storage-table.ts @@ -74,3 +74,12 @@ export function isStorageTable(value: unknown): value is StorageTable { if (typeof value !== 'object' || value === null) return false; return 'columns' in value && 'uniques' in value && 'indexes' in value && 'foreignKeys' in value; } + +export function assertStorageTable( + value: unknown, + coordinate: string, +): asserts value is StorageTable { + if (!isStorageTable(value)) { + throw new Error(`Expected a StorageTable at ${coordinate}`); + } +} diff --git a/packages/2-sql/1-core/contract/src/types.ts b/packages/2-sql/1-core/contract/src/types.ts index 8cf6196eca..b38b394b44 100644 --- a/packages/2-sql/1-core/contract/src/types.ts +++ b/packages/2-sql/1-core/contract/src/types.ts @@ -37,7 +37,12 @@ export { type SqlStorageTypeEntry, } from './ir/sql-storage'; export { StorageColumn, type StorageColumnInput } from './ir/storage-column'; -export { isStorageTable, StorageTable, type StorageTableInput } from './ir/storage-table'; +export { + assertStorageTable, + isStorageTable, + StorageTable, + type StorageTableInput, +} from './ir/storage-table'; export { CODEC_INSTANCE_KIND, isStorageTypeInstance, diff --git a/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts b/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts index e2940b413a..34ed9fec81 100644 --- a/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts +++ b/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts @@ -46,17 +46,13 @@ import { /** * Returns the per-schema namespace nodes of an introspected schema node, for - * the relational verify and schema-view to consume one at a time. - * - * Structure-agnostic — it imports no target node class. A tree root that - * exposes a `namespaces` record (Postgres) yields its namespace nodes; the - * nodes are NEVER merged, so same-named tables in different schemas cannot - * collide. A flat schema (SQLite, or a single namespace node) has no - * `namespaces` and is its own single namespace, so it yields itself. - * Duck-typing mirrors `projectSchemaToSpace`, which spreads these nodes into - * plain objects, so the helper also handles spread-flattened input. + * the relational verify to consume one at a time. Structure-agnostic — imports + * no target node class. A root exposing a `namespaces` record (Postgres) yields + * its namespace nodes (never merged, so same-named tables in different schemas + * cannot collide); a flat schema (SQLite) is its own single namespace and + * yields itself. Handles spread-flattened input (own-enumerable fields survive). */ -export function namespaceSchemaNodes(schema: SqlSchemaIRNode): readonly SqlSchemaIR[] { +function namespaceSchemaNodes(schema: SqlSchemaIRNode): readonly SqlSchemaIR[] { const obj = blindCast< { readonly namespaces?: Readonly> }, 'structural read of an own-enumerable namespaces record; survives the projectSchemaToSpace spread' diff --git a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts index 41a8134374..6a43f59498 100644 --- a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts +++ b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts @@ -1,6 +1,7 @@ import type { ColumnDefault, Contract, JsonValue } from '@prisma-next/contract/types'; import type { MigrationPlannerConflict } from '@prisma-next/framework-components/control'; import { + assertStorageTable, type CheckConstraint, type ForeignKey, type Index, @@ -386,16 +387,10 @@ export function contractNamespaceToSchemaIR( if (!namespace) { return { tables: {} }; } - const storageTypes: ResolvedStorageTypes = { - ...((storage.types ?? {}) as ResolvedStorageTypes), - }; + const storageTypes: ResolvedStorageTypes = { ...(storage.types ?? {}) }; const tables: Record = {}; for (const [tableName, tableDefRaw] of Object.entries(namespace.entries.table ?? {})) { - if (!isStorageTable(tableDefRaw)) { - throw new Error( - `contractNamespaceToSchemaIR: expected StorageTable at namespaces.${namespaceId}.entries.table.${tableName}`, - ); - } + assertStorageTable(tableDefRaw, `namespaces.${namespaceId}.entries.table.${tableName}`); tables[tableName] = convertTable( tableName, tableDefRaw, @@ -421,17 +416,11 @@ export function contractToSchemaIR( } const storage = contract.storage; - const storageTypes: ResolvedStorageTypes = { - ...((storage.types ?? {}) as ResolvedStorageTypes), - }; + const storageTypes: ResolvedStorageTypes = { ...(storage.types ?? {}) }; const tables: Record = {}; for (const ns of Object.values(storage.namespaces)) { for (const [tableName, tableDefRaw] of Object.entries(ns.entries.table ?? {})) { - if (!isStorageTable(tableDefRaw)) { - throw new Error( - `contractToSchemaIR: expected StorageTable at namespaces.${ns.id}.entries.table.${tableName}`, - ); - } + assertStorageTable(tableDefRaw, `namespaces.${ns.id}.entries.table.${tableName}`); const tableDef = tableDefRaw; if (tables[tableName] !== undefined) { throw new Error( @@ -468,7 +457,7 @@ function deriveAnnotations( ): SqlAnnotations | undefined { const storageTypes: Record = {}; - for (const typeInstance of Object.values((storage.types ?? {}) as ResolvedStorageTypes)) { + for (const typeInstance of Object.values(storage.types ?? {})) { if (isStorageTypeInstance(typeInstance)) { storageTypes[typeInstance.nativeType] = typeInstance; } diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index 63f47bf5e8..432ec1b322 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -311,10 +311,9 @@ export type SqlPlannerResult = export interface SqlMigrationPlannerPlanOptions { readonly contract: Contract; /** - * The "from"/live schema as the target's introspected node. SQLite returns a - * flat `SqlSchemaIR`; Postgres returns a `PostgresDatabaseSchemaNode` tree - * root. Structure-aware consumers (the differ, the relational verify glue) - * `ensure`/flatten the concrete shape before walking it. + * The "from"/live schema as the target's introspected node (SQLite a flat + * `SqlSchemaIR`, Postgres a `PostgresDatabaseSchemaNode` root). Structure-aware + * consumers narrow the concrete shape before walking it. */ readonly schema: SqlSchemaIRNode; readonly policy: MigrationOperationPolicy; @@ -485,13 +484,9 @@ export interface SqlControlTargetDescriptor< */ readonly schemaVerifier: SchemaVerifier; /** - * Database→PSL inference: walks the target's introspected schema tree into a - * `PslDocumentAst` for `contract infer`. Target logic (it owns the dialect - * type/default maps), so it lives on the descriptor beside `contractSerializer` - * — not in the family. Optional: targets that do not support `contract infer` - * (Mongo) omit it, and the family instance throws when it is absent. The param - * is the family-base node so the interface stays target-agnostic; the impl - * narrows to its own tree root. + * Database→PSL inference for `contract infer`. Target logic (owns the dialect + * maps), so it lives on the descriptor. Optional: targets without `contract + * infer` (Mongo) omit it, and the family instance throws when it is absent. */ readonly inferPslContract?: (schema: SqlSchemaIRNode) => PslDocumentAst; /** diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts index 0c1903570b..668fce324a 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts @@ -2,10 +2,9 @@ import type { ColumnDefault } from '@prisma-next/contract/types'; import type { DefaultMappingOptions } from './default-mapping'; /** - * Printer-shaped configuration for database→PSL inference. These shape-neutral - * types are exported from the SQL family (`@prisma-next/family-sql/psl-infer`) - * and consumed by the target that owns the dialect maps and walks its own - * schema tree (the Postgres target's `inferPostgresPslContract`). + * Printer-shaped configuration for database→PSL inference: dialect-neutral types + * the SQL family exports and the target's inference (which owns the dialect maps) + * consumes. */ export type PslNativeTypeAttribute = { diff --git a/packages/2-sql/9-family/src/exports/diff.ts b/packages/2-sql/9-family/src/exports/diff.ts index ea2c004038..3526df9b8c 100644 --- a/packages/2-sql/9-family/src/exports/diff.ts +++ b/packages/2-sql/9-family/src/exports/diff.ts @@ -14,11 +14,7 @@ export type { NativeTypeNormalizer, VerifySqlSchemaOptions, } from '../core/diff/sql-schema-diff'; -export { - namespaceSchemaNodes, - verifySqlSchema, - verifySqlSchemaTree, -} from '../core/diff/sql-schema-diff'; +export { verifySqlSchema, verifySqlSchemaTree } from '../core/diff/sql-schema-diff'; export { arraysEqual, isIndexSatisfied, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts index 3497151c5c..f0df1f3263 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts @@ -8,8 +8,8 @@ import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schem import { PostgresNamespaceSchemaNode } from '../schema-ir/postgres-namespace-schema-node'; import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; import { PostgresRoleSchemaNode } from '../schema-ir/postgres-role-schema-node'; -import { resolveDdlSchemaForNamespaceStorage } from '../schema-ir/postgres-schema-ir-annotations'; import { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; +import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; function toPolicyNode(policy: PostgresRlsPolicy, namespaceId: string): PostgresPolicySchemaNode { return new PostgresPolicySchemaNode({ diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index cae717a13e..94c56c607d 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -29,7 +29,6 @@ import { ifDefined } from '@prisma-next/utils/defined'; import { PostgresRlsPolicy } from '../postgres-rls-policy'; import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; -import { resolveDdlSchemaForNamespaceStorage } from '../schema-ir/postgres-schema-ir-annotations'; import { formatPostgresControlPolicySubjectLabel, resolvePostgresCallControlPolicySubject, @@ -46,6 +45,7 @@ import { } from './op-factory-call'; import { TypeScriptRenderablePostgresMigration } from './planner-produced-postgres-migration'; import { postgresPlannerStrategies } from './planner-strategies'; +import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; import { verifyPostgresNamespacePresence } from './verify-postgres-namespaces'; type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends { diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir-annotations.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/resolve-ddl-schema.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-schema-ir-annotations.ts rename to packages/3-targets/3-targets/postgres/src/core/migrations/resolve-ddl-schema.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts index 6aaa4dad8f..b151d9c34e 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts @@ -1,6 +1,6 @@ import type { DiffableNode } from '@prisma-next/framework-components/control'; import { freezeNode } from '@prisma-next/framework-components/ir'; -import { type SqlAnnotations, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; +import { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { PostgresTableSchemaNode, type PostgresTableSchemaNodeInput, @@ -21,19 +21,13 @@ export interface PostgresNamespaceSchemaNodeInput { * * `id` is the schema name. `isEqualTo` is identity — two namespace nodes are * equal iff their ids (schema names) match. `children()` returns the table - * nodes. - * - * The `annotations.pg` bag mirrors what `PostgresSchemaIR` carried for the - * per-schema slot (`schema` + `nativeEnumTypeNames`). `existingSchemas` is - * database-level and belongs on `PostgresDatabaseSchemaNode`, not here. - * The bag is carried only for legacy compatibility and will be retired with - * the annotations bag (TML-2936). + * nodes. Per-schema metadata is carried on the typed `nativeEnumTypeNames` + * field, not an annotations bag. */ export class PostgresNamespaceSchemaNode extends SqlSchemaIRNode implements DiffableNode { override readonly nodeKind = PostgresSchemaNodeKind.namespace; readonly schemaName: string; readonly tables: Readonly>; - declare readonly annotations?: SqlAnnotations; readonly nativeEnumTypeNames: readonly string[]; constructor(input: PostgresNamespaceSchemaNodeInput) { @@ -50,14 +44,6 @@ export class PostgresNamespaceSchemaNode extends SqlSchemaIRNode implements Diff ), ); this.nativeEnumTypeNames = Object.freeze([...input.nativeEnumTypeNames]); - this.annotations = { - pg: { - schema: input.schemaName, - ...(input.nativeEnumTypeNames.length > 0 && { - nativeEnumTypeNames: input.nativeEnumTypeNames, - }), - }, - }; freezeNode(this); } diff --git a/packages/3-targets/3-targets/postgres/src/exports/schema-ir-annotations.ts b/packages/3-targets/3-targets/postgres/src/exports/schema-ir-annotations.ts index c8f381170c..0617376a26 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/schema-ir-annotations.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/schema-ir-annotations.ts @@ -1 +1 @@ -export { resolveDdlSchemaForNamespaceStorage } from '../core/schema-ir/postgres-schema-ir-annotations'; +export { resolveDdlSchemaForNamespaceStorage } from '../core/migrations/resolve-ddl-schema'; diff --git a/packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts index 40e241e0c8..b6ee6ab159 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-namespace-schema-node.test.ts @@ -92,26 +92,14 @@ describe('PostgresNamespaceSchemaNode', () => { expect(node.tables['profiles']).toBe(tableA); }); - it('annotations.pg carries schema and nativeEnumTypeNames', () => { + it('carries nativeEnumTypeNames as a typed field', () => { const node = new PostgresNamespaceSchemaNode(baseInput); - const pg = node.annotations?.['pg'] as Record | undefined; - expect(pg?.['schema']).toBe('public'); - expect(pg?.['nativeEnumTypeNames']).toEqual(['status_enum']); - }); - - it('annotations.pg omits nativeEnumTypeNames when empty', () => { - const node = new PostgresNamespaceSchemaNode({ - ...baseInput, - nativeEnumTypeNames: [], - }); - const pg = node.annotations?.['pg'] as Record | undefined; - expect(pg?.['nativeEnumTypeNames']).toBeUndefined(); + expect(node.nativeEnumTypeNames).toEqual(['status_enum']); }); - it('annotations.pg does not carry existingSchemas (database-level field)', () => { - const node = new PostgresNamespaceSchemaNode(baseInput); - const pg = node.annotations?.['pg'] as Record | undefined; - expect(pg?.['existingSchemas']).toBeUndefined(); + it('nativeEnumTypeNames is empty when none are supplied', () => { + const node = new PostgresNamespaceSchemaNode({ ...baseInput, nativeEnumTypeNames: [] }); + expect(node.nativeEnumTypeNames).toEqual([]); }); it('instance is frozen', () => { diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/array-column-introspection.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/array-column-introspection.integration.test.ts index d1313a8e47..6f6cf7e407 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/array-column-introspection.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/array-column-introspection.integration.test.ts @@ -46,10 +46,7 @@ describe.sequential('array column introspection', () => { const result = await familyInstance.introspect({ driver: driver! }); PostgresDatabaseSchemaNode.assert(result); const col = result.namespaces['public']!.tables['arr_test']?.columns['tags']; - expect(col).toBeDefined(); - expect(col?.nativeType).toBe('text'); - expect(col?.many).toBe(true); - expect(col?.nullable).toBe(false); + expect(col).toMatchObject({ nativeType: 'text', many: true, nullable: false }); }); it('int4[] column → nativeType:int4 + many:true', { timeout: testTimeout }, async () => { @@ -58,9 +55,7 @@ describe.sequential('array column introspection', () => { const result = await familyInstance.introspect({ driver: driver! }); PostgresDatabaseSchemaNode.assert(result); const col = result.namespaces['public']!.tables['arr_test']?.columns['scores']; - expect(col).toBeDefined(); - expect(col?.nativeType).toBe('int4'); - expect(col?.many).toBe(true); + expect(col).toMatchObject({ nativeType: 'int4', many: true }); }); it('nullable text[] column → many:true + nullable:true', { timeout: testTimeout }, async () => { @@ -69,9 +64,7 @@ describe.sequential('array column introspection', () => { const result = await familyInstance.introspect({ driver: driver! }); PostgresDatabaseSchemaNode.assert(result); const col = result.namespaces['public']!.tables['arr_test']?.columns['labels']; - expect(col).toBeDefined(); - expect(col?.many).toBe(true); - expect(col?.nullable).toBe(true); + expect(col).toMatchObject({ many: true, nullable: true }); }); it('scalar text column carries no many property', { timeout: testTimeout }, async () => { diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts index 2c1f8a0fa3..743f43160e 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-collect-extension-issues.test.ts @@ -15,6 +15,7 @@ import { PostgresTableSchemaNode, } from '@prisma-next/target-postgres/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; +import { ifDefined } from '@prisma-next/utils/defined'; import { describe, expect, it } from 'vitest'; const TABLE_NAME = 'items'; @@ -62,8 +63,8 @@ function toPolicyNode(p: PostgresRlsPolicy): PostgresPolicySchemaNode { namespaceId: p.namespaceId, operation: p.operation, roles: [...p.roles], - ...(p.using !== undefined ? { using: p.using } : {}), - ...(p.withCheck !== undefined ? { withCheck: p.withCheck } : {}), + ...ifDefined('using', p.using), + ...ifDefined('withCheck', p.withCheck), permissive: p.permissive, }); } diff --git a/test/integration/test/family.introspect.integration.test.ts b/test/integration/test/family.introspect.integration.test.ts index 609313cb4d..6c197247d7 100644 --- a/test/integration/test/family.introspect.integration.test.ts +++ b/test/integration/test/family.introspect.integration.test.ts @@ -330,7 +330,7 @@ describe('family instance introspect', () => { ); it( - 'includes Postgres annotations', + 'carries per-namespace native enum type names', async () => { if (!connectionString) { throw new Error('Connection string not set'); @@ -354,8 +354,7 @@ describe('family instance introspect', () => { PostgresDatabaseSchemaNode.assert(schemaIR); const ns = schemaIR.namespaces['public']!; - expect(ns.annotations).toBeDefined(); - expect(ns.annotations?.['pg']).toBeDefined(); + expect(Array.isArray(ns.nativeEnumTypeNames)).toBe(true); } finally { await driver.close(); } diff --git a/test/integration/test/family.introspect.test.ts b/test/integration/test/family.introspect.test.ts index 0bcb13b06b..ccd248d3f2 100644 --- a/test/integration/test/family.introspect.test.ts +++ b/test/integration/test/family.introspect.test.ts @@ -192,12 +192,11 @@ describe('family instance introspect', () => { ); it( - 'includes Postgres annotations', + 'carries per-namespace native enum type names', async () => { await withIntrospection(connectionString!, (schemaIR) => { const ns = schemaIR.namespaces['public']!; - expect(ns.annotations).toBeDefined(); - expect(ns.annotations?.['pg']).toBeDefined(); + expect(Array.isArray(ns.nativeEnumTypeNames)).toBe(true); }); }, timeouts.spinUpPpgDev, From 833e054db2be4c97af22f6f6fbbbb7ee26f8cfa7 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 1 Jul 2026 18:02:24 +0200 Subject: [PATCH 28/49] docs(postgres-rls): schema diff/verify rework design (PR #894 review response) Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 4.8 (1M context) --- .../design-diff-and-verify.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md diff --git a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md new file mode 100644 index 0000000000..6defcfbdc6 --- /dev/null +++ b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md @@ -0,0 +1,114 @@ +# Design: schema diffing and verification + +Authoritative design for the PR #894 rework. Every "current" claim is grounded in a `file:line`; every "target" claim is a positive property the rework must satisfy. + +## 1. The model + +Schema comparison is **one black-box diff on the target**. Positive properties: + +1. **The diff takes two derived representations and returns two issue sets:** `diffDatabaseSchema(expected, actual) → { issues: SchemaIssue[]; schemaDiffIssues: SchemaDiffIssue[] }`. How it computes them — that it runs a relational check and a structural node differ, how it pairs namespaces, anything internal — is **private to the diff**. No consumer, and no other section of this design, describes it. +2. **The diff is a target-descriptor operation**, required for every SQL target (Postgres returns relational + policy issues; SQLite returns relational only). It is **not** on the control adapter — the adapter is database I/O, not schema logic. +3. **The verifier consumes only the output:** derive the expected representation, introspect the actual, call the diff, fail iff a surviving issue is a failure. It is blind to how the diff works. +4. The two issue sets stay distinct types — `SchemaIssue` (relational) and `SchemaDiffIssue` (the generic node differ). Merging them onto one type is a follow-on (§12). + +## 2. The diff's inputs: two derived representations + +- **Expected** — derived from the contract by the target's projection (`contractToPostgresDatabaseSchemaNode`, [contract-to-postgres-database-schema-node.ts:41](../../../packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts)). All contract-dependent resolution (value-sets, storage types, control) happens here, at derivation, so the diff reads no contract. +- **Actual** — introspected from the live database (`family.introspect`). +- Both are the same node-tree type: database → namespace → table [→ policy]. + +The contract is uniformly namespaced for every target — `contract.storage.namespaces[nsId].entries.table` (grounded: the verify walk iterates the namespaces at [verify-sql-schema.ts:335](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts) and `.entries.table` at [:346](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)). No family-wide namespace-node hierarchy is introduced: SQLite/Mongo are not wrapped in a namespace node. Multiple namespaces occur only in Postgres, and that is internal to the Postgres diff (§1.1). + +## 3. The diff lives on the target + +The diff already exists and already does the right thing at its core: `diffPostgresDatabaseSchema` ([diff-database-schema.ts:42](../../../packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts)) invokes both mechanisms and returns `{ issues, schemaDiffIssues }`. Two changes: + +- **Move it to the target descriptor and make it required.** It is currently reached through the control adapter — `SqlControlAdapter.diffDatabaseSchema`, **optional** ([control-adapter.ts:212](../../../packages/2-sql/9-family/src/core/control-adapter.ts)). It moves to the target descriptor (beside `contractSerializer` / `inferPslContract`), required for every SQL target. SQLite provides it too (relational only). The `optional`-and-fallback shape is removed. +- **The internals are private.** Whether the diff runs a relational check plus a structural differ, and how it walks namespaces, is the diff's own business — not exposed, not documented as a design concern. + +## 4. The diffing logic lives with the diff, not in "verify" + +The relational diffing code currently sits in the `schema-verify/` module: `verifySqlSchema` ([verify-sql-schema.ts:143](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `verifySqlSchemaTree` ([:1447](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `namespaceSchemaNodes` ([:59](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `restrictToNamespaceIds` ([:130](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `scopeContractToNamespace` ([:1380](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `namespaceSchemaName` ([:1367](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `mergeVerifyResults` ([:1407](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)). + +**Its logic is not rewritten.** It **moves** out of `schema-verify/` to live with the diff. Diffing code has no place in a "verify" module; once relocated it is the diff's private internal, and the "verify" module contains only the verifier's own concern (§5). + +## 5. The verifier consumes only the output + +`verifySchema` ([control-instance.ts:694](../../../packages/2-sql/9-family/src/core/control-instance.ts)) derives the expected tree, introspects the actual, calls `target.diffDatabaseSchema(expected, actual)`, and fails iff a surviving issue is a failure. Removed: + +- the SQLite **fallback branch** (`controlAdapter.diffDatabaseSchema ? … : verifySqlSchemaTree(…)`, [control-instance.ts:708](../../../packages/2-sql/9-family/src/core/control-instance.ts)) — with the diff required on the target, there is no fallback; +- the `namespaceSchemaNodes` + `verifySqlSchemaTree` **imports** ([control-instance.ts:57](../../../packages/2-sql/9-family/src/core/control-instance.ts)) — the verifier references no diffing internal. + +## 6. The schema view is unaware of the schema IR + +`toSchemaView` ([control-instance.ts:947](../../../packages/2-sql/9-family/src/core/control-instance.ts)) renders the human-readable schema view as a tree of printable `SchemaTreeNode`s. It currently reaches into the schema IR, flattening root→namespaces→tables via `namespaceSchemaNodes` ([:952](../../../packages/2-sql/9-family/src/core/control-instance.ts)) — coupling the view to a diff helper. + +Target: the schema view walks its **own** tree of printable nodes and is unaware of the schema IR. It uses no `namespaceSchemaNodes` and no diff helper. (How printable nodes are produced from the schema IR is the view's own concern, separate from the differ; it does not belong to this diff/verify design.) + +## 7. Node type guards (`.is` / `.assert` / `.ensure`) + +Guards downcast **from the base node to a specific node**: + +- signature is `static is(node: SqlSchemaIRNode): node is XSchemaNode` (and `assert`/`ensure` correspondingly take `SqlSchemaIRNode`) — never `unknown`, never `DiffableNode`; +- they discriminate on the node's own **`nodeKind`** identifier (§8), never `instanceof`. + +Current state (all five wrong on both counts): `PostgresNamespaceSchemaNode.is(node: unknown)` uses `instanceof` ([postgres-namespace-schema-node.ts:78-79](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts)); `PostgresPolicySchemaNode.is(node: DiffableNode)` uses `instanceof` ([postgres-policy-schema-node.ts:79-80](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts)); `PostgresRoleSchemaNode.is` ([:52-53](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts)); `PostgresTableSchemaNode.is(node: DiffableNode)` ([:102-103](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts)); `PostgresDatabaseSchemaNode.is(node: unknown)` uses `instanceof` with a field fallback ([:77-78](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts)). + +Discriminating on the field also resolves the review's A2 asymmetry: the field is what survives the `projectSchemaToSpace` spread, so a uniform field check makes every guard survive it. + +## 8. Node kinds and target ids are defined identifiers, not magic strings + +Two distinct discriminants, which the code currently conflates: + +- **`nodeKind`** — *which node* (database / namespace / table / policy / role). This is what the §7 guards compare, so **every one of the five nodes must carry a unique `nodeKind` identifier.** Today only `PostgresDatabaseSchemaNode` carries `nodeKind` ([postgres-database-schema-node.ts:36](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts) `= 'postgres-database'`); `PostgresNamespaceSchemaNode` carries only `nodeTarget`; and table / policy / role carry neither. The rework adds a unique `nodeKind` to all five; each guard is `node.nodeKind === ''`. +- **`nodeTarget`** — *which target*. `type SqlSchemaTarget = 'sql' | 'postgres'` ([sql-schema-ir.ts:14](../../../packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts)) hard-codes `'postgres'` in a SQL-*family* type (`nodeTarget` default `'sql'` at [:37](../../../packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts)) — an inverted dependency. + +Both are **defined identifiers**, not string literals scattered across guards, and the family enumerates no target ids. + +## 9. `isEqualTo` — identity only + +`isEqualTo` compares identity only: namespace nodes equal iff their `id`s match; table nodes equal iff their `id`s (names) match; columns are not compared by `isEqualTo` (columns become child nodes later, at which point the generic differ walks them). This is a real check, replacing the `isEqualTo => true` stopgap. + +## 10. Framework layer purity + +`1-framework/3-tooling/migration` code must not know any storage shape. Current violations: `projectSchemaToSpace` ([project-schema-to-space.ts:58](../../../packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts)) branches on `.namespaces`/`.tables`/`.collections` ([:68-104](../../../packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts)) and names `PostgresDatabaseSchemaNode` in comments; `collectLiveTableNames` ([verifier.ts:236](../../../packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts)) / `detectOrphanElements` ([:203](../../../packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts)) duck-type the same shapes. + +Target: the family supplies these as callbacks, exactly as it already supplies `verifySchemaForMember` ([verifier.ts:32](../../../packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts)). The framework calls target-agnostic callbacks — "project this schema to these owned names", "list this schema's entity names" — and touches no storage shape. + +## 11. Current → target + +| Component (current, grounded) | Target | +| --- | --- | +| `diffDatabaseSchema` on the control adapter, optional ([control-adapter.ts:212]) | on the **target descriptor**, required for every SQL target | +| verifier SQLite fallback branch ([control-instance.ts:708]) | deleted; the verifier only calls `target.diffDatabaseSchema` | +| verifier imports `namespaceSchemaNodes` + `verifySqlSchemaTree` ([control-instance.ts:57]) | removed | +| relational diffing lives in `schema-verify/verify-sql-schema.ts` (§4) | moved to live with the diff (logic untouched); out of the verify module | +| `toSchemaView` flattens the schema IR via `namespaceSchemaNodes` ([control-instance.ts:952]) | walks its own printable-node tree; unaware of the schema IR | +| SQLite `namespaceSchemaNodes(x)[0] ?? { tables: {} }` in [sqlite runner.ts:102] / [planner.ts:204] | SQLite goes through `diffDatabaseSchema`; the duplicated `?? {tables:{}}` fallback is gone | +| five `.is` guards using `instanceof` + `unknown`/`DiffableNode` (§7) | `(node: SqlSchemaIRNode): node is X`, `nodeKind`-discriminated | +| `isEqualTo => true` stopgap | identity comparison (§9) | +| `SqlSchemaTarget = 'sql' \| 'postgres'` ([sql-schema-ir.ts:14]); 3 of 5 nodes carry no `nodeKind` | defined `nodeKind` per node; family enumerates no target ids (§8) | +| framework `.tables/.collections/.namespaces` branching (§10) | family-supplied prune/enumerate callbacks | + +## 12. Out of scope (follow-ons, not this rework) + +- **Relational port / one issue type:** merging the relational check into the generic differ so there is a single issue type. The diff keeps two mechanisms and two issue types. +- **PSL-inference tree-walk (TML-2958):** `inferPostgresPslContract` ([infer-psl-contract.ts](../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts)) still gathers the tree into a flat `{ tables }` and emits one `__unspecified__` bucket. A known defect, guarded by a fail-loud throw; tracked in TML-2958. +- **`annotations.pg` full retirement (TML-2936):** this rework stops *populating* the bag (§13); typed-field replacement is TML-2936. + +## 13. Mechanical fixes (from the PR review, no design fork) + +- Replace the bespoke `throw new Error("expected StorageTable…")` with an assertion helper (`contractNamespaceToSchemaIR` [contract-to-schema-ir.ts:395](../../../packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts) and siblings). +- Remove `(storage.types ?? {}) as ResolvedStorageTypes` ([contract-to-schema-ir.ts:390](../../../packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts)) — real type, no cast/fallback (three occurrences: 390, 425, 471). +- Trim the verbose (attached, not orphaned) doc comments: `packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts:9`, `packages/2-sql/9-family/src/core/migrations/types.ts:317`, `:494` — and sweep the whole diff for the same. +- Test readability: `project-schema-to-space.test.ts:185`, `array-column-introspection.integration.test.ts:47`; `ifDefined` at `rls-collect-extension-issues.test.ts:66`. +- Move `postgres-schema-ir-annotations.ts` out of `schema-ir/` (the sixth non-node resident, so "only the five nodes" is false); stop populating the obsolete `annotations.pg` bag. +- Re-run the full slice-DoD gate set. + +## 14. Rejected alternatives (timeless) + +- **Rewriting the relational check to be a pure contract-free diff, adding `effectiveControlPolicy` / fully-expanded native types as new fields on the expected node, and moving disposition to a post-diff filter.** Rejected: the diff is a black box whose internals are untouched. The relational logic is relocated, not rewritten. +- **Exposing the diff through the control adapter.** Rejected: the diff is schema logic on the target; the adapter is database I/O. +- **A uniform family-wide namespace-node hierarchy (wrapping SQLite/Mongo in a namespace node).** Rejected: unnecessary; multiple namespaces occur only in Postgres, internal to its diff. +- **The framework duck-typing storage shapes.** Rejected: a layer violation; the framework delegates shape-specific work to family callbacks. +- **The verifier or the schema view knowing how the diff works.** Rejected: the verifier consumes the issue sets; the schema view walks its own printable-node tree. From e9ed2aad7c815b149d559b1aafae70e249ee0492 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 08:18:22 +0200 Subject: [PATCH 29/49] docs(postgres-rls): SchemaDiffer/SchemaDiff diff-verify design (round 2) Rework the diff/verify design to the settled shape from Will's second review: the differ is an SPI returning a SchemaDiff result (two issue lists + one filter over the union); verify and plan are symmetric (diff -> filter to contract space -> iterate); the framework filters issues by ownership and never prunes the schema; root/counts are verifier presentation, off the diff. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../design-diff-and-verify.md | 161 ++++++++++-------- 1 file changed, 93 insertions(+), 68 deletions(-) diff --git a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md index 6defcfbdc6..146b3a5dbe 100644 --- a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md +++ b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md @@ -1,114 +1,139 @@ # Design: schema diffing and verification -Authoritative design for the PR #894 rework. Every "current" claim is grounded in a `file:line`; every "target" claim is a positive property the rework must satisfy. +Authoritative design for the PR #894 rework. States the positive properties the code must satisfy; grounded in `file:line` where a claim rests on current code. ## 1. The model -Schema comparison is **one black-box diff on the target**. Positive properties: +Schema comparison is one operation — a **differ** — and verify and plan consume it identically. -1. **The diff takes two derived representations and returns two issue sets:** `diffDatabaseSchema(expected, actual) → { issues: SchemaIssue[]; schemaDiffIssues: SchemaDiffIssue[] }`. How it computes them — that it runs a relational check and a structural node differ, how it pairs namespaces, anything internal — is **private to the diff**. No consumer, and no other section of this design, describes it. -2. **The diff is a target-descriptor operation**, required for every SQL target (Postgres returns relational + policy issues; SQLite returns relational only). It is **not** on the control adapter — the adapter is database I/O, not schema logic. -3. **The verifier consumes only the output:** derive the expected representation, introspect the actual, call the diff, fail iff a surviving issue is a failure. It is blind to how the diff works. -4. The two issue sets stay distinct types — `SchemaIssue` (relational) and `SchemaDiffIssue` (the generic node differ). Merging them onto one type is a follow-on (§12). +1. **The differ is an SPI:** `SchemaDiffer.diff(contract, actual) → SchemaDiff`. Expected derives from the contract; actual is the introspected live schema. How the result is computed — a relational check plus a generic node differ, how namespaces are paired — is **private**. No consumer, and no other part of this design, describes it. +2. **`SchemaDiff` is a result over two issue lists plus one method** (§4). It carries no verdict, no verification tree, no counts. +3. **Verify and plan are the same shape:** `diff → filter the issues to a contract space → iterate`. Verify emits one diagnostic per surviving issue (none ⇒ success); plan emits one operation per surviving issue. Neither knows how the diff is computed. +4. **Two issue lists stay distinct types** — `SchemaIssue` (relational) and `SchemaDiffIssue` (the node differ) — because two diffing mechanisms exist today. Merging them is a follow-on (§13). ## 2. The diff's inputs: two derived representations -- **Expected** — derived from the contract by the target's projection (`contractToPostgresDatabaseSchemaNode`, [contract-to-postgres-database-schema-node.ts:41](../../../packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts)). All contract-dependent resolution (value-sets, storage types, control) happens here, at derivation, so the diff reads no contract. +- **Expected** — derived from the contract by the target's projection. All contract-dependent resolution (value-sets, storage types, control policy) happens at derivation, so the diff itself reads no contract. - **Actual** — introspected from the live database (`family.introspect`). - Both are the same node-tree type: database → namespace → table [→ policy]. -The contract is uniformly namespaced for every target — `contract.storage.namespaces[nsId].entries.table` (grounded: the verify walk iterates the namespaces at [verify-sql-schema.ts:335](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts) and `.entries.table` at [:346](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)). No family-wide namespace-node hierarchy is introduced: SQLite/Mongo are not wrapped in a namespace node. Multiple namespaces occur only in Postgres, and that is internal to the Postgres diff (§1.1). +The contract is uniformly namespaced for every target (`contract.storage.namespaces[nsId].entries.table`). No family-wide namespace-node hierarchy is introduced — SQLite/Mongo are not wrapped in a namespace node. Multiple namespaces occur only in Postgres, internal to the Postgres diff (§1). -## 3. The diff lives on the target +## 3. The differ is an SPI on the target -The diff already exists and already does the right thing at its core: `diffPostgresDatabaseSchema` ([diff-database-schema.ts:42](../../../packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts)) invokes both mechanisms and returns `{ issues, schemaDiffIssues }`. Two changes: +`SchemaDiffer` names the SPI the target already implements — `diffDatabaseSchema` on the SQL target descriptor ([types.ts:499](../../../../packages/2-sql/9-family/src/core/migrations/types.ts)). No new class implements it; the family/target that owns the diff today is the implementer. Two properties: -- **Move it to the target descriptor and make it required.** It is currently reached through the control adapter — `SqlControlAdapter.diffDatabaseSchema`, **optional** ([control-adapter.ts:212](../../../packages/2-sql/9-family/src/core/control-adapter.ts)). It moves to the target descriptor (beside `contractSerializer` / `inferPslContract`), required for every SQL target. SQLite provides it too (relational only). The `optional`-and-fallback shape is removed. -- **The internals are private.** Whether the diff runs a relational check plus a structural differ, and how it walks namespaces, is the diff's own business — not exposed, not documented as a design concern. +- **It returns `SchemaDiff`, not `VerifyDatabaseSchemaResult`.** A diff is not verify-specific. The verify envelope (`ok` / `summary` / `code` / `target` / `timings`) and the pass/warn/fail tree are the verifier's, built by the verifier (§6) — never returned by the differ. +- **It lives on the target descriptor, required for every SQL target** (Postgres: relational + policy; SQLite: relational only) — schema logic on the target, not database I/O on the control adapter. Its internals are private. -## 4. The diffing logic lives with the diff, not in "verify" +## 4. `SchemaDiff` — the result -The relational diffing code currently sits in the `schema-verify/` module: `verifySqlSchema` ([verify-sql-schema.ts:143](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `verifySqlSchemaTree` ([:1447](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `namespaceSchemaNodes` ([:59](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `restrictToNamespaceIds` ([:130](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `scopeContractToNamespace` ([:1380](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `namespaceSchemaName` ([:1367](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)), `mergeVerifyResults` ([:1407](../../../packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts)). +```ts +type DiffIssue = SchemaIssue | SchemaDiffIssue -**Its logic is not rewritten.** It **moves** out of `schema-verify/` to live with the diff. Diffing code has no place in a "verify" module; once relocated it is the diff's private internal, and the "verify" module contains only the verifier's own concern (§5). +class SchemaDiff { + readonly issues: readonly SchemaIssue[] + readonly schemaDiffIssues: readonly SchemaDiffIssue[] + filter(keep: (issue: DiffIssue) => boolean): SchemaDiff +} +``` -## 5. The verifier consumes only the output +- Its only job is to **abstract away that there are two issue lists.** `filter` fans one predicate across both and returns a narrowed `SchemaDiff` — still a passable unit. +- The predicate takes the **union**, not a normalized descriptor: any caller doing real work with the result already understands both issue types. There is no `DiffEntry` / coordinate abstraction layer. +- **Contract-space filtering and control-policy suppression are just callers passing predicates** — no policy-specific method, nothing special. `SchemaIssue` (`kind: 'extra_table'`, `table`, `namespaceId`) and `SchemaDiffIssue` (`outcome: 'extra'`, `actual` node) each express "extra" and their coordinate in their own way; the predicate discriminates. -`verifySchema` ([control-instance.ts:694](../../../packages/2-sql/9-family/src/core/control-instance.ts)) derives the expected tree, introspects the actual, calls `target.diffDatabaseSchema(expected, actual)`, and fails iff a surviving issue is a failure. Removed: +`SchemaIssue` ([control-result-types.ts:41](../../../../packages/1-framework/1-core/framework-components/src/control/control-result-types.ts)) and `SchemaDiffIssue` ([schema-diff.ts](../../../../packages/1-framework/1-core/framework-components/src/control/schema-diff.ts)) are the framework issue types Mongo also produces, so `filter` and the contract-space attribution (§11) are family-agnostic. -- the SQLite **fallback branch** (`controlAdapter.diffDatabaseSchema ? … : verifySqlSchemaTree(…)`, [control-instance.ts:708](../../../packages/2-sql/9-family/src/core/control-instance.ts)) — with the diff required on the target, there is no fallback; -- the `namespaceSchemaNodes` + `verifySqlSchemaTree` **imports** ([control-instance.ts:57](../../../packages/2-sql/9-family/src/core/control-instance.ts)) — the verifier references no diffing internal. +## 5. The diffing logic lives with the diff, not in "verify" -## 6. The schema view is unaware of the schema IR +The relational diffing code must not sit in a `schema-verify/` module. Its logic is **not rewritten** — it **moves** to live with the diff, where it becomes the diff's private internal. The "verify" module then holds only the verifier's own concern (§6). -`toSchemaView` ([control-instance.ts:947](../../../packages/2-sql/9-family/src/core/control-instance.ts)) renders the human-readable schema view as a tree of printable `SchemaTreeNode`s. It currently reaches into the schema IR, flattening root→namespaces→tables via `namespaceSchemaNodes` ([:952](../../../packages/2-sql/9-family/src/core/control-instance.ts)) — coupling the view to a diff helper. +## 6. Verify and plan consume the diff the same way -Target: the schema view walks its **own** tree of printable nodes and is unaware of the schema IR. It uses no `namespaceSchemaNodes` and no diff helper. (How printable nodes are produced from the schema IR is the view's own concern, separate from the differ; it does not belong to this diff/verify design.) +The verifier: -## 7. Node type guards (`.is` / `.assert` / `.ensure`) +1. `diff(contract, actual)` — derive expected, introspect actual, call the differ. +2. **filter the issues to the contract space** being verified (drop issues owned by other spaces). +3. iterate the surviving issues; **none ⇒ success**, else one verify diagnostic per issue. + +The planner is identical, emitting one migration operation per surviving issue. Verify and plan are symmetric — `diff → filter to space → iterate` — and both are blind to how the diff is computed. + +- **Tables no contract declares** are not a separate detection step: after attributing issues to spaces, they are the issues owned by **no** space. This deletes the live-entity enumeration entirely. +- **The pass/warn/fail tree (`root` / `counts`)** the CLI prints ([formatters/verify.ts](../../../../packages/1-framework/3-tooling/cli/src/utils/formatters/verify.ts), [combine-schema-results.ts](../../../../packages/1-framework/3-tooling/cli/src/utils/combine-schema-results.ts)) is the verifier's own presentation, produced by the relational walk — separate from the verdict (which is "iterate the issues") and never on `SchemaDiff`. + +## 7. The schema view is unaware of the schema IR + +The human-readable schema view walks its **own** tree of printable `SchemaTreeNode`s and is unaware of the schema IR. It uses no diff helper and does not flatten the schema-IR tree. How printable nodes are produced from the schema IR is the view's own concern, separate from the differ. + +## 8. Node type guards (`.is` / `.assert`) Guards downcast **from the base node to a specific node**: -- signature is `static is(node: SqlSchemaIRNode): node is XSchemaNode` (and `assert`/`ensure` correspondingly take `SqlSchemaIRNode`) — never `unknown`, never `DiffableNode`; -- they discriminate on the node's own **`nodeKind`** identifier (§8), never `instanceof`. +- signature is `static is(node: SqlSchemaIRNode): node is XSchemaNode` (and `assert` correspondingly) — never `unknown`, never `DiffableNode`; +- they discriminate on the node's own **`nodeKind`** identifier (§9), never `instanceof`; +- applied consistently across all five node classes, and on `StorageTable` and the RLS-policy guard. -Current state (all five wrong on both counts): `PostgresNamespaceSchemaNode.is(node: unknown)` uses `instanceof` ([postgres-namespace-schema-node.ts:78-79](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts)); `PostgresPolicySchemaNode.is(node: DiffableNode)` uses `instanceof` ([postgres-policy-schema-node.ts:79-80](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts)); `PostgresRoleSchemaNode.is` ([:52-53](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts)); `PostgresTableSchemaNode.is(node: DiffableNode)` ([:102-103](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts)); `PostgresDatabaseSchemaNode.is(node: unknown)` uses `instanceof` with a field fallback ([:77-78](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts)). +There is **no** `ensure()` that constructs a new node — a guard asserts, it does not build. Call sites `assert` and use the value in place. -Discriminating on the field also resolves the review's A2 asymmetry: the field is what survives the `projectSchemaToSpace` spread, so a uniform field check makes every guard survive it. +## 9. Node kinds and target ids are defined identifiers -## 8. Node kinds and target ids are defined identifiers, not magic strings +- **`nodeKind`** — *which node* (database / namespace / table / policy / role). Every one of the five nodes carries a unique `nodeKind` identifier; each §8 guard is `node.nodeKind === ''`. +- **`nodeTarget`** — *which target*. The SQL family enumerates no target ids; no `'postgres'` literal lives in a SQL-family type. -Two distinct discriminants, which the code currently conflates: +Both are defined identifiers, not string literals scattered across guards. -- **`nodeKind`** — *which node* (database / namespace / table / policy / role). This is what the §7 guards compare, so **every one of the five nodes must carry a unique `nodeKind` identifier.** Today only `PostgresDatabaseSchemaNode` carries `nodeKind` ([postgres-database-schema-node.ts:36](../../../packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts) `= 'postgres-database'`); `PostgresNamespaceSchemaNode` carries only `nodeTarget`; and table / policy / role carry neither. The rework adds a unique `nodeKind` to all five; each guard is `node.nodeKind === ''`. -- **`nodeTarget`** — *which target*. `type SqlSchemaTarget = 'sql' | 'postgres'` ([sql-schema-ir.ts:14](../../../packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts)) hard-codes `'postgres'` in a SQL-*family* type (`nodeTarget` default `'sql'` at [:37](../../../packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir.ts)) — an inverted dependency. +## 10. `isEqualTo` — identity only -Both are **defined identifiers**, not string literals scattered across guards, and the family enumerates no target ids. +`isEqualTo` compares identity only: nodes are equal iff their `id`s match. Columns are not compared by `isEqualTo` (they become child nodes the generic differ walks). This replaces the `isEqualTo => true` stopgap. -## 9. `isEqualTo` — identity only +## 11. Contract-space handling: filter the issues, never prune the schema -`isEqualTo` compares identity only: namespace nodes equal iff their `id`s match; table nodes equal iff their `id`s (names) match; columns are not compared by `isEqualTo` (columns become child nodes later, at which point the generic differ walks them). This is a real check, replacing the `isEqualTo => true` stopgap. +The framework **does not alter the schema before diffing and does not branch on any storage shape.** It diffs the full introspected schema and filters the resulting issues by contract-space ownership. Ownership is attributed with the target-agnostic `elementCoordinates(contract.storage)` — an issue belongs to whichever member claims its `(namespaceId, name)` coordinate. -## 10. Framework layer purity +Deleted: -`1-framework/3-tooling/migration` code must not know any storage shape. Current violations: `projectSchemaToSpace` ([project-schema-to-space.ts:58](../../../packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts)) branches on `.namespaces`/`.tables`/`.collections` ([:68-104](../../../packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts)) and names `PostgresDatabaseSchemaNode` in comments; `collectLiveTableNames` ([verifier.ts:236](../../../packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts)) / `detectOrphanElements` ([:203](../../../packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts)) duck-type the same shapes. +- `projectSchemaToSpace` ([project-schema-to-space.ts](../../../../packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts)) and both family `schema-shape.ts` modules ([SQL](../../../../packages/2-sql/9-family/src/core/diff/schema-shape.ts), [Mongo](../../../../packages/2-mongo-family/9-family/src/core/schema-shape.ts)) — the schema-pruning callbacks; +- the `projectSchemaToMember` / `listSchemaEntityNames` callbacks on the family instances and their CLI wiring; +- the `TSchemaResult` generic on the aggregate verifier ([verifier.ts](../../../../packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts)) — the family returns the framework issue types, which the framework reads directly. -Target: the family supplies these as callbacks, exactly as it already supplies `verifySchemaForMember` ([verifier.ts:32](../../../packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts)). The framework calls target-agnostic callbacks — "project this schema to these owned names", "list this schema's entity names" — and touches no storage shape. +The aggregate verifier and planner take the family's `SchemaDiffer`, diff the full schema per member, filter each member's issues to its space, and iterate. Issues owned by no member are the undeclared tables. -## 11. Current → target +## 12. What changes (from the state after the first rework round) -| Component (current, grounded) | Target | +| Now | Target | | --- | --- | -| `diffDatabaseSchema` on the control adapter, optional ([control-adapter.ts:212]) | on the **target descriptor**, required for every SQL target | -| verifier SQLite fallback branch ([control-instance.ts:708]) | deleted; the verifier only calls `target.diffDatabaseSchema` | -| verifier imports `namespaceSchemaNodes` + `verifySqlSchemaTree` ([control-instance.ts:57]) | removed | -| relational diffing lives in `schema-verify/verify-sql-schema.ts` (§4) | moved to live with the diff (logic untouched); out of the verify module | -| `toSchemaView` flattens the schema IR via `namespaceSchemaNodes` ([control-instance.ts:952]) | walks its own printable-node tree; unaware of the schema IR | -| SQLite `namespaceSchemaNodes(x)[0] ?? { tables: {} }` in [sqlite runner.ts:102] / [planner.ts:204] | SQLite goes through `diffDatabaseSchema`; the duplicated `?? {tables:{}}` fallback is gone | -| five `.is` guards using `instanceof` + `unknown`/`DiffableNode` (§7) | `(node: SqlSchemaIRNode): node is X`, `nodeKind`-discriminated | -| `isEqualTo => true` stopgap | identity comparison (§9) | -| `SqlSchemaTarget = 'sql' \| 'postgres'` ([sql-schema-ir.ts:14]); 3 of 5 nodes carry no `nodeKind` | defined `nodeKind` per node; family enumerates no target ids (§8) | -| framework `.tables/.collections/.namespaces` branching (§10) | family-supplied prune/enumerate callbacks | - -## 12. Out of scope (follow-ons, not this rework) - -- **Relational port / one issue type:** merging the relational check into the generic differ so there is a single issue type. The diff keeps two mechanisms and two issue types. -- **PSL-inference tree-walk (TML-2958):** `inferPostgresPslContract` ([infer-psl-contract.ts](../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts)) still gathers the tree into a flat `{ tables }` and emits one `__unspecified__` bucket. A known defect, guarded by a fail-loud throw; tracked in TML-2958. -- **`annotations.pg` full retirement (TML-2936):** this rework stops *populating* the bag (§13); typed-field replacement is TML-2936. - -## 13. Mechanical fixes (from the PR review, no design fork) - -- Replace the bespoke `throw new Error("expected StorageTable…")` with an assertion helper (`contractNamespaceToSchemaIR` [contract-to-schema-ir.ts:395](../../../packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts) and siblings). -- Remove `(storage.types ?? {}) as ResolvedStorageTypes` ([contract-to-schema-ir.ts:390](../../../packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts)) — real type, no cast/fallback (three occurrences: 390, 425, 471). -- Trim the verbose (attached, not orphaned) doc comments: `packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts:9`, `packages/2-sql/9-family/src/core/migrations/types.ts:317`, `:494` — and sweep the whole diff for the same. -- Test readability: `project-schema-to-space.test.ts:185`, `array-column-introspection.integration.test.ts:47`; `ifDefined` at `rls-collect-extension-issues.test.ts:66`. -- Move `postgres-schema-ir-annotations.ts` out of `schema-ir/` (the sixth non-node resident, so "only the five nodes" is false); stop populating the obsolete `annotations.pg` bag. +| `diffDatabaseSchema` returns `VerifyDatabaseSchemaResult` ([types.ts:499]) | returns `SchemaDiff` (two lists + `filter`); the SPI is named `SchemaDiffer` | +| verify verdict reads `counts.fail` off the verification tree | verdict iterates the filtered issues; none ⇒ success | +| aggregate verifier prunes the schema per member (`projectSchemaToSpace` + family `schema-shape` callbacks) | diffs the full schema, filters the issues by contract space; callbacks + `schema-shape` + `project-schema-to-space` deleted | +| undeclared tables via `listSchemaEntityNames` enumeration | issues owned by no space | +| `TSchemaResult` generic on the verifier | gone; framework reads the framework issue types | +| guards use `instanceof` / `unknown` / `DiffableNode`; `ensure()` constructs nodes | `(node: SqlSchemaIRNode): node is X`, `nodeKind`-discriminated; no node-constructing `ensure` | + +## 13. Out of scope (follow-ons) + +- **Relational port / one issue type:** merging the relational check into the generic node differ so there is a single issue type. Until then `SchemaDiff` carries two lists. Separating `root` / `counts` from the relational walk rides with this port. +- **PSL-inference tree-walk (TML-2958):** inference still gathers the tree into a flat document, guarded by a fail-loud throw. +- **`annotations.pg` full retirement (TML-2936):** this rework stops *populating* the bag (§14); typed-field replacement is TML-2936. + +## 14. Mechanical fixes (from the PR review, no design fork) + +- Replace the bespoke `throw new Error("expected StorageTable…")` with an assertion helper. +- Remove the `(storage.types ?? {}) as ResolvedStorageTypes` casts (×3) via a real type — no cast, no fallback. +- Trim the verbose doc comments (attached, not orphaned) and sweep the whole diff for the same; add none new. +- Delete the dead operations the review flags (`verify-postgres-namespaces` and the two unused `control-instance` methods). +- Extract review additions **out** of the catch-all `migrations/types.ts` into named, logical files. +- Correct the planner's transient-id string, its unreadable comment, and the "all namespace nodes are relational" note; stop *creating* contract nodes to refer to them — find them in the live contract. +- Move the non-node file out of `schema-ir/` (so only the five node classes remain); stop populating the obsolete `annotations.pg` bag. +- Reword the PSL-inference stopgap comment to state it converts the schema-IR **tree** into the flat structure the PSL writer expects (TML-2958), assigned to Will. - Re-run the full slice-DoD gate set. -## 14. Rejected alternatives (timeless) +## 15. Rejected alternatives (timeless) -- **Rewriting the relational check to be a pure contract-free diff, adding `effectiveControlPolicy` / fully-expanded native types as new fields on the expected node, and moving disposition to a post-diff filter.** Rejected: the diff is a black box whose internals are untouched. The relational logic is relocated, not rewritten. -- **Exposing the diff through the control adapter.** Rejected: the diff is schema logic on the target; the adapter is database I/O. +- **Utility methods on the `SchemaDiffer` interface (filter / extras / verdict on the SPI).** Rejected: those are pure functions of the result; they live on `SchemaDiff`, keeping the SPI a one-method factory. +- **Normalizing the two issue lists to a common `DiffEntry` for filtering.** Rejected: expose the union — callers already understand both types. +- **`root` / `counts` (the verification tree) on `SchemaDiff`.** Rejected: that is verifier presentation, not diff output. +- **Pruning the schema IR to a member's slice before diffing (family prune + enumerate callbacks).** Rejected: don't alter the schema; diff the full schema and filter the resulting issues by contract space. The framework never branches on storage shape. +- **The diff returning `VerifyDatabaseSchemaResult`, or exposing the diff through the control adapter.** Rejected: the diff returns `SchemaDiff`; it is schema logic on the target, not verify output and not database I/O. +- **Rewriting the relational check to be a pure contract-free diff, adding `effectiveControlPolicy` / fully-expanded native types as new fields on the expected node, and moving disposition to a post-diff filter.** Rejected: the relational logic is relocated, not rewritten. - **A uniform family-wide namespace-node hierarchy (wrapping SQLite/Mongo in a namespace node).** Rejected: unnecessary; multiple namespaces occur only in Postgres, internal to its diff. -- **The framework duck-typing storage shapes.** Rejected: a layer violation; the framework delegates shape-specific work to family callbacks. -- **The verifier or the schema view knowing how the diff works.** Rejected: the verifier consumes the issue sets; the schema view walks its own printable-node tree. +- **The verifier or the schema view knowing how the diff works.** Rejected: they consume the issue lists / walk their own printable-node tree. From 973000ee1ec7bd9ae2b79bff355b5f5f0c6f57a9 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 08:45:44 +0200 Subject: [PATCH 30/49] refactor(postgres): SchemaDiffer SPI returns SchemaDiff instead of VerifyDatabaseSchemaResult Adds SchemaDiff (two issue lists + filter) and the SchemaDiffer interface to framework-components/control, next to SchemaIssue/SchemaDiffIssue. Retypes diffDatabaseSchema on the SQL target descriptor to return SchemaDiff. Postgres and SQLite factor their existing relational-walk + policy-diff computation into a private compute helper so the walk still runs once per caller; diffDatabaseSchema projects it to the two issue lists, and a new verifyDatabaseSchema descriptor field wraps the same computation in the verify envelope (ok/summary/code/target/timings) plus the pass/warn/fail tree the CLI renders. verifySchema in the family now calls verifyDatabaseSchema instead of diffDatabaseSchema so it still gets the tree without re-running the walk. Updates the planner and RLS drift tests to read .issues/.schemaDiffIssues off the SchemaDiff result directly. Behaviour-neutral: fixtures:check, the full test suites, and typecheck are all green with no diff. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/schema-diff.ts | 37 +++++++++ .../src/exports/control.ts | 4 +- .../test/schema-diff.test.ts | 57 +++++++++++++- .../9-family/src/core/control-instance.ts | 31 ++++---- .../9-family/src/core/migrations/types.ts | 11 ++- ...stance.descriptor-self-consistency.test.ts | 6 +- .../test/cross-contract-validation.test.ts | 6 +- .../9-family/test/operation-preview.test.ts | 1 + .../9-family/test/schema-verify.helpers.ts | 13 +++- .../2-sql/9-family/test/schema-view.test.ts | 6 +- .../core/migrations/diff-database-schema.ts | 76 +++++++++++++------ .../postgres/src/core/migrations/planner.ts | 10 +-- .../3-targets/postgres/src/exports/control.ts | 14 +++- .../sqlite/src/core/control-target.ts | 10 +++ .../core/migrations/diff-database-schema.ts | 45 ++++++++--- .../sqlite/src/core/migrations/planner.ts | 4 +- .../sqlite/src/core/migrations/runner.ts | 4 +- .../rls-collect-extension-issues.test.ts | 2 +- 18 files changed, 267 insertions(+), 70 deletions(-) diff --git a/packages/1-framework/1-core/framework-components/src/control/schema-diff.ts b/packages/1-framework/1-core/framework-components/src/control/schema-diff.ts index 212ed5face..1c46f86b51 100644 --- a/packages/1-framework/1-core/framework-components/src/control/schema-diff.ts +++ b/packages/1-framework/1-core/framework-components/src/control/schema-diff.ts @@ -1,3 +1,5 @@ +import type { SchemaIssue } from './control-result-types'; + export type SchemaDiffOutcome = 'missing' | 'extra' | 'mismatch'; export interface SchemaDiffIssue { @@ -130,3 +132,38 @@ function diffChildren( return issues; } + +/** The union a `SchemaDiff` consumer filters over: either issue shape it carries. */ +export type DiffIssue = SchemaIssue | SchemaDiffIssue; + +/** + * The result of diffing a contract's expected schema against the introspected + * actual schema: two issue lists, kept distinct because two diffing mechanisms + * produce them (the relational check and the generic node differ). Carries no + * verdict, verification tree, or counts — those are the verifier's own + * presentation, built from the same underlying comparison. + */ +export class SchemaDiff { + readonly issues: readonly SchemaIssue[]; + readonly schemaDiffIssues: readonly SchemaDiffIssue[]; + + constructor(issues: readonly SchemaIssue[], schemaDiffIssues: readonly SchemaDiffIssue[]) { + this.issues = issues; + this.schemaDiffIssues = schemaDiffIssues; + } + + /** Fans `keep` across both issue lists, returning a new `SchemaDiff` narrowed to the survivors. */ + filter(keep: (issue: DiffIssue) => boolean): SchemaDiff { + return new SchemaDiff(this.issues.filter(keep), this.schemaDiffIssues.filter(keep)); + } +} + +/** + * The SPI a SQL target implements to compare a contract's expected schema + * against the introspected actual schema. How the comparison is computed — + * relational check, generic node differ, namespace pairing — is private to + * the implementer; verify and plan consume only the returned `SchemaDiff`. + */ +export interface SchemaDiffer { + diff(input: TInput): SchemaDiff; +} diff --git a/packages/1-framework/1-core/framework-components/src/exports/control.ts b/packages/1-framework/1-core/framework-components/src/exports/control.ts index e1ab709f12..221e8cd96a 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/control.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/control.ts @@ -103,10 +103,12 @@ export { } from '../control/control-stack'; export type { DiffableNode, + DiffIssue, + SchemaDiffer, SchemaDiffIssue, SchemaDiffOutcome, } from '../control/schema-diff'; -export { diffSchemas } from '../control/schema-diff'; +export { diffSchemas, SchemaDiff } from '../control/schema-diff'; export type { SchemaVerifier, SchemaVerifyOptions, diff --git a/packages/1-framework/1-core/framework-components/test/schema-diff.test.ts b/packages/1-framework/1-core/framework-components/test/schema-diff.test.ts index f1b40a0778..28ae789c82 100644 --- a/packages/1-framework/1-core/framework-components/test/schema-diff.test.ts +++ b/packages/1-framework/1-core/framework-components/test/schema-diff.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { DiffableNode, SchemaDiffIssue } from '../src/control/schema-diff'; -import { diffSchemas } from '../src/control/schema-diff'; +import type { SchemaIssue } from '../src/control/control-result-types'; +import type { DiffableNode, DiffIssue, SchemaDiffIssue } from '../src/control/schema-diff'; +import { diffSchemas, SchemaDiff } from '../src/control/schema-diff'; /** A synthetic root node whose `isEqualTo` is always true — used to wrap flat node lists. */ function rootOf(nodes: readonly DiffableNode[]): DiffableNode { @@ -237,3 +238,55 @@ describe('diffSchemas', () => { expect(issues[0]).toMatchObject({ outcome: 'missing', path: ['root', 'lone_leaf'] }); }); }); + +function makeSchemaIssue(table: string): SchemaIssue { + return { kind: 'extra_table', table, message: `Extra table "${table}"` }; +} + +function makeSchemaDiffIssue(path: readonly string[]): SchemaDiffIssue { + return { path, outcome: 'extra', message: outcomeMessageFor(path) }; +} + +function outcomeMessageFor(path: readonly string[]): string { + return `extra: ${path.join('/')}`; +} + +describe('SchemaDiff', () => { + it('exposes the issues and schemaDiffIssues it was constructed with', () => { + const issues = [makeSchemaIssue('a')]; + const schemaDiffIssues = [makeSchemaDiffIssue(['root', 'p'])]; + const diff = new SchemaDiff(issues, schemaDiffIssues); + expect(diff.issues).toBe(issues); + expect(diff.schemaDiffIssues).toBe(schemaDiffIssues); + }); + + it('filter narrows both issue lists using one predicate over the union', () => { + const keep = makeSchemaIssue('keep'); + const drop = makeSchemaIssue('drop'); + const keepDiff = makeSchemaDiffIssue(['root', 'keep']); + const dropDiff = makeSchemaDiffIssue(['root', 'drop']); + const diff = new SchemaDiff([keep, drop], [keepDiff, dropDiff]); + + const keepPredicate = (issue: DiffIssue): boolean => + 'outcome' in issue ? issue.path.includes('keep') : 'table' in issue && issue.table === 'keep'; + const filtered = diff.filter(keepPredicate); + + expect(filtered.issues).toEqual([keep]); + expect(filtered.schemaDiffIssues).toEqual([keepDiff]); + }); + + it('filter returns a new SchemaDiff, not a mutation of the original', () => { + const diff = new SchemaDiff([makeSchemaIssue('a')], []); + const filtered = diff.filter(() => false); + expect(filtered).not.toBe(diff); + expect(diff.issues).toHaveLength(1); + expect(filtered.issues).toHaveLength(0); + }); + + it('filter on an empty diff returns an empty diff', () => { + const diff = new SchemaDiff([], []); + const filtered = diff.filter(() => true); + expect(filtered.issues).toEqual([]); + expect(filtered.schemaDiffIssues).toEqual([]); + }); +}); diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index 21a80c512b..ff0eb21b15 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -555,18 +555,22 @@ export function createSqlFamilyInstance( { readonly inferPslContract?: (schema: SqlSchemaIRNode) => PslDocumentAst }, 'reading the optional target-descriptor inferPslContract hook' >(target).inferPslContract; - // The combined database-schema diff is a required target-descriptor operation: - // every SQL target provides it. Read it off the descriptor like the other + // The combined database-schema verify is a required target-descriptor + // operation: every SQL target provides it, wrapping the same comparison + // `diffDatabaseSchema` runs in the verify envelope plus the pass/warn/fail + // tree the CLI renders. Read it off the descriptor like the other // target-owned hooks. - const diffDatabaseSchema = blindCast< + const verifyDatabaseSchema = blindCast< { - readonly diffDatabaseSchema?: (input: DiffDatabaseSchemaInput) => VerifyDatabaseSchemaResult; + readonly verifyDatabaseSchema?: ( + input: DiffDatabaseSchemaInput, + ) => VerifyDatabaseSchemaResult; }, - 'reading the required target-descriptor diffDatabaseSchema hook' - >(target).diffDatabaseSchema; - if (!diffDatabaseSchema) { + 'reading the required target-descriptor verifyDatabaseSchema hook' + >(target).verifyDatabaseSchema; + if (!verifyDatabaseSchema) { throw new Error( - `SQL target "${target.targetId}" is missing the required diffDatabaseSchema descriptor operation`, + `SQL target "${target.targetId}" is missing the required verifyDatabaseSchema descriptor operation`, ); } const deserializeWithTargetSerializer = (contractOrJson: unknown): Contract => { @@ -726,11 +730,12 @@ export function createSqlFamilyInstance( readonly frameworkComponents: ReadonlyArray>; }): VerifyDatabaseSchemaResult { const contract = deserializeWithTargetSerializer(options.contract) as Contract; - // Verify is a thin consumer of the target's black-box diff: it introspects - // the actual schema (already in `options.schema`), calls the target's - // required `diffDatabaseSchema`, and rejects when a surviving issue is a - // failure. It composes no diffing itself and is blind to how the diff works. - const sqlResult = diffDatabaseSchema({ + // Verify is a thin consumer of the target's black-box comparison: it + // introspects the actual schema (already in `options.schema`), calls the + // target's required `verifyDatabaseSchema`, and rejects when a surviving + // issue is a failure. It composes no diffing itself and is blind to how + // the comparison works. + const sqlResult = verifyDatabaseSchema({ contract, schema: options.schema, strict: options.strict, diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index 432ec1b322..421646433e 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -18,6 +18,7 @@ import type { MigrationRunnerResult, OperationContext, OpFactoryCall, + SchemaDiffer, SchemaIssue, SchemaVerifier, VerifyDatabaseSchemaResult, @@ -496,7 +497,15 @@ export interface SqlControlTargetDescriptor< * schema logic on the target, not database I/O, so it lives here rather than * on the control adapter. How it computes the two issue sets is private. */ - readonly diffDatabaseSchema: (input: DiffDatabaseSchemaInput) => VerifyDatabaseSchemaResult; + readonly diffDatabaseSchema: SchemaDiffer['diff']; + /** + * The same combined comparison as {@link diffDatabaseSchema}, wrapped in the + * verify envelope (`ok`/`summary`/`code`/`target`/`timings`) plus the + * pass/warn/fail tree the CLI renders. Verify calls this instead of + * `diffDatabaseSchema` so the relational walk that produces the tree runs + * once per verify, not once for the diff and again for the tree. + */ + readonly verifyDatabaseSchema: (input: DiffDatabaseSchemaInput) => VerifyDatabaseSchemaResult; createPlanner(adapter: SqlControlAdapter): SqlMigrationPlanner; createRunner(family: SqlControlFamilyInstance): SqlMigrationRunner; } diff --git a/packages/2-sql/9-family/test/control-instance.descriptor-self-consistency.test.ts b/packages/2-sql/9-family/test/control-instance.descriptor-self-consistency.test.ts index f59644245d..09bbf747c3 100644 --- a/packages/2-sql/9-family/test/control-instance.descriptor-self-consistency.test.ts +++ b/packages/2-sql/9-family/test/control-instance.descriptor-self-consistency.test.ts @@ -16,7 +16,10 @@ import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; import { createSqlFamilyInstance } from '../src/core/control-instance'; import type { SqlControlExtensionDescriptor } from '../src/core/migrations/types'; -import { stubTargetDiffDatabaseSchema } from './schema-verify.helpers'; +import { + stubTargetDiffDatabaseSchema, + stubTargetVerifyDatabaseSchema, +} from './schema-verify.helpers'; const TARGET = 'postgres' as const; const TARGET_FAMILY = 'sql' as const; @@ -129,6 +132,7 @@ function makeStack( serializeContract: (contract) => contract as never, }, diffDatabaseSchema: stubTargetDiffDatabaseSchema, + verifyDatabaseSchema: stubTargetVerifyDatabaseSchema, create: () => ({ familyId: 'sql', targetId: 'postgres' }), } as ControlTargetDescriptor<'sql', 'postgres'>, adapter: { diff --git a/packages/2-sql/9-family/test/cross-contract-validation.test.ts b/packages/2-sql/9-family/test/cross-contract-validation.test.ts index 05cac37acc..998c56a108 100644 --- a/packages/2-sql/9-family/test/cross-contract-validation.test.ts +++ b/packages/2-sql/9-family/test/cross-contract-validation.test.ts @@ -15,7 +15,10 @@ import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; import { createSqlFamilyInstance } from '../src/core/control-instance'; import type { SqlControlExtensionDescriptor } from '../src/core/migrations/types'; -import { stubTargetDiffDatabaseSchema } from './schema-verify.helpers'; +import { + stubTargetDiffDatabaseSchema, + stubTargetVerifyDatabaseSchema, +} from './schema-verify.helpers'; const TARGET = 'postgres' as const; const TARGET_FAMILY = 'sql' as const; @@ -173,6 +176,7 @@ function makeStack( serializeContract: (contract) => contract as never, }, diffDatabaseSchema: stubTargetDiffDatabaseSchema, + verifyDatabaseSchema: stubTargetVerifyDatabaseSchema, create: () => ({ familyId: 'sql', targetId: 'postgres' }), } as ControlTargetDescriptor<'sql', 'postgres'>, adapter: { diff --git a/packages/2-sql/9-family/test/operation-preview.test.ts b/packages/2-sql/9-family/test/operation-preview.test.ts index 3634746960..baed7d8967 100644 --- a/packages/2-sql/9-family/test/operation-preview.test.ts +++ b/packages/2-sql/9-family/test/operation-preview.test.ts @@ -100,6 +100,7 @@ describe('SqlControlFamilyInstance OperationPreviewCapable', () => { kind: 'target', types: { storage: [] }, diffDatabaseSchema: () => ({}), + verifyDatabaseSchema: () => ({}), }, adapter: { targetId: 'postgres', diff --git a/packages/2-sql/9-family/test/schema-verify.helpers.ts b/packages/2-sql/9-family/test/schema-verify.helpers.ts index 044bb0bda7..d39695edbc 100644 --- a/packages/2-sql/9-family/test/schema-verify.helpers.ts +++ b/packages/2-sql/9-family/test/schema-verify.helpers.ts @@ -12,6 +12,7 @@ import { } from '@prisma-next/contract/types'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { VerifyDatabaseSchemaResult } from '@prisma-next/framework-components/control'; +import { SchemaDiff } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { applyFkDefaults, @@ -310,7 +311,17 @@ export function createMockPostgresComponent(): TargetBoundComponentDescriptor<'s * descriptor must provide `diffDatabaseSchema`; this satisfies that requirement * for construction-only tests. */ -export function stubTargetDiffDatabaseSchema(): VerifyDatabaseSchemaResult { +export function stubTargetDiffDatabaseSchema(): SchemaDiff { + return new SchemaDiff([], []); +} + +/** + * A no-op `verifyDatabaseSchema` for target-descriptor stubs in tests that + * construct a family instance but never call `verifySchema`. Every SQL target + * descriptor must provide `verifyDatabaseSchema`; this satisfies that + * requirement for construction-only tests. + */ +export function stubTargetVerifyDatabaseSchema(): VerifyDatabaseSchemaResult { return { ok: true, summary: 'stub', diff --git a/packages/2-sql/9-family/test/schema-view.test.ts b/packages/2-sql/9-family/test/schema-view.test.ts index ef15d79b5d..079f4580b1 100644 --- a/packages/2-sql/9-family/test/schema-view.test.ts +++ b/packages/2-sql/9-family/test/schema-view.test.ts @@ -6,7 +6,10 @@ import { createControlStack } from '@prisma-next/framework-components/control'; import type { SqlSchemaIR, SqlSchemaIRNode, SqlTableIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; import { createSqlFamilyInstance } from '../src/core/control-instance'; -import { stubTargetDiffDatabaseSchema } from './schema-verify.helpers'; +import { + stubTargetDiffDatabaseSchema, + stubTargetVerifyDatabaseSchema, +} from './schema-verify.helpers'; function createMockStack() { return createControlStack({ @@ -41,6 +44,7 @@ function createMockStack() { serializeContract: (contract) => contract as never, }, diffDatabaseSchema: stubTargetDiffDatabaseSchema, + verifyDatabaseSchema: stubTargetVerifyDatabaseSchema, create: () => ({ familyId: 'sql', targetId: 'postgres' }), } as ControlTargetDescriptor<'sql', 'postgres'>, adapter: { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts index f2131fca46..cb8f25ccbf 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts @@ -1,7 +1,11 @@ import type { Contract } from '@prisma-next/contract/types'; import { verifySqlSchemaTree } from '@prisma-next/family-sql/diff'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; -import type { VerifyDatabaseSchemaResult } from '@prisma-next/framework-components/control'; +import type { + SchemaDiffIssue, + VerifyDatabaseSchemaResult, +} from '@prisma-next/framework-components/control'; +import { SchemaDiff } from '@prisma-next/framework-components/control'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; @@ -12,10 +16,18 @@ import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schem import { contractToPostgresDatabaseSchemaNode } from './contract-to-postgres-database-schema-node'; import { diffPostgresSchema, filterIssuesByOwnership } from './diff-postgres-schema'; +interface PostgresDiffDatabaseSchemaInput { + readonly contract: Contract; + readonly actualSchema: SqlSchemaIRNode; + readonly strict: boolean; + readonly typeMetadataRegistry: ReadonlyMap; + readonly frameworkComponents: ReadonlyArray>; +} + /** - * The single combined database-schema diff of two schema-IR trees — the one - * operation the migration planner and the family schema verify both run. It - * composes, once each: + * The single combined database-schema comparison — the one computation the + * migration planner and the family schema verify both consume. Composes, + * once each: * * - the per-namespace-paired relational diff (`verifySqlSchemaTree`) → table / * column / constraint findings as framework `SchemaIssue`s (with the @@ -24,28 +36,14 @@ import { diffPostgresSchema, filterIssuesByOwnership } from './diff-postgres-sch * presence as `SchemaDiffIssue`s, ownership-filtered to the contract's owned * schemas. * - * The return is a `VerifyDatabaseSchemaResult` whose `schema` carries both shapes - * (`issues` + `schemaDiffIssues`) plus the relational `root`/`counts` the CLI - * renders — i.e. exactly the existing verify-result schema shape, so nothing - * downstream changes. The two issue shapes stay separate (the relational - * findings are stringly `SchemaIssue`s; the policy findings carry the live - * policy nodes the planner needs to build ops); merging them onto one type is - * the follow-on relational port, not here. - * - * Namespace presence (`missing_schema` → `CREATE SCHEMA`) is intentionally NOT - * composed here: it is a planner-only op-generation concern (verify rejects on - * the relational `missing_table` a missing schema already produces), so the - * planner stitches it in around this diff. Control-policy suppression of the - * policy issues is likewise a per-consumer post-step (verify filters the issues; - * the planner filters the calls). + * `diffPostgresDatabaseSchema` and `verifyPostgresDatabaseSchema` both read + * this single result, so the relational walk runs once per caller — never + * once for the diff and again for the verify tree. */ -export function diffPostgresDatabaseSchema(input: { - readonly contract: Contract; - readonly actualSchema: SqlSchemaIRNode; - readonly strict: boolean; - readonly typeMetadataRegistry: ReadonlyMap; - readonly frameworkComponents: ReadonlyArray>; -}): VerifyDatabaseSchemaResult { +function computePostgresSchemaComparison(input: PostgresDiffDatabaseSchemaInput): { + readonly relational: VerifyDatabaseSchemaResult; + readonly schemaDiffIssues: readonly SchemaDiffIssue[]; +} { const postgresContract = blindCast< PostgresContract, 'diffPostgresDatabaseSchema is only called with a postgres contract' @@ -86,6 +84,34 @@ export function diffPostgresDatabaseSchema(input: { ownedSchemaNames(expected), ); + return { relational, schemaDiffIssues }; +} + +/** + * The `SchemaDiffer` for Postgres: the target's black-box comparison, + * projected to the two issue lists. Namespace presence (`missing_schema` → + * `CREATE SCHEMA`) is intentionally NOT composed here: it is a planner-only + * op-generation concern (verify rejects on the relational `missing_table` a + * missing schema already produces), so the planner stitches it in around this + * diff. Control-policy suppression of the policy issues is likewise a + * per-consumer post-step (verify filters the issues; the planner filters the + * calls). + */ +export function diffPostgresDatabaseSchema(input: PostgresDiffDatabaseSchemaInput): SchemaDiff { + const { relational, schemaDiffIssues } = computePostgresSchemaComparison(input); + return new SchemaDiff(relational.schema.issues, schemaDiffIssues); +} + +/** + * The same combined comparison as {@link diffPostgresDatabaseSchema}, wrapped + * in the verify envelope (`ok`/`summary`/`code`/`target`/`timings`) plus the + * pass/warn/fail tree the CLI renders — i.e. exactly the existing verify-result + * schema shape, so nothing downstream changes. + */ +export function verifyPostgresDatabaseSchema( + input: PostgresDiffDatabaseSchemaInput, +): VerifyDatabaseSchemaResult { + const { relational, schemaDiffIssues } = computePostgresSchemaComparison(input); return { ...relational, schema: { ...relational.schema, schemaDiffIssues }, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 94c56c607d..93664716a8 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -161,7 +161,8 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // One combined database-schema diff drives the whole plan: the relational // findings (+ namespace presence) become structural DDL via `planIssues`, // the policy findings become RLS ops via `planPostgresSchemaDiff`. Verify - // runs the same `diffPostgresDatabaseSchema` and rejects on non-empty. + // runs the same underlying comparison (via `verifyDatabaseSchema`) and + // rejects on non-empty. PostgresDatabaseSchemaNode.assert(options.schema); const databaseDiff = diffPostgresDatabaseSchema({ contract: options.contract, @@ -172,7 +173,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr typeMetadataRegistry: new Map(), frameworkComponents: options.frameworkComponents, }); - const schemaIssues = this.collectSchemaIssues(options, databaseDiff.schema.issues); + const schemaIssues = this.collectSchemaIssues(options, databaseDiff.issues); const codecHooks = extractCodecControlHooks(options.frameworkComponents); const storageTypes = options.contract.storage.types ?? {}; // The strategy layer reads the live schema by bare table name for @@ -222,10 +223,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return plannerFailure(result.failure); } - const schemaDiffCalls = this.planPostgresSchemaDiff( - options, - databaseDiff.schema.schemaDiffIssues, - ); + const schemaDiffCalls = this.planPostgresSchemaDiff(options, databaseDiff.schemaDiffIssues); const schemaDiffPartition = partitionCallsByControlPolicy({ calls: schemaDiffCalls, contract: options.contract, diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index d1e7aa3829..25820fdf3d 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -12,7 +12,10 @@ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { postgresTargetDescriptorMeta } from '../core/descriptor-meta'; import { contractToPostgresDatabaseSchemaNode } from '../core/migrations/contract-to-postgres-database-schema-node'; -import { diffPostgresDatabaseSchema } from '../core/migrations/diff-database-schema'; +import { + diffPostgresDatabaseSchema, + verifyPostgresDatabaseSchema, +} from '../core/migrations/diff-database-schema'; import { createPostgresMigrationPlanner } from '../core/migrations/planner'; import { renderDefaultLiteral } from '../core/migrations/planner-ddl-builders'; import type { PostgresPlanTargetDetails } from '../core/migrations/planner-target-details'; @@ -68,6 +71,15 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP frameworkComponents: input.frameworkComponents, }); }, + verifyDatabaseSchema(input) { + return verifyPostgresDatabaseSchema({ + contract: input.contract, + actualSchema: input.schema, + strict: input.strict, + typeMetadataRegistry: input.typeMetadataRegistry, + frameworkComponents: input.frameworkComponents, + }); + }, migrations: { createPlanner(adapter: SqlControlAdapter<'postgres'>) { return createPostgresMigrationPlanner(adapter); diff --git a/packages/3-targets/3-targets/sqlite/src/core/control-target.ts b/packages/3-targets/3-targets/sqlite/src/core/control-target.ts index c13a4254ce..302181bda2 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/control-target.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/control-target.ts @@ -11,6 +11,7 @@ import { sqliteTargetDescriptorMeta } from './descriptor-meta'; import { diffSqliteDatabaseSchema, sqliteContractToSchema, + verifySqliteDatabaseSchema, } from './migrations/diff-database-schema'; import { createSqliteMigrationPlanner } from './migrations/planner'; import type { SqlitePlanTargetDetails } from './migrations/planner-target-details'; @@ -36,6 +37,15 @@ const sqliteControlTargetDescriptor: SqlControlTargetDescriptor<'sqlite', Sqlite frameworkComponents: input.frameworkComponents, }); }, + verifyDatabaseSchema(input) { + return verifySqliteDatabaseSchema({ + contract: input.contract, + actualSchema: input.schema, + strict: input.strict, + typeMetadataRegistry: input.typeMetadataRegistry, + frameworkComponents: input.frameworkComponents, + }); + }, migrations: { createPlanner(adapter: SqlControlAdapter<'sqlite'>): MigrationPlanner<'sql', 'sqlite'> { return createSqliteMigrationPlanner(adapter); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts index cea08f6dba..4f25b8ea5b 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts @@ -3,12 +3,21 @@ import { contractToSchemaIR } from '@prisma-next/family-sql/control'; import { verifySqlSchemaTree } from '@prisma-next/family-sql/diff'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { VerifyDatabaseSchemaResult } from '@prisma-next/framework-components/control'; +import { SchemaDiff } from '@prisma-next/framework-components/control'; import type { SqlStorage, StorageColumn } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { parseSqliteDefault } from '../default-normalizer'; import { normalizeSqliteNativeType } from '../native-type-normalizer'; import { renderDefaultLiteral } from './planner-ddl-builders'; +interface SqliteDiffDatabaseSchemaInput { + readonly contract: Contract; + readonly actualSchema: SqlSchemaIRNode; + readonly strict: boolean; + readonly typeMetadataRegistry: ReadonlyMap; + readonly frameworkComponents: ReadonlyArray>; +} + /** Renders a column default for the SQLite dialect. */ export function sqliteRenderDefault(def: ColumnDefault, _column: StorageColumn): string { if (def.kind === 'function') { @@ -28,18 +37,9 @@ export function sqliteContractToSchema(contract: Contract | null): S }); } -/** - * The SQLite combined database-schema diff — relational only. SQLite has a - * single flat schema and no structural (policy) diff, so it runs the shared - * per-schema relational diff and returns no `schemaDiffIssues`. - */ -export function diffSqliteDatabaseSchema(input: { - readonly contract: Contract; - readonly actualSchema: SqlSchemaIRNode; - readonly strict: boolean; - readonly typeMetadataRegistry: ReadonlyMap; - readonly frameworkComponents: ReadonlyArray>; -}): VerifyDatabaseSchemaResult { +function computeSqliteSchemaComparison( + input: SqliteDiffDatabaseSchemaInput, +): VerifyDatabaseSchemaResult { return verifySqlSchemaTree({ contract: input.contract, actualSchema: input.actualSchema, @@ -51,3 +51,24 @@ export function diffSqliteDatabaseSchema(input: { normalizeNativeType: normalizeSqliteNativeType, }); } + +/** + * The SQLite `SchemaDiffer` — relational only. SQLite has a single flat + * schema and no structural (policy) diff, so it runs the shared per-schema + * relational diff and returns no `schemaDiffIssues`. + */ +export function diffSqliteDatabaseSchema(input: SqliteDiffDatabaseSchemaInput): SchemaDiff { + const relational = computeSqliteSchemaComparison(input); + return new SchemaDiff(relational.schema.issues, relational.schema.schemaDiffIssues); +} + +/** + * The same comparison as {@link diffSqliteDatabaseSchema}, wrapped in the + * verify envelope (`ok`/`summary`/`code`/`target`/`timings`) plus the + * pass/warn/fail tree the CLI renders. + */ +export function verifySqliteDatabaseSchema( + input: SqliteDiffDatabaseSchemaInput, +): VerifyDatabaseSchemaResult { + return computeSqliteSchemaComparison(input); +} diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 7105be77d6..e0f98f7ac8 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -181,14 +181,14 @@ export class SqliteMigrationPlanner private collectSchemaIssues(options: SqlMigrationPlannerPlanOptions): readonly SchemaIssue[] { const allowed = options.policy.allowedOperationClasses; const strict = allowed.includes('widening') || allowed.includes('destructive'); - const verifyResult = diffSqliteDatabaseSchema({ + const diff = diffSqliteDatabaseSchema({ contract: options.contract, actualSchema: options.schema, strict, typeMetadataRegistry: new Map(), frameworkComponents: options.frameworkComponents, }); - return verifyResult.schema.issues; + return diff.issues; } } diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts index 600ac44318..a3aac5e692 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts @@ -21,7 +21,7 @@ import { ifDefined } from '@prisma-next/utils/defined'; import type { Result } from '@prisma-next/utils/result'; import { notOk, ok, okVoid } from '@prisma-next/utils/result'; import { MARKER_TABLE_NAME } from '../control-tables'; -import { diffSqliteDatabaseSchema } from './diff-database-schema'; +import { verifySqliteDatabaseSchema } from './diff-database-schema'; import type { SqlitePlanTargetDetails } from './planner-target-details'; export function createSqliteMigrationRunner( @@ -94,7 +94,7 @@ class SqliteMigrationRunner implements SqlMigrationRunner, schema: PostgresDataba strict: false, typeMetadataRegistry: new Map(), frameworkComponents: [], - }).schema.schemaDiffIssues; + }).schemaDiffIssues; } describe('diffDatabaseSchema — RLS drift detection', () => { From 4e256f82e3622edb56f4fb855b70249be28f02b5 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 09:42:57 +0200 Subject: [PATCH 31/49] refactor(migration): contract-space by issue-filtering; delete the schema-pruning layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework no longer prunes the introspected schema before diffing. It diffs the full schema per contract-space member and scopes the resulting findings to the member: dropping the `extra` findings for entities another member claims, keyed by entity name (the coordinate the pruning layer keyed on). Extras owned by no member survive as each member undeclared tables. New framework-level helper `scopeSchemaResultToSpace` filters a `VerifyDatabaseSchemaResult` over framework types only (SchemaIssue / SchemaDiffIssue / SchemaVerificationNode) — it reads no storage shape and branches on no family. The aggregate verifier verifies each member against the full schema then scopes the result; the Mongo runner does the same via a `scopeVerifyResult` hook (replacing its input-pruning `projectSchema` hook). The plan path keeps sibling tables out of the ops the same way: synth passes the full schema plus every other member entity names as `entitiesOwnedByOtherSpaces`, and each SQL planner runs `scopePlanDiffToSpace` over its `SchemaDiff` (dropping the `extra` findings) before building ops, so no DROP is emitted for a sibling space table. Deleted: `project-schema-to-space.ts`, both family `schema-shape.ts` modules, the `projectSchemaToMember` / `listSchemaEntityNames` family callbacks + the framework interface methods + their CLI wiring, the `TSchemaResult` generic on the aggregate verifier, and the now-unused `orphanElements` / `detectOrphanElements`. Behaviour-neutral: fixtures:check clean (planner ops byte-identical), the cross-namespace-fk / supabase classification + cross-contract-fk / multi-namespace-runtime guards green, lint:deps clean (no framework storage-shape branch). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/control-instances.ts | 28 +-- .../src/control/control-migration-types.ts | 7 + .../cli/src/control-api/operations/db-run.ts | 8 - .../src/control-api/operations/db-verify.ts | 47 ++--- .../3-tooling/cli/test/config-types.test.ts | 2 - .../migration/src/aggregate/planner-types.ts | 12 +- .../migration/src/aggregate/planner.ts | 2 - .../src/aggregate/project-schema-to-space.ts | 60 ------ .../src/aggregate/scope-schema-result.ts | 140 +++++++++++++ .../src/aggregate/strategies/synth.ts | 27 +-- .../migration/src/aggregate/verifier.ts | 142 +++---------- .../migration/src/exports/aggregate.ts | 9 +- .../migration/test/aggregate/planner.test.ts | 19 -- .../aggregate/project-schema-to-space.test.ts | 161 -------------- .../aggregate/scope-schema-result.test.ts | 162 ++++++++++++++ .../test/aggregate/strategies/synth.test.ts | 29 ++- .../migration/test/aggregate/verifier.test.ts | 198 ++++++++---------- .../test/deletable-node-modules.test.ts | 33 ++- .../9-family/src/core/control-instance.ts | 20 +- .../9-family/src/core/schema-shape.ts | 102 --------- .../9-family/src/exports/control.ts | 4 - .../9-family/test/schema-shape.test.ts | 74 ------- .../9-family/src/core/control-instance.ts | 35 +--- .../9-family/src/core/diff/schema-shape.ts | 104 --------- .../src/core/migrations/scope-plan-diff.ts | 33 +++ .../9-family/src/core/migrations/types.ts | 7 + .../2-sql/9-family/src/exports/control.ts | 1 + packages/2-sql/9-family/src/exports/diff.ts | 4 - .../2-sql/9-family/test/schema-shape.test.ts | 108 ---------- .../1-mongo-target/src/core/control-target.ts | 57 ++--- .../1-mongo-target/src/core/mongo-runner.ts | 36 ++-- .../postgres/src/core/migrations/planner.ts | 22 +- .../sqlite/src/core/migrations/planner.ts | 18 +- 33 files changed, 614 insertions(+), 1097 deletions(-) delete mode 100644 packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts create mode 100644 packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts delete mode 100644 packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts create mode 100644 packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts delete mode 100644 packages/2-mongo-family/9-family/src/core/schema-shape.ts delete mode 100644 packages/2-mongo-family/9-family/test/schema-shape.test.ts delete mode 100644 packages/2-sql/9-family/src/core/diff/schema-shape.ts create mode 100644 packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts delete mode 100644 packages/2-sql/9-family/test/schema-shape.test.ts diff --git a/packages/1-framework/1-core/framework-components/src/control/control-instances.ts b/packages/1-framework/1-core/framework-components/src/control/control-instances.ts index 7ff6887287..c31f09df7d 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-instances.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-instances.ts @@ -38,14 +38,13 @@ export interface ControlFamilyInstance }): Promise; /** - * Verify a contract against an already-introspected schema slice. + * Verify a contract against an already-introspected schema. * * Callers that need to verify against the live database compose - * {@link introspect} + `verifySchema` directly; the family - * interface deliberately exposes the introspection step so callers - * can pre-project the schema (e.g. the aggregate verifier projects - * each member's claimed slice via - * {@link import('@prisma-next/migration-tools/aggregate').projectSchemaToSpace}). + * {@link introspect} + `verifySchema` directly. The aggregate verifier + * verifies each member against the full introspected schema and scopes the + * result to that member's contract space afterwards — it never prunes the + * schema up front. * * Synchronous — no I/O. Idempotent. */ @@ -56,23 +55,6 @@ export interface ControlFamilyInstance readonly frameworkComponents: ReadonlyArray>; }): VerifyDatabaseSchemaResult; - /** - * Prunes the introspected live schema to the slice claimed by one aggregate - * member, given the entity names owned by the other members. The framework - * touches no storage shape: the aggregate verifier/planner call this so the - * per-space verify doesn't see sibling members' entities as extras. Only the - * family knows its introspected shape (SQL's flat/namespaced `SqlSchemaIRNode`, - * Mongo's `collections`). - */ - projectSchemaToMember(schema: TSchemaIR, ownedByOtherNames: ReadonlySet): TSchemaIR; - - /** - * Lists the bare names of every top-level entity in the introspected live - * schema. Used by the aggregate verifier's orphan detection. Family-provided - * for the same reason as {@link projectSchemaToMember}. - */ - listSchemaEntityNames(schema: TSchemaIR): readonly string[]; - sign(options: { readonly driver: ControlDriverInstance; readonly contract: unknown; diff --git a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts index 621e5affd7..385f000e2c 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts @@ -407,6 +407,13 @@ export interface MigrationPlanner< * per-extension callers pass the extension's space id. */ readonly spaceId: string; + /** + * Entity names every OTHER contract-space member claims. The planner + * diffs the full live schema, then drops the `extra` findings for these + * names so it never emits DROP ops against a sibling space's tables. + * Absent (or empty) for single-space plans. + */ + readonly entitiesOwnedByOtherSpaces?: ReadonlySet; }): MigrationPlannerResult; /** diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts index 6daae010bd..b9b7cc5d3b 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts @@ -185,14 +185,6 @@ export async function executeRun - familyInstance.projectSchemaToMember( - blindCast< - never, - 'family TSchemaIR is opaque to the CLI; schema is passed straight through' - >(schema), - ownedByOtherNames, - ), }); if (!planResult.ok) { onProgress?.({ action, kind: 'spanEnd', spanId: SPAN_IDS.plan, outcome: 'error' }); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts index f0c778413f..cff1cb24ad 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts @@ -37,9 +37,9 @@ const SPAN_IDS = { * * Loader → verifier pipeline. The loader (sole descriptor-import * boundary) builds a {@link import('@prisma-next/migration-tools/aggregate').ContractSpaceAggregate}; - * the aggregate verifier bundles `markerCheck` + per-space pre-projected - * `schemaCheck`. `mode: 'strict' | 'lenient'` maps directly to the user - * facing `--strict` flag. + * the aggregate verifier bundles `markerCheck` + per-space `schemaCheck` + * (each member verified against the full schema, then scoped to its space). + * `mode: 'strict' | 'lenient'` maps directly to the user facing `--strict` flag. */ export interface ExecuteDbVerifyOptions { readonly driver: ControlDriverInstance; @@ -85,11 +85,11 @@ export type ExecuteDbVerifyResult = Result( options: ExecuteDbVerifyOptions, @@ -116,21 +116,6 @@ export async function executeDbVerify - familyInstance.projectSchemaToMember( - blindCast< - never, - 'family TSchemaIR is opaque to the CLI; schema is passed straight through' - >(schema), - ownedByOtherNames, - ), - listEntityNames: (schema) => - familyInstance.listSchemaEntityNames( - blindCast< - never, - 'family TSchemaIR is opaque to the CLI; schema is passed straight through' - >(schema), - ), }); return finaliseVerifyResult({ verifyResult, aggregate, skipMarker, onProgress }); } @@ -189,22 +174,22 @@ async function runIntrospection( options: ExecuteDbVerifyOptions, ): ( - projectedSchema: unknown, + schema: unknown, member: ContractSpaceMember, verifyMode: 'strict' | 'lenient', ) => VerifyDatabaseSchemaResult { const { skipSchema, familyInstance, frameworkComponents } = options; - return (projectedSchema, member, verifyMode) => { + return (schema, member, verifyMode) => { if (skipSchema) return buildSkippedSchemaResult(member); return familyInstance.verifySchema({ contract: member.contract(), - // The family's `TSchemaIR` is opaque to migration-tools; the - // aggregate verifier passes through whatever we hand it. The - // family expects its own IR shape on the way back. + // The family's `TSchemaIR` is opaque to migration-tools; the aggregate + // verifier passes the full introspected schema through and scopes the + // result afterwards. The family expects its own IR shape on the way back. schema: blindCast< never, - 'family TSchemaIR is opaque to migration-tools; projectedSchema is passed straight through' - >(projectedSchema), + 'family TSchemaIR is opaque to migration-tools; the full schema is passed straight through' + >(schema), strict: verifyMode === 'strict', frameworkComponents, }); @@ -238,7 +223,7 @@ function emitVerifySpan( * by the CLI's `--schema-only` mode. */ function finaliseVerifyResult(args: { - verifyResult: VerifierOutput; + verifyResult: VerifierOutput; aggregate: { readonly app: { readonly spaceId: string }; readonly extensions: ReadonlyArray<{ readonly spaceId: string }>; diff --git a/packages/1-framework/3-tooling/cli/test/config-types.test.ts b/packages/1-framework/3-tooling/cli/test/config-types.test.ts index 36d848296a..c8f1d9d4d8 100644 --- a/packages/1-framework/3-tooling/cli/test/config-types.test.ts +++ b/packages/1-framework/3-tooling/cli/test/config-types.test.ts @@ -62,8 +62,6 @@ describe('defineConfig', () => { }, timings: { total: 0 }, }), - projectSchemaToMember: (schema: unknown) => schema, - listSchemaEntityNames: () => [], sign: async () => ({ ok: true, summary: 'test', diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts b/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts index dcee5de17f..97bd2e410e 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts @@ -12,7 +12,6 @@ import type { import type { Result } from '@prisma-next/utils/result'; import type { PathDecision } from '../migration-graph'; import type { ContractMarkerRecordLike } from './marker-types'; -import type { ProjectSchemaToMember } from './project-schema-to-space'; import type { ContractSpaceAggregate } from './types'; /** @@ -43,9 +42,9 @@ export interface CallerPolicy { * marker yet (greenfield space). The planner treats the marker's * `storageHash` as the graph-walk's `from` node, falling back to * {@link import('../constants').EMPTY_CONTRACT_HASH} when absent. - * - `schemaIntrospection`: the family's full live schema IR. Fed into - * the synth strategy after per-space pre-projection via - * {@link import('./project-schema-to-space').projectSchemaToSpace}. + * - `schemaIntrospection`: the family's full live schema IR. Fed into the + * synth strategy in full; the planner scopes the resulting diff to each + * member's own space rather than pruning the schema up front. * * Callers (CLI commands) gather this via the family's * `readAllMarkers` + `introspect` calls before invoking the planner. @@ -82,11 +81,6 @@ export interface PlannerInput>; readonly callerPolicy: CallerPolicy; readonly operationPolicy: MigrationOperationPolicy; - /** - * Family-provided callback that prunes the live schema to a member's slice. - * Threaded into the synth strategy; the planner never touches the shape. - */ - readonly projectSchemaToMember: ProjectSchemaToMember; } /** diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts b/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts index 3ba65f0f2c..c16aa2490f 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts @@ -81,7 +81,6 @@ export async function planMigration, -) => unknown; - -/** - * Lists the bare names of every top-level entity in the introspected live - * schema. Family-provided, for the same reason as {@link ProjectSchemaToMember}: - * only the family knows how its introspected schema is shaped. - */ -export type ListSchemaEntityNames = (schema: unknown) => readonly string[]; - -/** - * The entity names claimed by every member of the aggregate **other than** - * `member`. Target-agnostic: reads the contract-side storage IR through the - * framework's {@link elementCoordinates}, never the introspected schema shape. - */ -export function collectOwnedNames( - member: ContractSpaceMember, - otherMembers: ReadonlyArray, -): Set { - const owned = new Set(); - for (const other of otherMembers) { - if (other.spaceId === member.spaceId) continue; - for (const { entityName } of elementCoordinates(other.contract().storage)) { - owned.add(entityName); - } - } - return owned; -} - -/** - * Projects the live schema to `member`'s slice by collecting the names owned by - * the other members ({@link collectOwnedNames}) and handing them to the - * family-provided {@link ProjectSchemaToMember} callback. When nothing is owned - * by others, the schema is returned unchanged without invoking the callback. - */ -export function projectSchemaToSpace( - schema: unknown, - member: ContractSpaceMember, - otherMembers: ReadonlyArray, - projectSchemaToMember: ProjectSchemaToMember, -): unknown { - const ownedByOthers = collectOwnedNames(member, otherMembers); - if (ownedByOthers.size === 0) return schema; - return projectSchemaToMember(schema, ownedByOthers); -} diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts b/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts new file mode 100644 index 0000000000..52aa8afeef --- /dev/null +++ b/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts @@ -0,0 +1,140 @@ +import type { + BaseSchemaIssue, + SchemaDiffIssue, + SchemaIssue, + SchemaVerificationNode, + VerifyDatabaseSchemaResult, +} from '@prisma-next/framework-components/control'; +import { elementCoordinates } from '@prisma-next/framework-components/ir'; +import type { ContractSpaceMember } from './types'; + +/** + * The entity names claimed by every aggregate member other than `member`. + * Read from the contract-side storage IR through the framework's + * {@link elementCoordinates}; the introspected schema shape is never touched. + */ +export function otherMemberEntityNames( + member: ContractSpaceMember, + otherMembers: ReadonlyArray, +): Set { + const owned = new Set(); + for (const other of otherMembers) { + if (other.spaceId === member.spaceId) continue; + for (const { entityName } of elementCoordinates(other.contract().storage)) { + owned.add(entityName); + } + } + return owned; +} + +/** The entity name a verification node addresses: the last segment of its coordinate path. */ +function nodeEntityName(node: SchemaVerificationNode): string | undefined { + const segments = node.contractPath.split('.'); + return segments.length > 0 ? segments[segments.length - 1] : undefined; +} + +/** True when an issue reports an entity present in the database but claimed by no member (an extra). */ +function isExtraIssue(issue: SchemaIssue): issue is BaseSchemaIssue { + return ( + issue.kind === 'extra_table' || + issue.kind === 'extra_column' || + issue.kind === 'extra_primary_key' || + issue.kind === 'extra_foreign_key' || + issue.kind === 'extra_unique_constraint' || + issue.kind === 'extra_index' || + issue.kind === 'extra_validator' || + issue.kind === 'extra_default' + ); +} + +/** The bare entity name an extra `SchemaDiffIssue` addresses, read off its actual (live-DB) node. */ +function schemaDiffIssueEntityName(issue: SchemaDiffIssue): string | undefined { + const actual = issue.actual; + if (actual === undefined) return undefined; + const name = (actual as { readonly tableName?: unknown }).tableName; + return typeof name === 'string' ? name : undefined; +} + +function aggregateStatus(children: readonly SchemaVerificationNode[]): 'pass' | 'warn' | 'fail' { + let status: 'pass' | 'warn' | 'fail' = 'pass'; + for (const child of children) { + if (child.status === 'fail') return 'fail'; + if (child.status === 'warn') status = 'warn'; + } + return status; +} + +function pruneTree( + node: SchemaVerificationNode, + ownedByOthers: ReadonlySet, +): SchemaVerificationNode { + if (node.children.length === 0) return node; + const keptChildren = node.children + .filter((child) => { + const name = nodeEntityName(child); + return name === undefined || !ownedByOthers.has(name); + }) + .map((child) => pruneTree(child, ownedByOthers)); + return { ...node, status: aggregateStatus(keptChildren), children: keptChildren }; +} + +function countTree(node: SchemaVerificationNode): { + pass: number; + warn: number; + fail: number; + totalNodes: number; +} { + let pass = 0; + let warn = 0; + let fail = 0; + let totalNodes = 0; + const visit = (n: SchemaVerificationNode): void => { + totalNodes += 1; + if (n.status === 'pass') pass += 1; + else if (n.status === 'warn') warn += 1; + else fail += 1; + for (const child of n.children) visit(child); + }; + visit(node); + return { pass, warn, fail, totalNodes }; +} + +/** + * Scope a per-member verify result to the member's own contract space: drop the + * `extra` findings for entities another aggregate member claims. Diffing the + * full introspected schema surfaces every other member's tables as extras; + * this removes exactly those (keyed by entity name, the coordinate the pruning + * layer keyed on), leaving each member's own drift plus the truly undeclared + * tables (extras owned by no member). + * + * A framework-level filter over framework result types only — it reads no + * storage shape and branches on no family. `ownedByOthers` is the set of entity + * names every other member claims (see {@link otherMemberEntityNames}). + */ +export function scopeSchemaResultToSpace( + result: VerifyDatabaseSchemaResult, + ownedByOthers: ReadonlySet, +): VerifyDatabaseSchemaResult { + if (ownedByOthers.size === 0) return result; + + const issues = result.schema.issues.filter( + (issue) => + !(isExtraIssue(issue) && issue.table !== undefined && ownedByOthers.has(issue.table)), + ); + const schemaDiffIssues = result.schema.schemaDiffIssues.filter((issue) => { + if (issue.outcome !== 'extra') return true; + const name = schemaDiffIssueEntityName(issue); + return name === undefined || !ownedByOthers.has(name); + }); + const root = pruneTree(result.schema.root, ownedByOthers); + const counts = countTree(root); + const ok = counts.fail === 0; + + return { + ...result, + ok, + ...(ok ? {} : { code: result.code ?? 'PN-RUN-3010' }), + summary: ok ? 'Database schema satisfies contract' : result.summary, + schema: { issues, schemaDiffIssues, root, counts }, + }; +} diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts b/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts index aa15f206e9..2b161459e1 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts @@ -10,7 +10,7 @@ import type { } from '@prisma-next/framework-components/control'; import type { ContractMarkerRecordLike } from '../marker-types'; import type { PerSpacePlan } from '../planner-types'; -import { type ProjectSchemaToMember, projectSchemaToSpace } from '../project-schema-to-space'; +import { otherMemberEntityNames } from '../scope-schema-result'; import { buildSynthMigrationEdge } from '../synth-migration-edge'; import type { ContractSpaceMember } from '../types'; @@ -28,7 +28,6 @@ export interface SynthStrategyInputs; readonly frameworkComponents: ReadonlyArray>; readonly operationPolicy: MigrationOperationPolicy; - readonly projectSchemaToMember: ProjectSchemaToMember; } export type SynthStrategyOutcome = @@ -45,14 +44,14 @@ export type SynthStrategyOutcome = type MaybeAsyncPlannerResult = MigrationPlannerResult | Promise; /** - * Synthesise a migration plan for a single member by projecting the - * live schema down to that member's claimed slice and delegating to - * the family's `createPlanner(...).plan(...)`. + * Synthesise a migration plan for a single member from the full live schema, + * delegating to the family's `createPlanner(...).plan(...)`. * - * Pre-projection (via {@link projectSchemaToSpace}) closes the F23 - * concern: without it, the family's planner sees other members' - * tables as "extras" and emits destructive ops to drop them. With it, - * the planner only sees the slice this member claims. + * The planner diffs the whole introspected schema, so it sees other members' + * tables as "extras"; `entitiesOwnedByOtherSpaces` (every other member's + * claimed entity names) tells it to drop those extras before building ops, so + * it never emits a destructive drop for a sibling space's table. The schema is + * never pruned before planning. * * The synthesised plan's `targetId` is set from `aggregateTargetId` * (the aggregate's ambient target). The family's planner does not @@ -70,21 +69,15 @@ type MaybeAsyncPlannerResult = MigrationPlannerResult | Promise( input: SynthStrategyInputs, ): Promise { - const projectedSchema = projectSchemaToSpace( - input.schemaIntrospection, - input.member, - input.otherMembers, - input.projectSchemaToMember, - ); - const planner = input.migrations.createPlanner(input.adapter); const plannerResult: MigrationPlannerResult = await (planner.plan({ contract: input.member.contract(), - schema: projectedSchema, + schema: input.schemaIntrospection, policy: input.operationPolicy, fromContract: null, frameworkComponents: input.frameworkComponents, spaceId: input.member.spaceId, + entitiesOwnedByOtherSpaces: otherMemberEntityNames(input.member, input.otherMembers), }) as MaybeAsyncPlannerResult); if (plannerResult.kind === 'failure') { diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts b/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts index a20cc22e9d..f2ad1a0cc1 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts @@ -1,13 +1,9 @@ -import { elementCoordinates } from '@prisma-next/framework-components/ir'; +import type { VerifyDatabaseSchemaResult } from '@prisma-next/framework-components/control'; import type { Result } from '@prisma-next/utils/result'; import { notOk, ok } from '@prisma-next/utils/result'; import { requireHeadRef } from './aggregate'; import type { ContractMarkerRecordLike } from './marker-types'; -import { - type ListSchemaEntityNames, - type ProjectSchemaToMember, - projectSchemaToSpace, -} from './project-schema-to-space'; +import { otherMemberEntityNames, scopeSchemaResultToSpace } from './scope-schema-result'; import type { ContractSpaceAggregate, ContractSpaceMember } from './types'; /** @@ -16,37 +12,23 @@ import type { ContractSpaceAggregate, ContractSpaceMember } from './types'; * any aggregate member) as errors; `lenient` treats them as * informational. Maps directly to `db verify --strict`. */ -export interface VerifierInput { +export interface VerifierInput { readonly aggregate: ContractSpaceAggregate; readonly markersBySpaceId: ReadonlyMap; readonly schemaIntrospection: unknown; readonly mode: 'strict' | 'lenient'; /** - * Caller-supplied per-space schema verifier. The CLI wires this to - * the family's `verifySqlSchema` (SQL) / equivalent (other - * families). The verifier projects the schema to the - * member's slice via {@link projectSchemaToSpace} before invoking - * the callback, so single-contract semantics are preserved. - * - * Typed structurally with a generic `TSchemaResult` so the - * migration-tools layer doesn't depend on the SQL family's - * `VerifySqlSchemaResult`. CLI callers pass the family's type - * through unchanged. + * Caller-supplied per-space schema verifier. The CLI wires this to the + * family's `verifySchema`. It verifies the member against the **full** + * introspected schema; the verifier then scopes the result to the member's + * contract space (dropping the extras other members claim). It composes no + * pre-projection, so the framework never touches the storage shape. */ readonly verifySchemaForMember: ( - projectedSchema: unknown, + schema: unknown, member: ContractSpaceMember, mode: 'strict' | 'lenient', - ) => TSchemaResult; - /** - * Caller-supplied schema-shape callbacks. The framework touches no storage - * shape: `projectSchemaToMember` prunes the live schema to a member's slice, - * and `listEntityNames` enumerates the live entity names for orphan - * detection. The families provide both (each knows how its own introspected - * schema is shaped); the CLI wires them. - */ - readonly projectSchemaToMember: ProjectSchemaToMember; - readonly listEntityNames: ListSchemaEntityNames; + ) => VerifyDatabaseSchemaResult; } /** @@ -73,30 +55,13 @@ export interface MarkerCheckSection { }[]; } -/** - * A live storage element (today: a top-level table) not claimed by any - * member of the aggregate. The verifier always reports these; - * the caller decides what to do — `db verify --strict` treats them as - * errors, the lenient default treats them as informational. - * - * Today only `kind: 'table'` exists. The discriminated shape leaves - * room for orphan columns / indexes / sequences in the future without - * breaking the type contract. - */ -export type OrphanElement = { readonly kind: 'table'; readonly name: string }; - -export interface SchemaCheckSection { - readonly perSpace: ReadonlyMap; - /** - * Live elements present in the introspected schema that are not - * claimed by **any** aggregate member. Sorted alphabetically by name. - */ - readonly orphanElements: readonly OrphanElement[]; +export interface SchemaCheckSection { + readonly perSpace: ReadonlyMap; } -export interface VerifierSuccess { +export interface VerifierSuccess { readonly markerCheck: MarkerCheckSection; - readonly schemaCheck: SchemaCheckSection; + readonly schemaCheck: SchemaCheckSection; } export type VerifierError = { @@ -104,7 +69,7 @@ export type VerifierError = { readonly detail: string; }; -export type VerifierOutput = Result, VerifierError>; +export type VerifierOutput = Result; /** * Verify a {@link ContractSpaceAggregate} against the live database @@ -114,12 +79,11 @@ export type VerifierOutput = Result = Result( - input: VerifierInput, -): VerifierOutput { +export function verifyMigration(input: VerifierInput): VerifierOutput { try { return runVerifyMigration(input); } catch (error) { @@ -141,18 +103,8 @@ export function verifyMigration( } } -function runVerifyMigration( - input: VerifierInput, -): VerifierOutput { - const { - aggregate, - markersBySpaceId, - schemaIntrospection, - mode, - verifySchemaForMember, - projectSchemaToMember, - listEntityNames, - } = input; +function runVerifyMigration(input: VerifierInput): VerifierOutput { + const { aggregate, markersBySpaceId, schemaIntrospection, mode, verifySchemaForMember } = input; const allMembers: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; const memberSpaceIds = new Set(allMembers.map((m) => m.spaceId)); @@ -195,17 +147,16 @@ function runVerifyMigration( } orphanMarkers.sort((a, b) => a.spaceId.localeCompare(b.spaceId)); - // Schema check per member (with per-space pre-projection). - const schemaPerSpace = new Map(); + // Schema check per member: verify against the full schema, then scope the + // result to the member's contract space. + const schemaPerSpace = new Map(); for (const member of allMembers) { const others = allMembers.filter((m) => m.spaceId !== member.spaceId); - const projected = projectSchemaToSpace( - schemaIntrospection, - member, - others, - projectSchemaToMember, + const result = verifySchemaForMember(schemaIntrospection, member, mode); + schemaPerSpace.set( + member.spaceId, + scopeSchemaResultToSpace(result, otherMemberEntityNames(member, others)), ); - schemaPerSpace.set(member.spaceId, verifySchemaForMember(projected, member, mode)); } return ok({ @@ -215,39 +166,6 @@ function runVerifyMigration( }, schemaCheck: { perSpace: schemaPerSpace, - orphanElements: detectOrphanElements(schemaIntrospection, allMembers, listEntityNames), }, }); } - -/** - * Live entities not claimed by any aggregate member. The live entity names come - * from the family-provided {@link ListSchemaEntityNames} callback; the claimed - * names come from each member's contract storage via {@link elementCoordinates} - * (target-agnostic). The framework never inspects the schema shape. - */ -function detectOrphanElements( - schemaIntrospection: unknown, - members: ReadonlyArray, - listEntityNames: ListSchemaEntityNames, -): readonly OrphanElement[] { - const liveTableNames = listEntityNames(schemaIntrospection); - if (liveTableNames.length === 0) return []; - - const claimedTables = new Set(); - for (const member of members) { - const contract = member.contract(); - for (const { entityName } of elementCoordinates(contract.storage)) { - claimedTables.add(entityName); - } - } - - const orphans: OrphanElement[] = []; - for (const tableName of liveTableNames) { - if (!claimedTables.has(tableName)) { - orphans.push({ kind: 'table', name: tableName }); - } - } - orphans.sort((a, b) => a.name.localeCompare(b.name)); - return orphans; -} diff --git a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts index 17a3b4a32f..94b3ba40fb 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts @@ -24,11 +24,9 @@ export { planMigration, } from '../aggregate/planner'; export { - collectOwnedNames, - type ListSchemaEntityNames, - type ProjectSchemaToMember, - projectSchemaToSpace, -} from '../aggregate/project-schema-to-space'; + otherMemberEntityNames, + scopeSchemaResultToSpace, +} from '../aggregate/scope-schema-result'; export { type GraphWalkOutcome, type GraphWalkStrategyInputs, @@ -44,7 +42,6 @@ export type { export { type MarkerCheckResult, type MarkerCheckSection, - type OrphanElement, type SchemaCheckSection, type VerifierError, type VerifierInput, diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts index 099d1504e0..cd17f74f45 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts @@ -21,19 +21,6 @@ const POLICY: MigrationOperationPolicy = { allowedOperationClasses: ['additive', 'widening'], }; -// Flat-`tables` schema-shape pruning standing in for a family's callback. The -// planner is family-agnostic: it threads this into the synth strategy and never -// inspects the schema shape itself. -const STUB_PROJECT = (schema: unknown, ownedByOtherNames: ReadonlySet): unknown => { - const s = schema as { tables?: Record }; - if (typeof s !== 'object' || s === null || typeof s.tables !== 'object') return schema; - const pruned: Record = {}; - for (const [name, value] of Object.entries(s.tables)) { - if (!ownedByOtherNames.has(name)) pruned[name] = value; - } - return { ...s, tables: pruned }; -}; - function makeMember(args: { spaceId: string; contract?: Contract; @@ -121,7 +108,6 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, - projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(true); @@ -159,7 +145,6 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, - projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(true); @@ -198,7 +183,6 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, - projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(true); @@ -233,7 +217,6 @@ describe('planMigration', () => { // policy conflict. callerPolicy: { ignoreGraphFor: new Set(['app', 'cipherstash']) }, operationPolicy: POLICY, - projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(false); @@ -272,7 +255,6 @@ describe('planMigration', () => { // graph-walk can't satisfy its non-empty invariants. callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, - projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(false); @@ -303,7 +285,6 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, - projectSchemaToMember: STUB_PROJECT, }); expect(result.ok).toBe(false); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts deleted file mode 100644 index 282e1afea6..0000000000 --- a/packages/1-framework/3-tooling/migration/test/aggregate/project-schema-to-space.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { StorageBase } from '@prisma-next/contract/types'; -import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; -import { createContract, createSqlContract } from '@prisma-next/test-utils'; -import { describe, expect, it } from 'vitest'; -import { - collectOwnedNames, - projectSchemaToSpace, -} from '../../src/aggregate/project-schema-to-space'; -import type { ContractSpaceMember } from '../../src/aggregate/types'; -import { makeContractSpaceMember } from '../fixtures'; - -type MongoStorageLike = StorageBase & { - readonly namespaces: Record< - string, - { - readonly id: string; - readonly kind: string; - readonly entries: { readonly collection: Record }; - } - >; -}; - -/** - * `projectSchemaToSpace` is target-agnostic: it collects the entity names owned - * by the other members from their contract storage, then delegates the actual - * schema pruning to a family-provided callback. It never inspects the - * introspected schema shape (that lives in the SQL / Mongo family - * `schema-shape` modules and is tested there). - */ -describe('projectSchemaToSpace', () => { - function memberWithTables(spaceId: string, tables: Record): ContractSpaceMember { - return makeContractSpaceMember({ - spaceId, - contract: createSqlContract({ - storage: { - namespaces: { - [UNBOUND_NAMESPACE_ID]: { - id: UNBOUND_NAMESPACE_ID, - entries: { table: tables }, - }, - }, - }, - }), - }); - } - - function memberWithCollections( - spaceId: string, - collections: Record, - ): ContractSpaceMember { - return makeContractSpaceMember({ - spaceId, - contract: createContract({ - target: 'mongo', - targetFamily: 'mongo', - storage: { - namespaces: { - [UNBOUND_NAMESPACE_ID]: { - id: UNBOUND_NAMESPACE_ID, - kind: 'mongo-namespace', - entries: { collection: collections }, - }, - }, - }, - }), - }); - } - - // The callback the framework never implements; the test supplies a trivial - // one that records what it was handed, so we assert on the framework's - // target-agnostic behaviour rather than any storage shape. - const passthrough = (schema: unknown) => schema; - - describe('zero-cost path (does not invoke the callback)', () => { - it('returns the schema verbatim when the other-members list is empty', () => { - const schema = { tables: { user: {} } }; - const member = memberWithTables('app', { user: {} }); - let called = false; - const result = projectSchemaToSpace(schema, member, [], (s) => { - called = true; - return s; - }); - expect(result).toBe(schema); - expect(called).toBe(false); - }); - - it('returns the schema verbatim when other-members contains only the projection target', () => { - const schema = { tables: { user: {} } }; - const member = memberWithTables('app', { user: {} }); - let called = false; - projectSchemaToSpace(schema, member, [member], (s) => { - called = true; - return s; - }); - expect(called).toBe(false); - }); - }); - - describe('delegation', () => { - it('invokes the callback with the schema and the names owned by other members', () => { - const schema = { tables: { app_user: {} } }; - const member = memberWithTables('app', { app_user: {} }); - const others = [ - memberWithTables('audit', { ext_audit_log: {} }), - memberWithTables('flags', { ext_feature_flag: {} }), - ]; - let seenSchema: unknown; - let seenNames: ReadonlySet | undefined; - projectSchemaToSpace(schema, member, others, (s, names) => { - seenSchema = s; - seenNames = names; - return s; - }); - expect(seenSchema).toBe(schema); - expect([...(seenNames ?? [])].sort()).toEqual(['ext_audit_log', 'ext_feature_flag']); - }); - - it('returns whatever the callback returns', () => { - const schema = { tables: { app_user: {}, ext_owned: {} } }; - const pruned = { tables: { app_user: {} } }; - const member = memberWithTables('app', { app_user: {} }); - const others = [memberWithTables('ext', { ext_owned: {} })]; - const result = projectSchemaToSpace(schema, member, others, () => pruned); - expect(result).toBe(pruned); - }); - - it('excludes the projection target itself when it appears in other-members', () => { - const schema = { tables: { app_user: {}, ext_owned: {} } }; - const member = memberWithTables('app', { app_user: {} }); - const others = [member, memberWithTables('ext', { ext_owned: {} })]; - let seenNames: ReadonlySet | undefined; - projectSchemaToSpace(schema, member, others, (s, names) => { - seenNames = names; - return s; - }); - expect([...(seenNames ?? [])]).toEqual(['ext_owned']); - }); - }); - - describe('collectOwnedNames', () => { - it('collects table names from SQL-shaped other-member contracts', () => { - const member = memberWithTables('app', { app_user: {} }); - const others = [memberWithTables('ext', { ext_a: {}, ext_b: {} })]; - expect([...collectOwnedNames(member, others)].sort()).toEqual(['ext_a', 'ext_b']); - }); - - it('collects collection names from Mongo-shaped other-member contracts', () => { - const member = memberWithCollections('app', { users: {} }); - const others = [memberWithCollections('ext', { cipher_state: {} })]; - expect([...collectOwnedNames(member, others)]).toEqual(['cipher_state']); - }); - - it('returns an empty set when the only other member is the projection target', () => { - const member = memberWithTables('app', { user: {} }); - expect(collectOwnedNames(member, [member]).size).toBe(0); - // A callback-free projection with nothing owned returns the input. - const schema = { tables: { user: {} } }; - expect(projectSchemaToSpace(schema, member, [member], passthrough)).toBe(schema); - }); - }); -}); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts new file mode 100644 index 0000000000..0d890c3395 --- /dev/null +++ b/packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts @@ -0,0 +1,162 @@ +import type { + DiffableNode, + SchemaVerificationNode, + VerifyDatabaseSchemaResult, +} from '@prisma-next/framework-components/control'; +import { describe, expect, it } from 'vitest'; +import { scopeSchemaResultToSpace } from '../../src/aggregate/scope-schema-result'; + +function policyNode(id: string, tableName: string): DiffableNode { + return { id, tableName, isEqualTo: () => false, children: () => [] } as DiffableNode; +} + +function tableNode( + name: string, + status: 'pass' | 'warn' | 'fail', + code = '', +): SchemaVerificationNode { + return { + status, + kind: 'table', + name: `table ${name}`, + contractPath: `storage.namespaces.*.entries.table.${name}`, + code, + message: '', + expected: undefined, + actual: undefined, + children: [], + }; +} + +function resultWith( + children: readonly SchemaVerificationNode[], + issues: VerifyDatabaseSchemaResult['schema']['issues'], +): VerifyDatabaseSchemaResult { + const fail = children.filter((c) => c.status === 'fail').length; + const warn = children.filter((c) => c.status === 'warn').length; + const pass = children.filter((c) => c.status === 'pass').length; + const rootStatus = fail > 0 ? 'fail' : warn > 0 ? 'warn' : 'pass'; + return { + ok: fail === 0, + summary: 'original summary', + contract: { storageHash: 'sha256:test' }, + target: { expected: 'postgres' }, + schema: { + issues, + schemaDiffIssues: [], + root: { + status: rootStatus, + kind: 'contract', + name: 'contract', + contractPath: '', + code: '', + message: '', + expected: undefined, + actual: undefined, + children, + }, + counts: { pass: pass + 1, warn, fail, totalNodes: children.length + 1 }, + }, + timings: { total: 0 }, + }; +} + +describe('scopeSchemaResultToSpace', () => { + it('returns the input unchanged when no names are owned by others', () => { + const result = resultWith([tableNode('user', 'pass')], []); + expect(scopeSchemaResultToSpace(result, new Set())).toBe(result); + }); + + it('drops an extra-table issue owned by another member, keeps the undeclared one', () => { + const result = resultWith( + [tableNode('user', 'pass'), tableNode('cipher_state', 'warn'), tableNode('orphan', 'warn')], + [ + { kind: 'extra_table', table: 'cipher_state', message: 'extra cipher_state' }, + { kind: 'extra_table', table: 'orphan', message: 'extra orphan' }, + ], + ); + + const scoped = scopeSchemaResultToSpace(result, new Set(['cipher_state'])); + + expect(scoped.schema.issues).toEqual([ + { kind: 'extra_table', table: 'orphan', message: 'extra orphan' }, + ]); + expect(scoped.schema.root.children.map((c) => c.name)).toEqual(['table user', 'table orphan']); + }); + + it('recomputes counts over the pruned tree', () => { + const result = resultWith( + [tableNode('user', 'pass'), tableNode('sibling', 'fail', 'extra_table')], + [{ kind: 'extra_table', table: 'sibling', message: 'extra sibling' }], + ); + + const scoped = scopeSchemaResultToSpace(result, new Set(['sibling'])); + + expect(scoped.schema.counts).toEqual({ pass: 2, warn: 0, fail: 0, totalNodes: 2 }); + }); + + it('flips ok to true and re-derives the summary when the only failures were siblings', () => { + const result = resultWith( + [tableNode('user', 'pass'), tableNode('sibling', 'fail', 'extra_table')], + [{ kind: 'extra_table', table: 'sibling', message: 'extra sibling' }], + ); + expect(result.ok).toBe(false); + + const scoped = scopeSchemaResultToSpace(result, new Set(['sibling'])); + + expect(scoped.ok).toBe(true); + expect(scoped.code).toBeUndefined(); + expect(scoped.summary).toBe('Database schema satisfies contract'); + }); + + it('keeps a real failure and stays not-ok when a sibling is dropped alongside it', () => { + const result = resultWith( + [tableNode('user', 'fail', 'missing_column'), tableNode('sibling', 'fail', 'extra_table')], + [ + { kind: 'missing_column', table: 'user', column: 'age', message: 'missing age' }, + { kind: 'extra_table', table: 'sibling', message: 'extra sibling' }, + ], + ); + + const scoped = scopeSchemaResultToSpace(result, new Set(['sibling'])); + + expect(scoped.ok).toBe(false); + // The root node stays `fail` (user still fails) and is itself counted, so + // fail = root + user = 2 once the sibling leaf is dropped. + expect(scoped.schema.counts.fail).toBe(2); + expect(scoped.schema.issues).toEqual([ + { kind: 'missing_column', table: 'user', column: 'age', message: 'missing age' }, + ]); + }); + + it('drops an extra policy schemaDiffIssue owned by another member', () => { + const result: VerifyDatabaseSchemaResult = { + ...resultWith([], []), + schema: { + issues: [], + schemaDiffIssues: [ + { + path: ['db', 'auth', 'sibling', 'p'], + outcome: 'extra', + message: 'extra policy', + actual: policyNode('p', 'sibling'), + }, + { + path: ['db', 'public', 'orphan', 'q'], + outcome: 'extra', + message: 'extra policy', + actual: policyNode('q', 'orphan'), + }, + ], + root: resultWith([], []).schema.root, + counts: { pass: 1, warn: 0, fail: 0, totalNodes: 1 }, + }, + }; + + const scoped = scopeSchemaResultToSpace(result, new Set(['sibling'])); + + expect(scoped.schema.schemaDiffIssues.map((i) => i.path.join('/'))).toEqual([ + 'db/public/orphan/q', + ]); + }); +}); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts index a8e9ce45fc..c725feb938 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts @@ -17,17 +17,6 @@ const POLICY: MigrationOperationPolicy = { allowedOperationClasses: ['additive', 'widening'], }; -// Flat-`tables` schema-shape pruning standing in for a family's callback. -const STUB_PROJECT = (schema: unknown, ownedByOtherNames: ReadonlySet): unknown => { - const s = schema as { tables?: Record }; - if (typeof s !== 'object' || s === null || typeof s.tables !== 'object') return schema; - const pruned: Record = {}; - for (const [name, value] of Object.entries(s.tables)) { - if (!ownedByOtherNames.has(name)) pruned[name] = value; - } - return { ...s, tables: pruned }; -}; - const STUB_ADAPTER: ControlAdapterInstance<'sql', 'postgres'> = {} as unknown as ControlAdapterInstance<'sql', 'postgres'>; @@ -56,11 +45,13 @@ function makeStubPlan(targetId: string): MigrationPlanWithAuthoringSurface { } describe('synthStrategy', () => { - it('projects the live schema before passing it to the family planner', async () => { + it('passes the full schema and the other spaces’ entity names to the family planner', async () => { let observedSchema: unknown; + let observedOwned: ReadonlySet | undefined; const stubPlanner: MigrationPlanner<'sql', 'postgres'> = { - plan: ({ schema }) => { + plan: ({ schema, entitiesOwnedByOtherSpaces }) => { observedSchema = schema; + observedOwned = entitiesOwnedByOtherSpaces; return { kind: 'success', plan: makeStubPlan('placeholder') }; }, emptyMigration: () => { @@ -100,7 +91,6 @@ describe('synthStrategy', () => { migrations: stubMigrations, frameworkComponents: [], operationPolicy: POLICY, - projectSchemaToMember: STUB_PROJECT, }); expect(outcome.kind).toBe('ok'); @@ -118,9 +108,15 @@ describe('synthStrategy', () => { }, ]); - // Critical: the planner saw a schema with cipher_state pruned out. + // Critical: the planner saw the FULL schema (no pre-pruning) and the set of + // entity names the sibling space owns, so it can drop those extras itself. const observed = observedSchema as { tables: Record }; - expect(Object.keys(observed.tables).sort()).toEqual(['app_user', 'orphan_table']); + expect(Object.keys(observed.tables).sort()).toEqual([ + 'app_user', + 'cipher_state', + 'orphan_table', + ]); + expect([...(observedOwned ?? [])].sort()).toEqual(['cipher_state']); }); it('forwards planner failures verbatim', async () => { @@ -155,7 +151,6 @@ describe('synthStrategy', () => { migrations: stubMigrations, frameworkComponents: [], operationPolicy: POLICY, - projectSchemaToMember: STUB_PROJECT, }); expect(outcome.kind).toBe('failure'); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts index cf0c2e781c..6297e77da0 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts @@ -1,4 +1,8 @@ import type { Contract } from '@prisma-next/contract/types'; +import type { + SchemaVerificationNode, + VerifyDatabaseSchemaResult, +} from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { createSqlContract } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; @@ -8,10 +12,6 @@ import type { ContractSpaceAggregate, ContractSpaceMember } from '../../src/aggr import { verifyMigration } from '../../src/aggregate/verifier'; import { makeContractSpaceMember } from '../fixtures'; -interface StubSchemaResult { - readonly tablesSeen: readonly string[]; -} - function makeMember(args: { spaceId: string; headHash: string; @@ -46,35 +46,71 @@ function makeAggregate(args: { }); } -const STUB_VERIFY = ( - projectedSchema: unknown, - _member: ContractSpaceMember, - _mode: 'strict' | 'lenient', -): StubSchemaResult => { - const schema = projectedSchema as { tables?: Record } | null; - if (!schema || typeof schema !== 'object' || !schema.tables) { - return { tablesSeen: [] }; - } - return { tablesSeen: Object.keys(schema.tables).sort() }; -}; +function extraTableNode(name: string): SchemaVerificationNode { + return { + status: 'warn', + kind: 'table', + name: `table ${name}`, + contractPath: `storage.namespaces.*.entries.table.${name}`, + code: 'extra_table', + message: '', + expected: undefined, + actual: undefined, + children: [], + }; +} -// Flat-`tables` schema-shape callbacks standing in for a family's. The verifier -// is family-agnostic: it only calls these, never inspects the shape itself. -const STUB_PROJECT = (schema: unknown, ownedByOtherNames: ReadonlySet): unknown => { - const s = schema as { tables?: Record }; - if (typeof s !== 'object' || s === null || typeof s.tables !== 'object') return schema; - const pruned: Record = {}; - for (const [name, value] of Object.entries(s.tables)) { - if (!ownedByOtherNames.has(name)) pruned[name] = value; - } - return { ...s, tables: pruned }; +/** + * A per-member verifier standing in for a family's: it verifies the member's + * contract against the **full** live schema and flags every live table the + * member does not declare as an `extra_table` warning — exactly the shape the + * real family verify produces before the aggregate verifier scopes it. + */ +const FULL_SCHEMA_VERIFY = ( + schema: unknown, + member: ContractSpaceMember, + _mode: 'strict' | 'lenient', +): VerifyDatabaseSchemaResult => { + const liveTables = Object.keys((schema as { tables?: Record })?.tables ?? {}); + const declared = new Set( + Object.keys(member.contract().storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries['table'] ?? {}), + ); + const extras = liveTables.filter((name) => !declared.has(name)); + const children = extras.map(extraTableNode); + return { + ok: true, + summary: 'Database schema satisfies contract', + contract: { storageHash: 'sha256:test' }, + target: { expected: 'postgres' }, + schema: { + issues: extras.map((name) => ({ + kind: 'extra_table' as const, + table: name, + message: `Extra table "${name}"`, + })), + schemaDiffIssues: [], + root: { + status: children.some((c) => c.status === 'warn') ? 'warn' : 'pass', + kind: 'contract', + name: 'contract', + contractPath: '', + code: '', + message: '', + expected: undefined, + actual: undefined, + children, + }, + counts: { pass: 1, warn: children.length, fail: 0, totalNodes: children.length + 1 }, + }, + timings: { total: 0 }, + }; }; -const STUB_LIST = (schema: unknown): readonly string[] => { - const s = schema as { tables?: Record }; - if (typeof s !== 'object' || s === null || typeof s.tables !== 'object') return []; - return Object.keys(s.tables); -}; +function extraTables(result: VerifyDatabaseSchemaResult | undefined): string[] { + return (result?.schema.issues ?? []) + .flatMap((issue) => ('table' in issue && issue.table ? [issue.table] : [])) + .sort(); +} describe('verifyMigration', () => { describe('markerCheck', () => { @@ -87,9 +123,7 @@ describe('verifyMigration', () => { markersBySpaceId: new Map(), schemaIntrospection: { tables: {} }, mode: 'strict', - verifySchemaForMember: STUB_VERIFY, - projectSchemaToMember: STUB_PROJECT, - listEntityNames: STUB_LIST, + verifySchemaForMember: FULL_SCHEMA_VERIFY, }); expect(result.ok).toBe(true); expect(result.assertOk().markerCheck.perSpace.get('app')).toEqual({ kind: 'absent' }); @@ -111,9 +145,7 @@ describe('verifyMigration', () => { markersBySpaceId: markers, schemaIntrospection: { tables: {} }, mode: 'strict', - verifySchemaForMember: STUB_VERIFY, - projectSchemaToMember: STUB_PROJECT, - listEntityNames: STUB_LIST, + verifySchemaForMember: FULL_SCHEMA_VERIFY, }); expect(result.assertOk().markerCheck.perSpace.get('app')).toEqual({ kind: 'ok' }); }); @@ -130,9 +162,7 @@ describe('verifyMigration', () => { markersBySpaceId: markers, schemaIntrospection: { tables: {} }, mode: 'strict', - verifySchemaForMember: STUB_VERIFY, - projectSchemaToMember: STUB_PROJECT, - listEntityNames: STUB_LIST, + verifySchemaForMember: FULL_SCHEMA_VERIFY, }); expect(result.assertOk().markerCheck.perSpace.get('app')).toEqual({ kind: 'hashMismatch', @@ -160,9 +190,7 @@ describe('verifyMigration', () => { markersBySpaceId: markers, schemaIntrospection: { tables: {} }, mode: 'strict', - verifySchemaForMember: STUB_VERIFY, - projectSchemaToMember: STUB_PROJECT, - listEntityNames: STUB_LIST, + verifySchemaForMember: FULL_SCHEMA_VERIFY, }); expect(result.assertOk().markerCheck.perSpace.get('cipher')).toEqual({ kind: 'missingInvariants', @@ -184,9 +212,7 @@ describe('verifyMigration', () => { markersBySpaceId: markers, schemaIntrospection: { tables: {} }, mode: 'strict', - verifySchemaForMember: STUB_VERIFY, - projectSchemaToMember: STUB_PROJECT, - listEntityNames: STUB_LIST, + verifySchemaForMember: FULL_SCHEMA_VERIFY, }); expect(result.assertOk().markerCheck.orphanMarkers.map((o) => o.spaceId)).toEqual([ 'cipher', @@ -196,14 +222,9 @@ describe('verifyMigration', () => { }); describe('schemaCheck', () => { - it('projects the schema per member before invoking the verifier (F23 lock)', () => { - // Multi-member deployment: each member sees only its own tables. + it('scopes each member to its own space, dropping the extras another member claims', () => { const aggregate = makeAggregate({ - app: makeMember({ - spaceId: 'app', - headHash: 'sha256:h', - tables: { user: {} }, - }), + app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), extensions: [ makeMember({ spaceId: 'cipher', @@ -225,22 +246,17 @@ describe('verifyMigration', () => { markersBySpaceId: new Map(), schemaIntrospection: liveSchema, mode: 'strict', - verifySchemaForMember: STUB_VERIFY, - projectSchemaToMember: STUB_PROJECT, - listEntityNames: STUB_LIST, + verifySchemaForMember: FULL_SCHEMA_VERIFY, }); const schemaCheck = result.assertOk().schemaCheck; - // App member's pass saw `user` and `orphan_table` (cipher_state pruned). - expect(schemaCheck.perSpace.get('app')?.tablesSeen).toEqual(['orphan_table', 'user']); - // Cipher member's pass saw `cipher_state` and `orphan_table` (user pruned). - expect(schemaCheck.perSpace.get('cipher')?.tablesSeen).toEqual([ - 'cipher_state', - 'orphan_table', - ]); + // App keeps only the undeclared `orphan_table`; `cipher_state` is dropped. + expect(extraTables(schemaCheck.perSpace.get('app'))).toEqual(['orphan_table']); + // Cipher keeps only the undeclared `orphan_table`; `user` is dropped. + expect(extraTables(schemaCheck.perSpace.get('cipher'))).toEqual(['orphan_table']); }); - it('reports live tables not claimed by any member as `orphanElements`', () => { + it('keeps live tables claimed by no member as each member’s undeclared extras', () => { const aggregate = makeAggregate({ app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), extensions: [ @@ -264,21 +280,18 @@ describe('verifyMigration', () => { aggregate, markersBySpaceId: new Map(), schemaIntrospection: liveSchema, - // Lenient mode: the verifier still reports orphan elements; the - // caller (db verify) decides whether to treat them as errors. mode: 'lenient', - verifySchemaForMember: STUB_VERIFY, - projectSchemaToMember: STUB_PROJECT, - listEntityNames: STUB_LIST, + verifySchemaForMember: FULL_SCHEMA_VERIFY, }); - expect(result.assertOk().schemaCheck.orphanElements).toEqual([ - { kind: 'table', name: 'another_orphan' }, - { kind: 'table', name: 'mystery_table' }, + const schemaCheck = result.assertOk().schemaCheck; + expect(extraTables(schemaCheck.perSpace.get('app'))).toEqual([ + 'another_orphan', + 'mystery_table', ]); }); - it('returns an empty `orphanElements` list when every live table is claimed', () => { + it('leaves no extras when every live table is claimed by some member', () => { const aggregate = makeAggregate({ app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), extensions: [ @@ -300,12 +313,12 @@ describe('verifyMigration', () => { }, }, mode: 'strict', - verifySchemaForMember: STUB_VERIFY, - projectSchemaToMember: STUB_PROJECT, - listEntityNames: STUB_LIST, + verifySchemaForMember: FULL_SCHEMA_VERIFY, }); - expect(result.assertOk().schemaCheck.orphanElements).toEqual([]); + const schemaCheck = result.assertOk().schemaCheck; + expect(extraTables(schemaCheck.perSpace.get('app'))).toEqual([]); + expect(extraTables(schemaCheck.perSpace.get('cipher'))).toEqual([]); }); it('returns notOk(introspectionFailure) when verifySchemaForMember throws', () => { @@ -321,8 +334,6 @@ describe('verifyMigration', () => { verifySchemaForMember: () => { throw new Error('introspection broke'); }, - projectSchemaToMember: STUB_PROJECT, - listEntityNames: STUB_LIST, }); expect(result.ok).toBe(false); @@ -332,31 +343,6 @@ describe('verifyMigration', () => { }); }); - it('returns notOk(introspectionFailure) when a shape callback throws via a malformed schema', () => { - const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), - }); - - const exploding = { - get tables() { - throw new Error('schema access blew up'); - }, - }; - - const result = verifyMigration({ - aggregate, - markersBySpaceId: new Map(), - schemaIntrospection: exploding, - mode: 'strict', - verifySchemaForMember: STUB_VERIFY, - projectSchemaToMember: STUB_PROJECT, - listEntityNames: STUB_LIST, - }); - - expect(result.ok).toBe(false); - expect(result.assertNotOk().kind).toBe('introspectionFailure'); - }); - it('threads the verifier mode (strict / lenient) to the per-member callback verbatim', () => { let observedMode: 'strict' | 'lenient' | undefined; const aggregate = makeAggregate({ @@ -368,12 +354,10 @@ describe('verifyMigration', () => { markersBySpaceId: new Map(), schemaIntrospection: { tables: {} }, mode: 'lenient', - verifySchemaForMember: (_schema, _member, mode) => { + verifySchemaForMember: (schema, member, mode) => { observedMode = mode; - return { tablesSeen: [] }; + return FULL_SCHEMA_VERIFY(schema, member, mode); }, - projectSchemaToMember: STUB_PROJECT, - listEntityNames: STUB_LIST, }); expect(observedMode).toBe('lenient'); diff --git a/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts b/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts index 5118f09684..ac3b0a18b7 100644 --- a/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts +++ b/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts @@ -283,19 +283,34 @@ describe('aggregate pipeline (loader → planner → verifier) against deleted n markersBySpaceId: new Map(), schemaIntrospection: { tables: { user: { columns: {} }, test_box: { columns: {} } } }, mode: 'lenient', - verifySchemaForMember: () => ({ ok: true }), - projectSchemaToMember: (schema) => schema, - listEntityNames: (schema) => { - const s = schema as { tables?: Record }; - return typeof s === 'object' && s !== null && typeof s.tables === 'object' - ? Object.keys(s.tables) - : []; - }, + verifySchemaForMember: () => ({ + ok: true, + summary: 'Database schema satisfies contract', + contract: { storageHash: 'sha256:test' }, + target: { expected: 'postgres' }, + schema: { + issues: [], + schemaDiffIssues: [], + root: { + status: 'pass', + kind: 'contract', + name: 'contract', + contractPath: '', + code: '', + message: '', + expected: undefined, + actual: undefined, + children: [], + }, + counts: { pass: 1, warn: 0, fail: 0, totalNodes: 1 }, + }, + timings: { total: 0 }, + }), }); expect(verifyResult.ok).toBe(true); if (!verifyResult.ok) return; expect(verifyResult.value.markerCheck.perSpace.get('app')).toEqual({ kind: 'absent' }); expect(verifyResult.value.markerCheck.perSpace.get(TEST_SPACE_ID)).toEqual({ kind: 'absent' }); - expect(verifyResult.value.schemaCheck.orphanElements).toEqual([]); + expect(verifyResult.value.schemaCheck.perSpace.get('app')?.ok).toBe(true); }); }); diff --git a/packages/2-mongo-family/9-family/src/core/control-instance.ts b/packages/2-mongo-family/9-family/src/core/control-instance.ts index 7c2f905df0..8f413be000 100644 --- a/packages/2-mongo-family/9-family/src/core/control-instance.ts +++ b/packages/2-mongo-family/9-family/src/core/control-instance.ts @@ -26,15 +26,12 @@ import { import { assertDescriptorSelfConsistency } from '@prisma-next/migration-tools/spaces'; import type { MongoContract } from '@prisma-next/mongo-contract'; import { mongoContractCanonicalizationHooks } from '@prisma-next/mongo-contract/canonicalization-hooks'; -import type { MongoSchemaCollection } from '@prisma-next/mongo-schema-ir'; -import { MongoSchemaIR } from '@prisma-next/mongo-schema-ir'; -import { blindCast } from '@prisma-next/utils/casts'; +import type { MongoSchemaIR } from '@prisma-next/mongo-schema-ir'; import { ifDefined } from '@prisma-next/utils/defined'; import type { MongoControlAdapter, MongoControlAdapterDescriptor } from './control-adapter'; import type { MongoControlExtensionDescriptor } from './control-types'; import { MongoContractSerializer } from './ir/mongo-contract-serializer'; import { mongoOperationsToPreview } from './operation-preview'; -import { mongoListSchemaEntityNames, mongoProjectSchemaToMember } from './schema-shape'; import { mongoSchemaToView } from './schema-to-view'; import { verifyMongoSchema } from './schema-verify/verify-mongo-schema'; @@ -283,21 +280,6 @@ export function createMongoFamilyInstance(controlStack: ControlStack): MongoCont }); }, - projectSchemaToMember( - schema: MongoSchemaIR, - ownedByOtherNames: ReadonlySet, - ): MongoSchemaIR { - const projected = blindCast< - { readonly collections: ReadonlyArray }, - 'the Mongo shape pruner returns a { collections } object spread from MongoSchemaIR' - >(mongoProjectSchemaToMember(schema, ownedByOtherNames)); - return new MongoSchemaIR(projected.collections); - }, - - listSchemaEntityNames(schema: MongoSchemaIR): readonly string[] { - return mongoListSchemaEntityNames(schema); - }, - async sign(options): Promise { const { driver, contract: rawContract, contractPath, configPath } = options; const startTime = Date.now(); diff --git a/packages/2-mongo-family/9-family/src/core/schema-shape.ts b/packages/2-mongo-family/9-family/src/core/schema-shape.ts deleted file mode 100644 index 766f82b5dc..0000000000 --- a/packages/2-mongo-family/9-family/src/core/schema-shape.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Mongo-family schema-shape callbacks for the aggregate planner/verifier. - * - * The framework is unaware of any storage shape (ADR: framework layer purity); - * it hands the Mongo family its own introspected schema and asks two questions: - * "prune this to a member's slice" and "list its entity names". Mongo's - * introspected `MongoSchemaIR` exposes `collections` as an array of - * `{ name, ... }`; the callbacks walk that array. - */ - -import { blindCast } from '@prisma-next/utils/casts'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * Prunes collections owned by other members from a Mongo introspected schema. - * Returns a plain object (`{ ...schema, collections: prunedArray }`); the caller - * rewraps it into a `MongoSchemaIR` when the class accessors are needed. Returns - * the input unchanged when it is not an object or nothing was removed. - */ -export function mongoProjectSchemaToMember( - schema: unknown, - ownedByOtherNames: ReadonlySet, -): unknown { - if (!isRecord(schema)) return schema; - - if (Array.isArray(schema['collections'])) { - return pruneCollectionsArray(schema, ownedByOtherNames); - } - - if (isRecord(schema['collections'])) { - return pruneCollectionsRecord(schema, ownedByOtherNames); - } - - return schema; -} - -/** - * Bare names of every live collection in a Mongo introspected schema. - */ -export function mongoListSchemaEntityNames(schema: unknown): readonly string[] { - if (!isRecord(schema)) return []; - const collections = schema['collections']; - if (Array.isArray(collections)) { - const names: string[] = []; - for (const entry of collections) { - if (isRecord(entry) && typeof entry['name'] === 'string') { - names.push(entry['name']); - } - } - return names; - } - if (isRecord(collections)) { - return Object.keys(collections); - } - return []; -} - -function pruneCollectionsArray( - schema: Record, - ownedByOthers: ReadonlySet, -): unknown { - const source = blindCast, 'Array.isArray narrowed collections above'>( - schema['collections'], - ); - let removed = false; - const pruned: unknown[] = []; - for (const entry of source) { - if (isRecord(entry)) { - const name = entry['name']; - if (typeof name === 'string' && ownedByOthers.has(name)) { - removed = true; - continue; - } - } - pruned.push(entry); - } - if (!removed) return schema; - return { ...schema, collections: pruned }; -} - -function pruneCollectionsRecord( - schema: Record, - ownedByOthers: ReadonlySet, -): unknown { - const source = blindCast, 'isRecord narrowed collections above'>( - schema['collections'], - ); - let removed = false; - const pruned: Record = {}; - for (const [name, value] of Object.entries(source)) { - if (ownedByOthers.has(name)) { - removed = true; - } else { - pruned[name] = value; - } - } - if (!removed) return schema; - return { ...schema, collections: pruned }; -} diff --git a/packages/2-mongo-family/9-family/src/exports/control.ts b/packages/2-mongo-family/9-family/src/exports/control.ts index 61e1128360..8386ea3617 100644 --- a/packages/2-mongo-family/9-family/src/exports/control.ts +++ b/packages/2-mongo-family/9-family/src/exports/control.ts @@ -11,8 +11,4 @@ export { mongoOperationsToPreview, } from '../core/operation-preview'; export { diffMongoSchemas } from '../core/schema-diff'; -export { - mongoListSchemaEntityNames, - mongoProjectSchemaToMember, -} from '../core/schema-shape'; export { canonicalizeSchemasForVerification } from '../core/schema-verify/canonicalize-introspection'; diff --git a/packages/2-mongo-family/9-family/test/schema-shape.test.ts b/packages/2-mongo-family/9-family/test/schema-shape.test.ts deleted file mode 100644 index 474263afa0..0000000000 --- a/packages/2-mongo-family/9-family/test/schema-shape.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { mongoListSchemaEntityNames, mongoProjectSchemaToMember } from '../src/core/schema-shape'; - -/** - * The Mongo family owns the introspected-schema shape (the framework aggregate - * verifier/planner is shape-free). The introspected `MongoSchemaIR` exposes - * `collections` as an array of `{ name, ... }`. - */ -describe('mongoProjectSchemaToMember', () => { - it('removes other-member collections from the array form', () => { - const appColl = { name: 'users', indexes: [] }; - const extColl = { name: 'cipherstash_state', indexes: [] }; - const orphanColl = { name: 'legacy_audit', indexes: [] }; - const schema = { collections: [appColl, extColl, orphanColl] }; - - const projected = mongoProjectSchemaToMember(schema, new Set(['cipherstash_state'])) as { - readonly collections: ReadonlyArray<{ readonly name: string }>; - }; - - expect(projected.collections.map((c) => c.name).sort()).toEqual(['legacy_audit', 'users']); - expect(projected.collections).not.toBe(schema.collections); - expect(projected.collections.find((c) => c.name === 'users')).toBe(appColl); - expect(projected.collections.find((c) => c.name === 'legacy_audit')).toBe(orphanColl); - }); - - it('preserves non-`collections` fields', () => { - const schema = { - collections: [{ name: 'app_users' }, { name: 'ext_owned' }], - meta: { driverVersion: '6.0' }, - }; - const projected = mongoProjectSchemaToMember(schema, new Set(['ext_owned'])) as { - readonly collections: ReadonlyArray<{ readonly name: string }>; - readonly meta: unknown; - }; - expect(projected.collections.map((c) => c.name)).toEqual(['app_users']); - expect(projected.meta).toBe(schema.meta); - }); - - it('returns the schema verbatim when nothing is removed', () => { - const schema = { collections: [{ name: 'users' }] }; - expect(mongoProjectSchemaToMember(schema, new Set(['nope']))).toBe(schema); - }); - - it('prunes a record form of collections too', () => { - const schema = { collections: { users: {}, ext_owned: {} } }; - const projected = mongoProjectSchemaToMember(schema, new Set(['ext_owned'])) as { - readonly collections: Record; - }; - expect(Object.keys(projected.collections)).toEqual(['users']); - }); - - it('returns non-object schemas verbatim', () => { - expect(mongoProjectSchemaToMember(null, new Set(['x']))).toBe(null); - }); -}); - -describe('mongoListSchemaEntityNames', () => { - it('lists collection names from the array form', () => { - const schema = { collections: [{ name: 'a' }, { name: 'b' }] }; - expect([...mongoListSchemaEntityNames(schema)].sort()).toEqual(['a', 'b']); - }); - - it('lists collection names from the record form', () => { - expect([...mongoListSchemaEntityNames({ collections: { a: {}, b: {} } })].sort()).toEqual([ - 'a', - 'b', - ]); - }); - - it('returns none for an unrecognised shape', () => { - expect(mongoListSchemaEntityNames({ other: 'shape' })).toEqual([]); - expect(mongoListSchemaEntityNames(null)).toEqual([]); - }); -}); diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index ff0eb21b15..79101db7ec 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -48,7 +48,6 @@ import type { SqlSchemaIRNode, SqlTableIR } from '@prisma-next/sql-schema-ir/typ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { SqlControlAdapter } from './control-adapter'; -import { sqlListSchemaEntityNames, sqlProjectSchemaToMember } from './diff/schema-shape'; import { SqlContractSerializer } from './ir/sql-contract-serializer'; import type { DiffDatabaseSchemaInput, @@ -216,14 +215,13 @@ export interface SqlControlFamilyInstance }): Promise; /** - * Verify a contract against an already-introspected schema slice. + * Verify a contract against an already-introspected schema. * * Callers that need to verify against the live database compose * `introspect({ driver })` + `verifySchema({ contract, schema, ... })`. - * The aggregate verifier projects each member's claimed slice via - * `projectSchemaToSpace` and hands the projected slice in — this - * keeps per-member verification from surfacing sibling-space tables - * as `extras`. + * The aggregate verifier hands in the full introspected schema and scopes + * the returned result to each member's contract space afterwards — so + * sibling-space tables never survive as `extras`. */ verifySchema(options: { readonly contract: unknown; @@ -232,19 +230,6 @@ export interface SqlControlFamilyInstance readonly frameworkComponents: ReadonlyArray>; }): VerifyDatabaseSchemaResult; - /** - * Prunes the introspected SQL schema to a member's slice. Walks the family's - * flat/namespaced shape so the framework aggregate verifier/planner never has - * to. See the framework `ControlFamilyInstance.projectSchemaToMember`. - */ - projectSchemaToMember( - schema: SqlSchemaIRNode, - ownedByOtherNames: ReadonlySet, - ): SqlSchemaIRNode; - - /** Lists the live table names in the introspected SQL schema. */ - listSchemaEntityNames(schema: SqlSchemaIRNode): readonly string[]; - sign(options: { readonly driver: SqlControlDriverInstance; readonly contract: unknown; @@ -767,18 +752,6 @@ export function createSqlFamilyInstance( }, }; }, - projectSchemaToMember( - schema: SqlSchemaIRNode, - ownedByOtherNames: ReadonlySet, - ): SqlSchemaIRNode { - return blindCast< - SqlSchemaIRNode, - 'the SQL shape pruner returns the same SqlSchemaIRNode shape, spread into a plain object' - >(sqlProjectSchemaToMember(schema, ownedByOtherNames)); - }, - listSchemaEntityNames(schema: SqlSchemaIRNode): readonly string[] { - return sqlListSchemaEntityNames(schema); - }, async sign(options: { readonly driver: SqlControlDriverInstance; readonly contract: unknown; diff --git a/packages/2-sql/9-family/src/core/diff/schema-shape.ts b/packages/2-sql/9-family/src/core/diff/schema-shape.ts deleted file mode 100644 index 69c27a0b3f..0000000000 --- a/packages/2-sql/9-family/src/core/diff/schema-shape.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * SQL-family schema-shape callbacks for the aggregate planner/verifier. - * - * The framework is unaware of any storage shape (ADR: framework layer purity); - * it hands the SQL family its own introspected `SqlSchemaIRNode` and asks two - * questions: "prune this to a member's slice" and "list its entity names". Only - * the family knows whether the schema is a flat `tables` record (SQLite) or a - * namespaced tree (Postgres `PostgresDatabaseSchemaNode`), so it answers both. - */ - -import { blindCast } from '@prisma-next/utils/casts'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * Prunes tables owned by other members from a SQL introspected schema. A - * namespaced tree (Postgres) prunes inside each namespace's `tables`; a flat - * schema (SQLite) prunes its top-level `tables`. The result is a plain object - * (`{ ...schema, tables|namespaces: pruned }`); structure-aware consumers - * `ensure` a typed node from it. Returns the input unchanged when it is not an - * object or nothing was removed. - */ -export function sqlProjectSchemaToMember( - schema: unknown, - ownedByOtherNames: ReadonlySet, -): unknown { - if (!isRecord(schema)) return schema; - - if (isRecord(schema['namespaces'])) { - return pruneNamespaceTables(schema, ownedByOtherNames); - } - - if (isRecord(schema['tables'])) { - return pruneRecord(schema, 'tables', ownedByOtherNames); - } - - return schema; -} - -/** - * Bare names of every live table in a SQL introspected schema: gathered across - * namespaces for a namespaced tree, or the top-level `tables` keys for a flat - * schema. Any other shape yields none. - */ -export function sqlListSchemaEntityNames(schema: unknown): readonly string[] { - if (!isRecord(schema)) return []; - if (isRecord(schema['namespaces'])) { - const names: string[] = []; - for (const namespaceNode of Object.values(schema['namespaces'])) { - if (isRecord(namespaceNode) && isRecord(namespaceNode['tables'])) { - names.push(...Object.keys(namespaceNode['tables'])); - } - } - return names; - } - if (isRecord(schema['tables'])) { - return Object.keys(schema['tables']); - } - return []; -} - -function pruneNamespaceTables( - schema: Record, - ownedByOthers: ReadonlySet, -): unknown { - const namespaces = schema['namespaces']; - if (!isRecord(namespaces)) return schema; - let removed = false; - const prunedNamespaces: Record = {}; - for (const [namespaceId, namespaceNode] of Object.entries(namespaces)) { - if (isRecord(namespaceNode) && isRecord(namespaceNode['tables'])) { - const prunedNode = pruneRecord(namespaceNode, 'tables', ownedByOthers); - if (prunedNode !== namespaceNode) removed = true; - prunedNamespaces[namespaceId] = prunedNode; - } else { - prunedNamespaces[namespaceId] = namespaceNode; - } - } - if (!removed) return schema; - return { ...schema, namespaces: prunedNamespaces }; -} - -function pruneRecord( - schema: Record, - field: 'tables', - ownedByOthers: ReadonlySet, -): unknown { - const source = blindCast, 'isRecord narrowed the field above'>( - schema[field], - ); - let removed = false; - const pruned: Record = {}; - for (const [name, value] of Object.entries(source)) { - if (ownedByOthers.has(name)) { - removed = true; - } else { - pruned[name] = value; - } - } - if (!removed) return schema; - return { ...schema, [field]: pruned }; -} diff --git a/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts b/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts new file mode 100644 index 0000000000..02f4507b3a --- /dev/null +++ b/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts @@ -0,0 +1,33 @@ +import type { DiffIssue, SchemaDiff } from '@prisma-next/framework-components/control'; + +/** The entity name a diff issue addresses, for ownership scoping. */ +function issueEntityName(issue: DiffIssue): string | undefined { + if ('outcome' in issue) { + const actual = issue.actual; + if (actual === undefined) return undefined; + const tableName = (actual as { readonly tableName?: unknown }).tableName; + return typeof tableName === 'string' ? tableName : undefined; + } + return 'table' in issue ? issue.table : undefined; +} + +/** + * Drops the `extra` findings for entities another contract-space member claims, + * so the planner never emits DROP ops against a sibling space's tables. The + * planner diffs the full live schema; this scopes the result to the member's + * own space by entity name — the same coordinate the schema-pruning layer keyed + * on. Absent/empty `ownedByOtherSpaces` returns the diff unchanged. + */ +export function scopePlanDiffToSpace( + diff: SchemaDiff, + ownedByOtherSpaces: ReadonlySet | undefined, +): SchemaDiff { + if (ownedByOtherSpaces === undefined || ownedByOtherSpaces.size === 0) return diff; + return diff.filter((issue) => { + const isExtra = + 'outcome' in issue ? issue.outcome === 'extra' : issue.kind.startsWith('extra_'); + if (!isExtra) return true; + const name = issueEntityName(issue); + return name === undefined || !ownedByOtherSpaces.has(name); + }); +} diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index 421646433e..3d3ca98d47 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -351,6 +351,13 @@ export interface SqlMigrationPlannerPlanOptions { * All components must have matching familyId ('sql') and targetId. */ readonly frameworkComponents: ReadonlyArray>; + /** + * Entity names every OTHER contract-space member claims. The planner + * diffs the full live schema, then drops the `extra` findings for these + * names so it never emits DROP ops against a sibling space's tables. + * Absent (or empty) for single-space plans. + */ + readonly entitiesOwnedByOtherSpaces?: ReadonlySet; } export interface SqlMigrationPlanner { diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 7820b7fb51..10d149b45f 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -43,6 +43,7 @@ export { runnerSuccess, } from '../core/migrations/plan-helpers'; export { INIT_ADDITIVE_POLICY } from '../core/migrations/policies'; +export { scopePlanDiffToSpace } from '../core/migrations/scope-plan-diff'; export type { CodecControlHooks, CreateSqlMigrationPlanOptions, diff --git a/packages/2-sql/9-family/src/exports/diff.ts b/packages/2-sql/9-family/src/exports/diff.ts index 3526df9b8c..bb76de9d08 100644 --- a/packages/2-sql/9-family/src/exports/diff.ts +++ b/packages/2-sql/9-family/src/exports/diff.ts @@ -6,10 +6,6 @@ * top; SQLite is relational only). Pure — no database connection required. */ -export { - sqlListSchemaEntityNames, - sqlProjectSchemaToMember, -} from '../core/diff/schema-shape'; export type { NativeTypeNormalizer, VerifySqlSchemaOptions, diff --git a/packages/2-sql/9-family/test/schema-shape.test.ts b/packages/2-sql/9-family/test/schema-shape.test.ts deleted file mode 100644 index 744f5506de..0000000000 --- a/packages/2-sql/9-family/test/schema-shape.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { sqlListSchemaEntityNames, sqlProjectSchemaToMember } from '../src/core/diff/schema-shape'; - -/** - * The SQL family owns the introspected-schema shape (the framework aggregate - * verifier/planner is shape-free). These callbacks walk a flat `tables` record - * (SQLite) and a namespaced `namespaces[…].tables` tree (Postgres). - */ -describe('sqlProjectSchemaToMember', () => { - describe('flat schema', () => { - it('removes only tables owned by other members', () => { - const schema = { - tables: { - app_user: { columns: { id: {} } }, - ext_audit_log: { columns: { id: {} } }, - }, - }; - const projected = sqlProjectSchemaToMember(schema, new Set(['ext_audit_log'])) as { - readonly tables: Record; - }; - expect(Object.keys(projected.tables)).toEqual(['app_user']); - expect(projected.tables['app_user']).toBe(schema.tables['app_user']); - }); - - it('preserves orphan tables owned by no member', () => { - const orphan = { columns: {} }; - const schema = { tables: { app_user: {}, ext_owned: {}, orphan_table: orphan } }; - const projected = sqlProjectSchemaToMember(schema, new Set(['ext_owned'])) as { - readonly tables: Record; - }; - expect(Object.keys(projected.tables).sort()).toEqual(['app_user', 'orphan_table']); - expect(projected.tables['orphan_table']).toBe(orphan); - }); - - it('preserves non-`tables` fields', () => { - const schema = { tables: { ext_owned: {}, app_user: {} }, meta: { dialect: 'postgres' } }; - const projected = sqlProjectSchemaToMember(schema, new Set(['ext_owned'])) as { - readonly tables: Record; - readonly meta: unknown; - }; - expect(Object.keys(projected.tables)).toEqual(['app_user']); - expect(projected.meta).toBe(schema.meta); - }); - - it('returns the input unchanged when nothing is removed', () => { - const schema = { tables: { app_user: {} } }; - expect(sqlProjectSchemaToMember(schema, new Set(['nope']))).toBe(schema); - }); - }); - - describe('namespaced tree', () => { - it('prunes other-member tables within each namespace', () => { - const schema = { - nodeKind: 'postgres-database', - namespaces: { - public: { schemaName: 'public', tables: { app_user: {}, ext_owned: {} } }, - auth: { schemaName: 'auth', tables: { ext_session: {} } }, - }, - }; - const projected = sqlProjectSchemaToMember(schema, new Set(['ext_owned', 'ext_session'])) as { - readonly namespaces: Record }>; - }; - expect(Object.keys(projected.namespaces['public']!.tables)).toEqual(['app_user']); - expect(Object.keys(projected.namespaces['auth']!.tables)).toEqual([]); - }); - - it('returns the tree unchanged when nothing is removed', () => { - const schema = { - nodeKind: 'postgres-database', - namespaces: { public: { schemaName: 'public', tables: { app_user: {} } } }, - }; - expect(sqlProjectSchemaToMember(schema, new Set(['ext_owned']))).toBe(schema); - }); - }); - - describe('fall-through', () => { - it('returns non-object schemas verbatim', () => { - expect(sqlProjectSchemaToMember(null, new Set(['x']))).toBe(null); - expect(sqlProjectSchemaToMember(42, new Set(['x']))).toBe(42); - }); - - it('returns schemas without `tables`/`namespaces` unchanged', () => { - const schema = { other: 'shape' }; - expect(sqlProjectSchemaToMember(schema, new Set(['x']))).toBe(schema); - }); - }); -}); - -describe('sqlListSchemaEntityNames', () => { - it('lists top-level table names for a flat schema', () => { - expect([...sqlListSchemaEntityNames({ tables: { a: {}, b: {} } })].sort()).toEqual(['a', 'b']); - }); - - it('gathers table names across namespaces for a tree', () => { - const schema = { - namespaces: { - public: { tables: { app_user: {} } }, - auth: { tables: { session: {} } }, - }, - }; - expect([...sqlListSchemaEntityNames(schema)].sort()).toEqual(['app_user', 'session']); - }); - - it('returns none for an unrecognised shape', () => { - expect(sqlListSchemaEntityNames({ other: 'shape' })).toEqual([]); - expect(sqlListSchemaEntityNames(null)).toEqual([]); - }); -}); diff --git a/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts b/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts index 9acc64c79e..17dd2ea430 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts @@ -9,24 +9,21 @@ import type { MongoControlFamilyInstance, MongoControlTargetDescriptor, } from '@prisma-next/family-mongo/control'; -import { - contractToMongoSchemaIR, - mongoProjectSchemaToMember, -} from '@prisma-next/family-mongo/control'; +import { contractToMongoSchemaIR } from '@prisma-next/family-mongo/control'; import type { MongoControlAdapter } from '@prisma-next/family-mongo/control-adapter'; import type { MigrationRunner, MigrationRunnerPerSpaceOptions, MigrationRunnerPerSpaceSuccessValue, MigrationRunnerResult, + VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; import { createContractSpaceMember, - projectSchemaToSpace, + otherMemberEntityNames, + scopeSchemaResultToSpace, } from '@prisma-next/migration-tools/aggregate'; import type { MongoContract } from '@prisma-next/mongo-contract'; -import type { MongoSchemaCollection } from '@prisma-next/mongo-schema-ir'; -import { MongoSchemaIR } from '@prisma-next/mongo-schema-ir'; import { blindCast } from '@prisma-next/utils/casts'; import { notOk, ok } from '@prisma-next/utils/result'; import { mongoTargetDescriptorMeta } from './descriptor-meta'; @@ -96,11 +93,12 @@ export const mongoTargetDescriptor: MongoControlTargetDescriptor j !== i); - const projectSchema = (schema: MongoSchemaIR): MongoSchemaIR => { - // `projectSchemaToSpace` returns a plain object - // `{...schemaIR, collections: prunedArray}` (not a - // `MongoSchemaIR` instance), so the descriptor rewraps - // the pruned collections into a fresh `MongoSchemaIR` - // before handing it to `verifyMongoSchema` (which - // depends on the class's `collectionNames` / - // `collection(name)` accessors). - const projected = projectSchemaToSpace( - schema, - member, - others, - mongoProjectSchemaToMember, - ) as { - readonly collections: ReadonlyArray; - }; - return new MongoSchemaIR(projected.collections); - }; + // The runner verifies the destination contract against the full + // introspected schema; scope the result to this space, dropping the + // `extra` findings for collections a sibling space owns. + const ownedByOthers = otherMemberEntityNames(member, others); + const scopeVerifyResult = ( + result: VerifyDatabaseSchemaResult, + ): VerifyDatabaseSchemaResult => scopeSchemaResultToSpace(result, ownedByOthers); const { space, ...runnerOptions } = spaceOptions; - const result = await runMongo(driver, { ...runnerOptions, projectSchema }); + const result = await runMongo(driver, { ...runnerOptions, scopeVerifyResult }); if (!result.ok) { return notOk({ ...result.failure, @@ -159,10 +146,10 @@ export const mongoTargetDescriptor: MongoControlTargetDescriptor) { const contract = blindCast( diff --git a/packages/3-mongo-target/1-mongo-target/src/core/mongo-runner.ts b/packages/3-mongo-target/1-mongo-target/src/core/mongo-runner.ts index be881709ea..d1d4b86c7c 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/mongo-runner.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/mongo-runner.ts @@ -12,6 +12,7 @@ import { type MigrationRunnerFailure, type MigrationRunnerPerSpaceSuccessValue, type OperationContext, + type VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; import type { MongoContract } from '@prisma-next/mongo-contract'; @@ -24,7 +25,6 @@ import type { MongoMigrationCheck, MongoMigrationPlanOperation, } from '@prisma-next/mongo-query-ast/control'; -import type { MongoSchemaIR } from '@prisma-next/mongo-schema-ir'; import { notOk, ok, type Result } from '@prisma-next/utils/result'; import { FilterEvaluator } from './filter-evaluator'; import { deserializeMongoOps } from './mongo-ops-serializer'; @@ -46,16 +46,16 @@ export interface MongoMigrationRunnerExecuteOptions { readonly strictVerification?: boolean; readonly context?: OperationContext; /** - * Per-space schema projection. When set, the runner applies this - * function to the introspected schema before invoking - * `verifyMongoSchema`, so per-space verification only sees the slice - * the destination contract actually claims. + * Per-space verify-result scope. When set, the runner verifies the + * destination contract against the **full** introspected schema, then + * applies this to scope the result to the space the contract claims — + * dropping the `extra` findings for collections a sibling space owns. * - * The target descriptor's `execute` injects this callback, derived - * from the sibling spaces in the aggregate. Callers that don't project - * leave it unset and verify against the whole introspected schema. + * The target descriptor's `execute` injects this callback, derived from + * the sibling spaces in the aggregate. Callers that don't scope leave it + * unset and verify against the whole introspected schema. */ - readonly projectSchema?: (schema: MongoSchemaIR) => MongoSchemaIR; + readonly scopeVerifyResult?: (result: VerifyDatabaseSchemaResult) => VerifyDatabaseSchemaResult; /** Per-edge breakdown from graph-walk planning; drives per-edge ledger writes. */ readonly migrationEdges: readonly AggregateMigrationEdgeRef[]; } @@ -207,19 +207,21 @@ export class MongoMigrationRunner { if (!isNoOp) { const liveSchema = await this.deps.introspectSchema(); - // When an aggregate spans more than one space the live database - // holds collections owned by sibling spaces; the target descriptor's - // `execute` injects a `projectSchema` that strips them so per-space - // verify only checks the slice this contract claims. Callers that - // don't project leave the projection identity (no-op). - const verifySchema = options.projectSchema ? options.projectSchema(liveSchema) : liveSchema; - const verifyResult = verifyMongoSchema({ + // When an aggregate spans more than one space the live database holds + // collections owned by sibling spaces; verify against the full schema, + // then let the target descriptor's `execute`-injected `scopeVerifyResult` + // drop the `extra` findings for the collections those siblings own. + // Callers that don't scope leave the result unchanged. + const rawVerifyResult = verifyMongoSchema({ contract: options.destinationContract, - schema: verifySchema, + schema: liveSchema, strict: options.strictVerification ?? true, frameworkComponents: options.frameworkComponents, ...(options.context ? { context: options.context } : {}), }); + const verifyResult = options.scopeVerifyResult + ? options.scopeVerifyResult(rawVerifyResult) + : rawVerifyResult; if (!verifyResult.ok) { return runnerFailure('SCHEMA_VERIFY_FAILED', verifyResult.summary, { why: 'The resulting database schema does not satisfy the destination contract.', diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 93664716a8..c6b89fcd25 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -11,6 +11,7 @@ import { partitionIssuesByControlPolicy, planFieldEventOperations, plannerFailure, + scopePlanDiffToSpace, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; @@ -164,15 +165,18 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // runs the same underlying comparison (via `verifyDatabaseSchema`) and // rejects on non-empty. PostgresDatabaseSchemaNode.assert(options.schema); - const databaseDiff = diffPostgresDatabaseSchema({ - contract: options.contract, - actualSchema: options.schema, - strict: - options.policy.allowedOperationClasses.includes('widening') || - options.policy.allowedOperationClasses.includes('destructive'), - typeMetadataRegistry: new Map(), - frameworkComponents: options.frameworkComponents, - }); + const databaseDiff = scopePlanDiffToSpace( + diffPostgresDatabaseSchema({ + contract: options.contract, + actualSchema: options.schema, + strict: + options.policy.allowedOperationClasses.includes('widening') || + options.policy.allowedOperationClasses.includes('destructive'), + typeMetadataRegistry: new Map(), + frameworkComponents: options.frameworkComponents, + }), + options.entitiesOwnedByOtherSpaces, + ); const schemaIssues = this.collectSchemaIssues(options, databaseDiff.issues); const codecHooks = extractCodecControlHooks(options.frameworkComponents); const storageTypes = options.contract.storage.types ?? {}; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index e0f98f7ac8..55870560ac 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -9,6 +9,7 @@ import { extractCodecControlHooks, planFieldEventOperations, plannerFailure, + scopePlanDiffToSpace, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; @@ -181,13 +182,16 @@ export class SqliteMigrationPlanner private collectSchemaIssues(options: SqlMigrationPlannerPlanOptions): readonly SchemaIssue[] { const allowed = options.policy.allowedOperationClasses; const strict = allowed.includes('widening') || allowed.includes('destructive'); - const diff = diffSqliteDatabaseSchema({ - contract: options.contract, - actualSchema: options.schema, - strict, - typeMetadataRegistry: new Map(), - frameworkComponents: options.frameworkComponents, - }); + const diff = scopePlanDiffToSpace( + diffSqliteDatabaseSchema({ + contract: options.contract, + actualSchema: options.schema, + strict, + typeMetadataRegistry: new Map(), + frameworkComponents: options.frameworkComponents, + }), + options.entitiesOwnedByOtherSpaces, + ); return diff.issues; } } From 0627b8b8d9dac42b3045a10cd67b04c96af2f970 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 10:02:33 +0200 Subject: [PATCH 32/49] fix(migration): scope the verify tree by top-level tables only, never a member column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scopeSchemaResultToSpace` filtered the verification tree at every level by the last segment of each node contractPath. Because a contract column node (and a `storage.types` enum node) carries the column/type name as that last segment, a member own column named identically to another space table was dropped from the tree. The verdict is recomputed from the pruned tree (`ok = counts.fail === 0`) and `db verify` fails only on `!ok` — so dropping a *failing* column node named like a sibling table flipped the member to `ok`, passing verify while the real `missing_column` / type-mismatch issue was still in `issues`. A silent false-pass (same hazard on Mongo via the shared helper). Prune only `root.children` (the top-level table nodes); each surviving table keeps its full subtree — the top-level-only semantics of the pruning layer this replaced. Counts are now derived by subtracting the dropped top-level subtrees from the authoritative incoming counts (plus a root-status-flip reconciliation) rather than recomputed from the root, so a multi-schema result — whose retained root is first-namespace-only while its counts are summed across namespaces — keeps its authoritative counts unchanged when nothing is dropped. Regression test added first (a passing and a failing column both named like a sibling table survive scoping, `ok` stays false); it fails on the recursive version and passes on the fix. Gate: migration-tools 552 + framework-components 441 green; the four guards green; fixtures:check clean; lint:deps clean. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/aggregate/scope-schema-result.ts | 81 +++++++++---- .../aggregate/scope-schema-result.test.ts | 107 +++++++++++++++++- 2 files changed, 164 insertions(+), 24 deletions(-) diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts b/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts index 52aa8afeef..5ce3e7cf10 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts @@ -64,26 +64,35 @@ function aggregateStatus(children: readonly SchemaVerificationNode[]): 'pass' | return status; } -function pruneTree( - node: SchemaVerificationNode, +type Counts = { pass: number; warn: number; fail: number; totalNodes: number }; + +/** + * Partitions `root.children` into the top-level table nodes another member + * claims (dropped) and the rest (kept), then rebuilds the root over the kept + * children with a freshly aggregated status. Only `root.children` is filtered — + * each surviving table keeps its full subtree. Descending further would wrongly + * drop a member's own column (or `storage.types` enum) whose name collides with + * a sibling space's table; the pruning layer this replaces dropped top-level + * entities only. + */ +function pruneTopLevelTables( + root: SchemaVerificationNode, ownedByOthers: ReadonlySet, -): SchemaVerificationNode { - if (node.children.length === 0) return node; - const keptChildren = node.children - .filter((child) => { - const name = nodeEntityName(child); - return name === undefined || !ownedByOthers.has(name); - }) - .map((child) => pruneTree(child, ownedByOthers)); - return { ...node, status: aggregateStatus(keptChildren), children: keptChildren }; +): { readonly root: SchemaVerificationNode; readonly dropped: readonly SchemaVerificationNode[] } { + const kept: SchemaVerificationNode[] = []; + const dropped: SchemaVerificationNode[] = []; + for (const child of root.children) { + const name = nodeEntityName(child); + if (name !== undefined && ownedByOthers.has(name)) dropped.push(child); + else kept.push(child); + } + return { + root: { ...root, status: aggregateStatus(kept), children: kept }, + dropped, + }; } -function countTree(node: SchemaVerificationNode): { - pass: number; - warn: number; - fail: number; - totalNodes: number; -} { +function subtreeCounts(node: SchemaVerificationNode): Counts { let pass = 0; let warn = 0; let fail = 0; @@ -99,6 +108,35 @@ function countTree(node: SchemaVerificationNode): { return { pass, warn, fail, totalNodes }; } +/** + * The verify tree keeps only the first namespace's `root` on a multi-schema + * database (the counts are summed across namespaces), so recomputing counts + * from the root would undercount. Instead subtract the dropped top-level + * subtrees from the authoritative incoming counts, and reconcile the root's own + * status flip: if dropping the last failing/worst top-level node changes the + * root's status, that node is no longer counted at its old status. + */ +function countsAfterDrop( + original: Counts, + dropped: readonly SchemaVerificationNode[], + oldRootStatus: SchemaVerificationNode['status'], + newRootStatus: SchemaVerificationNode['status'], +): Counts { + const next = { ...original }; + for (const node of dropped) { + const c = subtreeCounts(node); + next.pass -= c.pass; + next.warn -= c.warn; + next.fail -= c.fail; + next.totalNodes -= c.totalNodes; + } + if (newRootStatus !== oldRootStatus) { + next[oldRootStatus] -= 1; + next[newRootStatus] += 1; + } + return next; +} + /** * Scope a per-member verify result to the member's own contract space: drop the * `extra` findings for entities another aggregate member claims. Diffing the @@ -126,8 +164,13 @@ export function scopeSchemaResultToSpace( const name = schemaDiffIssueEntityName(issue); return name === undefined || !ownedByOthers.has(name); }); - const root = pruneTree(result.schema.root, ownedByOthers); - const counts = countTree(root); + const { root, dropped } = pruneTopLevelTables(result.schema.root, ownedByOthers); + const counts = countsAfterDrop( + result.schema.counts, + dropped, + result.schema.root.status, + root.status, + ); const ok = counts.fail === 0; return { diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts index 0d890c3395..fd4a46128f 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts @@ -10,10 +10,30 @@ function policyNode(id: string, tableName: string): DiffableNode { return { id, tableName, isEqualTo: () => false, children: () => [] } as DiffableNode; } +function columnNode( + tableName: string, + columnName: string, + status: 'pass' | 'warn' | 'fail', + code = '', +): SchemaVerificationNode { + return { + status, + kind: 'column', + name: columnName, + contractPath: `storage.namespaces.*.entries.table.${tableName}.columns.${columnName}`, + code, + message: '', + expected: undefined, + actual: undefined, + children: [], + }; +} + function tableNode( name: string, status: 'pass' | 'warn' | 'fail', code = '', + children: readonly SchemaVerificationNode[] = [], ): SchemaVerificationNode { return { status, @@ -24,7 +44,7 @@ function tableNode( message: '', expected: undefined, actual: undefined, - children: [], + children, }; } @@ -32,10 +52,15 @@ function resultWith( children: readonly SchemaVerificationNode[], issues: VerifyDatabaseSchemaResult['schema']['issues'], ): VerifyDatabaseSchemaResult { - const fail = children.filter((c) => c.status === 'fail').length; - const warn = children.filter((c) => c.status === 'warn').length; - const pass = children.filter((c) => c.status === 'pass').length; + let fail = children.filter((c) => c.status === 'fail').length; + let warn = children.filter((c) => c.status === 'warn').length; + let pass = children.filter((c) => c.status === 'pass').length; const rootStatus = fail > 0 ? 'fail' : warn > 0 ? 'warn' : 'pass'; + // Count the root node itself at its own status — matching the family verify's + // `computeCounts`, which walks every node including the root. + if (rootStatus === 'fail') fail += 1; + else if (rootStatus === 'warn') warn += 1; + else pass += 1; return { ok: fail === 0, summary: 'original summary', @@ -55,7 +80,7 @@ function resultWith( actual: undefined, children, }, - counts: { pass: pass + 1, warn, fail, totalNodes: children.length + 1 }, + counts: { pass, warn, fail, totalNodes: children.length + 1 }, }, timings: { total: 0 }, }; @@ -67,6 +92,25 @@ describe('scopeSchemaResultToSpace', () => { expect(scopeSchemaResultToSpace(result, new Set())).toBe(result); }); + it('preserves the authoritative counts when a non-empty owned set drops nothing', () => { + // A multi-schema result keeps only the first namespace's root but sums the + // counts across every namespace, so `counts` is not derivable from `root`. + // Scoping must not recompute counts from the (partial) root when it drops + // nothing — it must leave the authoritative counts untouched. + const result = resultWith([tableNode('user', 'pass')], []); + const authoritative = { pass: 9, warn: 2, fail: 1, totalNodes: 12 }; + const multiSchema: VerifyDatabaseSchemaResult = { + ...result, + ok: false, + schema: { ...result.schema, counts: authoritative }, + }; + + const scoped = scopeSchemaResultToSpace(multiSchema, new Set(['nothing_here'])); + + expect(scoped.schema.counts).toEqual(authoritative); + expect(scoped.ok).toBe(false); + }); + it('drops an extra-table issue owned by another member, keeps the undeclared one', () => { const result = resultWith( [tableNode('user', 'pass'), tableNode('cipher_state', 'warn'), tableNode('orphan', 'warn')], @@ -129,6 +173,59 @@ describe('scopeSchemaResultToSpace', () => { ]); }); + it('prunes only top-level table nodes, never a member’s own column named like a sibling table', () => { + // A sibling space owns a table named `orders`. The member's own `user` table + // has two columns, `orders` (passing) and `orders` again as a failing type + // mismatch — both share the sibling table's name. Scoping must touch NEITHER + // column (they are not top-level tables), so the real failure survives and + // the verdict does not flip to a false pass. + const passCol = columnNode('user', 'orders', 'pass'); + const failCol = columnNode('user', 'orders', 'fail', 'type_mismatch'); + const userTable = tableNode('user', 'fail', 'type_mismatch', [passCol, failCol]); + const result: VerifyDatabaseSchemaResult = { + ok: false, + code: 'PN-RUN-3010', + summary: 'Database schema does not satisfy contract (1 failure)', + contract: { storageHash: 'sha256:test' }, + target: { expected: 'postgres' }, + schema: { + issues: [ + { + kind: 'type_mismatch', + table: 'user', + column: 'orders', + message: 'type mismatch on user.orders', + }, + ], + schemaDiffIssues: [], + root: { + status: 'fail', + kind: 'contract', + name: 'contract', + contractPath: '', + code: 'type_mismatch', + message: '', + expected: undefined, + actual: undefined, + children: [userTable], + }, + counts: { pass: 2, warn: 0, fail: 2, totalNodes: 4 }, + }, + timings: { total: 0 }, + }; + // The sibling space owns a table literally named `orders`. + const scoped = scopeSchemaResultToSpace(result, new Set(['orders'])); + + // The member's own `user` table and both its columns are untouched, so the + // failing column survives and the verdict does not flip to a false pass. + const scopedUser = scoped.schema.root.children[0]; + expect(scopedUser?.name).toBe('table user'); + expect(scopedUser?.children).toHaveLength(2); + expect(scoped.ok).toBe(false); + expect(scoped.schema.counts.fail).toBe(result.schema.counts.fail); + expect(scoped.schema.issues).toEqual(result.schema.issues); + }); + it('drops an extra policy schemaDiffIssue owned by another member', () => { const result: VerifyDatabaseSchemaResult = { ...resultWith([], []), From 4db45b544c3edd51296a92e1f3fa8aad009e7f52 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 10:13:26 +0200 Subject: [PATCH 33/49] fix(migration): only entity nodes are droppable from the scoped verify tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pruneTopLevelTables` keyed the drop on the last `contractPath` segment of any root child. The root also carries the synthesized `storageTypes` verify-node (`kind: storageTypes`, `name: types`, `contractPath: storage.types`), so a sibling space owning a table literally named `types` dropped that node — and if it was a failing enum-drift node, `counts.fail` fell, `ok` flipped true, and `db verify` passed while the enum issue stayed in `issues`. The same false-pass class as the column case, one node over; the deleted pruning layer never touched the synthesized enum node (it pruned input-schema tables). Close the class by allowlisting the droppable child: only a top-level entity node — a SQL `table` or a Mongo `collection` — may be dropped. Any other root child (the `storageTypes` node now, and anything added later) is never droppable, regardless of name. Confirmed the enum node kind is `storageTypes`, not `table`, and that Mongo top-level nodes are `collection` (so scoping still drops sibling collections). Regression test added first (a failing `storageTypes` node scoped against an owned set containing `types` survives, `ok` stays false); it fails on the name-only filter and passes on the allowlist. A companion test locks that a Mongo `collection` a sibling claims is still dropped. Gate: migration-tools 554 + framework-components 441 + mongo-target 420 green; the four guards green; fixtures:check clean; lint:deps clean. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/aggregate/scope-schema-result.ts | 17 ++- .../aggregate/scope-schema-result.test.ts | 106 ++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts b/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts index 5ce3e7cf10..45b7d1c740 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts @@ -33,6 +33,11 @@ function nodeEntityName(node: SchemaVerificationNode): string | undefined { return segments.length > 0 ? segments[segments.length - 1] : undefined; } +/** True for a top-level entity verify-node: a SQL `table` or a Mongo `collection`. */ +function isEntityNode(node: SchemaVerificationNode): boolean { + return node.kind === 'table' || node.kind === 'collection'; +} + /** True when an issue reports an entity present in the database but claimed by no member (an extra). */ function isExtraIssue(issue: SchemaIssue): issue is BaseSchemaIssue { return ( @@ -83,8 +88,16 @@ function pruneTopLevelTables( const dropped: SchemaVerificationNode[] = []; for (const child of root.children) { const name = nodeEntityName(child); - if (name !== undefined && ownedByOthers.has(name)) dropped.push(child); - else kept.push(child); + // Only top-level entity nodes are droppable — a SQL `table` or a Mongo + // `collection`. The root also carries the synthesized `storageTypes` node + // (`name: 'types'`); a sibling owning a table named `types` must never drop + // it, or an enum-drift failure would vanish. Any other root child (now or + // later) is likewise never droppable, regardless of name. + if (isEntityNode(child) && name !== undefined && ownedByOthers.has(name)) { + dropped.push(child); + } else { + kept.push(child); + } } return { root: { ...root, status: aggregateStatus(kept), children: kept }, diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts index fd4a46128f..762209fa6b 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts @@ -48,6 +48,36 @@ function tableNode( }; } +/** A Mongo top-level entity node (`kind: 'collection'`). */ +function collectionNode(name: string, status: 'pass' | 'warn' | 'fail'): SchemaVerificationNode { + return { + status, + kind: 'collection', + name, + contractPath: `storage.namespaces.unbound.entries.collection.${name}`, + code: status === 'pass' ? '' : 'extra_table', + message: '', + expected: null, + actual: name, + children: [], + }; +} + +/** The synthesized `storage.types` root child — named `types`, but not a table node. */ +function storageTypesNode(status: 'pass' | 'warn' | 'fail'): SchemaVerificationNode { + return { + status, + kind: 'storageTypes', + name: 'types', + contractPath: 'storage.types', + code: status === 'fail' ? 'type_mismatch' : '', + message: '', + expected: undefined, + actual: undefined, + children: [], + }; +} + function resultWith( children: readonly SchemaVerificationNode[], issues: VerifyDatabaseSchemaResult['schema']['issues'], @@ -226,6 +256,82 @@ describe('scopeSchemaResultToSpace', () => { expect(scoped.schema.issues).toEqual(result.schema.issues); }); + it('never drops the storageTypes node even when a sibling owns a table named `types`', () => { + // The synthesized `storage.types` root child is named `types` but is not a + // table node. A sibling space owning a table literally named `types` must + // not drop it — if it is a failing enum-drift node, dropping it would flip + // the member to a false pass. + const typesNode = storageTypesNode('fail'); + const result: VerifyDatabaseSchemaResult = { + ok: false, + code: 'PN-RUN-3010', + summary: 'Database schema does not satisfy contract (1 failure)', + contract: { storageHash: 'sha256:test' }, + target: { expected: 'postgres' }, + schema: { + issues: [ + { + kind: 'enum_values_changed', + namespaceId: 'public', + typeName: 'status', + addedValues: ['archived'], + removedValues: [], + message: 'enum status changed', + }, + ], + schemaDiffIssues: [], + root: { + status: 'fail', + kind: 'contract', + name: 'contract', + contractPath: '', + code: 'type_mismatch', + message: '', + expected: undefined, + actual: undefined, + children: [tableNode('user', 'pass'), typesNode], + }, + counts: { pass: 2, warn: 0, fail: 2, totalNodes: 3 }, + }, + timings: { total: 0 }, + }; + + const scoped = scopeSchemaResultToSpace(result, new Set(['types'])); + + expect(scoped.schema.root.children.map((c) => c.name)).toContain('types'); + expect(scoped.ok).toBe(false); + expect(scoped.schema.counts.fail).toBe(result.schema.counts.fail); + }); + + it('still drops a Mongo collection node another member claims', () => { + // The entity-node allowlist covers Mongo's `collection` kind too, so + // sibling-owned collections are dropped just like SQL tables. + const result: VerifyDatabaseSchemaResult = { + ...resultWith([], []), + schema: { + issues: [{ kind: 'extra_table', table: 'sibling_coll', message: 'extra collection' }], + schemaDiffIssues: [], + root: { + status: 'warn', + kind: 'root', + name: 'mongo-schema', + contractPath: 'storage', + code: 'DRIFT', + message: '', + expected: null, + actual: null, + children: [collectionNode('own_coll', 'pass'), collectionNode('sibling_coll', 'warn')], + }, + counts: { pass: 2, warn: 1, fail: 0, totalNodes: 3 }, + }, + }; + + const scoped = scopeSchemaResultToSpace(result, new Set(['sibling_coll'])); + + expect(scoped.schema.root.children.map((c) => c.name)).toEqual(['own_coll']); + expect(scoped.schema.issues).toEqual([]); + }); + it('drops an extra policy schemaDiffIssue owned by another member', () => { const result: VerifyDatabaseSchemaResult = { ...resultWith([], []), From a836ec6c7bc5e2086fc2c8d2adc41841db0d7d67 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 10:46:21 +0200 Subject: [PATCH 34/49] refactor(postgres): consistent static node guards; delete ensure() and dead policy guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings every schema-diff node to the §8/§9 guard standard. All five `…SchemaNode` classes (database, namespace, table, policy, role) now expose static `is(node: SqlSchemaIRNode)` and `assert(node)` that discriminate on the node own `nodeKind` — no `instanceof`, no `unknown`, no `DiffableNode` parameter. `namespace`, `table`, and `role` gain the `assert` they were missing; `policy` `assert` message is aligned. The policy/role `isEqualTo` now delegate to `assert()` instead of inlining the kind check (behaviour unchanged — still throws on a mismatched kind). Deletes `PostgresDatabaseSchemaNode.ensure()`. It existed to reconstruct a node from the plain object `projectSchemaToSpace` spread produced; V2 deleted that spread, so every call site already holds a real, narrowed instance. The five call sites now use the value in place (after the existing `is`/`assert`), and the node constructors drop the now-dead plain-object reconstruction of their child nodes (their `*Input` unions narrow to instances). Column/FK/index/PK reconstruction from plain field data stays — that is live JSON-input handling. `StorageTable.is` / `.assert` become static methods with the parameter un-widened from `unknown` to `StorageTable | undefined` (the exact type every call site already holds); the free functions and their re-exports are removed and the five call sites updated. The dead `isPostgresRlsPolicy` / `assertPostgresRlsPolicy` free guards (no production callers) are deleted with their re-exports and tests. The two bare `as MongoContract` casts in the Mongo target descriptor become `blindCast`. Behaviour-neutral: guard results and diff/verify verdicts unchanged. fixtures:check clean; lint:deps clean; lint:casts improved (delta -10). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-core/contract/src/exports/types.ts | 2 - .../1-core/contract/src/ir/storage-table.ts | 29 +++++++---- packages/2-sql/1-core/contract/src/types.ts | 7 +-- .../9-family/src/core/diff/sql-schema-diff.ts | 5 +- .../core/migrations/contract-to-schema-ir.ts | 12 ++--- .../core/migrations/field-event-planner.ts | 11 ++-- .../9-family/test/namespace-hydration.test.ts | 8 +-- .../1-mongo-target/src/core/control-target.ts | 12 ++++- .../core/migrations/diff-database-schema.ts | 3 +- .../core/migrations/diff-postgres-schema.ts | 5 +- .../postgres/src/core/migrations/planner.ts | 2 +- .../migrations/verify-postgres-namespaces.ts | 2 +- .../postgres/src/core/postgres-rls-policy.ts | 22 -------- .../postgres-database-schema-node.ts | 51 +++---------------- .../postgres-namespace-schema-node.ts | 26 ++++------ .../schema-ir/postgres-policy-schema-node.ts | 8 +-- .../schema-ir/postgres-role-schema-node.ts | 14 +++-- .../schema-ir/postgres-table-schema-node.ts | 23 ++++----- .../src/core/schema-ir/schema-node-kinds.ts | 7 ++- .../3-targets/postgres/src/exports/control.ts | 2 +- .../3-targets/postgres/src/exports/types.ts | 2 - .../postgres-database-schema-node.test.ts | 17 ------- .../postgres/test/rls-diffable-nodes.test.ts | 50 +----------------- 23 files changed, 92 insertions(+), 228 deletions(-) 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 5271b0ee22..826520cf8b 100644 --- a/packages/2-sql/1-core/contract/src/exports/types.ts +++ b/packages/2-sql/1-core/contract/src/exports/types.ts @@ -53,7 +53,6 @@ export type { } from '../types'; export { applyFkDefaults, - assertStorageTable, CheckConstraint, CODEC_INSTANCE_KIND, DEFAULT_FK_CONSTRAINT, @@ -63,7 +62,6 @@ export { Index, isMaterializedSqlNamespace, isSqlAuthoringContributions, - isStorageTable, isStorageTypeInstance, isStorageValueSet, PrimaryKey, diff --git a/packages/2-sql/1-core/contract/src/ir/storage-table.ts b/packages/2-sql/1-core/contract/src/ir/storage-table.ts index 361658c9f2..962562213a 100644 --- a/packages/2-sql/1-core/contract/src/ir/storage-table.ts +++ b/packages/2-sql/1-core/contract/src/ir/storage-table.ts @@ -68,18 +68,25 @@ export class StorageTable extends SqlNode { } freezeNode(this); } -} -export function isStorageTable(value: unknown): value is StorageTable { - if (typeof value !== 'object' || value === null) return false; - return 'columns' in value && 'uniques' in value && 'indexes' in value && 'foreignKeys' in value; -} + /** + * Runtime guard that a namespace `table` entry is really a `StorageTable`. + * The compiler already types the entry as `StorageTable`, but a + * freshly-deserialized contract may carry plain JSON at that slot until + * hydration; this duck-types the structural shape. Accepts `undefined` so + * optional-chained entry lookups pass straight through. + */ + static is(value: StorageTable | undefined): value is StorageTable { + if (typeof value !== 'object' || value === null) return false; + return 'columns' in value && 'uniques' in value && 'indexes' in value && 'foreignKeys' in value; + } -export function assertStorageTable( - value: unknown, - coordinate: string, -): asserts value is StorageTable { - if (!isStorageTable(value)) { - throw new Error(`Expected a StorageTable at ${coordinate}`); + static assert( + value: StorageTable | undefined, + coordinate: string, + ): asserts value is StorageTable { + if (!StorageTable.is(value)) { + throw new Error(`Expected a StorageTable at ${coordinate}`); + } } } diff --git a/packages/2-sql/1-core/contract/src/types.ts b/packages/2-sql/1-core/contract/src/types.ts index b38b394b44..660632f9ba 100644 --- a/packages/2-sql/1-core/contract/src/types.ts +++ b/packages/2-sql/1-core/contract/src/types.ts @@ -37,12 +37,7 @@ export { type SqlStorageTypeEntry, } from './ir/sql-storage'; export { StorageColumn, type StorageColumnInput } from './ir/storage-column'; -export { - assertStorageTable, - isStorageTable, - StorageTable, - type StorageTableInput, -} from './ir/storage-table'; +export { StorageTable, type StorageTableInput } from './ir/storage-table'; export { CODEC_INSTANCE_KIND, isStorageTypeInstance, diff --git a/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts b/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts index 34ed9fec81..0eb8616ce1 100644 --- a/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts +++ b/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts @@ -18,11 +18,10 @@ import type { } from '@prisma-next/framework-components/control'; import { - isStorageTable, isStorageTypeInstance, type SqlStorage, type StorageColumn, - type StorageTable, + StorageTable, type StorageTypeInstance, } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; @@ -340,7 +339,7 @@ function verifySchemaTables(options: { const ns = contract.storage.namespaces[namespaceId]; if (!ns) continue; for (const [tableName, contractTableRaw] of Object.entries(ns.entries.table ?? {})) { - if (!isStorageTable(contractTableRaw)) { + if (!StorageTable.is(contractTableRaw)) { throw new Error( `verifySqlSchema: expected StorageTable at storage.namespaces.${namespaceId}.entries.table.${tableName}`, ); diff --git a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts index 6a43f59498..33f69ce623 100644 --- a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts +++ b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts @@ -1,15 +1,13 @@ import type { ColumnDefault, Contract, JsonValue } from '@prisma-next/contract/types'; import type { MigrationPlannerConflict } from '@prisma-next/framework-components/control'; import { - assertStorageTable, type CheckConstraint, type ForeignKey, type Index, - isStorageTable, isStorageTypeInstance, type SqlStorage, type StorageColumn, - type StorageTable, + StorageTable, type StorageTypeInstance, type UniqueConstraint, } from '@prisma-next/sql-contract/types'; @@ -306,7 +304,7 @@ export function detectDestructiveChanges( for (const tableName of Object.keys(fromTables)) { const toTableRaw = toNs?.entries.table?.[tableName]; - if (!isStorageTable(toTableRaw)) { + if (!StorageTable.is(toTableRaw)) { conflicts.push({ kind: 'tableRemoved', summary: `Table "${tableName}" was removed`, @@ -316,7 +314,7 @@ export function detectDestructiveChanges( const toTable = toTableRaw; const fromTableRaw = fromTables[tableName]; - if (!isStorageTable(fromTableRaw)) continue; + if (!StorageTable.is(fromTableRaw)) continue; const fromTable = fromTableRaw; for (const columnName of Object.keys(fromTable.columns)) { @@ -390,7 +388,7 @@ export function contractNamespaceToSchemaIR( const storageTypes: ResolvedStorageTypes = { ...(storage.types ?? {}) }; const tables: Record = {}; for (const [tableName, tableDefRaw] of Object.entries(namespace.entries.table ?? {})) { - assertStorageTable(tableDefRaw, `namespaces.${namespaceId}.entries.table.${tableName}`); + StorageTable.assert(tableDefRaw, `namespaces.${namespaceId}.entries.table.${tableName}`); tables[tableName] = convertTable( tableName, tableDefRaw, @@ -420,7 +418,7 @@ export function contractToSchemaIR( const tables: Record = {}; for (const ns of Object.values(storage.namespaces)) { for (const [tableName, tableDefRaw] of Object.entries(ns.entries.table ?? {})) { - assertStorageTable(tableDefRaw, `namespaces.${ns.id}.entries.table.${tableName}`); + StorageTable.assert(tableDefRaw, `namespaces.${ns.id}.entries.table.${tableName}`); const tableDef = tableDefRaw; if (tables[tableName] !== undefined) { throw new Error( diff --git a/packages/2-sql/9-family/src/core/migrations/field-event-planner.ts b/packages/2-sql/9-family/src/core/migrations/field-event-planner.ts index 0b53f36c97..34b09ef594 100644 --- a/packages/2-sql/9-family/src/core/migrations/field-event-planner.ts +++ b/packages/2-sql/9-family/src/core/migrations/field-event-planner.ts @@ -24,12 +24,7 @@ import type { Contract } from '@prisma-next/contract/types'; import type { OpFactoryCall } from '@prisma-next/framework-components/control'; -import { - isStorageTable, - type SqlStorage, - type StorageColumn, - type StorageTable, -} from '@prisma-next/sql-contract/types'; +import { type SqlStorage, type StorageColumn, StorageTable } from '@prisma-next/sql-contract/types'; import type { CodecControlHooks, FieldEvent, FieldEventContext } from './types'; export interface PlanFieldEventOperationsOptions { @@ -93,8 +88,8 @@ export function planFieldEventOperations( for (const tableName of tableNames) { const priorTableRaw = priorTables?.[tableName]; const newTableRaw = newTables?.[tableName]; - const priorTable = isStorageTable(priorTableRaw) ? priorTableRaw : undefined; - const newTable = isStorageTable(newTableRaw) ? newTableRaw : undefined; + const priorTable = StorageTable.is(priorTableRaw) ? priorTableRaw : undefined; + const newTable = StorageTable.is(newTableRaw) ? newTableRaw : undefined; const fieldNames = unionSorted( priorTable ? Object.keys(priorTable.columns) : [], newTable ? Object.keys(newTable.columns) : [], diff --git a/packages/2-sql/9-family/test/namespace-hydration.test.ts b/packages/2-sql/9-family/test/namespace-hydration.test.ts index 73c9a0d775..20a9367fe3 100644 --- a/packages/2-sql/9-family/test/namespace-hydration.test.ts +++ b/packages/2-sql/9-family/test/namespace-hydration.test.ts @@ -1,10 +1,6 @@ import type { Contract } from '@prisma-next/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; -import { - isStorageTable, - isStorageValueSet, - type SqlStorage, -} from '@prisma-next/sql-contract/types'; +import { isStorageValueSet, type SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; import { createSqlContract } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { TestSqlContractSerializer } from './test-sql-contract-serializer'; @@ -42,7 +38,7 @@ describe('SqlContractSerializer — built-in kind hydration', () => { const contract = serializer.deserializeContract(json) as Contract; const ns = contract.storage.namespaces[UNBOUND_NAMESPACE_ID]; expect(ns).toBeDefined(); - expect(isStorageTable(ns!.entries.table?.['users'])).toBe(true); + expect(StorageTable.is(ns!.entries.table?.['users'])).toBe(true); }); it('hydrates valueSet entries to StorageValueSet instances', () => { diff --git a/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts b/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts index 17dd2ea430..190754f2ae 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts @@ -78,7 +78,10 @@ export const mongoTargetDescriptor: MongoControlTargetDescriptor(runnerOptions.destinationContract), }); }; @@ -137,7 +140,12 @@ export const mongoTargetDescriptor: MongoControlTargetDescriptor(contract), + ); }, }, create() { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts index cb8f25ccbf..9385df6ef6 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts @@ -78,9 +78,8 @@ function computePostgresSchemaComparison(input: PostgresDiffDatabaseSchemaInput) const expected = contractToPostgresDatabaseSchemaNode(postgresContract, { annotationNamespace: 'pg', }); - const actual = PostgresDatabaseSchemaNode.ensure(input.actualSchema); const schemaDiffIssues = filterIssuesByOwnership( - diffPostgresSchema(expected, actual), + diffPostgresSchema(expected, input.actualSchema), ownedSchemaNames(expected), ); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts index e7ccdbed5c..4338ebbf8f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts @@ -2,7 +2,7 @@ import type { DiffableNode, SchemaDiffIssue } from '@prisma-next/framework-compo import { diffSchemas } from '@prisma-next/framework-components/control'; import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; -import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import type { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; // Every node in a diff issue produced from Postgres schema trees is a @@ -38,8 +38,7 @@ export function diffPostgresSchema( expected: PostgresDatabaseSchemaNode, actual: PostgresDatabaseSchemaNode, ): readonly SchemaDiffIssue[] { - const safeActual = PostgresDatabaseSchemaNode.ensure(actual); - const issues = diffSchemas(expected, safeActual); + const issues = diffSchemas(expected, actual); return issues .filter((i) => { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index c6b89fcd25..3b1dcf018c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -404,7 +404,7 @@ function relationalNamespaceNode( schema: PostgresDatabaseSchemaNode, schemaName: string, ): SqlSchemaIR | undefined { - const namespaceNodes = Object.values(PostgresDatabaseSchemaNode.ensure(schema).namespaces); + const namespaceNodes = Object.values(schema.namespaces); const byName = namespaceNodes.find((node) => node.schemaName === schemaName); return byName ?? namespaceNodes[0]; } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/verify-postgres-namespaces.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/verify-postgres-namespaces.ts index d949eed281..09e94bc545 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/verify-postgres-namespaces.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/verify-postgres-namespaces.ts @@ -41,7 +41,7 @@ function resolveDdlSchemaName(storage: SqlStorage, namespaceId: string): string */ function existingSchemasFromSchema(schema: SqlSchemaIRNode): readonly string[] { if (PostgresDatabaseSchemaNode.is(schema)) { - return PostgresDatabaseSchemaNode.ensure(schema).existingSchemas; + return schema.existingSchemas; } return ['public']; } diff --git a/packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts index cf9944aee7..10c956af68 100644 --- a/packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts @@ -62,25 +62,3 @@ export class PostgresRlsPolicy extends SqlNode { freezeNode(this); } } - -export function isPostgresRlsPolicy(node: unknown): node is PostgresRlsPolicy { - return ( - node !== undefined && - node !== null && - typeof node === 'object' && - 'kind' in node && - node.kind === 'policy' - ); -} - -export function assertPostgresRlsPolicy(node: unknown): asserts node is PostgresRlsPolicy { - if (!isPostgresRlsPolicy(node)) { - const kind = - node !== undefined && node !== null && typeof node === 'object' && 'kind' in node - ? String(node.kind) - : typeof node; - throw new Error( - `planPostgresSchemaDiff: expected a PostgresRlsPolicy on the policy-diff path but got ${kind}`, - ); - } -} diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts index 7ebdb9124e..d6512dacf4 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts @@ -1,19 +1,13 @@ import type { DiffableNode } from '@prisma-next/framework-components/control'; import { freezeNode } from '@prisma-next/framework-components/ir'; import { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; -import { blindCast } from '@prisma-next/utils/casts'; -import { - PostgresNamespaceSchemaNode, - type PostgresNamespaceSchemaNodeInput, -} from './postgres-namespace-schema-node'; -import { PostgresRoleSchemaNode } from './postgres-role-schema-node'; +import type { PostgresNamespaceSchemaNode } from './postgres-namespace-schema-node'; +import type { PostgresRoleSchemaNode } from './postgres-role-schema-node'; import { PostgresSchemaNodeKind } from './schema-node-kinds'; export interface PostgresDatabaseSchemaNodeInput { - readonly namespaces: Readonly< - Record - >; - readonly roles: readonly (PostgresRoleSchemaNode | { name: string; namespaceId: string })[]; + readonly namespaces: Readonly>; + readonly roles: readonly PostgresRoleSchemaNode[]; readonly existingSchemas: readonly string[]; readonly pgVersion: string; } @@ -28,8 +22,8 @@ export interface PostgresDatabaseSchemaNodeInput { * later slice, R4). * * `nodeKind` is an enumerable own discriminant that identifies this node and - * distinguishes it from the other schema-diff nodes after the `{ ...node }` - * spread `projectSchemaToSpace` produces. + * distinguishes it from the other schema-diff nodes; the `is`/`assert` guards + * discriminate on it. */ export class PostgresDatabaseSchemaNode extends SqlSchemaIRNode implements DiffableNode { override readonly nodeKind = PostgresSchemaNodeKind.database; @@ -40,22 +34,8 @@ export class PostgresDatabaseSchemaNode extends SqlSchemaIRNode implements Diffa constructor(input: PostgresDatabaseSchemaNodeInput) { super(); - // Reconstruct namespace/role nodes from plain objects: `projectSchemaToSpace` - // spreads the tree into plain objects (losing prototypes) before this root - // is `ensure`d, so the differ must still see real `DiffableNode`s. - this.namespaces = Object.freeze( - Object.fromEntries( - Object.entries(input.namespaces).map(([key, ns]) => [ - key, - ns instanceof PostgresNamespaceSchemaNode ? ns : new PostgresNamespaceSchemaNode(ns), - ]), - ), - ); - this.roles = Object.freeze( - input.roles.map((r) => - r instanceof PostgresRoleSchemaNode ? r : new PostgresRoleSchemaNode(r), - ), - ); + this.namespaces = Object.freeze({ ...input.namespaces }); + this.roles = Object.freeze([...input.roles]); this.existingSchemas = Object.freeze([...input.existingSchemas]); this.pgVersion = input.pgVersion; freezeNode(this); @@ -84,19 +64,4 @@ export class PostgresDatabaseSchemaNode extends SqlSchemaIRNode implements Diffa ); } } - - /** - * Returns `node` as-is when it is a real instance, or reconstructs one when - * `projectSchemaToSpace` has spread the class into a plain object (losing - * prototype methods but preserving all own-enumerable fields). - */ - static ensure(node: SqlSchemaIRNode): PostgresDatabaseSchemaNode { - if (node instanceof PostgresDatabaseSchemaNode) return node; - return new PostgresDatabaseSchemaNode( - blindCast< - PostgresDatabaseSchemaNodeInput, - 'spread objects from projectSchemaToSpace preserve all own-enumerable fields' - >(node), - ); - } } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts index b151d9c34e..e2a3e12028 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts @@ -1,15 +1,12 @@ import type { DiffableNode } from '@prisma-next/framework-components/control'; import { freezeNode } from '@prisma-next/framework-components/ir'; import { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; -import { - PostgresTableSchemaNode, - type PostgresTableSchemaNodeInput, -} from './postgres-table-schema-node'; +import type { PostgresTableSchemaNode } from './postgres-table-schema-node'; import { PostgresSchemaNodeKind } from './schema-node-kinds'; export interface PostgresNamespaceSchemaNodeInput { readonly schemaName: string; - readonly tables: Readonly>; + readonly tables: Readonly>; readonly nativeEnumTypeNames: readonly string[]; } @@ -33,16 +30,7 @@ export class PostgresNamespaceSchemaNode extends SqlSchemaIRNode implements Diff constructor(input: PostgresNamespaceSchemaNodeInput) { super(); this.schemaName = input.schemaName; - // Reconstruct table nodes from plain objects: `projectSchemaToSpace` - // spreads the tree into plain objects before a consumer `ensure`s the root. - this.tables = Object.freeze( - Object.fromEntries( - Object.entries(input.tables).map(([key, t]) => [ - key, - t instanceof PostgresTableSchemaNode ? t : new PostgresTableSchemaNode(t), - ]), - ), - ); + this.tables = Object.freeze({ ...input.tables }); this.nativeEnumTypeNames = Object.freeze([...input.nativeEnumTypeNames]); freezeNode(this); } @@ -62,4 +50,12 @@ export class PostgresNamespaceSchemaNode extends SqlSchemaIRNode implements Diff static is(node: SqlSchemaIRNode): node is PostgresNamespaceSchemaNode { return node.nodeKind === PostgresSchemaNodeKind.namespace; } + + static assert(node: SqlSchemaIRNode): asserts node is PostgresNamespaceSchemaNode { + if (!PostgresNamespaceSchemaNode.is(node)) { + throw new Error( + `Expected a PostgresNamespaceSchemaNode but got nodeKind=${node.nodeKind ?? 'undefined'}`, + ); + } + } } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts index a2eff02e77..08477539ef 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts @@ -75,11 +75,7 @@ export class PostgresPolicySchemaNode extends SqlSchemaIRNode implements Diffabl SqlSchemaIRNode, 'every diff-tree node the differ pairs is a SqlSchemaIRNode; the guard rejects non-policy kinds' >(other); - if (!PostgresPolicySchemaNode.is(node)) { - throw new Error( - `PostgresPolicySchemaNode.isEqualTo: expected a PostgresPolicySchemaNode, got nodeKind=${node.nodeKind ?? 'undefined'}`, - ); - } + PostgresPolicySchemaNode.assert(node); return this.id === node.id; } @@ -90,7 +86,7 @@ export class PostgresPolicySchemaNode extends SqlSchemaIRNode implements Diffabl static assert(node: SqlSchemaIRNode | undefined): asserts node is PostgresPolicySchemaNode { if (node === undefined || !PostgresPolicySchemaNode.is(node)) { throw new Error( - `Expected a PostgresPolicySchemaNode, got nodeKind=${node?.nodeKind ?? 'undefined'}`, + `Expected a PostgresPolicySchemaNode but got nodeKind=${node?.nodeKind ?? 'undefined'}`, ); } } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts index 04eacfd91f..eb0a66ef98 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-role-schema-node.ts @@ -48,15 +48,19 @@ export class PostgresRoleSchemaNode extends SqlSchemaIRNode implements DiffableN SqlSchemaIRNode, 'every diff-tree node the differ pairs is a SqlSchemaIRNode; the guard rejects non-role kinds' >(other); - if (!PostgresRoleSchemaNode.is(node)) { - throw new Error( - `PostgresRoleSchemaNode.isEqualTo: expected a PostgresRoleSchemaNode, got nodeKind=${node.nodeKind ?? 'undefined'}`, - ); - } + PostgresRoleSchemaNode.assert(node); return this.id === node.id; } static is(node: SqlSchemaIRNode): node is PostgresRoleSchemaNode { return node.nodeKind === PostgresSchemaNodeKind.role; } + + static assert(node: SqlSchemaIRNode): asserts node is PostgresRoleSchemaNode { + if (!PostgresRoleSchemaNode.is(node)) { + throw new Error( + `Expected a PostgresRoleSchemaNode but got nodeKind=${node.nodeKind ?? 'undefined'}`, + ); + } + } } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts index aafff81fdd..b6a5f19849 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-schema-node.ts @@ -11,14 +11,11 @@ import { type SqlTableIRInput, SqlUniqueIR, } from '@prisma-next/sql-schema-ir/types'; -import { - PostgresPolicySchemaNode, - type PostgresPolicySchemaNodeInput, -} from './postgres-policy-schema-node'; +import type { PostgresPolicySchemaNode } from './postgres-policy-schema-node'; import { PostgresSchemaNodeKind } from './schema-node-kinds'; export interface PostgresTableSchemaNodeInput extends SqlTableIRInput { - readonly policies?: readonly (PostgresPolicySchemaNode | PostgresPolicySchemaNodeInput)[]; + readonly policies?: readonly PostgresPolicySchemaNode[]; } /** @@ -80,13 +77,7 @@ export class PostgresTableSchemaNode extends SqlSchemaIRNode implements Diffable ), ); } - // Reconstruct policy nodes from plain objects: `projectSchemaToSpace` - // spreads the tree into plain objects before a consumer `ensure`s the root. - this.policies = Object.freeze( - (input.policies ?? []).map((p) => - p instanceof PostgresPolicySchemaNode ? p : new PostgresPolicySchemaNode(p), - ), - ); + this.policies = Object.freeze([...(input.policies ?? [])]); freezeNode(this); } @@ -105,4 +96,12 @@ export class PostgresTableSchemaNode extends SqlSchemaIRNode implements Diffable static is(node: SqlSchemaIRNode): node is PostgresTableSchemaNode { return node.nodeKind === PostgresSchemaNodeKind.table; } + + static assert(node: SqlSchemaIRNode): asserts node is PostgresTableSchemaNode { + if (!PostgresTableSchemaNode.is(node)) { + throw new Error( + `Expected a PostgresTableSchemaNode but got nodeKind=${node.nodeKind ?? 'undefined'}`, + ); + } + } } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts index c34a1e30b4..67d4bfa9e3 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts @@ -1,9 +1,8 @@ /** * The `nodeKind` discriminant for each Postgres schema-diff node. Each node - * carries a unique value; the `.is`/`.assert`/`.ensure` guards compare against - * these identifiers rather than spelling the string inline. The field is an - * enumerable own property, so it survives the `projectSchemaToSpace` spread that - * flattens the tree into plain objects. + * carries a unique value; the static `is`/`assert` guards compare against these + * identifiers rather than spelling the string inline or using `instanceof`. The + * field is an enumerable own property carried on every node instance. */ export const PostgresSchemaNodeKind = { database: 'postgres-database', diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index 25820fdf3d..ed5c6c6c83 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -60,7 +60,7 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP schemaVerifier: new PostgresSchemaVerifier(), inferPslContract(schema) { PostgresDatabaseSchemaNode.assert(schema); - return inferPostgresPslContract(PostgresDatabaseSchemaNode.ensure(schema)); + return inferPostgresPslContract(schema); }, diffDatabaseSchema(input) { return diffPostgresDatabaseSchema({ diff --git a/packages/3-targets/3-targets/postgres/src/exports/types.ts b/packages/3-targets/3-targets/postgres/src/exports/types.ts index d7bf17a40b..7ccdb976f8 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/types.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/types.ts @@ -1,6 +1,4 @@ export { - assertPostgresRlsPolicy, - isPostgresRlsPolicy, PostgresRlsPolicy, type PostgresRlsPolicyInput, type RlsPolicyOperation, diff --git a/packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts index 1dee5ebb83..b2604461c8 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-database-schema-node.test.ts @@ -131,21 +131,4 @@ describe('PostgresDatabaseSchemaNode', () => { expect(() => PostgresDatabaseSchemaNode.assert(bad)).toThrow(); }); }); - - describe('PostgresDatabaseSchemaNode.ensure', () => { - it('returns the same instance when already a real instance', () => { - const node = new PostgresDatabaseSchemaNode(baseInput); - expect(PostgresDatabaseSchemaNode.ensure(node)).toBe(node); - }); - - it('reconstructs from a spread-flattened plain object', () => { - const node = new PostgresDatabaseSchemaNode(baseInput); - const spread = { ...node } as unknown as PostgresDatabaseSchemaNode; - const reconstructed = PostgresDatabaseSchemaNode.ensure(spread); - expect(reconstructed).toBeInstanceOf(PostgresDatabaseSchemaNode); - expect(reconstructed.id).toBe('database'); - expect(reconstructed.pgVersion).toBe('15.2'); - expect(Object.keys(reconstructed.namespaces)).toEqual(['public', 'app']); - }); - }); }); diff --git a/packages/3-targets/3-targets/postgres/test/rls-diffable-nodes.test.ts b/packages/3-targets/3-targets/postgres/test/rls-diffable-nodes.test.ts index b17244eeb5..a993cabb5b 100644 --- a/packages/3-targets/3-targets/postgres/test/rls-diffable-nodes.test.ts +++ b/packages/3-targets/3-targets/postgres/test/rls-diffable-nodes.test.ts @@ -4,11 +4,7 @@ * The DiffableNode role belongs to PostgresPolicySchemaNode / PostgresRoleSchemaNode. */ import { describe, expect, it } from 'vitest'; -import { - assertPostgresRlsPolicy, - isPostgresRlsPolicy, - PostgresRlsPolicy, -} from '../src/core/postgres-rls-policy'; +import { PostgresRlsPolicy } from '../src/core/postgres-rls-policy'; import { PostgresRole } from '../src/core/postgres-role'; describe('PostgresRlsPolicy — Contract-IR entity, not a DiffableNode', () => { @@ -86,47 +82,3 @@ describe('PostgresRole — Contract-IR entity, not a DiffableNode', () => { expect(json['kind']).toBe('role'); }); }); - -describe('isPostgresRlsPolicy guard', () => { - it('returns true for a PostgresRlsPolicy', () => { - const policy = new PostgresRlsPolicy({ - name: 'p_a1b2c3d4', - prefix: 'p', - tableName: 'users', - namespaceId: 'public', - operation: 'select', - roles: [], - permissive: true, - }); - expect(isPostgresRlsPolicy(policy)).toBe(true); - }); - - it('returns false for a PostgresRole', () => { - const role = new PostgresRole({ name: 'anon', namespaceId: 'public' }); - expect(isPostgresRlsPolicy(role)).toBe(false); - }); - - it('returns false for undefined', () => { - expect(isPostgresRlsPolicy(undefined)).toBe(false); - }); -}); - -describe('assertPostgresRlsPolicy guard', () => { - it('does not throw for a PostgresRlsPolicy', () => { - const policy = new PostgresRlsPolicy({ - name: 'p_a1b2c3d4', - prefix: 'p', - tableName: 'users', - namespaceId: 'public', - operation: 'select', - roles: [], - permissive: true, - }); - expect(() => assertPostgresRlsPolicy(policy)).not.toThrow(); - }); - - it('throws with a descriptive message for a non-policy value', () => { - const role = new PostgresRole({ name: 'anon', namespaceId: 'public' }); - expect(() => assertPostgresRlsPolicy(role)).toThrow(/planPostgresSchemaDiff/); - }); -}); From 0bc6c3655ee54d6883131e1b84cf209a78316042 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 10:55:34 +0200 Subject: [PATCH 35/49] docs(postgres-rls): reconcile diff-verify design to bare-name ownership keying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V2 keys contract-space ownership on bare entity name (matching the pruning layer it replaces, so behaviour-neutral); extra_table issues carry no namespaceId, so qualified (namespaceId, name) keying is a follow-on tied to the relational port. Update design §11/§13 to the accurate interim. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../schema-node-tree-restructure/design-diff-and-verify.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md index 146b3a5dbe..f985698496 100644 --- a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md +++ b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md @@ -88,7 +88,7 @@ Both are defined identifiers, not string literals scattered across guards. ## 11. Contract-space handling: filter the issues, never prune the schema -The framework **does not alter the schema before diffing and does not branch on any storage shape.** It diffs the full introspected schema and filters the resulting issues by contract-space ownership. Ownership is attributed with the target-agnostic `elementCoordinates(contract.storage)` — an issue belongs to whichever member claims its `(namespaceId, name)` coordinate. +The framework **does not alter the schema before diffing and does not branch on any storage shape.** It diffs the full introspected schema and filters the resulting issues by contract-space ownership. Ownership is attributed with the target-agnostic `elementCoordinates(contract.storage)` — an issue belongs to whichever member claims its entity **name** (this matches the pruning layer it replaces, so it is behaviour-neutral). An `extra_table` issue carries no `namespaceId` — a live-DB table sits in no contract namespace — so qualified `(namespaceId, name)` keying, which would let two members own same-named tables in different schemas independently, awaits the differ stamping the introspected namespace onto extra issues; that is a follow-on (§13), not a regression. Deleted: @@ -112,6 +112,7 @@ The aggregate verifier and planner take the family's `SchemaDiffer`, diff the fu ## 13. Out of scope (follow-ons) - **Relational port / one issue type:** merging the relational check into the generic node differ so there is a single issue type. Until then `SchemaDiff` carries two lists. Separating `root` / `counts` from the relational walk rides with this port. +- **Qualified-coordinate ownership keying:** attribute cross-space ownership by `(namespaceId, name)` instead of bare entity name. Needs `extra_table` issues to carry their introspected namespace; rides with the relational port. - **PSL-inference tree-walk (TML-2958):** inference still gathers the tree into a flat document, guarded by a fail-loud throw. - **`annotations.pg` full retirement (TML-2936):** this rework stops *populating* the bag (§14); typed-field replacement is TML-2936. From 70345d4479bbec2dc6e6af7857d16604ae034bf0 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 11:11:35 +0200 Subject: [PATCH 36/49] refactor(postgres): one diff module, extract diff-SPI types, drop a dead family method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File organisation and dead-code cleanup; behaviour-neutral (fixtures clean). - Fold `diff-postgres-schema.ts` into `diff-database-schema.ts`. The policy node-diff (`diffPostgresSchema` + `filterIssuesByOwnership`) was only ever an internal step of the combined comparison, with no external consumer — it now lives beside the differ it serves. Deletes the separate file and the unused `exports/planner` re-exports; the unit test is renamed to match the surviving module and repointed. - Extract the diff-SPI types out of the catch-all `migrations/types.ts` into a named `schema-differ.ts`: `DiffDatabaseSchemaInput` plus `SqlDiffDatabaseSchema` / `SqlVerifyDatabaseSchema` aliases for the two descriptor fields. `types.ts` and `control-instance.ts` import from there. - Delete the dead `bootstrapSignMarkerQueries` method (and its interface member) on the SQL family instance — nothing calls `family.bootstrapSignMarkerQueries()`; `sign()` calls the adapter method directly, which stays. Findings reported (no merge/delete — kept and clarified): - `contractToPostgresDatabaseSchemaNode` is NOT a duplicate of `contractToSchemaIR`: it builds the Postgres *tree* (multi-schema, RLS-policy- and role-aware) and reuses the family per-namespace table conversion, whereas `contractToSchemaIR` builds the flat single-map (SQLite). A clarifying note now says so. - `verifyPostgresNamespacePresence` is LIVE — the planner uses it to emit `CREATE SCHEMA` for multi-schema plans. A note now records where it is used. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../9-family/src/core/control-instance.ts | 8 +-- .../src/core/migrations/schema-differ.ts | 40 +++++++++++ .../9-family/src/core/migrations/types.ts | 21 ++---- ...ntract-to-postgres-database-schema-node.ts | 7 ++ .../core/migrations/diff-database-schema.ts | 72 ++++++++++++++++++- .../core/migrations/diff-postgres-schema.ts | 72 ------------------- .../migrations/verify-postgres-namespaces.ts | 6 +- .../3-targets/postgres/src/exports/planner.ts | 4 -- ...a.test.ts => diff-database-schema.test.ts} | 2 +- 9 files changed, 128 insertions(+), 104 deletions(-) create mode 100644 packages/2-sql/9-family/src/core/migrations/schema-differ.ts delete mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts rename packages/3-targets/3-targets/postgres/test/migrations/{diff-postgres-schema.test.ts => diff-database-schema.test.ts} (99%) diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index 79101db7ec..9218550c3a 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -49,8 +49,8 @@ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { SqlControlAdapter } from './control-adapter'; import { SqlContractSerializer } from './ir/sql-contract-serializer'; +import type { DiffDatabaseSchemaInput } from './migrations/schema-differ'; import type { - DiffDatabaseSchemaInput, SqlControlAdapterDescriptor, SqlControlExtensionDescriptor, } from './migrations/types'; @@ -298,8 +298,6 @@ export interface SqlControlFamilyInstance bootstrapControlTableQueries(): readonly DdlNode[]; - bootstrapSignMarkerQueries(): readonly DdlNode[]; - toOperationPreview(operations: readonly MigrationPlanOperation[]): OperationPreview; } @@ -939,10 +937,6 @@ export function createSqlFamilyInstance( return getControlAdapter().bootstrapControlTableQueries(); }, - bootstrapSignMarkerQueries(): readonly DdlNode[] { - return getControlAdapter().bootstrapSignMarkerQueries(); - }, - toOperationPreview(operations: readonly MigrationPlanOperation[]): OperationPreview { return sqlOperationsToPreview(operations); }, diff --git a/packages/2-sql/9-family/src/core/migrations/schema-differ.ts b/packages/2-sql/9-family/src/core/migrations/schema-differ.ts new file mode 100644 index 0000000000..946be0b458 --- /dev/null +++ b/packages/2-sql/9-family/src/core/migrations/schema-differ.ts @@ -0,0 +1,40 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; +import type { + SchemaDiffer, + VerifyDatabaseSchemaResult, +} from '@prisma-next/framework-components/control'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; + +/** + * Inputs to a SQL target's schema-differ (`diffDatabaseSchema` / + * `verifyDatabaseSchema` on the descriptor): the contract (the expected side + * derives from it), the introspected actual schema node, and the resolution + * context the relational diff needs. + */ +export interface DiffDatabaseSchemaInput { + readonly contract: Contract; + readonly schema: SqlSchemaIRNode; + readonly strict: boolean; + readonly typeMetadataRegistry: ReadonlyMap; + readonly frameworkComponents: ReadonlyArray>; +} + +/** + * The `SchemaDiffer` a SQL target implements: the black-box comparison of the + * contract's expected schema against the introspected actual schema, projected + * to the two issue lists. How it computes them is private to the target. + */ +export type SqlDiffDatabaseSchema = SchemaDiffer['diff']; + +/** + * The same combined comparison as {@link SqlDiffDatabaseSchema}, wrapped in the + * verify envelope (`ok`/`summary`/`code`/`target`/`timings`) plus the + * pass/warn/fail tree the CLI renders. Verify calls this instead of the diff so + * the relational walk that produces the tree runs once per verify, not once for + * the diff and again for the tree. + */ +export type SqlVerifyDatabaseSchema = ( + input: DiffDatabaseSchemaInput, +) => VerifyDatabaseSchemaResult; diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index 3d3ca98d47..9a2aec773b 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -18,10 +18,8 @@ import type { MigrationRunnerResult, OperationContext, OpFactoryCall, - SchemaDiffer, SchemaIssue, SchemaVerifier, - VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; import type { PslDocumentAst } from '@prisma-next/framework-components/psl-ast'; import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; @@ -37,6 +35,7 @@ import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/ty import type { Result } from '@prisma-next/utils/result'; import type { SqlControlAdapter } from '../control-adapter'; import type { SqlControlFamilyInstance } from '../control-instance'; +import type { SqlDiffDatabaseSchema, SqlVerifyDatabaseSchema } from './schema-differ'; export type AnyRecord = Readonly>; @@ -503,8 +502,9 @@ export interface SqlControlTargetDescriptor< * returns relational + policy issues; SQLite returns relational only). It is * schema logic on the target, not database I/O, so it lives here rather than * on the control adapter. How it computes the two issue sets is private. + * See {@link SqlDiffDatabaseSchema} / {@link SqlVerifyDatabaseSchema}. */ - readonly diffDatabaseSchema: SchemaDiffer['diff']; + readonly diffDatabaseSchema: SqlDiffDatabaseSchema; /** * The same combined comparison as {@link diffDatabaseSchema}, wrapped in the * verify envelope (`ok`/`summary`/`code`/`target`/`timings`) plus the @@ -512,24 +512,11 @@ export interface SqlControlTargetDescriptor< * `diffDatabaseSchema` so the relational walk that produces the tree runs * once per verify, not once for the diff and again for the tree. */ - readonly verifyDatabaseSchema: (input: DiffDatabaseSchemaInput) => VerifyDatabaseSchemaResult; + readonly verifyDatabaseSchema: SqlVerifyDatabaseSchema; createPlanner(adapter: SqlControlAdapter): SqlMigrationPlanner; createRunner(family: SqlControlFamilyInstance): SqlMigrationRunner; } -/** - * Inputs to a target descriptor's {@link SqlControlTargetDescriptor.diffDatabaseSchema}: - * the contract (the expected side derives from it), the introspected actual - * schema node, and the resolution context the relational diff needs. - */ -export interface DiffDatabaseSchemaInput { - readonly contract: Contract; - readonly schema: SqlSchemaIRNode; - readonly strict: boolean; - readonly typeMetadataRegistry: ReadonlyMap; - readonly frameworkComponents: ReadonlyArray>; -} - export interface CreateSqlMigrationPlanOptions { readonly targetId: string; /** diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts index f0df1f3263..ea4a969839 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts @@ -31,6 +31,13 @@ function toPolicyNode(policy: PostgresRlsPolicy, namespaceId: string): PostgresP * per Postgres namespace, each holding its `PostgresTableSchemaNode`s with * their `PostgresPolicySchemaNode`s, plus the database roles on the root. * + * Not a duplicate of the family's `contractToSchemaIR`: that builds a flat, + * single `{ tables }` map (and throws on cross-namespace name collisions, with + * no RLS/role concept) for SQLite's single-schema world. This is the + * Postgres-specific *tree* shape — multi-schema, RLS-policy-aware, role-aware. + * It reuses the family's per-namespace table conversion (`contractNamespaceToSchemaIR`) + * for column/FK/index building and only adds the Postgres tree/policy/role shape. + * * Tables are grouped by their owning namespace (resolved DDL schema name) so * the tree mirrors Postgres's object hierarchy. The DDL schema name is * resolved once per namespace. diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts index 9385df6ef6..3fecc7bc2f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts @@ -2,10 +2,11 @@ import type { Contract } from '@prisma-next/contract/types'; import { verifySqlSchemaTree } from '@prisma-next/family-sql/diff'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { + DiffableNode, SchemaDiffIssue, VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; -import { SchemaDiff } from '@prisma-next/framework-components/control'; +import { diffSchemas, SchemaDiff } from '@prisma-next/framework-components/control'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; @@ -13,8 +14,8 @@ import { parsePostgresDefault } from '../default-normalizer'; import { normalizeSchemaNativeType } from '../native-type-normalizer'; import type { PostgresContract } from '../postgres-schema'; import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; import { contractToPostgresDatabaseSchemaNode } from './contract-to-postgres-database-schema-node'; -import { diffPostgresSchema, filterIssuesByOwnership } from './diff-postgres-schema'; interface PostgresDiffDatabaseSchemaInput { readonly contract: Contract; @@ -123,3 +124,70 @@ function ownedSchemaNames(expected: PostgresDatabaseSchemaNode): ReadonlySet(node); +} + +// Renders a display-only reference string for the diff message. If policy +// rendering grows, route it through the adapter's SQL renderer so the message +// can't diverge from the emitted policy SQL. +function renderPostgresPolicyReference(policy: PostgresPolicySchemaNode): string { + return `policy "${policy.name}" on "${policy.namespaceId}"."${policy.tableName}"`; +} + +/** + * The policy node-diff — the structural half of the combined comparison above. + * Computes RLS-policy drift between two derived schema trees: + * + * 1. Runs the framework total diff over the two `PostgresDatabaseSchemaNode` + * roots (database → namespace → table → policy). + * 2. Filters to policy-subject issues only — this is transitional: the generic + * differ walks the whole tree, but the legacy relational verifier still owns + * table/column drift, so non-policy issues are dropped here. + * 3. Remaps the message to a human-readable policy reference. + * + * Ownership filtering (dropping `extra` issues in namespaces a contract doesn't + * own) is the caller's responsibility — use `filterIssuesByOwnership`. + */ +export function diffPostgresSchema( + expected: PostgresDatabaseSchemaNode, + actual: PostgresDatabaseSchemaNode, +): readonly SchemaDiffIssue[] { + const issues = diffSchemas(expected, actual); + + return issues + .filter((i) => { + const node = i.expected ?? i.actual; + return node !== undefined && PostgresPolicySchemaNode.is(asSchemaNode(node)); + }) + .map((i) => { + const node = i.expected ?? i.actual; + if (node === undefined) return i; + const policy = asSchemaNode(node); + if (!PostgresPolicySchemaNode.is(policy)) return i; + return { ...i, message: `${i.outcome}: ${renderPostgresPolicyReference(policy)}` }; + }); +} + +/** + * Filters `extra` policy issues to those in owned namespaces. Call after + * `diffPostgresSchema` with the union of namespace ids from the expected tree's + * policies and its `existingSchemas`. + */ +export function filterIssuesByOwnership( + issues: readonly SchemaDiffIssue[], + ownedSchemaNameSet: ReadonlySet, +): readonly SchemaDiffIssue[] { + return issues.filter((i) => { + if (i.outcome !== 'extra') return true; + if (i.actual === undefined) return false; + const policy = asSchemaNode(i.actual); + return PostgresPolicySchemaNode.is(policy) && ownedSchemaNameSet.has(policy.namespaceId); + }); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts deleted file mode 100644 index 4338ebbf8f..0000000000 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-postgres-schema.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { DiffableNode, SchemaDiffIssue } from '@prisma-next/framework-components/control'; -import { diffSchemas } from '@prisma-next/framework-components/control'; -import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; -import { blindCast } from '@prisma-next/utils/casts'; -import type { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; -import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; - -// Every node in a diff issue produced from Postgres schema trees is a -// `SqlSchemaIRNode`; the framework types it as the narrower `DiffableNode`. -function asSchemaNode(node: DiffableNode): SqlSchemaIRNode { - return blindCast< - SqlSchemaIRNode, - 'diff issues over Postgres schema trees carry SqlSchemaIRNode nodes' - >(node); -} - -// Renders a display-only reference string for the diff message. If policy -// rendering grows, route it through the adapter's SQL renderer so the message -// can't diverge from the emitted policy SQL. -function renderPostgresPolicyReference(policy: PostgresPolicySchemaNode): string { - return `policy "${policy.name}" on "${policy.namespaceId}"."${policy.tableName}"`; -} - -/** - * Computes schema drift between two derived schema trees. - * - * 1. Runs the framework total diff over the two `PostgresDatabaseSchemaNode` - * roots (database → namespace → table → policy). - * 2. Filters to policy-subject issues only — this is transitional: the generic - * differ walks the whole tree, but the legacy relational verifier still owns - * table/column drift, so non-policy issues are dropped here. - * 3. Remaps the message to a human-readable policy reference. - * - * Ownership filtering (dropping `extra` issues in namespaces a contract doesn't - * own) is the caller's responsibility — use `filterIssuesByOwnership`. - */ -export function diffPostgresSchema( - expected: PostgresDatabaseSchemaNode, - actual: PostgresDatabaseSchemaNode, -): readonly SchemaDiffIssue[] { - const issues = diffSchemas(expected, actual); - - return issues - .filter((i) => { - const node = i.expected ?? i.actual; - return node !== undefined && PostgresPolicySchemaNode.is(asSchemaNode(node)); - }) - .map((i) => { - const node = i.expected ?? i.actual; - if (node === undefined) return i; - const policy = asSchemaNode(node); - if (!PostgresPolicySchemaNode.is(policy)) return i; - return { ...i, message: `${i.outcome}: ${renderPostgresPolicyReference(policy)}` }; - }); -} - -/** - * Filters `extra` policy issues to those in owned namespaces. Call after - * `diffPostgresSchema` with the union of namespace ids from the expected tree's - * policies and its `existingSchemas`. - */ -export function filterIssuesByOwnership( - issues: readonly SchemaDiffIssue[], - ownedSchemaNames: ReadonlySet, -): readonly SchemaDiffIssue[] { - return issues.filter((i) => { - if (i.outcome !== 'extra') return true; - if (i.actual === undefined) return false; - const policy = asSchemaNode(i.actual); - return PostgresPolicySchemaNode.is(policy) && ownedSchemaNames.has(policy.namespaceId); - }); -} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/verify-postgres-namespaces.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/verify-postgres-namespaces.ts index 09e94bc545..a7526d144b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/verify-postgres-namespaces.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/verify-postgres-namespaces.ts @@ -48,7 +48,11 @@ function existingSchemasFromSchema(schema: SqlSchemaIRNode): readonly string[] { /** * Emits a `missing_schema` issue for every contract-declared Postgres - * namespace whose live container does not yet exist. + * namespace whose live container does not yet exist. The planner's + * `collectSchemaIssues` prepends these to the relational findings so a + * multi-schema plan emits `CREATE SCHEMA` before the tables that need it — + * a planner-only concern (verify already rejects via the `missing_table` a + * missing schema produces), so this is not part of the shared diff. * * A namespace's live container is the schema returned by its * polymorphic `ddlSchemaName(storage)` method — named schemas resolve diff --git a/packages/3-targets/3-targets/postgres/src/exports/planner.ts b/packages/3-targets/3-targets/postgres/src/exports/planner.ts index 08e04c0671..76dd6866a6 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/planner.ts @@ -1,7 +1,3 @@ export { contractToPostgresDatabaseSchemaNode } from '../core/migrations/contract-to-postgres-database-schema-node'; export { diffPostgresDatabaseSchema } from '../core/migrations/diff-database-schema'; -export { - diffPostgresSchema, - filterIssuesByOwnership, -} from '../core/migrations/diff-postgres-schema'; export { createPostgresMigrationPlanner } from '../core/migrations/planner'; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/diff-database-schema.test.ts similarity index 99% rename from packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts rename to packages/3-targets/3-targets/postgres/test/migrations/diff-database-schema.test.ts index d0c5289599..8a4f2f4a85 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/diff-postgres-schema.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/diff-database-schema.test.ts @@ -8,7 +8,7 @@ import { contractToPostgresDatabaseSchemaNode } from '../../src/core/migrations/ import { diffPostgresSchema, filterIssuesByOwnership, -} from '../../src/core/migrations/diff-postgres-schema'; +} from '../../src/core/migrations/diff-database-schema'; import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { PostgresSchema } from '../../src/core/postgres-schema'; import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; From d4040d3b2af2fe7387e152aa4b8c56c04aa54550 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 14:58:08 +0200 Subject: [PATCH 37/49] refactor(postgres): planner comment/code-quality cleanups, drop transient planning IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review-flagged mechanical cleanups, all behaviour-neutral (fixtures clean). - Planner: rewrite the `relationalNamespaceNode` doc in plain English; trim the `relationalSchema` and `collectSchemaIssues` comments; drop the transient planning IDs (`CF-1`, `CF-2`) baked into comments; clarify the `asSchemaNode` bridge comment; clarify why `policyNodeToContractPolicy` reconstructs the contract entity rather than looking it up (see below). - `postgres-database-schema-node.ts`: "the real root" → "the root"; drop the `nodeKind` explanation (it belongs to the base class) and the `R4` slice id. - `printer-config.ts`: remove the orphan file-level doc comment. - `resolve-ddl-schema.ts`: reword the doc to say what the function takes and returns (the "namespace storage" phrasing was unclear). - `runner.ts`: trim the verbose app-space-verify comment. - `infer-psl-contract.ts`: reword the flatten stopgap to state it converts the schema-IR *tree* into the flat map the PSL writer walks, cite the follow-up TML-2958 (extend the writer to walk the tree), and drop the `§ A9` spec id. - `sql-schema-diff.ts`: replace the hand-rolled `if (!StorageTable.is(x)) throw` with `StorageTable.assert(x, coordinate)` — the §14 assertion-helper item. `policyNodeToContractPolicy` is load-bearing, NOT replaced with a lookup: the diff node carries the resolved DDL-schema `namespaceId`, which the emitted `createRlsPolicy` op serializes byte-for-byte; the contract-stored entity holds the raw pre-resolution coordinate, so a lookup would change the migration output. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../9-family/src/core/diff/sql-schema-diff.ts | 9 ++-- .../core/psl-contract-infer/printer-config.ts | 6 --- .../postgres/src/core/migrations/planner.ts | 47 ++++++++++--------- .../src/core/migrations/resolve-ddl-schema.ts | 9 ++-- .../postgres/src/core/migrations/runner.ts | 15 ++---- .../src/core/psl-infer/infer-psl-contract.ts | 22 ++++----- .../postgres-database-schema-node.ts | 10 ++-- 7 files changed, 49 insertions(+), 69 deletions(-) diff --git a/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts b/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts index 0eb8616ce1..e5a3ec4e85 100644 --- a/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts +++ b/packages/2-sql/9-family/src/core/diff/sql-schema-diff.ts @@ -339,11 +339,10 @@ function verifySchemaTables(options: { const ns = contract.storage.namespaces[namespaceId]; if (!ns) continue; for (const [tableName, contractTableRaw] of Object.entries(ns.entries.table ?? {})) { - if (!StorageTable.is(contractTableRaw)) { - throw new Error( - `verifySqlSchema: expected StorageTable at storage.namespaces.${namespaceId}.entries.table.${tableName}`, - ); - } + StorageTable.assert( + contractTableRaw, + `storage.namespaces.${namespaceId}.entries.table.${tableName}`, + ); const contractTable = contractTableRaw; const tableControlPolicy = effectiveControlPolicy( contractTable.control, diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts index 668fce324a..5ace5b9c08 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts @@ -1,12 +1,6 @@ import type { ColumnDefault } from '@prisma-next/contract/types'; import type { DefaultMappingOptions } from './default-mapping'; -/** - * Printer-shaped configuration for database→PSL inference: dialect-neutral types - * the SQL family exports and the target's inference (which owns the dialect maps) - * consumes. - */ - export type PslNativeTypeAttribute = { readonly name: string; readonly args?: readonly string[]; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 3b1dcf018c..c6a48e6fd1 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -180,12 +180,11 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr const schemaIssues = this.collectSchemaIssues(options, databaseDiff.issues); const codecHooks = extractCodecControlHooks(options.frameworkComponents); const storageTypes = options.contract.storage.types ?? {}; - // The strategy layer reads the live schema by bare table name for - // existence checks (shared-temp-default safety, FK/unique probes). It - // takes the per-schema namespace node, never the whole tree root — and - // never a flat merge of every namespace (that would collide same-named - // tables across schemas). Single-schema is the one node matching the - // planner's resolved schema name; multi-schema scoping is CF-2. + // The strategy layer reads the live schema by bare table name for existence + // checks (shared-temp-default safety, FK/unique probes), so it takes one + // per-schema namespace node — never the whole tree root, and never a flat + // merge of every namespace (which would collide same-named tables across + // schemas). Probing more than one namespace at once is future work. const relationalSchema = relationalNamespaceNode(options.schema, schemaName); // Input-side control-policy partition. `external` / `observed` subjects @@ -359,7 +358,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr * op-generation concern, so it is stitched in here rather than inside the * shared diff — verify never needs it (a missing schema already surfaces as * `missing_table` in the relational findings). It reads `existingSchemas` off - * the database root (CF-1) so it takes the whole tree. Policy drift is handled + * the database root, so it takes the whole tree. Policy drift is handled * separately via `planPostgresSchemaDiff` from the same shared diff's * `schemaDiffIssues`. */ @@ -378,10 +377,11 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr } } -// Every node in a diff issue produced from Postgres schema trees is a -// `SqlSchemaIRNode`; the framework types issue nodes as the narrower -// `DiffableNode`. The `PostgresPolicySchemaNode` guards downcast from -// `SqlSchemaIRNode`, so bridge the framework type here. +// The framework types `SchemaDiffIssue`'s nodes as `DiffableNode`, but every +// node in a Postgres diff issue is really a `SqlSchemaIRNode` (the concrete +// schema-node classes). Bridge to that base so the `.is`/`.assert` guards can +// discriminate on `nodeKind`. The principled fix — a node-typed `SchemaDiffIssue` +// so this bridge is unnecessary — is a framework change tracked separately. function asSchemaNode(node: DiffableNode | undefined): SqlSchemaIRNode | undefined { if (node === undefined) return undefined; return blindCast< @@ -391,14 +391,13 @@ function asSchemaNode(node: DiffableNode | undefined): SqlSchemaIRNode | undefin } /** - * Selects the per-schema namespace node the relational strategy layer probes - * for live-table existence. Prefers the node matching the planner's resolved - * schema name; otherwise the sole namespace node (the single-schema common - * case). Returns `undefined` when the tree carries no namespaces, so the - * strategy context falls back to its empty-schema default. + * Returns the one namespace node whose tables the relational strategy layer + * probes for live-table existence — the node matching the planner's resolved + * schema name, or the first namespace when none matches. `undefined` when the + * tree has no namespaces, so the strategy context uses its empty-schema default. * - * Multi-schema selection by name is CF-2: the relational strategies key tables - * by bare name, so only one namespace's tables can be probed at a time. + * The relational strategies key tables by bare name, so they can only probe one + * namespace at a time; probing across every namespace at once is future work. */ function relationalNamespaceNode( schema: PostgresDatabaseSchemaNode, @@ -410,11 +409,13 @@ function relationalNamespaceNode( } /** - * Rebuilds the serialized `PostgresRlsPolicy` contract entity from a policy - * schema node. The migration op (`CreatePostgresRlsPolicyCall`) carries the - * authored contract entity — its `renderTypeScript`/`createRlsPolicy` paths - * serialize it — so the planner converts the diff node back to the entity the - * call type expects, preserving byte-identical migration output. + * Rebuilds the `PostgresRlsPolicy` contract entity `CreatePostgresRlsPolicyCall` + * carries (its `renderTypeScript`/`createRlsPolicy` paths serialize the whole + * entity, `namespaceId` included). This reconstructs rather than looking the + * original up in the contract on purpose: the diff node's `namespaceId` is the + * *resolved DDL schema* (set when the expected tree was built), which is the + * value the emitted op must carry; the contract-stored entity holds the raw, + * pre-resolution coordinate, so a lookup would change the migration output. */ function policyNodeToContractPolicy(node: PostgresPolicySchemaNode): PostgresRlsPolicy { return new PostgresRlsPolicy({ diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/resolve-ddl-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/resolve-ddl-schema.ts index cc07e1b6a4..797bfaf015 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/resolve-ddl-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/resolve-ddl-schema.ts @@ -3,10 +3,11 @@ import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { isPostgresSchema } from '../postgres-schema'; /** - * Resolves a namespace coordinate to its live DDL schema name. Named - * Postgres namespaces dispatch to `ddlSchemaName(storage)`; the unbound - * sentinel resolves to `public` (the search-path default for offline - * planning); bare object payloads fall back to the coordinate itself. + * Given the contract's storage and a namespace id, returns the live Postgres + * DDL schema name that namespace maps to. A named Postgres namespace dispatches + * to its `ddlSchemaName(storage)`; the unbound sentinel resolves to `public` + * (the search-path default for offline planning); a bare object payload (used + * by some tests) falls back to the namespace id itself. */ export function resolveDdlSchemaForNamespaceStorage( storage: SqlStorage, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts index 873d522265..f64349b1a5 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts @@ -121,17 +121,10 @@ class PostgresMigrationRunner implements SqlMigrationRunner = {}; for (const namespace of namespaces) { for (const [tableName, table] of Object.entries(namespace.tables)) { @@ -301,9 +298,8 @@ function buildModel( // Surface introspection advisory: tables without a primary key cannot serve // as the right-hand side of a `findUnique`-style query downstream, so the - // user should add an `@id` policy. This warning has shipped since - // `contract infer` was introduced and is part of the spec § A9 byte-identity - // contract for SQL output. + // user should add an `@id`. This warning is part of the emitted SQL output + // and is asserted byte-for-byte, so keep the exact wording. const comment = table.primaryKey ? undefined : '// WARNING: This table has no primary key in the database'; diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts index d6512dacf4..2c3dd21676 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-database-schema-node.ts @@ -13,17 +13,13 @@ export interface PostgresDatabaseSchemaNodeInput { } /** - * The real root of the Postgres schema-diff tree: one node per database. + * The root of the Postgres schema-diff tree: one node per database. * * `id` is the fixed sentinel `'database'` — the root has no siblings and * the value is never emitted into migration paths. `isEqualTo` is identity * (roots always share the `'database'` id). `children()` returns namespace - * nodes only; roles are held on the root but NOT yielded (role diffing is a - * later slice, R4). - * - * `nodeKind` is an enumerable own discriminant that identifies this node and - * distinguishes it from the other schema-diff nodes; the `is`/`assert` guards - * discriminate on it. + * nodes only; roles are held on the root but not yielded (role diffing is a + * later slice). */ export class PostgresDatabaseSchemaNode extends SqlSchemaIRNode implements DiffableNode { override readonly nodeKind = PostgresSchemaNodeKind.database; From 2ae324ef2c1c1ae5ee0229fa033586b18465d3d8 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 15:09:43 +0200 Subject: [PATCH 38/49] =?UTF-8?q?docs(postgres-rls):=20correct=20diff-veri?= =?UTF-8?q?fy=20design=20=C2=A714=20to=20V4=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-postgres-namespaces is live (drives multi-schema CREATE SCHEMA planning), and contract-to-postgres-database-schema-node is not a duplicate of contractToSchemaIR (tree vs flat) — both stay; only the dead family bootstrapSignMarkerQueries was removed. Consolidation + type-extraction recorded. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../schema-node-tree-restructure/design-diff-and-verify.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md index f985698496..97d8487828 100644 --- a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md +++ b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md @@ -121,7 +121,8 @@ The aggregate verifier and planner take the family's `SchemaDiffer`, diff the fu - Replace the bespoke `throw new Error("expected StorageTable…")` with an assertion helper. - Remove the `(storage.types ?? {}) as ResolvedStorageTypes` casts (×3) via a real type — no cast, no fallback. - Trim the verbose doc comments (attached, not orphaned) and sweep the whole diff for the same; add none new. -- Delete the dead operations the review flags (`verify-postgres-namespaces` and the two unused `control-instance` methods). +- Consolidate the two Postgres diff files into one (`diff-postgres-schema.ts` folds into `diff-database-schema.ts`), and extract the diff-SPI types out of the catch-all `migrations/types.ts` into a named `schema-differ.ts`. +- Delete confirmed-dead code only: the family-instance `bootstrapSignMarkerQueries` (the adapter method it duplicated stays; `sign()` uses that). `verify-postgres-namespaces` is **live** (its `missing_schema` issues drive multi-schema `CREATE SCHEMA` planning) and stays; `contract-to-postgres-database-schema-node` is **not** a duplicate of `contractToSchemaIR` (it builds the Postgres tree — RLS/roles/multi-schema — atop the shared per-namespace table conversion, whereas the flat map is SQLite-only) and stays. - Extract review additions **out** of the catch-all `migrations/types.ts` into named, logical files. - Correct the planner's transient-id string, its unreadable comment, and the "all namespace nodes are relational" note; stop *creating* contract nodes to refer to them — find them in the live contract. - Move the non-node file out of `schema-ir/` (so only the five node classes remain); stop populating the obsolete `annotations.pg` bag. From b85526cacea8fb0447f9c241056317f8302fe3cd Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 15:37:04 +0200 Subject: [PATCH 39/49] fix(migration): scope verify counts family-agnostically, unbreak Mongo cross-space verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DoD gate surfaced a real regression: the Mongo contract-space aggregate verify (`test/mongo/aggregate-e2e.test.ts`, `runner.test.ts`) failed deterministically — a spaces per-space verify returned `ok: false` when it should pass. Root cause: `countsAfterDrop` (added in the V2 verify-tree scoping) adjusted the counts for the root status flip on the assumption that the family counts the root node at its own status — true for SQL (`computeCounts` walks every node), false for Mongo (`diffMongoSchemas` tallies `fail++` per collection and never counts the root). So dropping a sibling `extra` collection subtracted its `fail` once for the subtree and again for the root flip, driving `counts.fail` to `-1`; `ok = counts.fail === 0` was then false and the verify wrongly failed. Fix: drop the family-specific root-flip arithmetic. When scoping drops nothing, keep the family authoritative counts/verdict unchanged (a multi-schema result keeps a first-namespace-only root but sums counts across namespaces, so recomputing from the root would undercount). When it drops a node, the pruned tree is self-consistent regardless of family, so recompute both counts and the verdict from a plain tree walk. No family branch, no negative counts. All V2-1/V2-2 unit tests still pass (migration-tools 554); the Mongo aggregate e2e + runner + codec-rehydration integration tests pass in isolation; the SQL scoping guards (supabase classification / cross-contract-fk) and framework-components stay green. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/aggregate/scope-schema-result.ts | 55 +++++++------------ 1 file changed, 19 insertions(+), 36 deletions(-) diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts b/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts index 45b7d1c740..e21f1a346c 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts @@ -105,7 +105,13 @@ function pruneTopLevelTables( }; } -function subtreeCounts(node: SchemaVerificationNode): Counts { +/** + * Counts the pass/warn/fail statuses over a verification tree (root included), + * the way the verdict-relevant tally is derived. Used only when scoping actually + * dropped a node — the pruned tree is then self-consistent regardless of family, + * so the recomputed `fail` is the honest verdict signal. + */ +function countTree(node: SchemaVerificationNode): Counts { let pass = 0; let warn = 0; let fail = 0; @@ -121,35 +127,6 @@ function subtreeCounts(node: SchemaVerificationNode): Counts { return { pass, warn, fail, totalNodes }; } -/** - * The verify tree keeps only the first namespace's `root` on a multi-schema - * database (the counts are summed across namespaces), so recomputing counts - * from the root would undercount. Instead subtract the dropped top-level - * subtrees from the authoritative incoming counts, and reconcile the root's own - * status flip: if dropping the last failing/worst top-level node changes the - * root's status, that node is no longer counted at its old status. - */ -function countsAfterDrop( - original: Counts, - dropped: readonly SchemaVerificationNode[], - oldRootStatus: SchemaVerificationNode['status'], - newRootStatus: SchemaVerificationNode['status'], -): Counts { - const next = { ...original }; - for (const node of dropped) { - const c = subtreeCounts(node); - next.pass -= c.pass; - next.warn -= c.warn; - next.fail -= c.fail; - next.totalNodes -= c.totalNodes; - } - if (newRootStatus !== oldRootStatus) { - next[oldRootStatus] -= 1; - next[newRootStatus] += 1; - } - return next; -} - /** * Scope a per-member verify result to the member's own contract space: drop the * `extra` findings for entities another aggregate member claims. Diffing the @@ -178,12 +155,18 @@ export function scopeSchemaResultToSpace( return name === undefined || !ownedByOthers.has(name); }); const { root, dropped } = pruneTopLevelTables(result.schema.root, ownedByOthers); - const counts = countsAfterDrop( - result.schema.counts, - dropped, - result.schema.root.status, - root.status, - ); + + // When nothing was dropped, keep the family's authoritative counts/verdict + // untouched (a multi-schema result keeps a first-namespace-only root but sums + // counts across namespaces, so recomputing from the root would undercount). + // When a node was dropped, the pruned tree is self-consistent, so recompute + // both counts and the verdict from it — family-agnostic, and free of any + // family-specific count arithmetic. + if (dropped.length === 0) { + return { ...result, schema: { ...result.schema, issues, schemaDiffIssues, root } }; + } + + const counts = countTree(root); const ok = counts.fail === 0; return { From e5c51a0cc7e406303f690efd2c7dd9a9ef8b846c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 18:17:03 +0200 Subject: [PATCH 40/49] docs(postgres-rls): collapse diff-verify design to passive-aggregate, issue-based verify (round 3) Will's partial review rejected V2's cross-space machinery (scope-schema-result, entitiesOwnedByOtherSpaces) as over-built. Rewrite the model: the contract-space aggregate is passive (answers ownership); the orchestration owns the verbs (runs the differ per space, composes the view, classifies extras, hands the planner its issues); verify's verdict is the space's issue list being empty; issues are node-typed (coupled to schema IR, not the contract IR). scope-schema-result and the cross-space plumbing delete. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../design-diff-and-verify.md | 127 ++++++++---------- 1 file changed, 59 insertions(+), 68 deletions(-) diff --git a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md index 97d8487828..82ae4bf63c 100644 --- a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md +++ b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md @@ -2,14 +2,16 @@ Authoritative design for the PR #894 rework. States the positive properties the code must satisfy; grounded in `file:line` where a claim rests on current code. -## 1. The model +## 1. The model — four actors, cleanly separated -Schema comparison is one operation — a **differ** — and verify and plan consume it identically. +Schema comparison is a **differ** that produces issues; verify and plan are **orchestrations** that consume them. -1. **The differ is an SPI:** `SchemaDiffer.diff(contract, actual) → SchemaDiff`. Expected derives from the contract; actual is the introspected live schema. How the result is computed — a relational check plus a generic node differ, how namespaces are paired — is **private**. No consumer, and no other part of this design, describes it. -2. **`SchemaDiff` is a result over two issue lists plus one method** (§4). It carries no verdict, no verification tree, no counts. -3. **Verify and plan are the same shape:** `diff → filter the issues to a contract space → iterate`. Verify emits one diagnostic per surviving issue (none ⇒ success); plan emits one operation per surviving issue. Neither knows how the diff is computed. -4. **Two issue lists stay distinct types** — `SchemaIssue` (relational) and `SchemaDiffIssue` (the node differ) — because two diffing mechanisms exist today. Merging them is a follow-on (§13). +1. **The differ** — an SPI on the target: `SchemaDiffer.diff(contract, actual) → SchemaDiff`. It compares two derived schema-IR trees and returns issues. How it computes them is private. It is **dumb**: one contract and the actual schema in, issues out; it knows nothing about contract spaces. +2. **`SchemaDiff`** — the result: two issue lists plus `filter` (§4). `SchemaDiffIssue` carries its **schema-IR node**, typed via a node type parameter. `SchemaDiff` carries no verdict, no verification tree, no counts. +3. **The contract-space aggregate** — **passive data**: the contract spaces and their contracts. It *answers* ownership interrogatives ("which contract nodes belong to which space", "is this entity declared by any space"). It diffs nothing, verifies nothing, classifies nothing. +4. **The orchestration** — the calling location that drives verify/plan (`verifyMigration`, `synthStrategy`). **It owns every verb**: it runs the differ per contract space, composes the per-space view, classifies extras (consulting the aggregate), and hands a space's issues to the planner. Verify's verdict is simply *does this space have any issue?* — `diff → its issues → empty ⇒ pass`. + +Two issue lists stay distinct types — `SchemaIssue` (relational) and `SchemaDiffIssue` (the generic node differ's) — because two diffing mechanisms exist today; merging them is a follow-on (§13). ## 2. The diff's inputs: two derived representations @@ -21,49 +23,49 @@ The contract is uniformly namespaced for every target (`contract.storage.namespa ## 3. The differ is an SPI on the target -`SchemaDiffer` names the SPI the target already implements — `diffDatabaseSchema` on the SQL target descriptor ([types.ts:499](../../../../packages/2-sql/9-family/src/core/migrations/types.ts)). No new class implements it; the family/target that owns the diff today is the implementer. Two properties: +`SchemaDiffer` names the SPI the target already implements — `diffDatabaseSchema` on the SQL target descriptor. No new class implements it; the family/target that owns the diff is the implementer. Two properties: -- **It returns `SchemaDiff`, not `VerifyDatabaseSchemaResult`.** A diff is not verify-specific. The verify envelope (`ok` / `summary` / `code` / `target` / `timings`) and the pass/warn/fail tree are the verifier's, built by the verifier (§6) — never returned by the differ. +- **It returns `SchemaDiff`, not `VerifyDatabaseSchemaResult`.** A diff is not verify-specific. The verify envelope and the pass/fail view are the orchestration's (§6), never returned by the differ. - **It lives on the target descriptor, required for every SQL target** (Postgres: relational + policy; SQLite: relational only) — schema logic on the target, not database I/O on the control adapter. Its internals are private. ## 4. `SchemaDiff` — the result ```ts -type DiffIssue = SchemaIssue | SchemaDiffIssue +type DiffIssue = SchemaIssue | SchemaDiffIssue -class SchemaDiff { +class SchemaDiff { readonly issues: readonly SchemaIssue[] - readonly schemaDiffIssues: readonly SchemaDiffIssue[] - filter(keep: (issue: DiffIssue) => boolean): SchemaDiff + readonly schemaDiffIssues: readonly SchemaDiffIssue[] + filter(keep: (issue: DiffIssue) => boolean): SchemaDiff } ``` -- Its only job is to **abstract away that there are two issue lists.** `filter` fans one predicate across both and returns a narrowed `SchemaDiff` — still a passable unit. -- The predicate takes the **union**, not a normalized descriptor: any caller doing real work with the result already understands both issue types. There is no `DiffEntry` / coordinate abstraction layer. -- **Contract-space filtering and control-policy suppression are just callers passing predicates** — no policy-specific method, nothing special. `SchemaIssue` (`kind: 'extra_table'`, `table`, `namespaceId`) and `SchemaDiffIssue` (`outcome: 'extra'`, `actual` node) each express "extra" and their coordinate in their own way; the predicate discriminates. - -`SchemaIssue` ([control-result-types.ts:41](../../../../packages/1-framework/1-core/framework-components/src/control/control-result-types.ts)) and `SchemaDiffIssue` ([schema-diff.ts](../../../../packages/1-framework/1-core/framework-components/src/control/schema-diff.ts)) are the framework issue types Mongo also produces, so `filter` and the contract-space attribution (§11) are family-agnostic. +- Its only job is to **abstract away that there are two issue lists.** `filter` fans one predicate across both and returns a narrowed `SchemaDiff`. The predicate takes the union; callers already understand both types. +- **`SchemaDiffIssue` carries its `expected` / `actual` schema-IR node**, so a caller reaches the node it concerns by a property access, not a lookup. `TNode` is **defaulted to `DiffableNode`** — a purely additive change: every existing caller keeps the default and is unbroken; only a caller that wants the concrete node opts in (the planner takes `SchemaDiff` and drops the `asSchemaNode` cast). The coupling is to the **schema IR** — the layer the differ diffs — never back to the contract IR it was derived from (§6). `SchemaIssue` (relational) stays coordinate-based; node-typing it is the relational-port follow-on (§13). ## 5. The diffing logic lives with the diff, not in "verify" -The relational diffing code must not sit in a `schema-verify/` module. Its logic is **not rewritten** — it **moves** to live with the diff, where it becomes the diff's private internal. The "verify" module then holds only the verifier's own concern (§6). +The relational diffing code must not sit in a `schema-verify/` module. Its logic is **not rewritten** — it lives with the diff as the diff's private internal. The "verify" module holds only the orchestration's concern (§6). + +## 6. Verify and plan: the orchestration owns the verbs -## 6. Verify and plan consume the diff the same way +The **contract-space aggregate is passive** — it holds the spaces and their contracts and answers ownership questions; it runs nothing. The **orchestration** (`verifyMigration`, `synthStrategy`) owns every verb. -The verifier: +**Verify.** For each contract space the orchestration: -1. `diff(contract, actual)` — derive expected, introspect actual, call the differ. -2. **filter the issues to the contract space** being verified (drop issues owned by other spaces). -3. iterate the surviving issues; **none ⇒ success**, else one verify diagnostic per issue. +1. runs the differ — `diff(space.contract, actual)` → `SchemaDiff`; +2. composes the space's view — walks the space's contract-derived (expected) nodes and, per node, attaches the issue that concerns it; a node is *pass* when nothing is attached, *fail* otherwise; +3. verdict — the space passes iff it has no issue. `diff → its issues → empty ⇒ pass`. There is no verdict-derived-from-a-tree computation. -The planner is identical, emitting one migration operation per surviving issue. Verify and plan are symmetric — `diff → filter to space → iterate` — and both are blind to how the diff is computed. +Because the differ runs **per space**, a space's missing/mismatch issues are inherently its own — their expected node came from its contract; no attribution, no per-issue lookup. The only cross-space step is **extras** (an actual entity in no contract-for-this-diff): the orchestration classifies each against the aggregate — declared by some space ⇒ that space's, not this one's concern; declared by none ⇒ **undeclared**, reported once at the aggregate level. That is a positive interrogative over the contracts the aggregate holds; the `SchemaDiff` result is never consulted for ownership and never references the contract IR. -- **Tables no contract declares** are not a separate detection step: after attributing issues to spaces, they are the issues owned by **no** space. This deletes the live-entity enumeration entirely. -- **The pass/warn/fail tree (`root` / `counts`)** the CLI prints ([formatters/verify.ts](../../../../packages/1-framework/3-tooling/cli/src/utils/formatters/verify.ts), [combine-schema-results.ts](../../../../packages/1-framework/3-tooling/cli/src/utils/combine-schema-results.ts)) is the verifier's own presentation, produced by the relational walk — separate from the verdict (which is "iterate the issues") and never on `SchemaDiff`. +**Plan.** The orchestration hands the planner **a space's issues**; the planner maps issue → op and nothing else. It takes no schema and no "other spaces" input — it works out no ownership, because the orchestration already handed it exactly its issues. The typed node on each `SchemaDiffIssue` is what the planner builds the op from (§4). Verify and plan are symmetric: run the differ per space, then verify checks emptiness while plan builds ops. + +**Compose, don't post-scope.** Today `verifySqlSchema` fuses the diff and the view — it walks the contract and, per element, diffs-against-actual *and* emits the tree node, then a post-step re-scopes that tree per space. The target separates them: the differ produces the issues; the per-space view is the space's own declared nodes annotated with their attached issues, and it grafts **no** extra nodes (extras are issues, not tree nodes). So the view is scoped **by construction** — no post-scoping, no recomputed counts. ## 7. The schema view is unaware of the schema IR -The human-readable schema view walks its **own** tree of printable `SchemaTreeNode`s and is unaware of the schema IR. It uses no diff helper and does not flatten the schema-IR tree. How printable nodes are produced from the schema IR is the view's own concern, separate from the differ. +The human-readable *live-schema* view (the `inspect live schema` rendering) walks its **own** tree of printable `SchemaTreeNode`s and is unaware of the schema IR. It is a separate rendering of the actual schema, distinct from the verify pass/fail view (§6). ## 8. Node type guards (`.is` / `.assert`) @@ -73,69 +75,58 @@ Guards downcast **from the base node to a specific node**: - they discriminate on the node's own **`nodeKind`** identifier (§9), never `instanceof`; - applied consistently across all five node classes, and on `StorageTable` and the RLS-policy guard. -There is **no** `ensure()` that constructs a new node — a guard asserts, it does not build. Call sites `assert` and use the value in place. +There is **no** `ensure()` that constructs a new node — a guard asserts, it does not build. ## 9. Node kinds and target ids are defined identifiers -- **`nodeKind`** — *which node* (database / namespace / table / policy / role). Every one of the five nodes carries a unique `nodeKind` identifier; each §8 guard is `node.nodeKind === ''`. +- **`nodeKind`** — *which node* (database / namespace / table / policy / role). Every one of the five nodes carries a unique `nodeKind`; each §8 guard is `node.nodeKind === ''`. - **`nodeTarget`** — *which target*. The SQL family enumerates no target ids; no `'postgres'` literal lives in a SQL-family type. -Both are defined identifiers, not string literals scattered across guards. - ## 10. `isEqualTo` — identity only `isEqualTo` compares identity only: nodes are equal iff their `id`s match. Columns are not compared by `isEqualTo` (they become child nodes the generic differ walks). This replaces the `isEqualTo => true` stopgap. -## 11. Contract-space handling: filter the issues, never prune the schema +## 11. Contract-space ownership is a passive interrogative; nothing prunes or post-scopes -The framework **does not alter the schema before diffing and does not branch on any storage shape.** It diffs the full introspected schema and filters the resulting issues by contract-space ownership. Ownership is attributed with the target-agnostic `elementCoordinates(contract.storage)` — an issue belongs to whichever member claims its entity **name** (this matches the pruning layer it replaces, so it is behaviour-neutral). An `extra_table` issue carries no `namespaceId` — a live-DB table sits in no contract namespace — so qualified `(namespaceId, name)` keying, which would let two members own same-named tables in different schemas independently, awaits the differ stamping the introspected namespace onto extra issues; that is a follow-on (§13), not a regression. +The framework alters no schema and post-scopes no result. The **contract-space aggregate** answers, from the contracts it holds, "is this entity declared by any space, and by which one." The **orchestration** uses that answer to classify the extras from each per-space diff. That is the whole of contract-space handling — positive ownership ("my contract declares this node"), so two spaces in the same namespace are unambiguous, and a genuine double-claim surfaces as a real conflict rather than a silent mis-attribution. -Deleted: +Deleted outright: -- `projectSchemaToSpace` ([project-schema-to-space.ts](../../../../packages/1-framework/3-tooling/migration/src/aggregate/project-schema-to-space.ts)) and both family `schema-shape.ts` modules ([SQL](../../../../packages/2-sql/9-family/src/core/diff/schema-shape.ts), [Mongo](../../../../packages/2-mongo-family/9-family/src/core/schema-shape.ts)) — the schema-pruning callbacks; -- the `projectSchemaToMember` / `listSchemaEntityNames` callbacks on the family instances and their CLI wiring; -- the `TSchemaResult` generic on the aggregate verifier ([verifier.ts](../../../../packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts)) — the family returns the framework issue types, which the framework reads directly. +- **`scope-schema-result.ts`** — the per-space tree pruning + counts/verdict recompute. It existed only because the previous pass stopped pre-pruning the schema but kept re-scoping the verification tree; the compose step (§6) makes the view scoped by construction, so there is nothing to post-scope. All three bugs it produced (a column false-pass, an enum-node false-pass, a Mongo counts-flip) disappear with the file. +- **`entitiesOwnedByOtherSpaces`** (the planner `plan()` input) and **`otherMemberEntityNames`** (the set-subtraction) — the planner receives its space's issues; it needs no "other spaces" input. +- The earlier pruning layer (`projectSchemaToSpace`, both family `schema-shape.ts`, the `projectSchemaToMember` / `listSchemaEntityNames` callbacks) stays removed. -The aggregate verifier and planner take the family's `SchemaDiffer`, diff the full schema per member, filter each member's issues to its space, and iterate. Issues owned by no member are the undeclared tables. +There is no bare-name keying, no name-subtraction, and no qualified-coordinate follow-on — positive ownership retires all three. The term is **contract space**, never "member"; there is no "schema result". -## 12. What changes (from the state after the first rework round) +## 12. What changes (this pass) | Now | Target | | --- | --- | -| `diffDatabaseSchema` returns `VerifyDatabaseSchemaResult` ([types.ts:499]) | returns `SchemaDiff` (two lists + `filter`); the SPI is named `SchemaDiffer` | -| verify verdict reads `counts.fail` off the verification tree | verdict iterates the filtered issues; none ⇒ success | -| aggregate verifier prunes the schema per member (`projectSchemaToSpace` + family `schema-shape` callbacks) | diffs the full schema, filters the issues by contract space; callbacks + `schema-shape` + `project-schema-to-space` deleted | -| undeclared tables via `listSchemaEntityNames` enumeration | issues owned by no space | -| `TSchemaResult` generic on the verifier | gone; framework reads the framework issue types | -| guards use `instanceof` / `unknown` / `DiffableNode`; `ensure()` constructs nodes | `(node: SqlSchemaIRNode): node is X`, `nodeKind`-discriminated; no node-constructing `ensure` | +| `scope-schema-result.ts` prunes each space's verification tree + recomputes counts/verdict | deleted; the per-space view is composed from the space's declared nodes + attached issues, scoped by construction (§6) | +| verify verdict = `counts.fail === 0` off the (post-scoped) tree | verdict = the space's issue list is empty | +| planner takes the full schema + `entitiesOwnedByOtherSpaces`; filters extras itself | planner takes its space's issues and maps issue → op; `entitiesOwnedByOtherSpaces` / `otherMemberEntityNames` deleted | +| `SchemaDiffIssue.expected/actual: DiffableNode`; planner `asSchemaNode` casts | `SchemaDiffIssue` carries the typed node (default `DiffableNode`); planner takes `SchemaDiff`, cast gone | +| the contract-space aggregate driver diffs / scopes / classifies | the aggregate is passive (answers ownership); the orchestration owns those verbs | +| "member" throughout the aggregate | "contract space" | + +Behaviour to preserve: `db verify` output (the per-space pass/fail view and undeclared-table reporting), planner ops, `contract infer`, single-space verify — all unchanged. The compose step must reproduce the view the post-scoped tree produced; validate byte-identity (fixtures + the multi-space guards). ## 13. Out of scope (follow-ons) -- **Relational port / one issue type:** merging the relational check into the generic node differ so there is a single issue type. Until then `SchemaDiff` carries two lists. Separating `root` / `counts` from the relational walk rides with this port. -- **Qualified-coordinate ownership keying:** attribute cross-space ownership by `(namespaceId, name)` instead of bare entity name. Needs `extra_table` issues to carry their introspected namespace; rides with the relational port. -- **PSL-inference tree-walk (TML-2958):** inference still gathers the tree into a flat document, guarded by a fail-loud throw. -- **`annotations.pg` full retirement (TML-2936):** this rework stops *populating* the bag (§14); typed-field replacement is TML-2936. +- **Relational port / one issue type:** merge the relational check into the generic node differ so there is a single, node-typed issue type (which also node-types `SchemaIssue`). Until then `SchemaDiff` carries two lists. +- **PSL-inference tree-walk (TML-2958):** inference still flattens the schema-IR tree into a flat document, fail-loud guarded. +- **`annotations.pg` full retirement (TML-2936).** -## 14. Mechanical fixes (from the PR review, no design fork) +## 14. Mechanical fixes (landed in the earlier passes; kept here for the record) -- Replace the bespoke `throw new Error("expected StorageTable…")` with an assertion helper. -- Remove the `(storage.types ?? {}) as ResolvedStorageTypes` casts (×3) via a real type — no cast, no fallback. -- Trim the verbose doc comments (attached, not orphaned) and sweep the whole diff for the same; add none new. -- Consolidate the two Postgres diff files into one (`diff-postgres-schema.ts` folds into `diff-database-schema.ts`), and extract the diff-SPI types out of the catch-all `migrations/types.ts` into a named `schema-differ.ts`. -- Delete confirmed-dead code only: the family-instance `bootstrapSignMarkerQueries` (the adapter method it duplicated stays; `sign()` uses that). `verify-postgres-namespaces` is **live** (its `missing_schema` issues drive multi-schema `CREATE SCHEMA` planning) and stays; `contract-to-postgres-database-schema-node` is **not** a duplicate of `contractToSchemaIR` (it builds the Postgres tree — RLS/roles/multi-schema — atop the shared per-namespace table conversion, whereas the flat map is SQLite-only) and stays. -- Extract review additions **out** of the catch-all `migrations/types.ts` into named, logical files. -- Correct the planner's transient-id string, its unreadable comment, and the "all namespace nodes are relational" note; stop *creating* contract nodes to refer to them — find them in the live contract. -- Move the non-node file out of `schema-ir/` (so only the five node classes remain); stop populating the obsolete `annotations.pg` bag. -- Reword the PSL-inference stopgap comment to state it converts the schema-IR **tree** into the flat structure the PSL writer expects (TML-2958), assigned to Will. -- Re-run the full slice-DoD gate set. +Assertion helper over the bespoke `throw`; `(storage.types ?? {}) as ResolvedStorageTypes` casts removed; verbose doc comments trimmed; the two Postgres diff files consolidated into one and the diff-SPI types extracted to `schema-differ.ts`; dead `bootstrapSignMarkerQueries` (family) removed (`verify-postgres-namespaces` and `contract-to-postgres-database-schema-node` are **live/distinct** and stay); planner transient IDs / unreadable comments fixed; non-node file moved out of `schema-ir/`; `annotations.pg` not populated; the PSL-inference stopgap comment cites TML-2958. ## 15. Rejected alternatives (timeless) -- **Utility methods on the `SchemaDiffer` interface (filter / extras / verdict on the SPI).** Rejected: those are pure functions of the result; they live on `SchemaDiff`, keeping the SPI a one-method factory. -- **Normalizing the two issue lists to a common `DiffEntry` for filtering.** Rejected: expose the union — callers already understand both types. -- **`root` / `counts` (the verification tree) on `SchemaDiff`.** Rejected: that is verifier presentation, not diff output. -- **Pruning the schema IR to a member's slice before diffing (family prune + enumerate callbacks).** Rejected: don't alter the schema; diff the full schema and filter the resulting issues by contract space. The framework never branches on storage shape. -- **The diff returning `VerifyDatabaseSchemaResult`, or exposing the diff through the control adapter.** Rejected: the diff returns `SchemaDiff`; it is schema logic on the target, not verify output and not database I/O. -- **Rewriting the relational check to be a pure contract-free diff, adding `effectiveControlPolicy` / fully-expanded native types as new fields on the expected node, and moving disposition to a post-diff filter.** Rejected: the relational logic is relocated, not rewritten. -- **A uniform family-wide namespace-node hierarchy (wrapping SQLite/Mongo in a namespace node).** Rejected: unnecessary; multiple namespaces occur only in Postgres, internal to its diff. -- **The verifier or the schema view knowing how the diff works.** Rejected: they consume the issue lists / walk their own printable-node tree. +- **Coupling the diff result back to the contract IR** (a contract-node handle on the issue). Rejected: the differ works in schema IR; the result couples to the schema IR node, never back to the authoring layer it was derived from. Contract knowledge lives in the aggregate, consulted for ownership — not on the result. +- **Post-scoping the verification tree per space (`scope-schema-result`).** Rejected: compose the per-space view from the space's own declared nodes so it is scoped by construction; there is nothing to prune. +- **Passing "other spaces' names" to the planner; the planner working out which issues are its own.** Rejected: the orchestration hands the planner exactly its space's issues; the planner is dumb. +- **The contract-space aggregate performing verbs (diff / verify / classify).** Rejected: the aggregate is passive data that answers ownership; the orchestration owns the verbs. +- **Utility methods on the `SchemaDiffer` interface; normalizing the two issue lists to a common `DiffEntry`.** Rejected: utilities live on `SchemaDiff`; `filter` takes the union. +- **`root` / `counts` on `SchemaDiff`; the diff returning `VerifyDatabaseSchemaResult`; the diff on the control adapter.** Rejected: the diff returns `SchemaDiff` — schema logic on the target, not verify output and not database I/O. +- **Rewriting the relational check pure / adding `effectiveControlPolicy` fields / a family-wide namespace-node hierarchy.** Rejected: the relational logic is relocated not rewritten; multiple namespaces are internal to the Postgres diff. From 5e3673318a525ae527cea83daaeeac76f934e680 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 18:36:19 +0200 Subject: [PATCH 41/49] refactor(diff): node-type the diff issues; drop the planner per-issue casts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 R1. Additive, non-breaking, behaviour-neutral (pure typing + comments). Adds a node type parameter to the diff-issue types, defaulted to `DiffableNode` so every existing use keeps compiling unchanged: - `SchemaDiffIssue` — `expected` / `actual` become `TNode`. - `SchemaDiff` — `schemaDiffIssues: SchemaDiffIssue[]`, and `filter(keep: (issue: DiffIssue) => boolean): SchemaDiff`. - `DiffIssue = SchemaIssue | SchemaDiffIssue`. The Postgres differ now returns the concrete type. `SqlSchemaIRNode` alone is not a `DiffableNode` (its relational subclasses carry no `id`/`isEqualTo`/ `children`), so the honest node type is `SqlSchemaDiffNode = SqlSchemaIRNode & DiffableNode` — the five `Postgres*SchemaNode` classes. `diffPostgresSchema` narrows the framework `SchemaDiffIssue` output ONCE at the diff boundary (a single justified `blindCast`, since both trees are `PostgresDatabaseSchemaNode`s), so `diffPostgresDatabaseSchema` is `SchemaDiff`. `scopePlanDiffToSpace` is generic over `TNode` so the type survives to the planner, which drops its per-issue `asSchemaNode` blindCast (and the helper) and reads the node directly. Two comment fixes: the empty `DiffIssue` comment now names the two issue representations; the `db-verify.ts` per-member verifier drops its `never` cast (the family instance is `ControlFamilyInstance<_, unknown>`, so `schema` already types as `unknown` — it passes straight through). Existing callers (framework `diffSchemas`, Mongo, SQLite, migration-tools, tests) are unbroken via the default `= DiffableNode` — all typecheck. `lint:casts` improves (per-issue casts → one boundary cast): delta -11. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/schema-diff.ts | 29 ++++++++---- .../src/control-api/operations/db-verify.ts | 13 +++--- .../src/core/migrations/scope-plan-diff.ts | 22 +++++++--- .../core/migrations/diff-database-schema.ts | 44 +++++++++---------- .../postgres/src/core/migrations/planner.ts | 23 +++------- .../src/core/schema-ir/schema-node-kinds.ts | 12 +++++ 6 files changed, 80 insertions(+), 63 deletions(-) diff --git a/packages/1-framework/1-core/framework-components/src/control/schema-diff.ts b/packages/1-framework/1-core/framework-components/src/control/schema-diff.ts index 1c46f86b51..165efac1b4 100644 --- a/packages/1-framework/1-core/framework-components/src/control/schema-diff.ts +++ b/packages/1-framework/1-core/framework-components/src/control/schema-diff.ts @@ -2,15 +2,15 @@ import type { SchemaIssue } from './control-result-types'; export type SchemaDiffOutcome = 'missing' | 'extra' | 'mismatch'; -export interface SchemaDiffIssue { +export interface SchemaDiffIssue { /** Path from the root node down to the diffed node, as a sequence of local keys. */ readonly path: readonly string[]; readonly outcome: SchemaDiffOutcome; readonly message: string; /** The expected (contract-side) node, when available. Absent for `extra` outcomes. */ - readonly expected?: DiffableNode; + readonly expected?: TNode; /** The actual (live-DB-side) node, when available. Absent for `missing` outcomes. */ - readonly actual?: DiffableNode; + readonly actual?: TNode; } /** @@ -133,8 +133,14 @@ function diffChildren( return issues; } -/** The union a `SchemaDiff` consumer filters over: either issue shape it carries. */ -export type DiffIssue = SchemaIssue | SchemaDiffIssue; +/** + * The two issue representations a `SchemaDiff` carries: `SchemaIssue` from the + * legacy relational differ (coordinate-based) and `SchemaDiffIssue` from the + * generic node differ (carrying the schema-IR node it concerns). + */ +export type DiffIssue = + | SchemaIssue + | SchemaDiffIssue; /** * The result of diffing a contract's expected schema against the introspected @@ -142,18 +148,23 @@ export type DiffIssue = SchemaIssue | SchemaDiffIssue; * produce them (the relational check and the generic node differ). Carries no * verdict, verification tree, or counts — those are the verifier's own * presentation, built from the same underlying comparison. + * + * `TNode` is the concrete schema-IR node the `schemaDiffIssues` carry; it + * defaults to `DiffableNode`, so this is purely additive — a caller that wants + * the concrete node opts in (the Postgres planner uses the concrete node type), + * everyone else keeps the default unchanged. */ -export class SchemaDiff { +export class SchemaDiff { readonly issues: readonly SchemaIssue[]; - readonly schemaDiffIssues: readonly SchemaDiffIssue[]; + readonly schemaDiffIssues: readonly SchemaDiffIssue[]; - constructor(issues: readonly SchemaIssue[], schemaDiffIssues: readonly SchemaDiffIssue[]) { + constructor(issues: readonly SchemaIssue[], schemaDiffIssues: readonly SchemaDiffIssue[]) { this.issues = issues; this.schemaDiffIssues = schemaDiffIssues; } /** Fans `keep` across both issue lists, returning a new `SchemaDiff` narrowed to the survivors. */ - filter(keep: (issue: DiffIssue) => boolean): SchemaDiff { + filter(keep: (issue: DiffIssue) => boolean): SchemaDiff { return new SchemaDiff(this.issues.filter(keep), this.schemaDiffIssues.filter(keep)); } } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts index cff1cb24ad..3f1be1652e 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts @@ -13,7 +13,7 @@ import { type VerifierOutput, verifyMigration, } from '@prisma-next/migration-tools/aggregate'; -import { blindCast, castAs } from '@prisma-next/utils/casts'; +import { castAs } from '@prisma-next/utils/casts'; import { notOk, ok, type Result } from '@prisma-next/utils/result'; import { CliStructuredError } from '../../utils/cli-errors'; import { @@ -183,13 +183,10 @@ export function createPerMemberVerifier(schema), + // `familyInstance` is `ControlFamilyInstance<_, unknown>`, so `verifySchema` + // takes its `TSchemaIR` as `unknown` — the introspected schema passes + // straight through; the family narrows to its own IR node internally. + schema, strict: verifyMode === 'strict', frameworkComponents, }); diff --git a/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts b/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts index 02f4507b3a..8d72e3d137 100644 --- a/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts +++ b/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts @@ -1,11 +1,19 @@ -import type { DiffIssue, SchemaDiff } from '@prisma-next/framework-components/control'; +import type { + DiffableNode, + DiffIssue, + SchemaDiff, +} from '@prisma-next/framework-components/control'; +import { blindCast } from '@prisma-next/utils/casts'; /** The entity name a diff issue addresses, for ownership scoping. */ function issueEntityName(issue: DiffIssue): string | undefined { if ('outcome' in issue) { const actual = issue.actual; if (actual === undefined) return undefined; - const tableName = (actual as { readonly tableName?: unknown }).tableName; + const tableName = blindCast< + { readonly tableName?: unknown }, + 'entity-name scoping reads the optional target-specific tableName off a diff node' + >(actual).tableName; return typeof tableName === 'string' ? tableName : undefined; } return 'table' in issue ? issue.table : undefined; @@ -17,11 +25,15 @@ function issueEntityName(issue: DiffIssue): string | undefined { * planner diffs the full live schema; this scopes the result to the member's * own space by entity name — the same coordinate the schema-pruning layer keyed * on. Absent/empty `ownedByOtherSpaces` returns the diff unchanged. + * + * Generic over `TNode` so a caller passing a node-typed `SchemaDiff` + * (the Postgres planner passes `SchemaDiff`) gets the same + * concrete type back. */ -export function scopePlanDiffToSpace( - diff: SchemaDiff, +export function scopePlanDiffToSpace( + diff: SchemaDiff, ownedByOtherSpaces: ReadonlySet | undefined, -): SchemaDiff { +): SchemaDiff { if (ownedByOtherSpaces === undefined || ownedByOtherSpaces.size === 0) return diff; return diff.filter((issue) => { const isExtra = diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts index 3fecc7bc2f..0efcd847a6 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts @@ -2,7 +2,6 @@ import type { Contract } from '@prisma-next/contract/types'; import { verifySqlSchemaTree } from '@prisma-next/family-sql/diff'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { - DiffableNode, SchemaDiffIssue, VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; @@ -15,6 +14,7 @@ import { normalizeSchemaNativeType } from '../native-type-normalizer'; import type { PostgresContract } from '../postgres-schema'; import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; +import type { SqlSchemaDiffNode } from '../schema-ir/schema-node-kinds'; import { contractToPostgresDatabaseSchemaNode } from './contract-to-postgres-database-schema-node'; interface PostgresDiffDatabaseSchemaInput { @@ -43,7 +43,7 @@ interface PostgresDiffDatabaseSchemaInput { */ function computePostgresSchemaComparison(input: PostgresDiffDatabaseSchemaInput): { readonly relational: VerifyDatabaseSchemaResult; - readonly schemaDiffIssues: readonly SchemaDiffIssue[]; + readonly schemaDiffIssues: readonly SchemaDiffIssue[]; } { const postgresContract = blindCast< PostgresContract, @@ -97,7 +97,9 @@ function computePostgresSchemaComparison(input: PostgresDiffDatabaseSchemaInput) * per-consumer post-step (verify filters the issues; the planner filters the * calls). */ -export function diffPostgresDatabaseSchema(input: PostgresDiffDatabaseSchemaInput): SchemaDiff { +export function diffPostgresDatabaseSchema( + input: PostgresDiffDatabaseSchemaInput, +): SchemaDiff { const { relational, schemaDiffIssues } = computePostgresSchemaComparison(input); return new SchemaDiff(relational.schema.issues, schemaDiffIssues); } @@ -125,15 +127,6 @@ function ownedSchemaNames(expected: PostgresDatabaseSchemaNode): ReadonlySet(node); -} - // Renders a display-only reference string for the diff message. If policy // rendering grows, route it through the adapter's SQL renderer so the message // can't diverge from the emitted policy SQL. @@ -152,26 +145,32 @@ function renderPostgresPolicyReference(policy: PostgresPolicySchemaNode): string * table/column drift, so non-policy issues are dropped here. * 3. Remaps the message to a human-readable policy reference. * + * Both trees are `PostgresDatabaseSchemaNode`s, so every issue node is a + * `SqlSchemaDiffNode` — narrow the framework's `SchemaDiffIssue` + * output once here (the single boundary cast), so every downstream consumer + * (the ownership filter, the planner) reads the concrete node with no cast. + * * Ownership filtering (dropping `extra` issues in namespaces a contract doesn't * own) is the caller's responsibility — use `filterIssuesByOwnership`. */ export function diffPostgresSchema( expected: PostgresDatabaseSchemaNode, actual: PostgresDatabaseSchemaNode, -): readonly SchemaDiffIssue[] { - const issues = diffSchemas(expected, actual); +): readonly SchemaDiffIssue[] { + const issues = blindCast< + readonly SchemaDiffIssue[], + 'both trees are PostgresDatabaseSchemaNodes, so every diff-issue node is a SqlSchemaDiffNode' + >(diffSchemas(expected, actual)); return issues .filter((i) => { const node = i.expected ?? i.actual; - return node !== undefined && PostgresPolicySchemaNode.is(asSchemaNode(node)); + return node !== undefined && PostgresPolicySchemaNode.is(node); }) .map((i) => { const node = i.expected ?? i.actual; - if (node === undefined) return i; - const policy = asSchemaNode(node); - if (!PostgresPolicySchemaNode.is(policy)) return i; - return { ...i, message: `${i.outcome}: ${renderPostgresPolicyReference(policy)}` }; + if (node === undefined || !PostgresPolicySchemaNode.is(node)) return i; + return { ...i, message: `${i.outcome}: ${renderPostgresPolicyReference(node)}` }; }); } @@ -181,13 +180,12 @@ export function diffPostgresSchema( * policies and its `existingSchemas`. */ export function filterIssuesByOwnership( - issues: readonly SchemaDiffIssue[], + issues: readonly SchemaDiffIssue[], ownedSchemaNameSet: ReadonlySet, -): readonly SchemaDiffIssue[] { +): readonly SchemaDiffIssue[] { return issues.filter((i) => { if (i.outcome !== 'extra') return true; if (i.actual === undefined) return false; - const policy = asSchemaNode(i.actual); - return PostgresPolicySchemaNode.is(policy) && ownedSchemaNameSet.has(policy.namespaceId); + return PostgresPolicySchemaNode.is(i.actual) && ownedSchemaNameSet.has(i.actual.namespaceId); }); } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index c6a48e6fd1..d27d8ad603 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -16,7 +16,6 @@ import { import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { - DiffableNode, MigrationPlanner, MigrationPlanWithAuthoringSurface, MigrationScaffoldContext, @@ -24,12 +23,13 @@ import type { SchemaIssue, } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; -import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; +import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { PostgresRlsPolicy } from '../postgres-rls-policy'; import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; +import type { SqlSchemaDiffNode } from '../schema-ir/schema-node-kinds'; import { formatPostgresControlPolicySubjectLabel, resolvePostgresCallControlPolicySubject, @@ -292,7 +292,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr */ private planPostgresSchemaDiff( options: PlannerOptionsWithComponents, - filteredDiffIssues: readonly SchemaDiffIssue[], + filteredDiffIssues: readonly SchemaDiffIssue[], ): readonly PostgresOpFactoryCall[] { const allowsDestructive = options.policy.allowedOperationClasses.includes('destructive'); const calls: PostgresOpFactoryCall[] = []; @@ -303,7 +303,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // encodes the body hash, so two policies sharing a local key (same name) // are always equal and isEqualTo never returns false. if (issue.outcome === 'missing') { - const expected = asSchemaNode(issue.expected); + const expected = issue.expected; PostgresPolicySchemaNode.assert(expected); // expected.namespaceId is the DDL schema name (resolved during projection); // this re-resolution is a no-op as long as PostgresSchema.ddlSchemaName() returns this.id. @@ -324,7 +324,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr ), ); } else if (issue.outcome === 'extra' && allowsDestructive) { - const actual = asSchemaNode(issue.actual); + const actual = issue.actual; PostgresPolicySchemaNode.assert(actual); const schemaForTable = resolveDdlSchemaForNamespaceStorage( options.contract.storage, @@ -377,19 +377,6 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr } } -// The framework types `SchemaDiffIssue`'s nodes as `DiffableNode`, but every -// node in a Postgres diff issue is really a `SqlSchemaIRNode` (the concrete -// schema-node classes). Bridge to that base so the `.is`/`.assert` guards can -// discriminate on `nodeKind`. The principled fix — a node-typed `SchemaDiffIssue` -// so this bridge is unnecessary — is a framework change tracked separately. -function asSchemaNode(node: DiffableNode | undefined): SqlSchemaIRNode | undefined { - if (node === undefined) return undefined; - return blindCast< - SqlSchemaIRNode, - 'diff issues over Postgres schema trees carry SqlSchemaIRNode nodes' - >(node); -} - /** * Returns the one namespace node whose tables the relational strategy layer * probes for live-table existence — the node matching the planner's resolved diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts index 67d4bfa9e3..7012d61f7a 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts @@ -1,3 +1,15 @@ +import type { DiffableNode } from '@prisma-next/framework-components/control'; +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; + +/** + * A Postgres schema-diff-tree node: a `SqlSchemaIRNode` that also implements + * `DiffableNode` (the five `Postgres*SchemaNode` classes). `SqlSchemaIRNode` + * alone is not a `DiffableNode` — its relational subclasses (`SqlColumnIR`, …) + * carry no `id`/`isEqualTo`/`children` — so this intersection is the honest node + * type the differ produces and the planner consumes (`SchemaDiff`). + */ +export type SqlSchemaDiffNode = SqlSchemaIRNode & DiffableNode; + /** * The `nodeKind` discriminant for each Postgres schema-diff node. Each node * carries a unique value; the static `is`/`assert` guards compare against these From 689f650af7f6ec1920c6fd376d5c835ed08e033e Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 20:31:54 +0200 Subject: [PATCH 42/49] =?UTF-8?q?docs(postgres-rls):=20verifier=20output?= =?UTF-8?q?=20is=20two=20parts=20=E2=80=94=20contract=20satisfaction=20+?= =?UTF-8?q?=20unclaimed=20elements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Will's resolution to the R2 tripwire: the verifier presents two distinct pieces of information — (1) per contract space, is the contract satisfied (declared elements, missing/mismatch); (2) a standalone list of live schema elements unclaimed by any contract. An unclaimed element is never forced into a contract's structure, and is reported once (fixing the N-times-per-space duplication). Also reconcile the node type to SqlSchemaDiffNode (SqlSchemaIRNode & DiffableNode). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../design-diff-and-verify.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md index 82ae4bf63c..9024c66dc5 100644 --- a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md +++ b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md @@ -41,7 +41,7 @@ class SchemaDiff { ``` - Its only job is to **abstract away that there are two issue lists.** `filter` fans one predicate across both and returns a narrowed `SchemaDiff`. The predicate takes the union; callers already understand both types. -- **`SchemaDiffIssue` carries its `expected` / `actual` schema-IR node**, so a caller reaches the node it concerns by a property access, not a lookup. `TNode` is **defaulted to `DiffableNode`** — a purely additive change: every existing caller keeps the default and is unbroken; only a caller that wants the concrete node opts in (the planner takes `SchemaDiff` and drops the `asSchemaNode` cast). The coupling is to the **schema IR** — the layer the differ diffs — never back to the contract IR it was derived from (§6). `SchemaIssue` (relational) stays coordinate-based; node-typing it is the relational-port follow-on (§13). +- **`SchemaDiffIssue` carries its `expected` / `actual` schema-IR node**, so a caller reaches the node it concerns by a property access, not a lookup. `TNode` is **defaulted to `DiffableNode`** — a purely additive change: every existing caller keeps the default and is unbroken; only a caller that wants the concrete node opts in. The Postgres side uses `SqlSchemaDiffNode = SqlSchemaIRNode & DiffableNode` (the honest constraint — `SqlSchemaIRNode` alone is *not* a `DiffableNode`, since its relational subclasses lack `id`/`isEqualTo`/`children`; only the five `Postgres*SchemaNode` classes implement it): the differ returns `SchemaDiff` and the planner consumes it, dropping the per-issue `asSchemaNode` cast. The coupling is to the **schema IR** — the layer the differ diffs — never back to the contract IR it was derived from (§6). `SchemaIssue` (relational) stays coordinate-based; node-typing it is the relational-port follow-on (§13). ## 5. The diffing logic lives with the diff, not in "verify" @@ -51,13 +51,12 @@ The relational diffing code must not sit in a `schema-verify/` module. Its logic The **contract-space aggregate is passive** — it holds the spaces and their contracts and answers ownership questions; it runs nothing. The **orchestration** (`verifyMigration`, `synthStrategy`) owns every verb. -**Verify.** For each contract space the orchestration: +**Verify produces two distinct outputs.** A diff of expected-vs-actual reports *against the contract*, but an **unclaimed** live element has no place in a contract's structure — so it is not forced into one. The verifier presents: -1. runs the differ — `diff(space.contract, actual)` → `SchemaDiff`; -2. composes the space's view — walks the space's contract-derived (expected) nodes and, per node, attaches the issue that concerns it; a node is *pass* when nothing is attached, *fail* otherwise; -3. verdict — the space passes iff it has no issue. `diff → its issues → empty ⇒ pass`. There is no verdict-derived-from-a-tree computation. +1. **Per contract space — is the contract satisfied?** The orchestration runs the differ (`diff(space.contract, actual)`) and composes the space's view: its declared nodes, each *pass* or *fail* by whether a **missing/mismatch** issue concerns it. Verdict = the space has no missing/mismatch issue. Extras are **not** represented here. +2. **Across the database — which live elements are unclaimed?** A separate, standalone list: introspected elements no contract space declares. The orchestration takes the diff's **extra** findings, deduplicates them, and asks the passive aggregate "does any contract declare this?" — the ones no contract claims are the unclaimed list, reported **once**. Its disposition is a rendering policy over the one list (strict fails on it; lenient shows it informationally). -Because the differ runs **per space**, a space's missing/mismatch issues are inherently its own — their expected node came from its contract; no attribution, no per-issue lookup. The only cross-space step is **extras** (an actual entity in no contract-for-this-diff): the orchestration classifies each against the aggregate — declared by some space ⇒ that space's, not this one's concern; declared by none ⇒ **undeclared**, reported once at the aggregate level. That is a positive interrogative over the contracts the aggregate holds; the `SchemaDiff` result is never consulted for ownership and never references the contract IR. +The CLI renders both. This dissolves the "represent an unclaimed element against a contract" problem — it is a second list, never a contract-tree node — and fixes today's bug (an unclaimed element duplicated once per space, N times, across `issues` / `counts` / tree). Because the differ runs **per space**, a space's missing/mismatch issues are inherently its own; the `SchemaDiff` result is never consulted for ownership and never references the contract IR — the aggregate answers ownership for the unclaimed list. **Plan.** The orchestration hands the planner **a space's issues**; the planner maps issue → op and nothing else. It takes no schema and no "other spaces" input — it works out no ownership, because the orchestration already handed it exactly its issues. The typed node on each `SchemaDiffIssue` is what the planner builds the op from (§4). Verify and plan are symmetric: run the differ per space, then verify checks emptiness while plan builds ops. @@ -105,7 +104,7 @@ There is no bare-name keying, no name-subtraction, and no qualified-coordinate f | `scope-schema-result.ts` prunes each space's verification tree + recomputes counts/verdict | deleted; the per-space view is composed from the space's declared nodes + attached issues, scoped by construction (§6) | | verify verdict = `counts.fail === 0` off the (post-scoped) tree | verdict = the space's issue list is empty | | planner takes the full schema + `entitiesOwnedByOtherSpaces`; filters extras itself | planner takes its space's issues and maps issue → op; `entitiesOwnedByOtherSpaces` / `otherMemberEntityNames` deleted | -| `SchemaDiffIssue.expected/actual: DiffableNode`; planner `asSchemaNode` casts | `SchemaDiffIssue` carries the typed node (default `DiffableNode`); planner takes `SchemaDiff`, cast gone | +| `SchemaDiffIssue.expected/actual: DiffableNode`; planner `asSchemaNode` casts | `SchemaDiffIssue` carries the typed node (default `DiffableNode`); planner takes `SchemaDiff` (= `SqlSchemaIRNode & DiffableNode`), per-issue casts gone (one boundary narrowing at the differ) | | the contract-space aggregate driver diffs / scopes / classifies | the aggregate is passive (answers ownership); the orchestration owns those verbs | | "member" throughout the aggregate | "contract space" | From a61c3f1ee0c317b18592266d8f454a1f5edd18e7 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 20:43:01 +0200 Subject: [PATCH 43/49] docs(postgres-rls): the two-part verify split lives in the aggregate layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-space family differ (verifySqlSchema / diffMongoSchemas) is shared with the migration planner and the runner's post-apply verify (which reads its ok = counts.fail); it stays unchanged. The two-part output — Part 1 per-space contract satisfaction, Part 2 the unclaimed-elements list — is an aggregate concern, so verifyMigration (replacing scope-schema-result) strips each per-space result's extras to Part 1 and gathers them into the unclaimed list. Planner and runner single-space paths stay byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../schema-node-tree-restructure/design-diff-and-verify.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md index 9024c66dc5..a5e6bf3cd0 100644 --- a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md +++ b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md @@ -60,7 +60,7 @@ The CLI renders both. This dissolves the "represent an unclaimed element against **Plan.** The orchestration hands the planner **a space's issues**; the planner maps issue → op and nothing else. It takes no schema and no "other spaces" input — it works out no ownership, because the orchestration already handed it exactly its issues. The typed node on each `SchemaDiffIssue` is what the planner builds the op from (§4). Verify and plan are symmetric: run the differ per space, then verify checks emptiness while plan builds ops. -**Compose, don't post-scope.** Today `verifySqlSchema` fuses the diff and the view — it walks the contract and, per element, diffs-against-actual *and* emits the tree node, then a post-step re-scopes that tree per space. The target separates them: the differ produces the issues; the per-space view is the space's own declared nodes annotated with their attached issues, and it grafts **no** extra nodes (extras are issues, not tree nodes). So the view is scoped **by construction** — no post-scoping, no recomputed counts. +**The two-part split lives in the aggregate layer, not the family differ.** `verifySqlSchema` (the single-space family check) is **shared** with the migration planner (reads its `issues`) and the runner's post-apply verify (reads its `ok` = `counts.fail`, a tree walk) — so it stays **unchanged**; it keeps grafting `extra_*` as fail nodes, and the single-space verdict planner/runner depend on is preserved byte-identical. The two-part output is inherently an aggregate concern — "unclaimed by *any* space" only has meaning across spaces — so the aggregate driver (`verifyMigration`, **replacing** `scope-schema-result`) does the split: from each per-space result it strips the `extra_*` nodes/issues to leave **Part 1** (the space's declared nodes), and gathers the stripped extras, deduplicated and filtered by the passive aggregate's ownership query, into **Part 2** (the unclaimed list). No per-space tree post-scoping, no per-family counts recompute. ## 7. The schema view is unaware of the schema IR @@ -91,7 +91,7 @@ The framework alters no schema and post-scopes no result. The **contract-space a Deleted outright: -- **`scope-schema-result.ts`** — the per-space tree pruning + counts/verdict recompute. It existed only because the previous pass stopped pre-pruning the schema but kept re-scoping the verification tree; the compose step (§6) makes the view scoped by construction, so there is nothing to post-scope. All three bugs it produced (a column false-pass, an enum-node false-pass, a Mongo counts-flip) disappear with the file. +- **`scope-schema-result.ts`** — the per-space tree pruning + counts/verdict recompute. The aggregate two-part split (§6) replaces it: strip each per-space result's extras to Part 1, gather them into the Part 2 unclaimed list — no post-scoping. All three bugs it produced (a column false-pass, an enum-node false-pass, a Mongo counts-flip) disappear with the file. - **`entitiesOwnedByOtherSpaces`** (the planner `plan()` input) and **`otherMemberEntityNames`** (the set-subtraction) — the planner receives its space's issues; it needs no "other spaces" input. - The earlier pruning layer (`projectSchemaToSpace`, both family `schema-shape.ts`, the `projectSchemaToMember` / `listSchemaEntityNames` callbacks) stays removed. From 9e2e80f0fbe67bf69f8bb39c2a0d9351f4a2e9b6 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 21:11:09 +0200 Subject: [PATCH 44/49] refactor(migration): split db verify into per-space satisfaction + one unclaimed list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aggregate verifier now produces two distinct outputs instead of grafting every undeclared live element into each contract space's tree N times. Part 1 — per contract space, its contract-satisfaction view: the space's declared nodes only, each pass/fail by a missing/mismatch issue. Part 2 — one deduplicated list of live elements no contract space declares, reported once, built from the diffs' extra findings filtered by a new passive-aggregate ownership query (declaresEntity). The split lives entirely in the aggregate layer (verifyMigration). The family differ (verifySqlSchema / diffMongoSchemas) is untouched: it still grafts extra nodes so the single-space verdict the migration planner and the runner's post-apply verify depend on stays byte-identical. verifyMigration strips those extras from each per-space result (subtracting their tally from the family's authoritative counts, so Mongo's no-root-count basis is preserved) and gathers them into the unclaimed list. CLI: db verify threads the unclaimed list as a top-level field in --json / --schema-only, rendered once. Strict mode fails on a non-empty list; lenient shows it informationally. scope-schema-result.ts is retained: the plan path (synth) and the Mongo runner still consume it; the verifier no longer does. Also folds in the R1 comment correction in scope-plan-diff. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/commands/db-verify.ts | 38 ++-- .../src/control-api/operations/db-verify.ts | 7 + .../cli/src/utils/combine-schema-results.ts | 102 ++++++---- .../cli/src/utils/formatters/verify.ts | 39 +++- .../test/utils/combine-schema-results.test.ts | 76 +++++--- .../migration/src/aggregate/aggregate.ts | 8 + .../migration/src/aggregate/types.ts | 5 + .../src/aggregate/unclaimed-elements.ts | 154 ++++++++++++++++ .../migration/src/aggregate/verifier.ts | 59 ++++-- .../test/aggregate/unclaimed-elements.test.ts | 174 ++++++++++++++++++ .../migration/test/aggregate/verifier.test.ts | 84 +++++++-- .../src/core/migrations/scope-plan-diff.ts | 2 +- 12 files changed, 634 insertions(+), 114 deletions(-) create mode 100644 packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts create mode 100644 packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts diff --git a/packages/1-framework/3-tooling/cli/src/commands/db-verify.ts b/packages/1-framework/3-tooling/cli/src/commands/db-verify.ts index 334f635593..f7648a68c0 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/db-verify.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/db-verify.ts @@ -1,10 +1,7 @@ import { readFile } from 'node:fs/promises'; import { loadConfig } from '@prisma-next/config-loader'; import type { Contract } from '@prisma-next/contract/types'; -import type { - VerifyDatabaseResult, - VerifyDatabaseSchemaResult, -} from '@prisma-next/framework-components/control'; +import type { VerifyDatabaseResult } from '@prisma-next/framework-components/control'; import { createControlStack, VERIFY_CODE_HASH_MISMATCH, @@ -29,7 +26,7 @@ import { errorTargetMismatch, errorUnexpected, } from '../utils/cli-errors'; -import { combineSchemaResults } from '../utils/combine-schema-results'; +import { type CombinedSchemaResult, combineSchemaResults } from '../utils/combine-schema-results'; import { addGlobalOptions, maskConnectionUrl, @@ -103,7 +100,7 @@ function mapVerifyFailure(verifyResult: VerifyDatabaseResult): CliStructuredErro return errorRuntime(verifyResult.summary); } -type DbVerifyFailure = CliStructuredError | VerifyDatabaseSchemaResult; +type DbVerifyFailure = CliStructuredError | CombinedSchemaResult; function errorInvalidVerifyMode(options: { readonly why: string; @@ -423,8 +420,9 @@ async function executeDbVerifyCommand( aggregateResult.value.schemaResults, aggregateResult.value.appSpaceId, options.strict ?? false, + aggregateResult.value.unclaimed, ); - if (!combined.ok) { + if (!combined.result.ok) { return notOk(combined); } @@ -438,10 +436,11 @@ async function executeDbVerifyCommand( ...ifDefined('missingCodecs', verifyResult.missingCodecs), ...ifDefined('codecCoverageSkipped', verifyResult.codecCoverageSkipped), schema: { - summary: combined.summary, - counts: combined.schema.counts, - strict: combined.meta?.strict ?? false, + summary: combined.result.summary, + counts: combined.result.schema.counts, + strict: combined.result.meta?.strict ?? false, }, + unclaimed: combined.unclaimed, meta: { ...(verifyResult.meta ?? {}), schemaVerification: 'performed', @@ -459,7 +458,7 @@ async function executeDbSchemaOnlyVerifyCommand( options: DbVerifyOptions, flags: GlobalFlags, ui: TerminalUI, -): Promise> { +): Promise> { const paths = await resolveVerifyPaths(options); renderVerifyHeader(paths, options, 'schema-only', flags, ui); @@ -488,6 +487,7 @@ async function executeDbSchemaOnlyVerifyCommand( aggregateResult.value.schemaResults, aggregateResult.value.appSpaceId, options.strict ?? false, + aggregateResult.value.unclaimed, ), ); } catch (error) { @@ -542,18 +542,18 @@ export function createDbVerifyCommand(): Command { if (mode === 'schema-only') { const result = await executeDbSchemaOnlyVerifyCommand(options, flags, ui); - const exitCode = handleResult(result, flags, ui, (schemaVerifyResult) => { + const exitCode = handleResult(result, flags, ui, (combined) => { if (flags.json) { - ui.output(formatSchemaVerifyJson(schemaVerifyResult)); + ui.output(formatSchemaVerifyJson(combined.result, combined.unclaimed)); } else { - const output = formatSchemaVerifyOutput(schemaVerifyResult, flags); + const output = formatSchemaVerifyOutput(combined.result, flags, combined.unclaimed); if (output) { ui.log(output); } } }); - if (result.ok && !result.value.ok) { + if (result.ok && !result.value.result.ok) { process.exit(1); } @@ -580,11 +580,15 @@ export function createDbVerifyCommand(): Command { } if (flags.json) { - ui.output(formatSchemaVerifyJson(result.failure)); + ui.output(formatSchemaVerifyJson(result.failure.result, result.failure.unclaimed)); } else { // Always show schema-drift failures, even in quiet mode — exiting 1 without // diagnostics is unhelpful. - const output = formatSchemaVerifyOutput(result.failure, { ...flags, quiet: false }); + const output = formatSchemaVerifyOutput( + result.failure.result, + { ...flags, quiet: false }, + result.failure.unclaimed, + ); if (output) { ui.log(output); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts index 3f1be1652e..6f8f14dd6a 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts @@ -69,6 +69,12 @@ export interface ExecuteDbVerifyOptions; + /** + * Live element names no contract space declares, deduplicated and reported + * once for the whole database (never per space). Strict mode fails on a + * non-empty list; lenient mode surfaces it informationally. + */ + readonly unclaimed: readonly string[]; readonly memberOrder: readonly string[]; readonly appSpaceId: string; } @@ -250,6 +256,7 @@ function finaliseVerifyResult(args: { emitVerifySpan(onProgress, 'spanEndOk'); return ok({ schemaResults: verifyResult.value.schemaCheck.perSpace, + unclaimed: verifyResult.value.schemaCheck.unclaimed, memberOrder: [aggregate.app.spaceId, ...aggregate.extensions.map((e) => e.spaceId)], appSpaceId: aggregate.app.spaceId, }); diff --git a/packages/1-framework/3-tooling/cli/src/utils/combine-schema-results.ts b/packages/1-framework/3-tooling/cli/src/utils/combine-schema-results.ts index 7c03d0cb47..2cef3ba1db 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/combine-schema-results.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/combine-schema-results.ts @@ -1,19 +1,36 @@ import type { VerifyDatabaseSchemaResult } from '@prisma-next/framework-components/control'; /** - * Collapse the aggregate verifier's per-space schema results into a - * single {@link VerifyDatabaseSchemaResult} for the existing CLI - * display surface. Concatenates issues across members; sums counts; - * uses the app member's result as the structural envelope (storage - * hash, target). + * The combined per-space contract-satisfaction result (Part 1) plus the + * standalone unclaimed-elements list (Part 2), reported once. The CLI renders + * both; `unclaimed` is never folded into the combined tree or its issues. + */ +export interface CombinedSchemaResult { + readonly result: VerifyDatabaseSchemaResult; + readonly unclaimed: readonly string[]; +} + +/** + * Collapse the aggregate verifier's per-space contract-satisfaction results + * (Part 1) into a single {@link VerifyDatabaseSchemaResult} for the existing CLI + * display surface, and carry the deduplicated unclaimed-elements list (Part 2) + * alongside it. Concatenates issues across spaces; sums counts; uses the app + * space's result as the structural envelope (storage hash, target). Extras are + * already stripped from each per-space result, so nothing here duplicates an + * unclaimed element per space. + * + * **Unclaimed disposition.** In strict mode a non-empty `unclaimed` list fails + * the combined verdict (`ok: false`); in lenient mode it is carried for + * informational rendering only. The list itself is returned unchanged for the + * renderer. * * **Summary policy.** Preserve the per-family phrasing whenever the - * combined `ok` flag agrees with the app member's `ok` flag — this is + * combined `ok` flag agrees with the app space's `ok` flag — this is * the common case (single-family deployments, single-app deployments) * and the family's "satisfies / does not satisfy contract" phrasing * stays user-visible. When the app passes but an extension fails (or * vice versa) the app's summary contradicts the envelope, so fall back - * to the first failing member's summary. This keeps family phrasing + * to the first failing space's summary. This keeps family phrasing * intact and the envelope internally consistent (`ok: false` ↔ failure * summary). */ @@ -21,7 +38,8 @@ export function combineSchemaResults( perSpace: ReadonlyMap, appSpaceId: string, strict: boolean, -): VerifyDatabaseSchemaResult { + unclaimed: readonly string[], +): CombinedSchemaResult { const appResult = perSpace.get(appSpaceId) ?? perSpace.values().next().value; if (appResult === undefined) { throw new Error('Aggregate verifier returned no schema results — this is a wiring bug.'); @@ -47,41 +65,45 @@ export function combineSchemaResults( childRoots.push(result.schema.root); } - // When `okAll !== appResult.ok`, exactly one shape is reachable: app passes - // (`appResult.ok === true`) and at least one other member failed - // (`okAll === false`). In that shape the failure was assigned to - // `firstFailure` during iteration, so non-null assertion is safe. The mirror - // shape (app fails while every member passes) is impossible because - // `appResult` either *is* a member of `perSpace` or is the first iterator - // value; either way its `ok` flag participates in `okAll`. - const summary = - okAll === appResult.ok - ? appResult.summary - : (firstFailure as VerifyDatabaseSchemaResult).summary; + const unclaimedFails = strict && unclaimed.length > 0; + const ok = okAll && !unclaimedFails; + + // Prefer a failing space's family phrasing; else, when only the unclaimed list + // fails the verdict, say so; else keep the app space's phrasing. + const summary = okAll + ? unclaimedFails + ? `Database schema has ${unclaimed.length} unclaimed element${unclaimed.length === 1 ? '' : 's'} (not in any contract)` + : appResult.summary + : appResult.ok + ? (firstFailure as VerifyDatabaseSchemaResult).summary + : appResult.summary; return { - ok: okAll, - ...(okAll ? {} : { code: appResult.code ?? 'PN-RUN-3010' }), - summary, - contract: appResult.contract, - target: appResult.target, - schema: { - issues, - schemaDiffIssues, - root: { - status: okAll ? 'pass' : 'fail', - kind: 'aggregate', - name: 'aggregate', - contractPath: '', - code: 'AGGREGATE', - message: okAll ? 'Aggregate schema matches' : 'Aggregate schema mismatch', - expected: undefined, - actual: undefined, - children: childRoots, + result: { + ok, + ...(ok ? {} : { code: appResult.code ?? 'PN-RUN-3010' }), + summary, + contract: appResult.contract, + target: appResult.target, + schema: { + issues, + schemaDiffIssues, + root: { + status: ok ? 'pass' : 'fail', + kind: 'aggregate', + name: 'aggregate', + contractPath: '', + code: 'AGGREGATE', + message: ok ? 'Aggregate schema matches' : 'Aggregate schema mismatch', + expected: undefined, + actual: undefined, + children: childRoots, + }, + counts, }, - counts, + meta: { strict }, + timings: { total: 0 }, }, - meta: { strict }, - timings: { total: 0 }, + unclaimed, }; } diff --git a/packages/1-framework/3-tooling/cli/src/utils/formatters/verify.ts b/packages/1-framework/3-tooling/cli/src/utils/formatters/verify.ts index 78b9d0ba5a..49bb80adb6 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/formatters/verify.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/formatters/verify.ts @@ -30,6 +30,12 @@ export interface DbVerifyCommandSuccessResult { readonly counts: VerifyDatabaseSchemaResult['schema']['counts']; readonly strict: boolean; }; + /** + * Live element names no contract space declares (Part 2). In full success this + * is only ever non-empty in lenient mode — strict mode fails on it — and is + * rendered informationally. + */ + readonly unclaimed?: readonly string[]; readonly warning?: string; readonly meta?: | (NonNullable & { @@ -76,6 +82,14 @@ export function formatVerifyOutput( `${formatDimText(` schema: pass=${result.schema.counts.pass} warn=${result.schema.counts.warn} fail=${result.schema.counts.fail}`)}`, ); } + if (result.unclaimed && result.unclaimed.length > 0) { + lines.push(''); + lines.push(formatYellow('Unclaimed elements (declared by no contract):')); + for (const name of result.unclaimed) { + lines.push(` ${formatYellow('⚠')} ${name}`); + } + } + if (result.warning) { lines.push(''); lines.push(`${formatYellow('⚠')} ${result.warning}`); @@ -107,6 +121,7 @@ export function formatVerifyJson(result: DbVerifyCommandSuccessResult): string { ...ifDefined('missingCodecs', result.missingCodecs), ...ifDefined('codecCoverageSkipped', result.codecCoverageSkipped), ...ifDefined('schema', result.schema), + unclaimed: result.unclaimed ?? [], ...ifDefined('warning', result.warning), ...ifDefined('meta', result.meta), timings: result.timings, @@ -517,6 +532,7 @@ function renderSchemaVerificationTree( export function formatSchemaVerifyOutput( result: VerifyDatabaseSchemaResult, flags: GlobalFlags, + unclaimed: readonly string[] = [], ): string { if (flags.quiet) { return ''; @@ -527,6 +543,7 @@ export function formatSchemaVerifyOutput( const useColor = flags.color !== false; const formatGreen = createColorFormatter(useColor, green); const formatRed = createColorFormatter(useColor, red); + const formatYellow = createColorFormatter(useColor, yellow); const formatDimText = (text: string) => formatDim(useColor, text); // Render verification tree first @@ -547,6 +564,17 @@ export function formatSchemaVerifyOutput( } } + if (unclaimed.length > 0) { + const strict = result.meta?.strict ?? false; + lines.push(''); + lines.push( + (strict ? formatRed : formatYellow)('Unclaimed elements (declared by no contract):'), + ); + for (const name of unclaimed) { + lines.push(` ${(strict ? formatRed : formatYellow)(strict ? '✖' : '⚠')} ${name}`); + } + } + // Add counts and timings in verbose mode if (isVerbose(flags, 1)) { lines.push(`${formatDimText(` Total time: ${result.timings.total}ms`)}`); @@ -570,10 +598,15 @@ export function formatSchemaVerifyOutput( } /** - * Formats JSON output for database schema verification. + * Formats JSON output for database schema verification. The unclaimed-elements + * list (Part 2) is a top-level field alongside the combined result, reported + * once for the whole database. */ -export function formatSchemaVerifyJson(result: VerifyDatabaseSchemaResult): string { - return JSON.stringify(result, null, 2); +export function formatSchemaVerifyJson( + result: VerifyDatabaseSchemaResult, + unclaimed: readonly string[] = [], +): string { + return JSON.stringify({ ...result, unclaimed }, null, 2); } // ============================================================================ diff --git a/packages/1-framework/3-tooling/cli/test/utils/combine-schema-results.test.ts b/packages/1-framework/3-tooling/cli/test/utils/combine-schema-results.test.ts index 0c6c77812b..7b8aa491e3 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/combine-schema-results.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/combine-schema-results.test.ts @@ -54,12 +54,13 @@ describe('combineSchemaResults', () => { ['cipher', makeResult({ spaceId: 'cipher', ok: true, summary: 'Schema matches contract' })], ]); - const combined = combineSchemaResults(perSpace, 'app', false); + const combined = combineSchemaResults(perSpace, 'app', false, []); - expect(combined).toMatchObject({ + expect(combined.result).toMatchObject({ ok: true, summary: 'Database schema satisfies contract', }); + expect(combined.unclaimed).toEqual([]); }); it('preserves the per-family failure summary when every member fails', () => { @@ -74,9 +75,9 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', false); + const combined = combineSchemaResults(perSpace, 'app', false, []); - expect(combined).toMatchObject({ + expect(combined.result).toMatchObject({ ok: false, summary: 'Database schema does not satisfy contract (1 failure)', }); @@ -99,9 +100,9 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', false); + const combined = combineSchemaResults(perSpace, 'app', false, []); - expect(combined).toMatchObject({ + expect(combined.result).toMatchObject({ ok: false, summary: 'Database schema does not satisfy contract (1 failure)', schema: { counts: { fail: 1 } }, @@ -123,18 +124,49 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', true); + const combined = combineSchemaResults(perSpace, 'app', true, []); - expect(combined.ok).toBe(false); - expect(combined.summary).not.toContain('matches contract'); - expect(combined.schema.root.status).toBe('fail'); - expect(combined.schema.root.message).toBe('Aggregate schema mismatch'); - expect(combined.meta?.strict).toBe(true); + expect(combined.result.ok).toBe(false); + expect(combined.result.summary).not.toContain('matches contract'); + expect(combined.result.schema.root.status).toBe('fail'); + expect(combined.result.schema.root.message).toBe('Aggregate schema mismatch'); + expect(combined.result.meta?.strict).toBe(true); + }); + + it('fails the verdict in strict mode when the unclaimed list is non-empty', () => { + const perSpace = new Map([ + [ + 'app', + makeResult({ spaceId: 'app', ok: true, summary: 'Database schema satisfies contract' }), + ], + ]); + + const combined = combineSchemaResults(perSpace, 'app', true, ['legacy_events']); + + expect(combined.result.ok).toBe(false); + expect(combined.result.summary).toContain('1 unclaimed element'); + expect(combined.result.code).toBe('PN-RUN-3010'); + expect(combined.unclaimed).toEqual(['legacy_events']); + }); + + it('keeps the verdict `ok` in lenient mode when the unclaimed list is non-empty', () => { + const perSpace = new Map([ + [ + 'app', + makeResult({ spaceId: 'app', ok: true, summary: 'Database schema satisfies contract' }), + ], + ]); + + const combined = combineSchemaResults(perSpace, 'app', false, ['legacy_events', 'old_audit']); + + expect(combined.result.ok).toBe(true); + expect(combined.result.summary).toBe('Database schema satisfies contract'); + expect(combined.unclaimed).toEqual(['legacy_events', 'old_audit']); }); it('throws a wiring-bug error when the per-space map is empty', () => { const empty = new Map(); - expect(() => combineSchemaResults(empty, 'app', false)).toThrow(/wiring bug/); + expect(() => combineSchemaResults(empty, 'app', false, [])).toThrow(/wiring bug/); }); it('falls back to the first iterator value when the app id is absent from the per-space map', () => { @@ -142,9 +174,9 @@ describe('combineSchemaResults', () => { ['cipher', makeResult({ spaceId: 'cipher', ok: true, summary: 'Schema matches contract' })], ]); - const combined = combineSchemaResults(perSpace, 'app', false); + const combined = combineSchemaResults(perSpace, 'app', false, []); - expect(combined).toMatchObject({ + expect(combined.result).toMatchObject({ ok: true, summary: 'Schema matches contract', contract: { storageHash: 'sha256:cipher-storage' }, @@ -164,9 +196,9 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', false); + const combined = combineSchemaResults(perSpace, 'app', false, []); - expect(combined).toMatchObject({ + expect(combined.result).toMatchObject({ ok: false, summary: 'cipher failure', schema: { counts: { fail: 2 } }, @@ -186,9 +218,9 @@ describe('combineSchemaResults', () => { delete stripped.code; const perSpace = new Map([['app', stripped]]); - const combined = combineSchemaResults(perSpace, 'app', false); + const combined = combineSchemaResults(perSpace, 'app', false, []); - expect(combined).toMatchObject({ + expect(combined.result).toMatchObject({ ok: false, code: 'PN-RUN-3010', }); @@ -233,9 +265,9 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', false); + const combined = combineSchemaResults(perSpace, 'app', false, []); - expect(combined.schema.issues).toEqual([appStructuralIssue]); - expect(combined.schema.schemaDiffIssues).toEqual([appDiffIssue, extDiffIssue]); + expect(combined.result.schema.issues).toEqual([appStructuralIssue]); + expect(combined.result.schema.schemaDiffIssues).toEqual([appDiffIssue, extDiffIssue]); }); }); diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts b/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts index a674e9f7b9..087c8b112c 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; import type { Contract, StorageNamespace } from '@prisma-next/contract/types'; +import { elementCoordinates } from '@prisma-next/framework-components/ir'; import { join } from 'pathe'; import { errorBundleNotFoundForGraphNode, @@ -283,6 +284,13 @@ export function createContractSpaceAggregate(args: { hasSpace: (id) => byId.has(id), space: (id) => byId.get(id), spaces: () => ordered, + declaresEntity: (entityName) => + ordered.some((member) => { + for (const coord of elementCoordinates(member.contract().storage)) { + if (coord.entityName === entityName) return true; + } + return false; + }), checkIntegrity, }; } diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/types.ts b/packages/1-framework/3-tooling/migration/src/aggregate/types.ts index 685c60afad..022a6d1120 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/types.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/types.ts @@ -91,6 +91,10 @@ export interface ContractSpaceMember { * - `listSpaces()` / `hasSpace()` / `space()` / `spaces()`: the query * surface the read commands consume — `app` first, then extension ids * lex-ascending. + * - `declaresEntity(name)`: ownership query — does any contract space + * declare a storage entity with this bare name? The verifier's + * unclaimed-elements pass asks this of each live element the diff + * reports as extra; the passive aggregate answers, it runs no diff. * - `checkIntegrity()`: judges the loaded model and returns every * violation (never bailing at the first). Config/contract-dependent * checks run only when the matching {@link IntegrityQueryOptions} opt @@ -104,5 +108,6 @@ export interface ContractSpaceAggregate { hasSpace(id: string): boolean; space(id: string): ContractSpaceMember | undefined; spaces(): readonly ContractSpaceMember[]; + declaresEntity(entityName: string): boolean; checkIntegrity(opts?: IntegrityQueryOptions): readonly IntegrityViolation[]; } diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts b/packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts new file mode 100644 index 0000000000..a6c9e8c9a5 --- /dev/null +++ b/packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts @@ -0,0 +1,154 @@ +import type { + BaseSchemaIssue, + SchemaDiffIssue, + SchemaIssue, + SchemaVerificationNode, + VerifyDatabaseSchemaResult, +} from '@prisma-next/framework-components/control'; + +/** + * True for a top-level entity verify-node: a SQL `table` or a Mongo `collection`. + */ +function isEntityNode(node: SchemaVerificationNode): boolean { + return node.kind === 'table' || node.kind === 'collection'; +} + +/** True when an issue reports an element present in the database but declared by no contract (an extra). */ +function isExtraIssue(issue: SchemaIssue): issue is BaseSchemaIssue { + return ( + issue.kind === 'extra_table' || + issue.kind === 'extra_column' || + issue.kind === 'extra_primary_key' || + issue.kind === 'extra_foreign_key' || + issue.kind === 'extra_unique_constraint' || + issue.kind === 'extra_index' || + issue.kind === 'extra_validator' || + issue.kind === 'extra_default' + ); +} + +/** The bare entity name an extra `SchemaDiffIssue` addresses, read off its actual (live-DB) node. */ +function schemaDiffIssueEntityName(issue: SchemaDiffIssue): string | undefined { + const actual = issue.actual; + if (actual === undefined) return undefined; + const name = (actual as { readonly tableName?: unknown }).tableName; + return typeof name === 'string' ? name : undefined; +} + +function aggregateStatus(children: readonly SchemaVerificationNode[]): 'pass' | 'warn' | 'fail' { + let status: 'pass' | 'warn' | 'fail' = 'pass'; + for (const child of children) { + if (child.status === 'fail') return 'fail'; + if (child.status === 'warn') status = 'warn'; + } + return status; +} + +type Counts = { pass: number; warn: number; fail: number; totalNodes: number }; + +/** + * Counts the pass/warn/fail statuses over a subtree, root included. Used only to + * measure the contribution of a stripped extra node so it can be subtracted from + * the family's authoritative counts — never to re-tally the whole result, whose + * count basis varies by family (SQL counts the root, Mongo does not). + */ +function countSubtree(node: SchemaVerificationNode): Counts { + let pass = 0; + let warn = 0; + let fail = 0; + let totalNodes = 0; + const visit = (n: SchemaVerificationNode): void => { + totalNodes += 1; + if (n.status === 'pass') pass += 1; + else if (n.status === 'warn') warn += 1; + else fail += 1; + for (const child of n.children) visit(child); + }; + visit(node); + return { pass, warn, fail, totalNodes }; +} + +/** + * Part 1 — a contract space's contract-satisfaction view. Strips every `extra_*` + * finding (the family differ grafts them as fail nodes / issues so the shared + * single-space verdict stays correct for the planner and runner) so the view is + * the space's **declared** nodes only, each pass/fail by whether a + * missing/mismatch issue concerns it. Extras belong to the separate unclaimed + * list ({@link collectExtraElementNames}), never a contract-tree node. + * + * Only top-level entity nodes (a SQL `table` / a Mongo `collection`) are + * droppable — an extra column or constraint lives inside a declared table's + * subtree and is stripped from `issues`, not by pruning the tree. The verdict is + * recomputed by subtracting each stripped node's own tally from the family's + * authoritative counts (family-agnostic — SQL counts the root, Mongo does not, + * so re-tallying the whole tree would drift the count basis). Only the root + * status is re-derived from the surviving children. + */ +export function stripExtraFindings(result: VerifyDatabaseSchemaResult): VerifyDatabaseSchemaResult { + const issues = result.schema.issues.filter((issue) => !isExtraIssue(issue)); + const schemaDiffIssues = result.schema.schemaDiffIssues.filter( + (issue) => issue.outcome !== 'extra', + ); + const keptChildren: SchemaVerificationNode[] = []; + const dropped: SchemaVerificationNode[] = []; + for (const child of result.schema.root.children) { + if (isEntityNode(child) && isExtraTableNode(child)) dropped.push(child); + else keptChildren.push(child); + } + + const nothingStripped = + issues.length === result.schema.issues.length && + schemaDiffIssues.length === result.schema.schemaDiffIssues.length && + dropped.length === 0; + if (nothingStripped) return result; + + const counts = { ...result.schema.counts }; + for (const node of dropped) { + const sub = countSubtree(node); + counts.pass -= sub.pass; + counts.warn -= sub.warn; + counts.fail -= sub.fail; + counts.totalNodes -= sub.totalNodes; + } + const root: SchemaVerificationNode = { + ...result.schema.root, + status: aggregateStatus(keptChildren), + children: keptChildren, + }; + const ok = counts.fail === 0; + return { + ...result, + ok, + ...(ok ? {} : { code: result.code ?? 'PN-RUN-3010' }), + summary: ok ? 'Database schema satisfies contract' : result.summary, + schema: { issues, schemaDiffIssues, root, counts }, + }; +} + +/** + * A top-level entity node the family grafted for a live element declared by no + * contract. The family sets `code: 'extra_table'` (SQL) or + * `code: 'EXTRA_COLLECTION'` (Mongo) on these nodes, whatever disposition the + * control policy reconciled the node status to. + */ +function isExtraTableNode(node: SchemaVerificationNode): boolean { + return node.code === 'extra_table' || node.code === 'EXTRA_COLLECTION'; +} + +/** + * Part 2 (per-space contribution) — the bare names of every live element this + * space's diff reports as an extra. The verifier gathers these across all + * spaces, deduplicates, and keeps only the names no contract space declares. + */ +export function collectExtraElementNames(result: VerifyDatabaseSchemaResult): Set { + const names = new Set(); + for (const issue of result.schema.issues) { + if (isExtraIssue(issue) && issue.table !== undefined) names.add(issue.table); + } + for (const issue of result.schema.schemaDiffIssues) { + if (issue.outcome !== 'extra') continue; + const name = schemaDiffIssueEntityName(issue); + if (name !== undefined) names.add(name); + } + return names; +} diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts b/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts index f2ad1a0cc1..cd7c9c9ea5 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts @@ -3,13 +3,13 @@ import type { Result } from '@prisma-next/utils/result'; import { notOk, ok } from '@prisma-next/utils/result'; import { requireHeadRef } from './aggregate'; import type { ContractMarkerRecordLike } from './marker-types'; -import { otherMemberEntityNames, scopeSchemaResultToSpace } from './scope-schema-result'; import type { ContractSpaceAggregate, ContractSpaceMember } from './types'; +import { collectExtraElementNames, stripExtraFindings } from './unclaimed-elements'; /** * Caller policy for the verifier. Today's only knob is - * `mode`: `strict` treats orphan elements (live tables not claimed by - * any aggregate member) as errors; `lenient` treats them as + * `mode`: `strict` treats unclaimed elements (live tables declared by + * no contract space) as errors; `lenient` treats them as * informational. Maps directly to `db verify --strict`. */ export interface VerifierInput { @@ -19,10 +19,10 @@ export interface VerifierInput { readonly mode: 'strict' | 'lenient'; /** * Caller-supplied per-space schema verifier. The CLI wires this to the - * family's `verifySchema`. It verifies the member against the **full** - * introspected schema; the verifier then scopes the result to the member's - * contract space (dropping the extras other members claim). It composes no - * pre-projection, so the framework never touches the storage shape. + * family's `verifySchema`, run against the **full** introspected schema. The + * verifier then produces two outputs from the per-space results: each space's + * contract-satisfaction view (extras stripped) and one deduplicated list of + * live elements no contract space declares. It touches no storage shape. */ readonly verifySchemaForMember: ( schema: unknown, @@ -56,7 +56,21 @@ export interface MarkerCheckSection { } export interface SchemaCheckSection { + /** + * Part 1 — per contract space, its contract-satisfaction view: the space's + * declared nodes only, each pass/fail by whether a missing/mismatch issue + * concerns it. Extras are stripped; the space's verdict is missing/mismatch + * only. + */ readonly perSpace: ReadonlyMap; + /** + * Part 2 — one deduplicated, sorted list of live element names no contract + * space declares (built from the diffs' extra findings, filtered by the + * passive aggregate's ownership query). Reported once for the whole database, + * not per space. Strict callers fail on a non-empty list; lenient callers show + * it informationally. + */ + readonly unclaimed: readonly string[]; } export interface VerifierSuccess { @@ -79,11 +93,14 @@ export type VerifierOutput = Result; * member's `headRef.hash` + `headRef.invariants`. Absence is a * distinct kind, not an error (callers — `db verify` strict vs * `db init` precondition — choose how to interpret it). - * - `schemaCheck` per member: verify the member against the **full** - * introspected schema, then scope the result to the member's contract - * space via {@link scopeSchemaResultToSpace} — dropping the extras every - * other member claims. Extras owned by no member survive as each member's - * undeclared-table findings. No schema is pruned before verifying. + * - `schemaCheck`: two distinct outputs from the per-space diffs. + * **Part 1** (`perSpace`) — each space verified against the **full** + * introspected schema, then its extras stripped, leaving the space's + * declared nodes (its contract-satisfaction view; verdict is + * missing/mismatch only). **Part 2** (`unclaimed`) — the extras gathered + * across every space, deduplicated, and filtered to the names no contract + * space declares (via the passive aggregate's ownership query); reported + * once for the database. No schema is pruned before verifying. * * `markerCheck.orphanMarkers` lists every marker row whose `space` is * not a member of the aggregate. `db verify` callers reject orphans; @@ -147,17 +164,20 @@ function runVerifyMigration(input: VerifierInput): VerifierOutput { } orphanMarkers.sort((a, b) => a.spaceId.localeCompare(b.spaceId)); - // Schema check per member: verify against the full schema, then scope the - // result to the member's contract space. + // Schema check: verify each space against the full schema, then split the + // results in two. Part 1 — each space's contract-satisfaction view, extras + // stripped. Part 2 — every extra name across all spaces, deduplicated and kept + // only when no contract space declares it. const schemaPerSpace = new Map(); + const extraNames = new Set(); for (const member of allMembers) { - const others = allMembers.filter((m) => m.spaceId !== member.spaceId); const result = verifySchemaForMember(schemaIntrospection, member, mode); - schemaPerSpace.set( - member.spaceId, - scopeSchemaResultToSpace(result, otherMemberEntityNames(member, others)), - ); + schemaPerSpace.set(member.spaceId, stripExtraFindings(result)); + for (const name of collectExtraElementNames(result)) extraNames.add(name); } + const unclaimed = [...extraNames] + .filter((name) => !aggregate.declaresEntity(name)) + .sort((a, b) => a.localeCompare(b)); return ok({ markerCheck: { @@ -166,6 +186,7 @@ function runVerifyMigration(input: VerifierInput): VerifierOutput { }, schemaCheck: { perSpace: schemaPerSpace, + unclaimed, }, }); } diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts new file mode 100644 index 0000000000..bb5fd97068 --- /dev/null +++ b/packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts @@ -0,0 +1,174 @@ +import type { + SchemaVerificationNode, + VerifyDatabaseSchemaResult, +} from '@prisma-next/framework-components/control'; +import { describe, expect, it } from 'vitest'; +import { + collectExtraElementNames, + stripExtraFindings, +} from '../../src/aggregate/unclaimed-elements'; + +function tableNode( + name: string, + status: SchemaVerificationNode['status'], + code = '', +): SchemaVerificationNode { + return { + status, + kind: 'table', + name, + contractPath: `storage.namespaces.public.entries.table.${name}`, + code, + message: '', + expected: undefined, + actual: undefined, + children: [], + }; +} + +function extraTableNode(name: string, status: 'fail' | 'warn'): SchemaVerificationNode { + return { ...tableNode(`table ${name}`, status, 'extra_table') }; +} + +function makeResult(args: { + ok: boolean; + children: SchemaVerificationNode[]; + counts: VerifyDatabaseSchemaResult['schema']['counts']; + issues?: VerifyDatabaseSchemaResult['schema']['issues']; + schemaDiffIssues?: VerifyDatabaseSchemaResult['schema']['schemaDiffIssues']; +}): VerifyDatabaseSchemaResult { + const rootStatus = args.children.some((c) => c.status === 'fail') + ? 'fail' + : args.children.some((c) => c.status === 'warn') + ? 'warn' + : 'pass'; + return { + ok: args.ok, + ...(args.ok ? {} : { code: 'PN-RUN-3010' }), + summary: args.ok ? 'Database schema satisfies contract' : 'does not satisfy', + contract: { storageHash: 'sha256:x' }, + target: { expected: 'postgres' }, + schema: { + issues: args.issues ?? [], + schemaDiffIssues: args.schemaDiffIssues ?? [], + root: { + status: rootStatus, + kind: 'contract', + name: 'contract', + contractPath: '', + code: '', + message: '', + expected: undefined, + actual: undefined, + children: args.children, + }, + counts: args.counts, + }, + timings: { total: 0 }, + }; +} + +describe('stripExtraFindings', () => { + it('returns the result unchanged when there are no extras', () => { + const result = makeResult({ + ok: true, + children: [tableNode('user', 'pass')], + counts: { pass: 2, warn: 0, fail: 0, totalNodes: 2 }, + }); + expect(stripExtraFindings(result)).toBe(result); + }); + + it('drops extra-table nodes and issues, leaving the declared nodes', () => { + const result = makeResult({ + ok: false, + children: [tableNode('user', 'pass'), extraTableNode('legacy', 'fail')], + counts: { pass: 2, warn: 0, fail: 1, totalNodes: 3 }, + issues: [{ kind: 'extra_table', table: 'legacy', message: 'x' }], + }); + + const stripped = stripExtraFindings(result); + + expect(stripped.schema.root.children.map((c) => c.name)).toEqual(['user']); + expect(stripped.schema.issues).toEqual([]); + // The extra node contributed the only failure; the space now satisfies. + expect(stripped.ok).toBe(true); + expect(stripped.schema.counts).toEqual({ pass: 2, warn: 0, fail: 0, totalNodes: 2 }); + }); + + it('keeps a real missing/mismatch failure after stripping extras', () => { + const result = makeResult({ + ok: false, + children: [tableNode('user', 'fail', 'missing_column'), extraTableNode('legacy', 'fail')], + counts: { pass: 0, warn: 0, fail: 2, totalNodes: 3 }, + issues: [ + { kind: 'missing_column', table: 'user', column: 'email', message: 'm' }, + { kind: 'extra_table', table: 'legacy', message: 'x' }, + ], + }); + + const stripped = stripExtraFindings(result); + + expect(stripped.ok).toBe(false); + expect(stripped.schema.issues.map((i) => i.kind)).toEqual(['missing_column']); + expect(stripped.schema.counts.fail).toBe(1); + }); + + it('subtracts from authoritative counts rather than re-tallying (Mongo does not count the root)', () => { + // Mongo-shaped counts: fail-per-collection, root not counted. Two extra + // collections at fail; totalNodes counts only the collection children. + const result = makeResult({ + ok: false, + children: [extraTableNode('a', 'fail'), extraTableNode('b', 'fail')], + counts: { pass: 0, warn: 0, fail: 2, totalNodes: 2 }, + issues: [ + { kind: 'extra_table', table: 'a', message: 'x' }, + { kind: 'extra_table', table: 'b', message: 'x' }, + ], + }); + + const stripped = stripExtraFindings(result); + + // Both extras removed; no declared nodes remain; verdict passes. + expect(stripped.schema.counts).toEqual({ pass: 0, warn: 0, fail: 0, totalNodes: 0 }); + expect(stripped.ok).toBe(true); + }); + + it('strips an extra node whatever disposition the control policy reconciled it to', () => { + const result = makeResult({ + ok: true, + children: [tableNode('user', 'pass'), extraTableNode('legacy', 'warn')], + counts: { pass: 2, warn: 1, fail: 0, totalNodes: 3 }, + issues: [{ kind: 'extra_table', table: 'legacy', message: 'x' }], + }); + + const stripped = stripExtraFindings(result); + + expect(stripped.schema.root.children.map((c) => c.name)).toEqual(['user']); + expect(stripped.schema.counts.warn).toBe(0); + }); +}); + +describe('collectExtraElementNames', () => { + it('gathers extra names from issues and extra schemaDiffIssues', () => { + const result = makeResult({ + ok: false, + children: [], + counts: { pass: 0, warn: 0, fail: 0, totalNodes: 0 }, + issues: [ + { kind: 'extra_table', table: 'legacy', message: 'x' }, + { kind: 'missing_table', table: 'wanted', message: 'm' }, + ], + schemaDiffIssues: [ + { + path: ['public', 'audit', 'p'], + outcome: 'extra', + message: 'e', + actual: { tableName: 'audit' } as never, + }, + { path: ['public', 'x', 'p'], outcome: 'missing', message: 'm' }, + ], + }); + + expect([...collectExtraElementNames(result)].sort()).toEqual(['audit', 'legacy']); + }); +}); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts index 6297e77da0..e38f93abdc 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts @@ -108,7 +108,14 @@ const FULL_SCHEMA_VERIFY = ( function extraTables(result: VerifyDatabaseSchemaResult | undefined): string[] { return (result?.schema.issues ?? []) - .flatMap((issue) => ('table' in issue && issue.table ? [issue.table] : [])) + .flatMap((issue) => (issue.kind === 'extra_table' && issue.table ? [issue.table] : [])) + .sort(); +} + +/** The names of any grafted extra-table nodes that survive in a space view (should be none). */ +function extraNodeNames(result: VerifyDatabaseSchemaResult | undefined): string[] { + return (result?.schema.root.children ?? []) + .flatMap((node) => (node.code === 'extra_table' ? [node.name] : [])) .sort(); } @@ -222,7 +229,7 @@ describe('verifyMigration', () => { }); describe('schemaCheck', () => { - it('scopes each member to its own space, dropping the extras another member claims', () => { + it('Part 1: each space view shows its declared nodes only, no extras', () => { const aggregate = makeAggregate({ app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), extensions: [ @@ -250,13 +257,47 @@ describe('verifyMigration', () => { }); const schemaCheck = result.assertOk().schemaCheck; - // App keeps only the undeclared `orphan_table`; `cipher_state` is dropped. - expect(extraTables(schemaCheck.perSpace.get('app'))).toEqual(['orphan_table']); - // Cipher keeps only the undeclared `orphan_table`; `user` is dropped. - expect(extraTables(schemaCheck.perSpace.get('cipher'))).toEqual(['orphan_table']); + // No space's contract-satisfaction view carries the undeclared table + // (nor a sibling's table) — extras are stripped from every per-space view. + expect(extraTables(schemaCheck.perSpace.get('app'))).toEqual([]); + expect(extraTables(schemaCheck.perSpace.get('cipher'))).toEqual([]); + expect(extraNodeNames(schemaCheck.perSpace.get('app'))).toEqual([]); + expect(extraNodeNames(schemaCheck.perSpace.get('cipher'))).toEqual([]); }); - it('keeps live tables claimed by no member as each member’s undeclared extras', () => { + it('Part 2: reports a table no space declares once in the unclaimed list', () => { + const aggregate = makeAggregate({ + app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), + extensions: [ + makeMember({ + spaceId: 'cipher', + headHash: 'sha256:cipher', + tables: { cipher_state: {} }, + }), + ], + }); + const liveSchema = { + tables: { + user: { columns: {} }, + cipher_state: { columns: {} }, + orphan_table: { columns: {} }, + }, + }; + + const result = verifyMigration({ + aggregate, + markersBySpaceId: new Map(), + schemaIntrospection: liveSchema, + mode: 'strict', + verifySchemaForMember: FULL_SCHEMA_VERIFY, + }); + + // `orphan_table` is declared by no space, so it appears exactly once — + // not once per space, the bug the two-part split fixes. + expect(result.assertOk().schemaCheck.unclaimed).toEqual(['orphan_table']); + }); + + it('Part 2: deduplicates and sorts multiple undeclared tables into one list', () => { const aggregate = makeAggregate({ app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), extensions: [ @@ -284,14 +325,32 @@ describe('verifyMigration', () => { verifySchemaForMember: FULL_SCHEMA_VERIFY, }); + expect(result.assertOk().schemaCheck.unclaimed).toEqual(['another_orphan', 'mystery_table']); + }); + + it('single-space: an undeclared table is unclaimed, not a node in the space view', () => { + const aggregate = makeAggregate({ + app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), + }); + const liveSchema = { + tables: { user: { columns: {} }, legacy_events: { columns: {} } }, + }; + + const result = verifyMigration({ + aggregate, + markersBySpaceId: new Map(), + schemaIntrospection: liveSchema, + mode: 'strict', + verifySchemaForMember: FULL_SCHEMA_VERIFY, + }); + const schemaCheck = result.assertOk().schemaCheck; - expect(extraTables(schemaCheck.perSpace.get('app'))).toEqual([ - 'another_orphan', - 'mystery_table', - ]); + expect(extraTables(schemaCheck.perSpace.get('app'))).toEqual([]); + expect(extraNodeNames(schemaCheck.perSpace.get('app'))).toEqual([]); + expect(schemaCheck.unclaimed).toEqual(['legacy_events']); }); - it('leaves no extras when every live table is claimed by some member', () => { + it('leaves the unclaimed list empty when every live table is declared by some space', () => { const aggregate = makeAggregate({ app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), extensions: [ @@ -319,6 +378,7 @@ describe('verifyMigration', () => { const schemaCheck = result.assertOk().schemaCheck; expect(extraTables(schemaCheck.perSpace.get('app'))).toEqual([]); expect(extraTables(schemaCheck.perSpace.get('cipher'))).toEqual([]); + expect(schemaCheck.unclaimed).toEqual([]); }); it('returns notOk(introspectionFailure) when verifySchemaForMember throws', () => { diff --git a/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts b/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts index 8d72e3d137..0240fcf077 100644 --- a/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts +++ b/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts @@ -27,7 +27,7 @@ function issueEntityName(issue: DiffIssue): string | undefined { * on. Absent/empty `ownedByOtherSpaces` returns the diff unchanged. * * Generic over `TNode` so a caller passing a node-typed `SchemaDiff` - * (the Postgres planner passes `SchemaDiff`) gets the same + * (the Postgres planner passes `SchemaDiff`) gets the same * concrete type back. */ export function scopePlanDiffToSpace( From 8c3d4a10bebe19520ec85b5ac29c4f2a182acbe6 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 21:34:51 +0200 Subject: [PATCH 45/49] fix(migration): strip only top-level extras, recompute stripped counts from the pruned tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two verdict bugs in the Part-1 strip (stripExtraFindings): Counts basis: subtracting a stripped extra node's tally from the family's authoritative counts left SQL's root contribution stale — SQL counts the root at its own status, so a strict space whose only failures were extras kept fail=1 from the root and false-failed with an empty issue list (every clean space in a multi-space strict verify). Reuse the proven shape: when nothing was stripped, keep the authoritative counts; when a node was stripped, the pruned tree is self-consistent in both count bases, so recompute counts and verdict from a plain tree walk. Fixture now counts the root at its own status; both bases (SQL root-counted, Mongo root-not-counted) are pinned by tests. Strip scope: the strip removed ALL extra_* issues and all extra schemaDiffIssues, including an extra column on the space's own declared table and extra RLS policies — whose contributions remain in the tree/counts. A space could fail with no visible evidence, unreachable in Part 2 too (the ownership query filters declared-table names). The strip is now scoped to top-level entity extras only (extra_table issues + the grafted entity nodes); nested extras and policy schemaDiffIssues stay in Part 1 as the space's own drift. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/aggregate/unclaimed-elements.ts | 68 +++++++------- .../test/aggregate/unclaimed-elements.test.ts | 90 ++++++++++++++++--- 2 files changed, 109 insertions(+), 49 deletions(-) diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts b/packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts index a6c9e8c9a5..d9267a0c31 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts @@ -47,12 +47,14 @@ function aggregateStatus(children: readonly SchemaVerificationNode[]): 'pass' | type Counts = { pass: number; warn: number; fail: number; totalNodes: number }; /** - * Counts the pass/warn/fail statuses over a subtree, root included. Used only to - * measure the contribution of a stripped extra node so it can be subtracted from - * the family's authoritative counts — never to re-tally the whole result, whose - * count basis varies by family (SQL counts the root, Mongo does not). + * Counts the pass/warn/fail statuses over a verification tree (root included). + * Used only when the strip actually dropped a node — the pruned tree is then + * self-consistent regardless of family, so the recomputed `fail` is the honest + * verdict signal in both count bases (SQL counts the root at its recomputed + * status; Mongo's root was never a failure carrier, so a fresh walk of the + * surviving collection nodes matches its tally). */ -function countSubtree(node: SchemaVerificationNode): Counts { +function countTree(node: SchemaVerificationNode): Counts { let pass = 0; let warn = 0; let fail = 0; @@ -69,26 +71,27 @@ function countSubtree(node: SchemaVerificationNode): Counts { } /** - * Part 1 — a contract space's contract-satisfaction view. Strips every `extra_*` - * finding (the family differ grafts them as fail nodes / issues so the shared - * single-space verdict stays correct for the planner and runner) so the view is - * the space's **declared** nodes only, each pass/fail by whether a - * missing/mismatch issue concerns it. Extras belong to the separate unclaimed - * list ({@link collectExtraElementNames}), never a contract-tree node. + * Part 1 — a contract space's contract-satisfaction view. Strips the + * **top-level entity extras only**: `extra_table` issues and the grafted + * top-level extra-entity nodes (a SQL `table` / a Mongo `collection` the family + * added for a live element declared by no contract). Those belong to the + * separate unclaimed list ({@link collectExtraElementNames}), never a + * contract-tree node. * - * Only top-level entity nodes (a SQL `table` / a Mongo `collection`) are - * droppable — an extra column or constraint lives inside a declared table's - * subtree and is stripped from `issues`, not by pruning the tree. The verdict is - * recomputed by subtracting each stripped node's own tally from the family's - * authoritative counts (family-agnostic — SQL counts the root, Mongo does not, - * so re-tallying the whole tree would drift the count basis). Only the root - * status is re-derived from the surviving children. + * Nested `extra_*` findings (an extra column on the space's own declared + * table…) and extra-policy `schemaDiffIssues` are the space's **own drift** and + * stay in Part 1: their contribution is baked into the declared table's subtree + * and the family's verdict, so stripping the issue would leave a failing space + * with no visible evidence. + * + * Counts: when nothing was dropped, the family's authoritative counts and + * verdict are untouched. When a node was dropped, both are recomputed from the + * pruned tree with a plain self-consistent walk ({@link countTree}) — + * family-agnostic, correct in the SQL (root-counted) and Mongo (root-not- + * counted) bases alike, and free of family-specific count arithmetic. */ export function stripExtraFindings(result: VerifyDatabaseSchemaResult): VerifyDatabaseSchemaResult { - const issues = result.schema.issues.filter((issue) => !isExtraIssue(issue)); - const schemaDiffIssues = result.schema.schemaDiffIssues.filter( - (issue) => issue.outcome !== 'extra', - ); + const issues = result.schema.issues.filter((issue) => issue.kind !== 'extra_table'); const keptChildren: SchemaVerificationNode[] = []; const dropped: SchemaVerificationNode[] = []; for (const child of result.schema.root.children) { @@ -96,32 +99,25 @@ export function stripExtraFindings(result: VerifyDatabaseSchemaResult): VerifyDa else keptChildren.push(child); } - const nothingStripped = - issues.length === result.schema.issues.length && - schemaDiffIssues.length === result.schema.schemaDiffIssues.length && - dropped.length === 0; - if (nothingStripped) return result; - - const counts = { ...result.schema.counts }; - for (const node of dropped) { - const sub = countSubtree(node); - counts.pass -= sub.pass; - counts.warn -= sub.warn; - counts.fail -= sub.fail; - counts.totalNodes -= sub.totalNodes; + const strippedIssues = issues.length !== result.schema.issues.length; + if (!strippedIssues && dropped.length === 0) return result; + if (dropped.length === 0) { + return { ...result, schema: { ...result.schema, issues } }; } + const root: SchemaVerificationNode = { ...result.schema.root, status: aggregateStatus(keptChildren), children: keptChildren, }; + const counts = countTree(root); const ok = counts.fail === 0; return { ...result, ok, ...(ok ? {} : { code: result.code ?? 'PN-RUN-3010' }), summary: ok ? 'Database schema satisfies contract' : result.summary, - schema: { issues, schemaDiffIssues, root, counts }, + schema: { ...result.schema, issues, root, counts }, }; } diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts index bb5fd97068..4a5d73b4a1 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts @@ -12,6 +12,7 @@ function tableNode( name: string, status: SchemaVerificationNode['status'], code = '', + children: SchemaVerificationNode[] = [], ): SchemaVerificationNode { return { status, @@ -22,7 +23,7 @@ function tableNode( message: '', expected: undefined, actual: undefined, - children: [], + children, }; } @@ -73,16 +74,19 @@ describe('stripExtraFindings', () => { const result = makeResult({ ok: true, children: [tableNode('user', 'pass')], + // Faithful SQL basis: the root is counted at its own status (pass). counts: { pass: 2, warn: 0, fail: 0, totalNodes: 2 }, }); expect(stripExtraFindings(result)).toBe(result); }); - it('drops extra-table nodes and issues, leaving the declared nodes', () => { + it('SQL basis (root counted): strict extras-only failure passes after the strip', () => { + // Faithful SQL counts: computeCounts walks EVERY node including the root at + // its own status. Root fail + user pass + extra fail => pass=1, fail=2. const result = makeResult({ ok: false, children: [tableNode('user', 'pass'), extraTableNode('legacy', 'fail')], - counts: { pass: 2, warn: 0, fail: 1, totalNodes: 3 }, + counts: { pass: 1, warn: 0, fail: 2, totalNodes: 3 }, issues: [{ kind: 'extra_table', table: 'legacy', message: 'x' }], }); @@ -90,16 +94,18 @@ describe('stripExtraFindings', () => { expect(stripped.schema.root.children.map((c) => c.name)).toEqual(['user']); expect(stripped.schema.issues).toEqual([]); - // The extra node contributed the only failure; the space now satisfies. + // The only failures were the extra node and the root's echo of it. The + // pruned tree is all-pass, so the space satisfies its contract. expect(stripped.ok).toBe(true); expect(stripped.schema.counts).toEqual({ pass: 2, warn: 0, fail: 0, totalNodes: 2 }); }); - it('keeps a real missing/mismatch failure after stripping extras', () => { + it('SQL basis: a real missing/mismatch failure survives the strip', () => { + // Root fail + user fail + extra fail => fail=3 under the SQL basis. const result = makeResult({ ok: false, children: [tableNode('user', 'fail', 'missing_column'), extraTableNode('legacy', 'fail')], - counts: { pass: 0, warn: 0, fail: 2, totalNodes: 3 }, + counts: { pass: 0, warn: 0, fail: 3, totalNodes: 3 }, issues: [ { kind: 'missing_column', table: 'user', column: 'email', message: 'm' }, { kind: 'extra_table', table: 'legacy', message: 'x' }, @@ -110,12 +116,13 @@ describe('stripExtraFindings', () => { expect(stripped.ok).toBe(false); expect(stripped.schema.issues.map((i) => i.kind)).toEqual(['missing_column']); - expect(stripped.schema.counts.fail).toBe(1); + // Recomputed from the pruned tree: root fail + user fail. + expect(stripped.schema.counts.fail).toBe(2); }); - it('subtracts from authoritative counts rather than re-tallying (Mongo does not count the root)', () => { - // Mongo-shaped counts: fail-per-collection, root not counted. Two extra - // collections at fail; totalNodes counts only the collection children. + it('Mongo basis (root not counted): strict extras-only failure passes after the strip', () => { + // Faithful Mongo counts: fail++ per collection, the root is never counted. + // Two extra collections => fail=2, totalNodes=2. const result = makeResult({ ok: false, children: [extraTableNode('a', 'fail'), extraTableNode('b', 'fail')], @@ -128,16 +135,18 @@ describe('stripExtraFindings', () => { const stripped = stripExtraFindings(result); - // Both extras removed; no declared nodes remain; verdict passes. - expect(stripped.schema.counts).toEqual({ pass: 0, warn: 0, fail: 0, totalNodes: 0 }); + // Recomputed with a plain self-consistent walk of the pruned tree: only the + // (now passing) root remains. + expect(stripped.schema.counts).toEqual({ pass: 1, warn: 0, fail: 0, totalNodes: 1 }); expect(stripped.ok).toBe(true); }); it('strips an extra node whatever disposition the control policy reconciled it to', () => { + // Mongo lenient basis: user pass + extra warn => pass=1, warn=1, root not counted. const result = makeResult({ ok: true, children: [tableNode('user', 'pass'), extraTableNode('legacy', 'warn')], - counts: { pass: 2, warn: 1, fail: 0, totalNodes: 3 }, + counts: { pass: 1, warn: 1, fail: 0, totalNodes: 2 }, issues: [{ kind: 'extra_table', table: 'legacy', message: 'x' }], }); @@ -145,6 +154,61 @@ describe('stripExtraFindings', () => { expect(stripped.schema.root.children.map((c) => c.name)).toEqual(['user']); expect(stripped.schema.counts.warn).toBe(0); + expect(stripped.ok).toBe(true); + }); + + it('keeps an extra column on a declared table in Part 1 as the space’s own drift', () => { + // An extra column lives INSIDE a declared table's subtree; its fail is baked + // into the tree and counts. Stripping the issue while the failure stays + // would make the space fail with an empty issue list. + const columnNode: SchemaVerificationNode = { + status: 'fail', + kind: 'column', + name: 'stale', + contractPath: 'storage.namespaces.public.entries.table.user.columns.stale', + code: 'extra_column', + message: 'Extra column "stale"', + expected: undefined, + actual: 'stale', + children: [], + }; + const result = makeResult({ + ok: false, + children: [tableNode('user', 'fail', 'extra_column', [columnNode])], + counts: { pass: 0, warn: 0, fail: 3, totalNodes: 3 }, + issues: [{ kind: 'extra_column', table: 'user', column: 'stale', message: 'x' }], + }); + + const stripped = stripExtraFindings(result); + + // Nothing top-level was stripped, so the result is untouched: the issue + // stays as evidence and the verdict stays consistent with it. + expect(stripped).toBe(result); + expect(stripped.schema.issues.map((i) => i.kind)).toEqual(['extra_column']); + expect(stripped.ok).toBe(false); + }); + + it('keeps an extra-policy schemaDiffIssue in Part 1 as the space’s own drift', () => { + const result = makeResult({ + ok: false, + children: [tableNode('user', 'pass')], + counts: { pass: 2, warn: 0, fail: 1, totalNodes: 2 }, + schemaDiffIssues: [ + { + path: ['public', 'user', 'policy_rogue'], + outcome: 'extra', + message: "RLS policy 'policy_rogue' is present in the database but not in the contract", + }, + ], + }); + + const stripped = stripExtraFindings(result); + + // Policy extras are the space's own drift evidence; the family already + // folded them into the verdict, so they must stay visible in Part 1. + expect(stripped).toBe(result); + expect(stripped.schema.schemaDiffIssues).toHaveLength(1); + expect(stripped.ok).toBe(false); }); }); From 9309fd00dbb20656fda1a7b8fdc6ef0bdea3c94d Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 21:44:11 +0200 Subject: [PATCH 46/49] fix(migration): re-fold schemaDiffIssues into the recomputed strip verdict The dropped-branch recompute in stripExtraFindings derived the verdict from a tree walk alone. But the family folds schemaDiffIssues.length into counts.fail after its own walk (control-instance totalFails), and policy issues carry no tree node - so a space that was relationally clean but had an extra RLS policy on its own table flipped ok:true whenever a sibling extra node was dropped. db verify --strict would false-pass with live policy drift. Re-fold: after countTree, add schemaDiffIssues.length to counts.fail before deriving ok, matching how the family built the counts being replaced. The composed case (policy schemaDiffIssue + dropped sibling extra) is pinned red- first: after the strip, ok stays false and the schemaDiffIssue stays in Part 1. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/aggregate/unclaimed-elements.ts | 4 +++ .../test/aggregate/unclaimed-elements.test.ts | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts b/packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts index d9267a0c31..b6d6069330 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/unclaimed-elements.ts @@ -111,6 +111,10 @@ export function stripExtraFindings(result: VerifyDatabaseSchemaResult): VerifyDa children: keptChildren, }; const counts = countTree(root); + // schemaDiffIssues (extra RLS policies…) carry no tree node; the family folds + // their count into `counts.fail` after its own tree walk. Re-fold them here, + // or a policy-only failure would vanish from the recomputed verdict. + counts.fail += result.schema.schemaDiffIssues.length; const ok = counts.fail === 0; return { ...result, diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts index 4a5d73b4a1..64aed2467d 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/unclaimed-elements.test.ts @@ -188,6 +188,37 @@ describe('stripExtraFindings', () => { expect(stripped.ok).toBe(false); }); + it('keeps failing on an extra RLS policy when a sibling extra node is dropped', () => { + // Composed case: the space is relationally clean but has one extra RLS + // policy on its own table — a schemaDiffIssue with NO tree node; the family + // folds schemaDiffIssues.length into counts.fail. A sibling space's table + // shows up as a dropped top-level extra, so the dropped branch recomputes + // counts from the pruned tree — which must re-fold the policy contribution, + // or the space false-passes with live policy drift. + const policyIssue = { + path: ['public', 'user', 'policy_rogue'], + outcome: 'extra' as const, + message: "RLS policy 'policy_rogue' is present in the database but not in the contract", + }; + const result = makeResult({ + ok: false, + children: [tableNode('user', 'pass'), extraTableNode('cipher_state', 'fail')], + // Faithful SQL counts: computeCounts (root fail + user pass + extra fail) + // then the family fold adds the policy: fail = 2 + 1 = 3. + counts: { pass: 1, warn: 0, fail: 3, totalNodes: 3 }, + issues: [{ kind: 'extra_table', table: 'cipher_state', message: 'x' }], + schemaDiffIssues: [policyIssue], + }); + + const stripped = stripExtraFindings(result); + + expect(stripped.schema.schemaDiffIssues).toEqual([policyIssue]); + // Pruned tree is all-pass, but the policy drift remains the space's own + // failure: countTree (fail 0) + refolded schemaDiffIssues (1). + expect(stripped.schema.counts.fail).toBe(1); + expect(stripped.ok).toBe(false); + }); + it('keeps an extra-policy schemaDiffIssue in Part 1 as the space’s own drift', () => { const result = makeResult({ ok: false, From 14f5b7a735ad875f2ef375816515e0bed5366bde Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 22:05:19 +0200 Subject: [PATCH 47/49] refactor(migration): planner takes a diff keep-predicate, drop the cross-space plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The planner no longer works out contract-space ownership. Its plan() input swaps the entitiesOwnedByOtherSpaces name-set for an optional keepDiffIssue predicate, applied blindly to its schema diff via SchemaDiff.filter before building ops. The orchestration (synthStrategy) constructs the predicate over the passive aggregate's new declaringSpaces ownership query: drop the extra findings for elements a sibling contract space declares, keep everything else (including extras no space declares, which the planner may DROP under a destructive policy). scopePlanDiffToSpace and its export are deleted; the postgres and sqlite planners hold no scoping logic. The orchestration does not run the diff itself: the differ lives on the SQL family target descriptor, invisible to the framework strategy, its strictness derives from the planner's policy, and plan() needs the schema regardless for the strategy layer's existence probes — so the faithful seam is the predicate, not a pre-computed diff. scope-schema-result.ts (scopeSchemaResultToSpace / otherMemberEntityNames) is deleted. Its last consumer, the Mongo runner's per-space post-apply verify, is rewired to a mongo-target-local equivalent (scope-verify-result.ts) with identical verdicts: drop the extra findings for collections a sibling space claims, keep truly undeclared extras so genuine drift still fails; counts stay authoritative when nothing is dropped and are recomputed from the pruned tree otherwise. Mongo runner results carry no schemaDiffIssues, so no re-fold. Planner ops are byte-identical (fixtures:check clean) and the multi-space guards stay green. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/control-migration-types.ts | 14 +- .../migration/src/aggregate/aggregate.ts | 16 +- .../migration/src/aggregate/planner.ts | 8 +- .../src/aggregate/scope-schema-result.ts | 179 --------- .../src/aggregate/strategies/synth.ts | 65 +++- .../migration/src/aggregate/types.ts | 10 +- .../migration/src/exports/aggregate.ts | 4 - .../aggregate/scope-schema-result.test.ts | 365 ------------------ .../test/aggregate/strategies/synth.test.ts | 57 ++- .../src/core/migrations/scope-plan-diff.ts | 45 --- .../9-family/src/core/migrations/types.ts | 14 +- .../2-sql/9-family/src/exports/control.ts | 1 - .../1-mongo-target/src/core/control-target.ts | 56 +-- .../src/core/scope-verify-result.ts | 148 +++++++ .../test/scope-verify-result.test.ts | 173 +++++++++ .../postgres/src/core/migrations/planner.ts | 27 +- .../sqlite/src/core/migrations/planner.ts | 21 +- 17 files changed, 496 insertions(+), 707 deletions(-) delete mode 100644 packages/1-framework/3-tooling/migration/src/aggregate/scope-schema-result.ts delete mode 100644 packages/1-framework/3-tooling/migration/test/aggregate/scope-schema-result.test.ts delete mode 100644 packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts create mode 100644 packages/3-mongo-target/1-mongo-target/src/core/scope-verify-result.ts create mode 100644 packages/3-mongo-target/1-mongo-target/test/scope-verify-result.test.ts diff --git a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts index 385f000e2c..fa290e38b0 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts @@ -19,6 +19,7 @@ import type { ControlFamilyInstance, } from './control-instances'; import type { OperationContext } from './control-result-types'; +import type { DiffIssue } from './schema-diff'; // ============================================================================ // Migration Package Metadata @@ -408,12 +409,15 @@ export interface MigrationPlanner< */ readonly spaceId: string; /** - * Entity names every OTHER contract-space member claims. The planner - * diffs the full live schema, then drops the `extra` findings for these - * names so it never emits DROP ops against a sibling space's tables. - * Absent (or empty) for single-space plans. + * Caller-supplied keep-predicate the planner applies to its schema diff + * (via `SchemaDiff.filter`) before building operations. The orchestration + * constructs it so the diff findings reaching op-building are exactly the + * contract space's own — e.g. dropping the `extra` findings for elements + * a sibling contract space declares, so the planner never emits DROP ops + * against another space's tables. The planner applies it blindly and + * holds no ownership logic. Absent for single-space plans. */ - readonly entitiesOwnedByOtherSpaces?: ReadonlySet; + readonly keepDiffIssue?: (issue: DiffIssue) => boolean; }): MigrationPlannerResult; /** diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts b/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts index 087c8b112c..123db530a0 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts @@ -276,6 +276,12 @@ export function createContractSpaceAggregate(args: { const { targetId, app, extensions, checkIntegrity } = args; const ordered: readonly ContractSpaceMember[] = [app, ...extensions]; const byId = new Map(ordered.map((m) => [m.spaceId, m])); + const spaceDeclares = (member: ContractSpaceMember, entityName: string): boolean => { + for (const coord of elementCoordinates(member.contract().storage)) { + if (coord.entityName === entityName) return true; + } + return false; + }; return { targetId, app, @@ -284,13 +290,9 @@ export function createContractSpaceAggregate(args: { hasSpace: (id) => byId.has(id), space: (id) => byId.get(id), spaces: () => ordered, - declaresEntity: (entityName) => - ordered.some((member) => { - for (const coord of elementCoordinates(member.contract().storage)) { - if (coord.entityName === entityName) return true; - } - return false; - }), + declaresEntity: (entityName) => ordered.some((member) => spaceDeclares(member, entityName)), + declaringSpaces: (entityName) => + ordered.filter((member) => spaceDeclares(member, entityName)).map((m) => m.spaceId), checkIntegrity, }; } diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts b/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts index c16aa2490f..deed21aa1e 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts @@ -42,7 +42,6 @@ export async function planMigration, ): Promise { const { aggregate, currentDBState, callerPolicy } = input; - const allMembers: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; const perSpace = new Map(); @@ -54,7 +53,8 @@ export async function planMigration m.spaceId !== member.spaceId); + const declaredByAnotherSpace = (entityName: string): boolean => + aggregate.declaringSpaces(entityName).some((spaceId) => spaceId !== member.spaceId); const currentMarker = currentDBState.markersBySpaceId.get(member.spaceId) ?? null; const headRef = requireHeadRef(member); @@ -75,7 +75,7 @@ export async function planMigration, -): Set { - const owned = new Set(); - for (const other of otherMembers) { - if (other.spaceId === member.spaceId) continue; - for (const { entityName } of elementCoordinates(other.contract().storage)) { - owned.add(entityName); - } - } - return owned; -} - -/** The entity name a verification node addresses: the last segment of its coordinate path. */ -function nodeEntityName(node: SchemaVerificationNode): string | undefined { - const segments = node.contractPath.split('.'); - return segments.length > 0 ? segments[segments.length - 1] : undefined; -} - -/** True for a top-level entity verify-node: a SQL `table` or a Mongo `collection`. */ -function isEntityNode(node: SchemaVerificationNode): boolean { - return node.kind === 'table' || node.kind === 'collection'; -} - -/** True when an issue reports an entity present in the database but claimed by no member (an extra). */ -function isExtraIssue(issue: SchemaIssue): issue is BaseSchemaIssue { - return ( - issue.kind === 'extra_table' || - issue.kind === 'extra_column' || - issue.kind === 'extra_primary_key' || - issue.kind === 'extra_foreign_key' || - issue.kind === 'extra_unique_constraint' || - issue.kind === 'extra_index' || - issue.kind === 'extra_validator' || - issue.kind === 'extra_default' - ); -} - -/** The bare entity name an extra `SchemaDiffIssue` addresses, read off its actual (live-DB) node. */ -function schemaDiffIssueEntityName(issue: SchemaDiffIssue): string | undefined { - const actual = issue.actual; - if (actual === undefined) return undefined; - const name = (actual as { readonly tableName?: unknown }).tableName; - return typeof name === 'string' ? name : undefined; -} - -function aggregateStatus(children: readonly SchemaVerificationNode[]): 'pass' | 'warn' | 'fail' { - let status: 'pass' | 'warn' | 'fail' = 'pass'; - for (const child of children) { - if (child.status === 'fail') return 'fail'; - if (child.status === 'warn') status = 'warn'; - } - return status; -} - -type Counts = { pass: number; warn: number; fail: number; totalNodes: number }; - -/** - * Partitions `root.children` into the top-level table nodes another member - * claims (dropped) and the rest (kept), then rebuilds the root over the kept - * children with a freshly aggregated status. Only `root.children` is filtered — - * each surviving table keeps its full subtree. Descending further would wrongly - * drop a member's own column (or `storage.types` enum) whose name collides with - * a sibling space's table; the pruning layer this replaces dropped top-level - * entities only. - */ -function pruneTopLevelTables( - root: SchemaVerificationNode, - ownedByOthers: ReadonlySet, -): { readonly root: SchemaVerificationNode; readonly dropped: readonly SchemaVerificationNode[] } { - const kept: SchemaVerificationNode[] = []; - const dropped: SchemaVerificationNode[] = []; - for (const child of root.children) { - const name = nodeEntityName(child); - // Only top-level entity nodes are droppable — a SQL `table` or a Mongo - // `collection`. The root also carries the synthesized `storageTypes` node - // (`name: 'types'`); a sibling owning a table named `types` must never drop - // it, or an enum-drift failure would vanish. Any other root child (now or - // later) is likewise never droppable, regardless of name. - if (isEntityNode(child) && name !== undefined && ownedByOthers.has(name)) { - dropped.push(child); - } else { - kept.push(child); - } - } - return { - root: { ...root, status: aggregateStatus(kept), children: kept }, - dropped, - }; -} - -/** - * Counts the pass/warn/fail statuses over a verification tree (root included), - * the way the verdict-relevant tally is derived. Used only when scoping actually - * dropped a node — the pruned tree is then self-consistent regardless of family, - * so the recomputed `fail` is the honest verdict signal. - */ -function countTree(node: SchemaVerificationNode): Counts { - let pass = 0; - let warn = 0; - let fail = 0; - let totalNodes = 0; - const visit = (n: SchemaVerificationNode): void => { - totalNodes += 1; - if (n.status === 'pass') pass += 1; - else if (n.status === 'warn') warn += 1; - else fail += 1; - for (const child of n.children) visit(child); - }; - visit(node); - return { pass, warn, fail, totalNodes }; -} - -/** - * Scope a per-member verify result to the member's own contract space: drop the - * `extra` findings for entities another aggregate member claims. Diffing the - * full introspected schema surfaces every other member's tables as extras; - * this removes exactly those (keyed by entity name, the coordinate the pruning - * layer keyed on), leaving each member's own drift plus the truly undeclared - * tables (extras owned by no member). - * - * A framework-level filter over framework result types only — it reads no - * storage shape and branches on no family. `ownedByOthers` is the set of entity - * names every other member claims (see {@link otherMemberEntityNames}). - */ -export function scopeSchemaResultToSpace( - result: VerifyDatabaseSchemaResult, - ownedByOthers: ReadonlySet, -): VerifyDatabaseSchemaResult { - if (ownedByOthers.size === 0) return result; - - const issues = result.schema.issues.filter( - (issue) => - !(isExtraIssue(issue) && issue.table !== undefined && ownedByOthers.has(issue.table)), - ); - const schemaDiffIssues = result.schema.schemaDiffIssues.filter((issue) => { - if (issue.outcome !== 'extra') return true; - const name = schemaDiffIssueEntityName(issue); - return name === undefined || !ownedByOthers.has(name); - }); - const { root, dropped } = pruneTopLevelTables(result.schema.root, ownedByOthers); - - // When nothing was dropped, keep the family's authoritative counts/verdict - // untouched (a multi-schema result keeps a first-namespace-only root but sums - // counts across namespaces, so recomputing from the root would undercount). - // When a node was dropped, the pruned tree is self-consistent, so recompute - // both counts and the verdict from it — family-agnostic, and free of any - // family-specific count arithmetic. - if (dropped.length === 0) { - return { ...result, schema: { ...result.schema, issues, schemaDiffIssues, root } }; - } - - const counts = countTree(root); - const ok = counts.fail === 0; - - return { - ...result, - ok, - ...(ok ? {} : { code: result.code ?? 'PN-RUN-3010' }), - summary: ok ? 'Database schema satisfies contract' : result.summary, - schema: { issues, schemaDiffIssues, root, counts }, - }; -} diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts b/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts index 2b161459e1..6a579881ad 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts @@ -2,15 +2,16 @@ import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-comp import type { ControlAdapterInstance, ControlFamilyInstance, + DiffIssue, MigrationOperationPolicy, MigrationPlan, MigrationPlannerConflict, MigrationPlannerResult, TargetMigrationsCapability, } from '@prisma-next/framework-components/control'; +import { blindCast } from '@prisma-next/utils/casts'; import type { ContractMarkerRecordLike } from '../marker-types'; import type { PerSpacePlan } from '../planner-types'; -import { otherMemberEntityNames } from '../scope-schema-result'; import { buildSynthMigrationEdge } from '../synth-migration-edge'; import type { ContractSpaceMember } from '../types'; @@ -18,7 +19,13 @@ export interface SynthStrategyInputs; + /** + * Ownership query over the passive contract-space aggregate: does a contract + * space OTHER than this one declare a storage entity with this bare name? + * The strategy uses it to scope the planner's diff (see + * {@link keepIssuesOfThisSpace}); it runs no diff of its own. + */ + readonly declaredByAnotherSpace: (entityName: string) => boolean; readonly schemaIntrospection: unknown; readonly adapter: ControlAdapterInstance; readonly migrations: TargetMigrationsCapability< @@ -43,15 +50,49 @@ export type SynthStrategyOutcome = */ type MaybeAsyncPlannerResult = MigrationPlannerResult | Promise; +/** The bare entity name a diff issue addresses, for ownership scoping. */ +function issueEntityName(issue: DiffIssue): string | undefined { + if ('outcome' in issue) { + const actual = issue.actual; + if (actual === undefined) return undefined; + const tableName = blindCast< + { readonly tableName?: unknown }, + 'entity-name scoping reads the optional target-specific tableName off a diff node' + >(actual).tableName; + return typeof tableName === 'string' ? tableName : undefined; + } + return 'table' in issue ? issue.table : undefined; +} + +/** + * Builds the keep-predicate the planner applies to its diff: drop the `extra` + * findings for entities another contract space declares (so the planner never + * emits DROP ops against a sibling space's tables), keep everything else — + * including extras no space declares, which the planner may DROP under a + * destructive policy. + */ +function keepIssuesOfThisSpace( + declaredByAnotherSpace: (entityName: string) => boolean, +): (issue: DiffIssue) => boolean { + return (issue) => { + const isExtra = + 'outcome' in issue ? issue.outcome === 'extra' : issue.kind.startsWith('extra_'); + if (!isExtra) return true; + const name = issueEntityName(issue); + return name === undefined || !declaredByAnotherSpace(name); + }; +} + /** - * Synthesise a migration plan for a single member from the full live schema, - * delegating to the family's `createPlanner(...).plan(...)`. + * Synthesise a migration plan for a single contract space from the full live + * schema, delegating to the family's `createPlanner(...).plan(...)`. * - * The planner diffs the whole introspected schema, so it sees other members' - * tables as "extras"; `entitiesOwnedByOtherSpaces` (every other member's - * claimed entity names) tells it to drop those extras before building ops, so - * it never emits a destructive drop for a sibling space's table. The schema is - * never pruned before planning. + * The planner diffs the whole introspected schema, so it sees other contract + * spaces' tables as "extras"; the orchestration scopes the diff by handing the + * planner a keep-predicate (built over the passive aggregate's ownership + * query) that drops exactly those extras, so the planner never emits a + * destructive drop for a sibling space's table and holds no ownership logic. + * The schema is never pruned before planning. * * The synthesised plan's `targetId` is set from `aggregateTargetId` * (the aggregate's ambient target). The family's planner does not @@ -60,9 +101,9 @@ type MaybeAsyncPlannerResult = MigrationPlannerResult | Promise false, children: () => [] } as DiffableNode; -} - -function columnNode( - tableName: string, - columnName: string, - status: 'pass' | 'warn' | 'fail', - code = '', -): SchemaVerificationNode { - return { - status, - kind: 'column', - name: columnName, - contractPath: `storage.namespaces.*.entries.table.${tableName}.columns.${columnName}`, - code, - message: '', - expected: undefined, - actual: undefined, - children: [], - }; -} - -function tableNode( - name: string, - status: 'pass' | 'warn' | 'fail', - code = '', - children: readonly SchemaVerificationNode[] = [], -): SchemaVerificationNode { - return { - status, - kind: 'table', - name: `table ${name}`, - contractPath: `storage.namespaces.*.entries.table.${name}`, - code, - message: '', - expected: undefined, - actual: undefined, - children, - }; -} - -/** A Mongo top-level entity node (`kind: 'collection'`). */ -function collectionNode(name: string, status: 'pass' | 'warn' | 'fail'): SchemaVerificationNode { - return { - status, - kind: 'collection', - name, - contractPath: `storage.namespaces.unbound.entries.collection.${name}`, - code: status === 'pass' ? '' : 'extra_table', - message: '', - expected: null, - actual: name, - children: [], - }; -} - -/** The synthesized `storage.types` root child — named `types`, but not a table node. */ -function storageTypesNode(status: 'pass' | 'warn' | 'fail'): SchemaVerificationNode { - return { - status, - kind: 'storageTypes', - name: 'types', - contractPath: 'storage.types', - code: status === 'fail' ? 'type_mismatch' : '', - message: '', - expected: undefined, - actual: undefined, - children: [], - }; -} - -function resultWith( - children: readonly SchemaVerificationNode[], - issues: VerifyDatabaseSchemaResult['schema']['issues'], -): VerifyDatabaseSchemaResult { - let fail = children.filter((c) => c.status === 'fail').length; - let warn = children.filter((c) => c.status === 'warn').length; - let pass = children.filter((c) => c.status === 'pass').length; - const rootStatus = fail > 0 ? 'fail' : warn > 0 ? 'warn' : 'pass'; - // Count the root node itself at its own status — matching the family verify's - // `computeCounts`, which walks every node including the root. - if (rootStatus === 'fail') fail += 1; - else if (rootStatus === 'warn') warn += 1; - else pass += 1; - return { - ok: fail === 0, - summary: 'original summary', - contract: { storageHash: 'sha256:test' }, - target: { expected: 'postgres' }, - schema: { - issues, - schemaDiffIssues: [], - root: { - status: rootStatus, - kind: 'contract', - name: 'contract', - contractPath: '', - code: '', - message: '', - expected: undefined, - actual: undefined, - children, - }, - counts: { pass, warn, fail, totalNodes: children.length + 1 }, - }, - timings: { total: 0 }, - }; -} - -describe('scopeSchemaResultToSpace', () => { - it('returns the input unchanged when no names are owned by others', () => { - const result = resultWith([tableNode('user', 'pass')], []); - expect(scopeSchemaResultToSpace(result, new Set())).toBe(result); - }); - - it('preserves the authoritative counts when a non-empty owned set drops nothing', () => { - // A multi-schema result keeps only the first namespace's root but sums the - // counts across every namespace, so `counts` is not derivable from `root`. - // Scoping must not recompute counts from the (partial) root when it drops - // nothing — it must leave the authoritative counts untouched. - const result = resultWith([tableNode('user', 'pass')], []); - const authoritative = { pass: 9, warn: 2, fail: 1, totalNodes: 12 }; - const multiSchema: VerifyDatabaseSchemaResult = { - ...result, - ok: false, - schema: { ...result.schema, counts: authoritative }, - }; - - const scoped = scopeSchemaResultToSpace(multiSchema, new Set(['nothing_here'])); - - expect(scoped.schema.counts).toEqual(authoritative); - expect(scoped.ok).toBe(false); - }); - - it('drops an extra-table issue owned by another member, keeps the undeclared one', () => { - const result = resultWith( - [tableNode('user', 'pass'), tableNode('cipher_state', 'warn'), tableNode('orphan', 'warn')], - [ - { kind: 'extra_table', table: 'cipher_state', message: 'extra cipher_state' }, - { kind: 'extra_table', table: 'orphan', message: 'extra orphan' }, - ], - ); - - const scoped = scopeSchemaResultToSpace(result, new Set(['cipher_state'])); - - expect(scoped.schema.issues).toEqual([ - { kind: 'extra_table', table: 'orphan', message: 'extra orphan' }, - ]); - expect(scoped.schema.root.children.map((c) => c.name)).toEqual(['table user', 'table orphan']); - }); - - it('recomputes counts over the pruned tree', () => { - const result = resultWith( - [tableNode('user', 'pass'), tableNode('sibling', 'fail', 'extra_table')], - [{ kind: 'extra_table', table: 'sibling', message: 'extra sibling' }], - ); - - const scoped = scopeSchemaResultToSpace(result, new Set(['sibling'])); - - expect(scoped.schema.counts).toEqual({ pass: 2, warn: 0, fail: 0, totalNodes: 2 }); - }); - - it('flips ok to true and re-derives the summary when the only failures were siblings', () => { - const result = resultWith( - [tableNode('user', 'pass'), tableNode('sibling', 'fail', 'extra_table')], - [{ kind: 'extra_table', table: 'sibling', message: 'extra sibling' }], - ); - expect(result.ok).toBe(false); - - const scoped = scopeSchemaResultToSpace(result, new Set(['sibling'])); - - expect(scoped.ok).toBe(true); - expect(scoped.code).toBeUndefined(); - expect(scoped.summary).toBe('Database schema satisfies contract'); - }); - - it('keeps a real failure and stays not-ok when a sibling is dropped alongside it', () => { - const result = resultWith( - [tableNode('user', 'fail', 'missing_column'), tableNode('sibling', 'fail', 'extra_table')], - [ - { kind: 'missing_column', table: 'user', column: 'age', message: 'missing age' }, - { kind: 'extra_table', table: 'sibling', message: 'extra sibling' }, - ], - ); - - const scoped = scopeSchemaResultToSpace(result, new Set(['sibling'])); - - expect(scoped.ok).toBe(false); - // The root node stays `fail` (user still fails) and is itself counted, so - // fail = root + user = 2 once the sibling leaf is dropped. - expect(scoped.schema.counts.fail).toBe(2); - expect(scoped.schema.issues).toEqual([ - { kind: 'missing_column', table: 'user', column: 'age', message: 'missing age' }, - ]); - }); - - it('prunes only top-level table nodes, never a member’s own column named like a sibling table', () => { - // A sibling space owns a table named `orders`. The member's own `user` table - // has two columns, `orders` (passing) and `orders` again as a failing type - // mismatch — both share the sibling table's name. Scoping must touch NEITHER - // column (they are not top-level tables), so the real failure survives and - // the verdict does not flip to a false pass. - const passCol = columnNode('user', 'orders', 'pass'); - const failCol = columnNode('user', 'orders', 'fail', 'type_mismatch'); - const userTable = tableNode('user', 'fail', 'type_mismatch', [passCol, failCol]); - const result: VerifyDatabaseSchemaResult = { - ok: false, - code: 'PN-RUN-3010', - summary: 'Database schema does not satisfy contract (1 failure)', - contract: { storageHash: 'sha256:test' }, - target: { expected: 'postgres' }, - schema: { - issues: [ - { - kind: 'type_mismatch', - table: 'user', - column: 'orders', - message: 'type mismatch on user.orders', - }, - ], - schemaDiffIssues: [], - root: { - status: 'fail', - kind: 'contract', - name: 'contract', - contractPath: '', - code: 'type_mismatch', - message: '', - expected: undefined, - actual: undefined, - children: [userTable], - }, - counts: { pass: 2, warn: 0, fail: 2, totalNodes: 4 }, - }, - timings: { total: 0 }, - }; - // The sibling space owns a table literally named `orders`. - const scoped = scopeSchemaResultToSpace(result, new Set(['orders'])); - - // The member's own `user` table and both its columns are untouched, so the - // failing column survives and the verdict does not flip to a false pass. - const scopedUser = scoped.schema.root.children[0]; - expect(scopedUser?.name).toBe('table user'); - expect(scopedUser?.children).toHaveLength(2); - expect(scoped.ok).toBe(false); - expect(scoped.schema.counts.fail).toBe(result.schema.counts.fail); - expect(scoped.schema.issues).toEqual(result.schema.issues); - }); - - it('never drops the storageTypes node even when a sibling owns a table named `types`', () => { - // The synthesized `storage.types` root child is named `types` but is not a - // table node. A sibling space owning a table literally named `types` must - // not drop it — if it is a failing enum-drift node, dropping it would flip - // the member to a false pass. - const typesNode = storageTypesNode('fail'); - const result: VerifyDatabaseSchemaResult = { - ok: false, - code: 'PN-RUN-3010', - summary: 'Database schema does not satisfy contract (1 failure)', - contract: { storageHash: 'sha256:test' }, - target: { expected: 'postgres' }, - schema: { - issues: [ - { - kind: 'enum_values_changed', - namespaceId: 'public', - typeName: 'status', - addedValues: ['archived'], - removedValues: [], - message: 'enum status changed', - }, - ], - schemaDiffIssues: [], - root: { - status: 'fail', - kind: 'contract', - name: 'contract', - contractPath: '', - code: 'type_mismatch', - message: '', - expected: undefined, - actual: undefined, - children: [tableNode('user', 'pass'), typesNode], - }, - counts: { pass: 2, warn: 0, fail: 2, totalNodes: 3 }, - }, - timings: { total: 0 }, - }; - - const scoped = scopeSchemaResultToSpace(result, new Set(['types'])); - - expect(scoped.schema.root.children.map((c) => c.name)).toContain('types'); - expect(scoped.ok).toBe(false); - expect(scoped.schema.counts.fail).toBe(result.schema.counts.fail); - }); - - it('still drops a Mongo collection node another member claims', () => { - // The entity-node allowlist covers Mongo's `collection` kind too, so - // sibling-owned collections are dropped just like SQL tables. - const result: VerifyDatabaseSchemaResult = { - ...resultWith([], []), - schema: { - issues: [{ kind: 'extra_table', table: 'sibling_coll', message: 'extra collection' }], - schemaDiffIssues: [], - root: { - status: 'warn', - kind: 'root', - name: 'mongo-schema', - contractPath: 'storage', - code: 'DRIFT', - message: '', - expected: null, - actual: null, - children: [collectionNode('own_coll', 'pass'), collectionNode('sibling_coll', 'warn')], - }, - counts: { pass: 2, warn: 1, fail: 0, totalNodes: 3 }, - }, - }; - - const scoped = scopeSchemaResultToSpace(result, new Set(['sibling_coll'])); - - expect(scoped.schema.root.children.map((c) => c.name)).toEqual(['own_coll']); - expect(scoped.schema.issues).toEqual([]); - }); - - it('drops an extra policy schemaDiffIssue owned by another member', () => { - const result: VerifyDatabaseSchemaResult = { - ...resultWith([], []), - schema: { - issues: [], - schemaDiffIssues: [ - { - path: ['db', 'auth', 'sibling', 'p'], - outcome: 'extra', - message: 'extra policy', - actual: policyNode('p', 'sibling'), - }, - { - path: ['db', 'public', 'orphan', 'q'], - outcome: 'extra', - message: 'extra policy', - actual: policyNode('q', 'orphan'), - }, - ], - root: resultWith([], []).schema.root, - counts: { pass: 1, warn: 0, fail: 0, totalNodes: 1 }, - }, - }; - - const scoped = scopeSchemaResultToSpace(result, new Set(['sibling'])); - - expect(scoped.schema.schemaDiffIssues.map((i) => i.path.join('/'))).toEqual([ - 'db/public/orphan/q', - ]); - }); -}); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts index c725feb938..136882cdab 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts @@ -1,6 +1,7 @@ import type { ControlAdapterInstance, ControlFamilyInstance, + DiffIssue, MigrationOperationPolicy, MigrationPlanner, MigrationPlanWithAuthoringSurface, @@ -45,13 +46,13 @@ function makeStubPlan(targetId: string): MigrationPlanWithAuthoringSurface { } describe('synthStrategy', () => { - it('passes the full schema and the other spaces’ entity names to the family planner', async () => { + it('passes the full schema and a diff filter that drops only other spaces’ extras', async () => { let observedSchema: unknown; - let observedOwned: ReadonlySet | undefined; + let observedKeep: ((issue: DiffIssue) => boolean) | undefined; const stubPlanner: MigrationPlanner<'sql', 'postgres'> = { - plan: ({ schema, entitiesOwnedByOtherSpaces }) => { + plan: ({ schema, keepDiffIssue }) => { observedSchema = schema; - observedOwned = entitiesOwnedByOtherSpaces; + observedKeep = keepDiffIssue; return { kind: 'success', plan: makeStubPlan('placeholder') }; }, emptyMigration: () => { @@ -71,7 +72,6 @@ describe('synthStrategy', () => { }; const appMember = makeMember('app', { app_user: {} }); - const extMember = makeMember('cipher', { cipher_state: {} }); const liveSchema = { tables: { @@ -85,7 +85,7 @@ describe('synthStrategy', () => { aggregateTargetId: 'postgres', currentMarker: null, member: appMember, - otherMembers: [extMember], + declaredByAnotherSpace: (name) => name === 'cipher_state', schemaIntrospection: liveSchema, adapter: STUB_ADAPTER, migrations: stubMigrations, @@ -108,15 +108,52 @@ describe('synthStrategy', () => { }, ]); - // Critical: the planner saw the FULL schema (no pre-pruning) and the set of - // entity names the sibling space owns, so it can drop those extras itself. + // Critical: the planner saw the FULL schema (no pre-pruning) … const observed = observedSchema as { tables: Record }; expect(Object.keys(observed.tables).sort()).toEqual([ 'app_user', 'cipher_state', 'orphan_table', ]); - expect([...(observedOwned ?? [])].sort()).toEqual(['cipher_state']); + + // … and a keep-predicate it applies to its diff. The planner holds no + // ownership logic: the predicate drops exactly the extras a sibling + // contract space declares, keeps every non-extra finding, and keeps + // extras no space declares (the planner may DROP those under policy). + const keep = observedKeep; + expect(keep).toBeDefined(); + if (keep === undefined) return; + const missingIssue: DiffIssue = { + kind: 'missing_table', + table: 'cipher_state', + message: 'missing', + }; + const siblingExtraIssue: DiffIssue = { + kind: 'extra_table', + table: 'cipher_state', + message: 'extra', + }; + const undeclaredExtraIssue: DiffIssue = { + kind: 'extra_table', + table: 'orphan_table', + message: 'extra', + }; + const siblingExtraDiffIssue: DiffIssue = { + path: ['public', 'cipher_state'], + outcome: 'extra', + message: 'extra', + actual: { tableName: 'cipher_state' } as never, + }; + const missingDiffIssue: DiffIssue = { + path: ['public', 'app_user', 'policy_x'], + outcome: 'missing', + message: 'missing', + }; + expect(keep(missingIssue)).toBe(true); + expect(keep(siblingExtraIssue)).toBe(false); + expect(keep(undeclaredExtraIssue)).toBe(true); + expect(keep(siblingExtraDiffIssue)).toBe(false); + expect(keep(missingDiffIssue)).toBe(true); }); it('forwards planner failures verbatim', async () => { @@ -145,7 +182,7 @@ describe('synthStrategy', () => { aggregateTargetId: 'postgres', currentMarker: null, member: makeMember('app', {}), - otherMembers: [], + declaredByAnotherSpace: () => false, schemaIntrospection: { tables: {} }, adapter: STUB_ADAPTER, migrations: stubMigrations, diff --git a/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts b/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts deleted file mode 100644 index 0240fcf077..0000000000 --- a/packages/2-sql/9-family/src/core/migrations/scope-plan-diff.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { - DiffableNode, - DiffIssue, - SchemaDiff, -} from '@prisma-next/framework-components/control'; -import { blindCast } from '@prisma-next/utils/casts'; - -/** The entity name a diff issue addresses, for ownership scoping. */ -function issueEntityName(issue: DiffIssue): string | undefined { - if ('outcome' in issue) { - const actual = issue.actual; - if (actual === undefined) return undefined; - const tableName = blindCast< - { readonly tableName?: unknown }, - 'entity-name scoping reads the optional target-specific tableName off a diff node' - >(actual).tableName; - return typeof tableName === 'string' ? tableName : undefined; - } - return 'table' in issue ? issue.table : undefined; -} - -/** - * Drops the `extra` findings for entities another contract-space member claims, - * so the planner never emits DROP ops against a sibling space's tables. The - * planner diffs the full live schema; this scopes the result to the member's - * own space by entity name — the same coordinate the schema-pruning layer keyed - * on. Absent/empty `ownedByOtherSpaces` returns the diff unchanged. - * - * Generic over `TNode` so a caller passing a node-typed `SchemaDiff` - * (the Postgres planner passes `SchemaDiff`) gets the same - * concrete type back. - */ -export function scopePlanDiffToSpace( - diff: SchemaDiff, - ownedByOtherSpaces: ReadonlySet | undefined, -): SchemaDiff { - if (ownedByOtherSpaces === undefined || ownedByOtherSpaces.size === 0) return diff; - return diff.filter((issue) => { - const isExtra = - 'outcome' in issue ? issue.outcome === 'extra' : issue.kind.startsWith('extra_'); - if (!isExtra) return true; - const name = issueEntityName(issue); - return name === undefined || !ownedByOtherSpaces.has(name); - }); -} diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index 9a2aec773b..c6b04ea287 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -5,6 +5,7 @@ import type { ContractSpace, ControlAdapterDescriptor, ControlExtensionDescriptor, + DiffIssue, MigratableTargetDescriptor, MigrationOperationPolicy, MigrationPlan, @@ -351,12 +352,15 @@ export interface SqlMigrationPlannerPlanOptions { */ readonly frameworkComponents: ReadonlyArray>; /** - * Entity names every OTHER contract-space member claims. The planner - * diffs the full live schema, then drops the `extra` findings for these - * names so it never emits DROP ops against a sibling space's tables. - * Absent (or empty) for single-space plans. + * Caller-supplied keep-predicate the planner applies to its schema diff + * (via `SchemaDiff.filter`) before building operations. The orchestration + * constructs it so the diff findings reaching op-building are exactly the + * contract space's own — e.g. dropping the `extra` findings for elements a + * sibling contract space declares, so the planner never emits DROP ops + * against another space's tables. The planner applies it blindly and holds + * no ownership logic. Absent for single-space plans. */ - readonly entitiesOwnedByOtherSpaces?: ReadonlySet; + readonly keepDiffIssue?: (issue: DiffIssue) => boolean; } export interface SqlMigrationPlanner { diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 10d149b45f..7820b7fb51 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -43,7 +43,6 @@ export { runnerSuccess, } from '../core/migrations/plan-helpers'; export { INIT_ADDITIVE_POLICY } from '../core/migrations/policies'; -export { scopePlanDiffToSpace } from '../core/migrations/scope-plan-diff'; export type { CodecControlHooks, CreateSqlMigrationPlanOptions, diff --git a/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts b/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts index 190754f2ae..1e33cd3ed2 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/control-target.ts @@ -13,16 +13,10 @@ import { contractToMongoSchemaIR } from '@prisma-next/family-mongo/control'; import type { MongoControlAdapter } from '@prisma-next/family-mongo/control-adapter'; import type { MigrationRunner, - MigrationRunnerPerSpaceOptions, MigrationRunnerPerSpaceSuccessValue, MigrationRunnerResult, VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; -import { - createContractSpaceMember, - otherMemberEntityNames, - scopeSchemaResultToSpace, -} from '@prisma-next/migration-tools/aggregate'; import type { MongoContract } from '@prisma-next/mongo-contract'; import { blindCast } from '@prisma-next/utils/casts'; import { notOk, ok } from '@prisma-next/utils/result'; @@ -32,6 +26,7 @@ import { MongoMigrationRunner, type MongoMigrationRunnerExecuteOptions } from '. import type { MongoTargetContract } from './mongo-target-contract'; import { MongoTargetContractSerializer } from './mongo-target-contract-serializer'; import { MongoTargetSchemaVerifier } from './mongo-target-schema-verifier'; +import { entityNamesDeclaredBy, scopeVerifyResultToSpace } from './scope-verify-result'; export type { MongoControlTargetDescriptor }; @@ -96,17 +91,21 @@ export const mongoTargetDescriptor: MongoControlTargetDescriptor { - const members = perSpaceOptions.map(toSpaceMember); + const contracts = perSpaceOptions.map((opts) => + blindCast( + opts.destinationContract, + ), + ); const perSpaceResults: Array<{ space: string; value: MigrationRunnerPerSpaceSuccessValue; @@ -114,16 +113,13 @@ export const mongoTargetDescriptor: MongoControlTargetDescriptor j !== i); // The runner verifies the destination contract against the full // introspected schema; scope the result to this space, dropping the - // `extra` findings for collections a sibling space owns. - const ownedByOthers = otherMemberEntityNames(member, others); + // `extra` findings for collections a sibling space claims. + const ownedByOtherSpaces = entityNamesDeclaredBy(contracts.filter((_, j) => j !== i)); const scopeVerifyResult = ( result: VerifyDatabaseSchemaResult, - ): VerifyDatabaseSchemaResult => scopeSchemaResultToSpace(result, ownedByOthers); + ): VerifyDatabaseSchemaResult => scopeVerifyResultToSpace(result, ownedByOtherSpaces); const { space, ...runnerOptions } = spaceOptions; const result = await runMongo(driver, { ...runnerOptions, scopeVerifyResult }); if (!result.ok) { @@ -152,25 +148,3 @@ export const mongoTargetDescriptor: MongoControlTargetDescriptor) { - const contract = blindCast( - opts.destinationContract, - ); - return createContractSpaceMember({ - spaceId: opts.space, - packages: [], - refs: {}, - headRef: null, - refsDir: '', - resolveContract: () => contract, - deserializeContract: (raw) => - blindCast(raw), - }); -} diff --git a/packages/3-mongo-target/1-mongo-target/src/core/scope-verify-result.ts b/packages/3-mongo-target/1-mongo-target/src/core/scope-verify-result.ts new file mode 100644 index 0000000000..7f7e40b030 --- /dev/null +++ b/packages/3-mongo-target/1-mongo-target/src/core/scope-verify-result.ts @@ -0,0 +1,148 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { + BaseSchemaIssue, + SchemaIssue, + SchemaVerificationNode, + VerifyDatabaseSchemaResult, +} from '@prisma-next/framework-components/control'; +import { elementCoordinates } from '@prisma-next/framework-components/ir'; + +/** + * The bare entity names the given contracts declare, unioned. The Mongo runner + * asks this of every OTHER contract space in a multi-space apply, so each + * space's post-apply verify can drop the extras those siblings claim. + */ +export function entityNamesDeclaredBy(contracts: ReadonlyArray): Set { + const owned = new Set(); + for (const contract of contracts) { + for (const { entityName } of elementCoordinates(contract.storage)) { + owned.add(entityName); + } + } + return owned; +} + +/** The entity name a verification node addresses: the last segment of its coordinate path. */ +function nodeEntityName(node: SchemaVerificationNode): string | undefined { + const segments = node.contractPath.split('.'); + return segments.length > 0 ? segments[segments.length - 1] : undefined; +} + +/** True for a top-level entity verify-node (a Mongo `collection`). */ +function isEntityNode(node: SchemaVerificationNode): boolean { + return node.kind === 'collection'; +} + +/** True when an issue reports an element present in the database but declared by no contract (an extra). */ +function isExtraIssue(issue: SchemaIssue): issue is BaseSchemaIssue { + return ( + issue.kind === 'extra_table' || + issue.kind === 'extra_column' || + issue.kind === 'extra_primary_key' || + issue.kind === 'extra_foreign_key' || + issue.kind === 'extra_unique_constraint' || + issue.kind === 'extra_index' || + issue.kind === 'extra_validator' || + issue.kind === 'extra_default' + ); +} + +function aggregateStatus(children: readonly SchemaVerificationNode[]): 'pass' | 'warn' | 'fail' { + let status: 'pass' | 'warn' | 'fail' = 'pass'; + for (const child of children) { + if (child.status === 'fail') return 'fail'; + if (child.status === 'warn') status = 'warn'; + } + return status; +} + +type Counts = { pass: number; warn: number; fail: number; totalNodes: number }; + +/** + * Counts the pass/warn/fail statuses over a verification tree (root included). + * Used only when scoping actually dropped a node — the pruned tree is then + * self-consistent, so the recomputed `fail` is the honest verdict signal. + */ +function countTree(node: SchemaVerificationNode): Counts { + let pass = 0; + let warn = 0; + let fail = 0; + let totalNodes = 0; + const visit = (n: SchemaVerificationNode): void => { + totalNodes += 1; + if (n.status === 'pass') pass += 1; + else if (n.status === 'warn') warn += 1; + else fail += 1; + for (const child of n.children) visit(child); + }; + visit(node); + return { pass, warn, fail, totalNodes }; +} + +/** + * Partitions `root.children` into the top-level collection nodes another + * contract space claims (dropped) and the rest (kept), then rebuilds the root + * over the kept children with a freshly aggregated status. Only `root.children` + * is filtered — each surviving collection keeps its full subtree, so a space's + * own field named like a sibling's collection is never dropped. + */ +function pruneTopLevelCollections( + root: SchemaVerificationNode, + ownedByOtherSpaces: ReadonlySet, +): { readonly root: SchemaVerificationNode; readonly dropped: readonly SchemaVerificationNode[] } { + const kept: SchemaVerificationNode[] = []; + const dropped: SchemaVerificationNode[] = []; + for (const child of root.children) { + const name = nodeEntityName(child); + if (isEntityNode(child) && name !== undefined && ownedByOtherSpaces.has(name)) { + dropped.push(child); + } else { + kept.push(child); + } + } + return { + root: { ...root, status: aggregateStatus(kept), children: kept }, + dropped, + }; +} + +/** + * Scope a per-space post-apply verify result to the contract space's own + * elements: drop the `extra` findings for collections another contract space + * claims. The runner verifies the destination contract against the full live + * database, which holds sibling spaces' collections — without the scoping a + * multi-space apply could never pass strict verify. Extras claimed by NO space + * survive, so genuine drift still fails the runner's verdict. + * + * Counts: when nothing was dropped, the family's authoritative counts/verdict + * are untouched; when a collection was dropped, both are recomputed from the + * pruned tree (self-consistent in Mongo's count basis). Mongo runner results + * carry no `schemaDiffIssues`, so no re-fold is needed. + */ +export function scopeVerifyResultToSpace( + result: VerifyDatabaseSchemaResult, + ownedByOtherSpaces: ReadonlySet, +): VerifyDatabaseSchemaResult { + if (ownedByOtherSpaces.size === 0) return result; + + const issues = result.schema.issues.filter( + (issue) => + !(isExtraIssue(issue) && issue.table !== undefined && ownedByOtherSpaces.has(issue.table)), + ); + const { root, dropped } = pruneTopLevelCollections(result.schema.root, ownedByOtherSpaces); + + if (dropped.length === 0) { + return { ...result, schema: { ...result.schema, issues, root } }; + } + + const counts = countTree(root); + const ok = counts.fail === 0; + + return { + ...result, + ok, + ...(ok ? {} : { code: result.code ?? 'PN-RUN-3010' }), + summary: ok ? 'Database schema satisfies contract' : result.summary, + schema: { ...result.schema, issues, root, counts }, + }; +} diff --git a/packages/3-mongo-target/1-mongo-target/test/scope-verify-result.test.ts b/packages/3-mongo-target/1-mongo-target/test/scope-verify-result.test.ts new file mode 100644 index 0000000000..3aed74904d --- /dev/null +++ b/packages/3-mongo-target/1-mongo-target/test/scope-verify-result.test.ts @@ -0,0 +1,173 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { + SchemaVerificationNode, + VerifyDatabaseSchemaResult, +} from '@prisma-next/framework-components/control'; +import { describe, expect, it } from 'vitest'; +import { entityNamesDeclaredBy, scopeVerifyResultToSpace } from '../src/core/scope-verify-result'; + +function makeContract(collections: readonly string[]): Contract { + const entries: Record> = { + collection: Object.fromEntries(collections.map((name) => [name, {}])), + }; + return { + storage: { namespaces: { mongo: { id: 'mongo', entries } } }, + } as unknown as Contract; +} + +function collectionNode( + name: string, + status: SchemaVerificationNode['status'], + children: SchemaVerificationNode[] = [], +): SchemaVerificationNode { + return { + status, + kind: 'collection', + name, + contractPath: `storage.namespaces.mongo.entries.collection.${name}`, + code: status === 'pass' ? 'MATCH' : 'EXTRA_COLLECTION', + message: '', + expected: undefined, + actual: undefined, + children, + }; +} + +function makeResult(args: { + ok: boolean; + children: SchemaVerificationNode[]; + counts: VerifyDatabaseSchemaResult['schema']['counts']; + issues?: VerifyDatabaseSchemaResult['schema']['issues']; +}): VerifyDatabaseSchemaResult { + const rootStatus = args.children.some((c) => c.status === 'fail') + ? 'fail' + : args.children.some((c) => c.status === 'warn') + ? 'warn' + : 'pass'; + return { + ok: args.ok, + ...(args.ok ? {} : { code: 'PN-RUN-3010' }), + summary: args.ok ? 'Database schema satisfies contract' : 'does not satisfy', + contract: { storageHash: 'sha256:x' }, + target: { expected: 'mongo' }, + schema: { + issues: args.issues ?? [], + schemaDiffIssues: [], + root: { + status: rootStatus, + kind: 'schema', + name: 'schema', + contractPath: '', + code: rootStatus === 'pass' ? 'MATCH' : 'DRIFT', + message: '', + expected: undefined, + actual: undefined, + children: args.children, + }, + counts: args.counts, + }, + timings: { total: 0 }, + }; +} + +describe('entityNamesDeclaredBy', () => { + it('unions entity names across the given contracts', () => { + const names = entityNamesDeclaredBy([ + makeContract(['cipher_state']), + makeContract(['audit_log', 'cipher_state']), + ]); + expect([...names].sort()).toEqual(['audit_log', 'cipher_state']); + }); +}); + +describe('scopeVerifyResultToSpace', () => { + it('returns the input unchanged when no names are owned by other spaces', () => { + const result = makeResult({ + ok: true, + children: [collectionNode('user', 'pass')], + counts: { pass: 1, warn: 0, fail: 0, totalNodes: 1 }, + }); + expect(scopeVerifyResultToSpace(result, new Set())).toBe(result); + }); + + it('preserves the authoritative counts when a non-empty owned set drops nothing', () => { + const result = makeResult({ + ok: true, + children: [collectionNode('user', 'pass')], + counts: { pass: 1, warn: 0, fail: 0, totalNodes: 1 }, + }); + const scoped = scopeVerifyResultToSpace(result, new Set(['cipher_state'])); + expect(scoped.schema.counts).toEqual({ pass: 1, warn: 0, fail: 0, totalNodes: 1 }); + expect(scoped.ok).toBe(true); + }); + + it('drops a sibling space’s collection, keeps the undeclared extra, and flips ok', () => { + // Mongo basis: fail per collection, root not counted. Both extras fail. + const result = makeResult({ + ok: false, + children: [ + collectionNode('user', 'pass'), + collectionNode('cipher_state', 'fail'), + collectionNode('junk', 'fail'), + ], + counts: { pass: 1, warn: 0, fail: 2, totalNodes: 3 }, + issues: [ + { kind: 'extra_table', table: 'cipher_state', message: 'extra' }, + { kind: 'extra_table', table: 'junk', message: 'extra' }, + ], + }); + + const scoped = scopeVerifyResultToSpace(result, new Set(['cipher_state'])); + + // The sibling's collection is dropped; the truly undeclared `junk` stays, + // so the runner still fails on genuine drift. + expect(scoped.schema.root.children.map((c) => c.name)).toEqual(['user', 'junk']); + expect(scoped.schema.issues.map((i) => ('table' in i ? i.table : ''))).toEqual(['junk']); + expect(scoped.ok).toBe(false); + }); + + it('flips ok to true when the only failures were sibling collections', () => { + const result = makeResult({ + ok: false, + children: [collectionNode('user', 'pass'), collectionNode('cipher_state', 'fail')], + counts: { pass: 1, warn: 0, fail: 1, totalNodes: 2 }, + issues: [{ kind: 'extra_table', table: 'cipher_state', message: 'extra' }], + }); + + const scoped = scopeVerifyResultToSpace(result, new Set(['cipher_state'])); + + expect(scoped.ok).toBe(true); + expect(scoped.schema.counts.fail).toBe(0); + expect(scoped.schema.issues).toEqual([]); + }); + + it('never drops a space’s own field node named like a sibling collection', () => { + const fieldNode: SchemaVerificationNode = { + status: 'fail', + kind: 'field', + name: 'cipher_state', + contractPath: 'storage.namespaces.mongo.entries.collection.user.fields.cipher_state', + code: 'MISSING_FIELD', + message: '', + expected: undefined, + actual: undefined, + children: [], + }; + const result = makeResult({ + ok: false, + children: [collectionNode('user', 'fail', [fieldNode])], + counts: { pass: 0, warn: 0, fail: 1, totalNodes: 2 }, + issues: [ + { kind: 'missing_column', table: 'user', column: 'cipher_state', message: 'missing' }, + ], + }); + + const scoped = scopeVerifyResultToSpace(result, new Set(['cipher_state'])); + + // Only top-level collection nodes are droppable; the nested field node and + // its failure survive, so the space still fails on its own drift. + expect(scoped.schema.root.children.map((c) => c.name)).toEqual(['user']); + expect(scoped.schema.root.children[0]?.children.map((c) => c.name)).toEqual(['cipher_state']); + expect(scoped.ok).toBe(false); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index d27d8ad603..b91d482084 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -11,7 +11,6 @@ import { partitionIssuesByControlPolicy, planFieldEventOperations, plannerFailure, - scopePlanDiffToSpace, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; @@ -163,20 +162,20 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // findings (+ namespace presence) become structural DDL via `planIssues`, // the policy findings become RLS ops via `planPostgresSchemaDiff`. Verify // runs the same underlying comparison (via `verifyDatabaseSchema`) and - // rejects on non-empty. + // rejects on non-empty. The caller-supplied `keepDiffIssue` predicate is + // applied blindly — any scoping (e.g. multi-space ownership) is the + // orchestration's, never worked out here. PostgresDatabaseSchemaNode.assert(options.schema); - const databaseDiff = scopePlanDiffToSpace( - diffPostgresDatabaseSchema({ - contract: options.contract, - actualSchema: options.schema, - strict: - options.policy.allowedOperationClasses.includes('widening') || - options.policy.allowedOperationClasses.includes('destructive'), - typeMetadataRegistry: new Map(), - frameworkComponents: options.frameworkComponents, - }), - options.entitiesOwnedByOtherSpaces, - ); + const rawDiff = diffPostgresDatabaseSchema({ + contract: options.contract, + actualSchema: options.schema, + strict: + options.policy.allowedOperationClasses.includes('widening') || + options.policy.allowedOperationClasses.includes('destructive'), + typeMetadataRegistry: new Map(), + frameworkComponents: options.frameworkComponents, + }); + const databaseDiff = options.keepDiffIssue ? rawDiff.filter(options.keepDiffIssue) : rawDiff; const schemaIssues = this.collectSchemaIssues(options, databaseDiff.issues); const codecHooks = extractCodecControlHooks(options.frameworkComponents); const storageTypes = options.contract.storage.types ?? {}; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 55870560ac..b9d7360139 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -9,7 +9,6 @@ import { extractCodecControlHooks, planFieldEventOperations, plannerFailure, - scopePlanDiffToSpace, } from '@prisma-next/family-sql/control'; import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; @@ -182,16 +181,16 @@ export class SqliteMigrationPlanner private collectSchemaIssues(options: SqlMigrationPlannerPlanOptions): readonly SchemaIssue[] { const allowed = options.policy.allowedOperationClasses; const strict = allowed.includes('widening') || allowed.includes('destructive'); - const diff = scopePlanDiffToSpace( - diffSqliteDatabaseSchema({ - contract: options.contract, - actualSchema: options.schema, - strict, - typeMetadataRegistry: new Map(), - frameworkComponents: options.frameworkComponents, - }), - options.entitiesOwnedByOtherSpaces, - ); + const rawDiff = diffSqliteDatabaseSchema({ + contract: options.contract, + actualSchema: options.schema, + strict, + typeMetadataRegistry: new Map(), + frameworkComponents: options.frameworkComponents, + }); + // The caller-supplied predicate is applied blindly — any scoping (e.g. + // multi-space ownership) is the orchestration's, never worked out here. + const diff = options.keepDiffIssue ? rawDiff.filter(options.keepDiffIssue) : rawDiff; return diff.issues; } } From 5fc61c8e31b0467eb26dc8ad9fa87c9272fe8099 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 22:11:20 +0200 Subject: [PATCH 48/49] docs(postgres-rls): reconcile plan seam to the keep-predicate; record follow-ons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 landed the plan-side scoping as a blind keep-predicate injected by the orchestration (the differ is target-descriptor-only, the diff recipe is planner-internal, and plan() consumes the schema for existence probes) — update §6 and record the rejected pre-scoped-issues shape in §15. Add the multi-namespace verify-tree false-fail residual to §13. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../schema-node-tree-restructure/design-diff-and-verify.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md index a5e6bf3cd0..bc72700857 100644 --- a/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md +++ b/projects/postgres-rls/slices/schema-node-tree-restructure/design-diff-and-verify.md @@ -58,7 +58,7 @@ The **contract-space aggregate is passive** — it holds the spaces and their co The CLI renders both. This dissolves the "represent an unclaimed element against a contract" problem — it is a second list, never a contract-tree node — and fixes today's bug (an unclaimed element duplicated once per space, N times, across `issues` / `counts` / tree). Because the differ runs **per space**, a space's missing/mismatch issues are inherently its own; the `SchemaDiff` result is never consulted for ownership and never references the contract IR — the aggregate answers ownership for the unclaimed list. -**Plan.** The orchestration hands the planner **a space's issues**; the planner maps issue → op and nothing else. It takes no schema and no "other spaces" input — it works out no ownership, because the orchestration already handed it exactly its issues. The typed node on each `SchemaDiffIssue` is what the planner builds the op from (§4). Verify and plan are symmetric: run the differ per space, then verify checks emptiness while plan builds ops. +**Plan.** The orchestration owns the space-scoping; the planner interprets nothing about spaces. The differ is reachable only on the family target descriptor (not from framework orchestration), the diff recipe is planner-internal (`strict` derives from the planner's policy), and `plan()` consumes the schema for its own existence probes — so the diff itself stays inside `plan()`, and the orchestration injects the scoping as a **blind predicate**: `plan({ …, keepDiffIssue })`, which the planner applies verbatim via `SchemaDiff.filter` before building ops. The predicate — built by the orchestration over the passive aggregate's `declaringSpaces` — drops `extra` findings for elements a sibling space declares and keeps everything else (undeclared extras stay eligible for destructive-policy DROPs; missing/mismatch untouched). The planner requires nothing about spaces. The typed node on each `SchemaDiffIssue` is what the planner builds the op from (§4). **The two-part split lives in the aggregate layer, not the family differ.** `verifySqlSchema` (the single-space family check) is **shared** with the migration planner (reads its `issues`) and the runner's post-apply verify (reads its `ok` = `counts.fail`, a tree walk) — so it stays **unchanged**; it keeps grafting `extra_*` as fail nodes, and the single-space verdict planner/runner depend on is preserved byte-identical. The two-part output is inherently an aggregate concern — "unclaimed by *any* space" only has meaning across spaces — so the aggregate driver (`verifyMigration`, **replacing** `scope-schema-result`) does the split: from each per-space result it strips the `extra_*` nodes/issues to leave **Part 1** (the space's declared nodes), and gathers the stripped extras, deduplicated and filtered by the passive aggregate's ownership query, into **Part 2** (the unclaimed list). No per-space tree post-scoping, no per-family counts recompute. @@ -113,6 +113,7 @@ Behaviour to preserve: `db verify` output (the per-space pass/fail view and unde ## 13. Out of scope (follow-ons) - **Relational port / one issue type:** merge the relational check into the generic node differ so there is a single, node-typed issue type (which also node-types `SchemaIssue`). Until then `SchemaDiff` carries two lists. +- **Multi-namespace verify-tree shaping:** the family verify retains only the first namespace's tree while its counts sum every namespace (`mergeVerifyResults`), so a stripped extra in a non-first namespace can leave a conservative false-*fail* in SQL-strict. Rooted in that pre-existing partial-root limitation; rides with the tree-shaping follow-on. - **PSL-inference tree-walk (TML-2958):** inference still flattens the schema-IR tree into a flat document, fail-loud guarded. - **`annotations.pg` full retirement (TML-2936).** @@ -124,7 +125,8 @@ Assertion helper over the bespoke `throw`; `(storage.types ?? {}) as ResolvedSto - **Coupling the diff result back to the contract IR** (a contract-node handle on the issue). Rejected: the differ works in schema IR; the result couples to the schema IR node, never back to the authoring layer it was derived from. Contract knowledge lives in the aggregate, consulted for ownership — not on the result. - **Post-scoping the verification tree per space (`scope-schema-result`).** Rejected: compose the per-space view from the space's own declared nodes so it is scoped by construction; there is nothing to prune. -- **Passing "other spaces' names" to the planner; the planner working out which issues are its own.** Rejected: the orchestration hands the planner exactly its space's issues; the planner is dumb. +- **Passing "other spaces' names" to the planner; the planner working out which issues are its own.** Rejected: the orchestration owns every ownership semantic and injects a blind keep-predicate; the planner applies it verbatim and interprets nothing. +- **The orchestration running the diff itself and handing the planner pre-scoped issues.** Rejected: the differ lives on the family target descriptor (invisible to framework orchestration without new SPI); the diff recipe is planner-internal; `plan()` consumes the schema for existence probes regardless; and offline `migration plan` callers have no orchestration to pre-diff — the planner would keep a duplicated internal diff. - **The contract-space aggregate performing verbs (diff / verify / classify).** Rejected: the aggregate is passive data that answers ownership; the orchestration owns the verbs. - **Utility methods on the `SchemaDiffer` interface; normalizing the two issue lists to a common `DiffEntry`.** Rejected: utilities live on `SchemaDiff`; `filter` takes the union. - **`root` / `counts` on `SchemaDiff`; the diff returning `VerifyDatabaseSchemaResult`; the diff on the control adapter.** Rejected: the diff returns `SchemaDiff` — schema logic on the target, not verify output and not database I/O. From 9d56e8eba4f7af2d9961627b05e9fc491a7b08ec Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 22:24:18 +0200 Subject: [PATCH 49/49] refactor(migration): contract-space naming across the aggregate, retire "member" and "schema result" Every identifier, parameter, comment, and user-facing string that used "member" for a contract space now says space: AggregateContractSpace (ContractSpaceMember; the bare ContractSpace name is taken by the framework descriptor SPI type), createAggregateContractSpace, verifySchemaForSpace, planSpacePath / SpacePathOutcome, spaceOrder, createPerSpaceVerifier, and the locals/prose across the aggregate, its strategies, the CLI wiring, and tests. "member" survives only where it is not contract-space vocabulary (graph membership, TypeScript class/AST members, enum members). The loose "schema result(s)" term is retired: combineVerifyResults / CombinedVerifyResult (file renamed to combine-verify-results.ts), and prose now says per-space verify results. The schemaResults field stays - it directly mirrors the framework type VerifyDatabaseSchemaResult and is consumed by example apps and guard tests. Stale comments describing pre-collapse behaviour are rewritten: the planner no longer "scopes the resulting diff" itself (it applies the orchestration- built keep-predicate), and SqlSchemaIRNode.nodeKind no longer cites the deleted .ensure guard or projectSchemaToSpace spread. Names and comments only; no behaviour change (planner ops byte-identical). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 2 +- .../3-tooling/cli/src/commands/db-verify.ts | 10 +- .../3-tooling/cli/src/commands/migrate.ts | 85 +++++------ .../cli/src/commands/migration-check.ts | 10 +- .../cli/src/commands/migration-graph.ts | 8 +- .../cli/src/commands/migration-list.ts | 24 ++-- .../cli/src/commands/migration-plan.ts | 6 +- .../cli/src/commands/migration-status.ts | 18 +-- .../cli/src/control-api/operations/db-init.ts | 4 +- .../cli/src/control-api/operations/db-run.ts | 14 +- .../src/control-api/operations/db-verify.ts | 36 ++--- .../cli/src/control-api/operations/migrate.ts | 132 +++++++++--------- .../control-api/operations/run-migration.ts | 4 +- .../3-tooling/cli/src/control-api/types.ts | 14 +- ...a-results.ts => combine-verify-results.ts} | 10 +- .../cli/src/utils/extension-pack-inputs.ts | 2 +- .../cli/src/utils/plan-resolution.ts | 36 ++--- .../cli/test/commands/migrate-show.test.ts | 36 ++--- .../commands/migration-plan-command.test.ts | 20 +-- .../migration-read-commands-parity.test.ts | 12 +- .../cli/test/control-api/apply.test.ts | 10 +- ...s => db-verify.per-space-verifier.test.ts} | 16 +-- ...test.ts => combine-verify-results.test.ts} | 38 ++--- .../cli/test/utils/plan-resolution.test.ts | 108 +++++++------- .../migration/src/aggregate/aggregate.ts | 52 +++---- .../src/aggregate/check-integrity.ts | 43 +++--- .../migration/src/aggregate/loader.ts | 16 +-- .../migration/src/aggregate/planner-types.ts | 25 ++-- .../migration/src/aggregate/planner.ts | 58 ++++---- .../src/aggregate/strategies/graph-walk.ts | 24 ++-- .../src/aggregate/strategies/synth.ts | 10 +- .../migration/src/aggregate/types.ts | 24 ++-- .../migration/src/aggregate/verifier.ts | 46 +++--- .../migration/src/exports/aggregate.ts | 4 +- .../src/gather-disk-contract-space-state.ts | 2 +- .../migration/src/integrity-violation.ts | 2 +- .../migration/src/verify-contract-spaces.ts | 2 +- .../test/aggregate/check-integrity.test.ts | 22 +-- .../test/aggregate/contract-at.test.ts | 56 ++++---- .../migration/test/aggregate/loader.test.ts | 16 +-- .../migration/test/aggregate/planner.test.ts | 42 +++--- .../aggregate/strategies/graph-walk.test.ts | 26 ++-- .../test/aggregate/strategies/synth.test.ts | 14 +- .../migration/test/aggregate/verifier.test.ts | 90 ++++++------ .../test/deletable-node-modules.test.ts | 2 +- .../3-tooling/migration/test/fixtures.ts | 12 +- .../schema-ir/src/ir/sql-schema-ir-node.ts | 6 +- .../supabase/test/classification.e2e.test.ts | 4 +- 48 files changed, 628 insertions(+), 625 deletions(-) rename packages/1-framework/3-tooling/cli/src/utils/{combine-schema-results.ts => combine-verify-results.ts} (94%) rename packages/1-framework/3-tooling/cli/test/control-api/{db-verify.per-member-verifier.test.ts => db-verify.per-space-verifier.test.ts} (83%) rename packages/1-framework/3-tooling/cli/test/utils/{combine-schema-results.test.ts => combine-verify-results.test.ts} (85%) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 808200bc2c..60b1aaa033 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -596,7 +596,7 @@ After plain `migrate`, refresh a stale `db` ref with `db update` (no-op on DB wh `ContractSpaceAggregate` is the single in-memory model of a project's on-disk migration state — the contract spaces, each with its migration packages, its refs, and the graph those packages induce. Every command that reads migration state loads it **once** and queries it; none re-derives that state from disk afterwards. The model and its loader live in `@prisma-next/migration-tools` ([`src/aggregate/`](../../../packages/1-framework/3-tooling/migration/src/aggregate/)). It is the in-memory realisation of the contract-space concept defined in [ADR 212 — Contract spaces](../adrs/ADR%20212%20-%20Contract%20spaces.md). -The aggregate exposes the application member plus the extension members (`ContractSpaceMember`, one per space) and query methods over them — `listSpaces()`, `hasSpace(id)`, `space(id)`, `spaces()`. Each member carries its raw on-disk `packages`, its user-authored `refs`, and a nullable `headRef`, plus two lazy memoised facets: `graph()` (the reconstructed `MigrationGraph`) and `contract()` (the deserialised contract). +The aggregate exposes the application space plus the extension spaces (`AggregateContractSpace`, one per contract space) and query methods over them — `listSpaces()`, `hasSpace(id)`, `space(id)`, `spaces()`. Each space carries its raw on-disk `packages`, its user-authored `refs`, and a nullable `headRef`, plus two lazy memoised facets: `graph()` (the reconstructed `MigrationGraph`) and `contract()` (the deserialised contract). ### Build / query / judge are separate responsibilities diff --git a/packages/1-framework/3-tooling/cli/src/commands/db-verify.ts b/packages/1-framework/3-tooling/cli/src/commands/db-verify.ts index f7648a68c0..75f525d4b5 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/db-verify.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/db-verify.ts @@ -26,7 +26,7 @@ import { errorTargetMismatch, errorUnexpected, } from '../utils/cli-errors'; -import { type CombinedSchemaResult, combineSchemaResults } from '../utils/combine-schema-results'; +import { type CombinedVerifyResult, combineVerifyResults } from '../utils/combine-verify-results'; import { addGlobalOptions, maskConnectionUrl, @@ -100,7 +100,7 @@ function mapVerifyFailure(verifyResult: VerifyDatabaseResult): CliStructuredErro return errorRuntime(verifyResult.summary); } -type DbVerifyFailure = CliStructuredError | CombinedSchemaResult; +type DbVerifyFailure = CliStructuredError | CombinedVerifyResult; function errorInvalidVerifyMode(options: { readonly why: string; @@ -416,7 +416,7 @@ async function executeDbVerifyCommand( }); } - const combined = combineSchemaResults( + const combined = combineVerifyResults( aggregateResult.value.schemaResults, aggregateResult.value.appSpaceId, options.strict ?? false, @@ -458,7 +458,7 @@ async function executeDbSchemaOnlyVerifyCommand( options: DbVerifyOptions, flags: GlobalFlags, ui: TerminalUI, -): Promise> { +): Promise> { const paths = await resolveVerifyPaths(options); renderVerifyHeader(paths, options, 'schema-only', flags, ui); @@ -483,7 +483,7 @@ async function executeDbSchemaOnlyVerifyCommand( if (!aggregateResult.ok) return notOk(aggregateResult.failure); return ok( - combineSchemaResults( + combineVerifyResults( aggregateResult.value.schemaResults, aggregateResult.value.appSpaceId, options.strict ?? false, diff --git a/packages/1-framework/3-tooling/cli/src/commands/migrate.ts b/packages/1-framework/3-tooling/cli/src/commands/migrate.ts index 628fec374d..f49a2e6627 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/migrate.ts @@ -2,7 +2,10 @@ import { readFile } from 'node:fs/promises'; import { loadConfig } from '@prisma-next/config-loader'; import type { Contract } from '@prisma-next/contract/types'; import { createControlStack } from '@prisma-next/framework-components/control'; -import { type ContractSpaceMember, requireHeadRef } from '@prisma-next/migration-tools/aggregate'; +import { + type AggregateContractSpace, + requireHeadRef, +} from '@prisma-next/migration-tools/aggregate'; import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants'; import { errorUnknownInvariant, MigrationToolsError } from '@prisma-next/migration-tools/errors'; import { findLatestMigration, isGraphNode } from '@prisma-next/migration-tools/migration-graph'; @@ -13,7 +16,7 @@ import { ifDefined } from '@prisma-next/utils/defined'; import { notOk, ok, type Result } from '@prisma-next/utils/result'; import { Command } from 'commander'; import { createControlClient } from '../control-api/client'; -import { planMemberPath } from '../control-api/operations/migrate'; +import { planSpacePath } from '../control-api/operations/migrate'; import type { MigrateFailure, MigratePathDecision, @@ -149,7 +152,7 @@ export interface MigrateResult { * Computes the path through the SAME seam as `executeMigrate`: * - `readAllMarkers()` for the from-state (when no `--from` is given), preserving * the full marker including `invariants` (not just `storageHash`). - * - `planMemberPath()` (shared with `executeMigrate`) for per-member path selection, + * - `planSpacePath()` (shared with `executeMigrate`) for per-space path selection, * which feeds `graphWalkStrategy()` with the same target hash, target invariants, * and current marker as the real apply path uses. * @@ -203,7 +206,7 @@ async function executeMigrateShowCommand( const appGraph = aggregate.app.graph(); // Resolve the --to target (defaults to the on-disk contract, same as migrate). - // Also capture the ref's invariants so planMemberPath feeds graphWalkStrategy the + // Also capture the ref's invariants so planSpacePath feeds graphWalkStrategy the // same target invariants that real migrate would use (refInvariants ?? headRef.invariants). let targetHash: string = contractHash; let refInvariants: readonly string[] | undefined; @@ -258,13 +261,13 @@ async function executeMigrateShowCommand( // - Explicit --from: parse it offline (no connection). // - Omitted: read the live DB marker via readAllMarkers() — the same source migrate uses. // - // Full marker records (storageHash + invariants) are preserved so planMemberPath + // Full marker records (storageHash + invariants) are preserved so planSpacePath // can feed graphWalkStrategy the complete currentMarker — exactly as executeMigrate // does via familyInstance.readAllMarkers(). A stripped { storageHash, invariants: [] } // marker would produce a different `required` set and a different (incorrect) path. type LiveMarker = { readonly storageHash: string; readonly invariants: readonly string[] }; const markerBySpace = new Map(); - const allMembers: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; + const allSpaces: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; if (hasExplicitFrom) { // @db with explicit --from requires a connection @@ -301,7 +304,7 @@ async function executeMigrateShowCommand( } else { // Offline hypothetical: the --from ref only carries a hash (no live invariants). // Apply the from-hash marker to the APP space only. Extension spaces are left - // absent from markerBySpace (treated as null / greenfield by planMemberPath), + // absent from markerBySpace (treated as null / greenfield by planSpacePath), // so they plan from their own marker → own head — exactly as executeMigrate does. const fromHash = fromResult.value.hash; const offlineMarker: LiveMarker | null = @@ -334,9 +337,9 @@ async function executeMigrateShowCommand( const allMarkers = await client.readAllMarkers(); // Store the full marker record (storageHash + invariants) per space. // This is the same data executeMigrate uses via familyInstance.readAllMarkers(). - for (const member of allMembers) { - const marker = allMarkers.get(member.spaceId); - markerBySpace.set(member.spaceId, marker ?? null); + for (const space of allSpaces) { + const marker = allMarkers.get(space.spaceId); + markerBySpace.set(space.spaceId, marker ?? null); } } catch (error) { if (CliStructuredError.is(error)) { @@ -355,35 +358,35 @@ async function executeMigrateShowCommand( } } - // Walk the path via planMemberPath — the same helper executeMigrate uses. - // planMemberPath feeds graphWalkStrategy identical inputs (targetHash, targetInvariants, + // Walk the path via planSpacePath — the same helper executeMigrate uses. + // planSpacePath feeds graphWalkStrategy identical inputs (targetHash, targetInvariants, // currentMarker with full invariants), so the preview path is always the path migrate runs. // // Canonical schedule order: extensions alphabetically first, then app — mirroring the // runner's `applyOrder` in operations/migrate.ts so the "Will run, in order:" list // reflects the actual execution sequence (extensions install first, app last). - const canonicalOrderMembers: ReadonlyArray = [ + const canonicalOrderSpaces: ReadonlyArray = [ ...aggregate.extensions, aggregate.app, ]; const orderedMigrations: MigrateShowMigration[] = []; - for (const member of canonicalOrderMembers) { - const isAppMember = member.spaceId === aggregate.app.spaceId; - const headRef = requireHeadRef(member); - const memberTargetHash = isAppMember ? targetHash : headRef.hash; - const memberRefInvariants = isAppMember ? refInvariants : undefined; - const liveMarker = markerBySpace.get(member.spaceId) ?? null; - - const outcome = planMemberPath({ - member, + for (const space of canonicalOrderSpaces) { + const isAppSpace = space.spaceId === aggregate.app.spaceId; + const headRef = requireHeadRef(space); + const spaceTargetHash = isAppSpace ? targetHash : headRef.hash; + const spaceRefInvariants = isAppSpace ? refInvariants : undefined; + const liveMarker = markerBySpace.get(space.spaceId) ?? null; + + const outcome = planSpacePath({ + space, aggregate, - targetHash: memberTargetHash, - refInvariants: memberRefInvariants, + targetHash: spaceTargetHash, + refInvariants: spaceRefInvariants, liveMarker, }); if (outcome.kind === 'at-head') { - // Empty-graph member already at target — nothing to run for this space. + // Empty-graph space already at target — nothing to run for this space. continue; } if (outcome.kind === 'never-planned') { @@ -417,7 +420,7 @@ async function executeMigrateShowCommand( for (const edge of outcome.plan.migrationEdges) { orderedMigrations.push({ - spaceId: member.spaceId, + spaceId: space.spaceId, dirName: edge.dirName, migrationHash: edge.migrationHash, from: edge.from, @@ -445,12 +448,12 @@ async function executeMigrateShowCommand( // before rendering. This ensures the name column, hash column, and ops column // start at the same horizontal offset across every space section AND the // "Will run, in order:" list below. - const memberLayouts = allMembers.map((member) => { - const isApp = member.spaceId === aggregate.app.spaceId; - const memberGraph = member.graph(); - const rowModel = buildMigrationGraphRows(memberGraph, isApp ? { contractHash } : {}); + const spaceLayouts = allSpaces.map((space) => { + const isApp = space.spaceId === aggregate.app.spaceId; + const spaceGraph = space.graph(); + const rowModel = buildMigrationGraphRows(spaceGraph, isApp ? { contractHash } : {}); const edgeAnnotations = new Map(); - for (const edge of memberGraph.migrationByHash.values()) { + for (const edge of spaceGraph.migrationByHash.values()) { edgeAnnotations.set(edge.migrationHash, { pathHighlight: onPathHashes.has(edge.migrationHash) ? 'on-path' : 'off-path', }); @@ -459,17 +462,17 @@ async function executeMigrateShowCommand( // green/continuous; off-path lanes dim. Rows, gutter, and labels all come // from this one grid. const grid = buildGrid(rowModel, {}, highlightFromEdgeAnnotations(edgeAnnotations)); - return { member, isApp, memberGraph, rowModel, grid, edgeAnnotations }; + return { space, isApp, spaceGraph, rowModel, grid, edgeAnnotations }; }); // Global max across all space grids so every section's labels share columns. const globalLabelColumn = - memberLayouts.length > 1 - ? Math.max(...memberLayouts.map(({ grid }) => computeLabelColumn(grid, 'unicode'))) + spaceLayouts.length > 1 + ? Math.max(...spaceLayouts.map(({ grid }) => computeLabelColumn(grid, 'unicode'))) : undefined; const globalMaxDirNameWidthFromLayouts = - memberLayouts.length > 1 - ? Math.max(...memberLayouts.map(({ rowModel }) => computeMaxDirNameWidth(rowModel))) + spaceLayouts.length > 1 + ? Math.max(...spaceLayouts.map(({ rowModel }) => computeMaxDirNameWidth(rowModel))) : undefined; // The run-list name column width must be at least as wide as the global tree dirName // width so that tree sections and the list align at the hash column. @@ -485,10 +488,10 @@ async function executeMigrateShowCommand( runListLeftPad = globalLabelColumn; // Render each space section with globally computed widths. - const showSpaceHeadings = allMembers.length > 1; + const showSpaceHeadings = allSpaces.length > 1; const sections: string[] = []; - for (const { member, isApp, rowModel, grid, edgeAnnotations } of memberLayouts) { - const liveMarker = markerBySpace.get(member.spaceId) ?? null; + for (const { space, isApp, rowModel, grid, edgeAnnotations } of spaceLayouts) { + const liveMarker = markerBySpace.get(space.spaceId) ?? null; const liveMarkerHash = liveMarker?.storageHash ?? EMPTY_CONTRACT_HASH; const tree = renderMigrationGraphCommand({ grid, @@ -496,7 +499,7 @@ async function executeMigrateShowCommand( contractHash, isAppSpace: isApp, ...(needsLiveMarker ? { dbHash: liveMarkerHash } : {}), - refsByHash: listRefsByContractHash(member), + refsByHash: listRefsByContractHash(space), edgeAnnotationsByHash: edgeAnnotations, colorize, glyphMode: 'unicode', @@ -505,7 +508,7 @@ async function executeMigrateShowCommand( }); if (tree.length === 0) continue; if (showSpaceHeadings) { - sections.push(`${member.spaceId}:\n${indentMigrationGraphTreeBlock(tree, ' ')}`); + sections.push(`${space.spaceId}:\n${indentMigrationGraphTreeBlock(tree, ' ')}`); } else { sections.push(tree); } diff --git a/packages/1-framework/3-tooling/cli/src/commands/migration-check.ts b/packages/1-framework/3-tooling/cli/src/commands/migration-check.ts index 46ec62a186..5e9be0481a 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/migration-check.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/migration-check.ts @@ -156,16 +156,16 @@ export async function enumerateCheckSpaces( candidateDirs.filter((name) => !RESERVED_SPACE_SUBDIR_NAMES.has(name)).filter(isValidSpaceId), ); const spaces: CheckSpace[] = []; - for (const member of aggregate.spaces()) { - const spaceId = member.spaceId; + for (const space of aggregate.spaces()) { + const spaceId = space.spaceId; if (!isValidSpaceId(spaceId)) continue; if (!onDiskSpaceIds.has(spaceId)) continue; const migrationsDir = spaceMigrationDirectory(projectMigrationsDir, spaceId); spaces.push({ spaceId, - packages: member.packages, - refs: member.refs, - graph: member.graph(), + packages: space.packages, + refs: space.refs, + graph: space.graph(), migrationsDir, refsDir: spaceRefsDirectory(migrationsDir), }); diff --git a/packages/1-framework/3-tooling/cli/src/commands/migration-graph.ts b/packages/1-framework/3-tooling/cli/src/commands/migration-graph.ts index 50ff6e8d7c..58612f9497 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/migration-graph.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/migration-graph.ts @@ -154,13 +154,13 @@ export async function executeMigrationGraphCommand( const treeSections: MigrationGraphTreeSection[] = []; const spaces: MigrationSpaceGraphEntry[] = []; for (const spaceEntry of scopedSpaces) { - const member = aggregate.space(spaceEntry.space); - if (member === undefined) { + const space = aggregate.space(spaceEntry.space); + if (space === undefined) { continue; } - const graph = member.graph(); + const graph = space.graph(); const isAppSpace = spaceEntry.space === aggregate.app.spaceId; - const refsByHash = listRefsByContractHash(member); + const refsByHash = listRefsByContractHash(space); const tree = spaceEntry.migrations.length === 0 ? '' diff --git a/packages/1-framework/3-tooling/cli/src/commands/migration-list.ts b/packages/1-framework/3-tooling/cli/src/commands/migration-list.ts index 92f38db473..81d681ddf3 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/migration-list.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/migration-list.ts @@ -1,7 +1,7 @@ import { loadConfig } from '@prisma-next/config-loader'; import type { + AggregateContractSpace, ContractSpaceAggregate, - ContractSpaceMember, } from '@prisma-next/migration-tools/aggregate'; import type { MigrationGraph } from '@prisma-next/migration-tools/graph'; import { HEAD_REF_NAME, refsByContractHash } from '@prisma-next/migration-tools/refs'; @@ -59,18 +59,18 @@ function compareDirNamesDescending(a: MigrationListEntry, b: MigrationListEntry) /** * Ref names decorating a space's destination contract hashes. The - * tolerant `member.refs` deliberately omits the structural `head.json`; + * tolerant `space.refs` deliberately omits the structural `head.json`; * for extension spaces the old enumerator surfaced it as a `head` - * decoration on the tip migration, so fold `member.headRef` back in to + * decoration on the tip migration, so fold `space.headRef` back in to * keep that output. The app space synthesises its head, so it carries * no on-disk `head` ref to restore. */ export function listRefsByContractHash( - member: ContractSpaceMember, + space: AggregateContractSpace, ): ReadonlyMap { - const byHash = new Map(refsByContractHash(member.refs)); - if (member.spaceId !== APP_SPACE_ID && member.headRef !== null) { - const hash = member.headRef.hash; + const byHash = new Map(refsByContractHash(space.refs)); + if (space.spaceId !== APP_SPACE_ID && space.headRef !== null) { + const hash = space.headRef.hash; const bucket = byHash.get(hash) ?? []; if (!bucket.includes(HEAD_REF_NAME)) { byHash.set(hash, [...bucket, HEAD_REF_NAME].sort()); @@ -92,7 +92,7 @@ async function orderedOnDiskSpaceIds(projectMigrationsDir: string): Promise ({ name: pkg.dirName, hash: pkg.metadata.migrationHash, diff --git a/packages/1-framework/3-tooling/cli/src/commands/migration-plan.ts b/packages/1-framework/3-tooling/cli/src/commands/migration-plan.ts index 2b734a0082..c9d159330c 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/migration-plan.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/migration-plan.ts @@ -321,11 +321,11 @@ async function executeMigrationPlanCommand( if (!tolerantAggregateResult.ok) { return notOk(tolerantAggregateResult.failure); } - const resolutionMember = tolerantAggregateResult.value.app; + const resolutionSpace = tolerantAggregateResult.value.app; const resolutionResult = await resolveFromForPlan({ optionsFrom: options.from, - member: resolutionMember, + space: resolutionSpace, }); if (!resolutionResult.ok) { @@ -365,7 +365,7 @@ async function executeMigrationPlanCommand( // change. if (options.to !== undefined) { const toResolution = await resolveToForPlan(options.to, { - member: resolutionMember, + space: resolutionSpace, }); if (!toResolution.ok) { return notOk(toResolution.failure); diff --git a/packages/1-framework/3-tooling/cli/src/commands/migration-status.ts b/packages/1-framework/3-tooling/cli/src/commands/migration-status.ts index 01c76c7ba0..e6647906e7 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/migration-status.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/migration-status.ts @@ -1,8 +1,8 @@ import { loadConfig } from '@prisma-next/config-loader'; import type { LedgerEntryRecord } from '@prisma-next/contract/types'; import type { + AggregateContractSpace, ContractMarkerRecordLike, - ContractSpaceMember, } from '@prisma-next/migration-tools/aggregate'; import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants'; import { @@ -130,7 +130,7 @@ function buildStatusMigrations( } function renderSpaceTree(args: { - readonly member: ContractSpaceMember; + readonly space: AggregateContractSpace; readonly liveContractHash: string; readonly migrations: readonly MigrationListEntry[]; readonly markerHash: string | undefined; @@ -142,7 +142,7 @@ function renderSpaceTree(args: { readonly globalMaxEdgeTreePrefixWidth?: number; readonly globalMaxDirNameWidth?: number; }): string { - const graph = args.member.graph(); + const graph = args.space.graph(); if (graph.nodes.size === 0) { return ''; } @@ -150,7 +150,7 @@ function renderSpaceTree(args: { graph, migrations: args.migrations, liveContractHash: args.liveContractHash, - refsByHash: listRefsByContractHash(args.member), + refsByHash: listRefsByContractHash(args.space), statusOverlayByHash: args.statusOverlay, colorize: args.colorize, glyphMode: args.glyphMode, @@ -493,12 +493,12 @@ export async function executeMigrationStatusCommand( globalLayoutInputs.length > 0 ? computeGlobalMaxDirNameWidth(globalLayoutInputs) : undefined; for (const spaceEntry of scopedSpaces) { - const member = aggregate.space(spaceEntry.space); - if (member === undefined) { + const space = aggregate.space(spaceEntry.space); + if (space === undefined) { continue; } - const graph = member.graph(); - const spaceContractHash = member.contract().storage.storageHash; + const graph = space.graph(); + const spaceContractHash = space.contract().storage.storageHash; const targetHash = resolveTarget(spaceContractHash, activeRefHash); if (spaceEntry.space === aggregate.app.spaceId) { headlineTargetHash = targetHash; @@ -547,7 +547,7 @@ export async function executeMigrationStatusCommand( }); const isAppSpace = spaceEntry.space === aggregate.app.spaceId; const tree = renderSpaceTree({ - member, + space, liveContractHash: contractHash, migrations: spaceEntry.migrations, markerHash, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-init.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-init.ts index 2b943ba1ad..7aad7891f6 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-init.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-init.ts @@ -20,7 +20,7 @@ import { executeRun } from './db-run'; * {@link import('@prisma-next/migration-tools/aggregate').loadContractSpaceAggregate} * from the supplied descriptor set + on-disk on-disk artefacts. * 2. The aggregate planner runs with `callerPolicy.ignoreGraphFor` - * locked to the app member — synth strategy for the app, graph-walk + * locked to the app space — synth strategy for the app, graph-walk * for every extension. * 3. The runner's `execute` applies the per-space plans * inside one outer transaction. @@ -53,7 +53,7 @@ export interface ExecuteDbInitOptions>; /** Optional progress callback for observing operation progress */ diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts index b9b7cc5d3b..b11519fbb6 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts @@ -98,9 +98,9 @@ export interface ExecuteRunOptions r.spaceId === aggregate.app.spaceId); if (!appResolution) { throw new Error( - 'Aggregate planner returned no plan for the app member — the planner is supposed to always emit one.', + 'Aggregate planner returned no plan for the app space — the planner is supposed to always emit one.', ); } const appPlan = appResolution.entry.plan; @@ -280,14 +280,14 @@ function aggregatePlannerWarnings( /** * Compare the live `_prisma_marker` rows against the aggregate's - * declared members. Any marker row whose `space` is not a member of + * declared contract spaces. Any marker row whose `space` is not a space of * the aggregate is an "orphan" — typically a marker left behind by * an extension that was removed from `extensionPacks` without first * cleaning up its on-disk migrations / database tables. * * Returns a {@link CliStructuredError} envelope (code `5002`, * `kind: 'orphanMarker'`) for the first orphan it finds, or `null` - * when every marker row maps to a declared member. Mirrors the M2 + * when every marker row maps to a declared contract space. Mirrors the M2 * `runContractSpaceVerifierMarkerCheck` envelope so downstream * tooling (integration tests, JSON consumers) keeps asserting on the * same shape. @@ -296,13 +296,13 @@ function detectOrphanMarkers( aggregate: ContractSpaceAggregate, markerRows: ReadonlyMap, ): CliStructuredError | null { - const memberSpaceIds = new Set([ + const aggregateSpaceIds = new Set([ aggregate.app.spaceId, ...aggregate.extensions.map((m) => m.spaceId), ]); const orphans: string[] = []; for (const [spaceId, row] of markerRows) { - if (row !== null && row !== undefined && !memberSpaceIds.has(spaceId)) { + if (row !== null && row !== undefined && !aggregateSpaceIds.has(spaceId)) { orphans.push(spaceId); } } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts index 6f8f14dd6a..c3134859af 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-verify.ts @@ -7,7 +7,7 @@ import type { VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; import { - type ContractSpaceMember, + type AggregateContractSpace, collectAggregateNamespaces, requireHeadRef, type VerifierOutput, @@ -38,7 +38,7 @@ const SPAN_IDS = { * Loader → verifier pipeline. The loader (sole descriptor-import * boundary) builds a {@link import('@prisma-next/migration-tools/aggregate').ContractSpaceAggregate}; * the aggregate verifier bundles `markerCheck` + per-space `schemaCheck` - * (each member verified against the full schema, then scoped to its space). + * (each contract space verified against the full schema; extras stripped to its own view). * `mode: 'strict' | 'lenient'` maps directly to the user facing `--strict` flag. */ export interface ExecuteDbVerifyOptions { @@ -63,7 +63,7 @@ export interface ExecuteDbVerifyOptions( options: ExecuteDbVerifyOptions, @@ -121,7 +121,7 @@ export async function executeDbVerify( +export function createPerSpaceVerifier( options: ExecuteDbVerifyOptions, ): ( schema: unknown, - member: ContractSpaceMember, + space: AggregateContractSpace, verifyMode: 'strict' | 'lenient', ) => VerifyDatabaseSchemaResult { const { skipSchema, familyInstance, frameworkComponents } = options; - return (schema, member, verifyMode) => { - if (skipSchema) return buildSkippedSchemaResult(member); + return (schema, space, verifyMode) => { + if (skipSchema) return buildSkippedSchemaResult(space); return familyInstance.verifySchema({ - contract: member.contract(), + contract: space.contract(), // `familyInstance` is `ControlFamilyInstance<_, unknown>`, so `verifySchema` // takes its `TSchemaIR` as `unknown` — the introspected schema passes // straight through; the family narrows to its own IR node internally. @@ -257,14 +257,14 @@ function finaliseVerifyResult(args: { return ok({ schemaResults: verifyResult.value.schemaCheck.perSpace, unclaimed: verifyResult.value.schemaCheck.unclaimed, - memberOrder: [aggregate.app.spaceId, ...aggregate.extensions.map((e) => e.spaceId)], + spaceOrder: [aggregate.app.spaceId, ...aggregate.extensions.map((e) => e.spaceId)], appSpaceId: aggregate.app.spaceId, }); } -function buildSkippedSchemaResult(member: ContractSpaceMember): VerifyDatabaseSchemaResult { - const contract = member.contract(); - const headRef = requireHeadRef(member); +function buildSkippedSchemaResult(space: AggregateContractSpace): VerifyDatabaseSchemaResult { + const contract = space.contract(); + const headRef = requireHeadRef(space); const profileHash = castAs<{ profileHash?: string }>(contract).profileHash; return { ok: true, @@ -280,7 +280,7 @@ function buildSkippedSchemaResult(member: ContractSpaceMember): VerifyDatabaseSc root: { status: 'pass', kind: 'skipped', - name: member.spaceId, + name: space.spaceId, contractPath: '', code: 'SKIPPED', message: 'Schema verification skipped', diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts index 59d8bbcd68..97ac1f6946 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts @@ -1,5 +1,5 @@ /** - * Backs the `migrate` command. Strategy: graph-walk-all-members, replay-only (no introspect/synth/planner). + * Backs the `migrate` command. Strategy: graph-walk-all-spaces, replay-only (no introspect/synth/planner). */ import type { Contract } from '@prisma-next/contract/types'; @@ -11,10 +11,10 @@ import type { TargetMigrationsCapability, } from '@prisma-next/framework-components/control'; import { + type AggregateContractSpace, buildSynthMigrationEdge, type ContractMarkerRecordLike, type ContractSpaceAggregate, - type ContractSpaceMember, graphWalkStrategy, type PerSpacePlan, requireHeadRef, @@ -62,8 +62,8 @@ export interface ExecuteMigrateOptions>; readonly targetId: TTargetId; /** - * Optional app-space ref override. When provided, the app member's - * graph-walk targets this hash instead of `member.headRef.hash`. + * Optional app-space ref override. When provided, the app space's + * graph-walk targets this hash instead of `space.headRef.hash`. * Extensions are unaffected — they always walk to their own head. */ readonly refHash?: string; @@ -72,7 +72,7 @@ export interface ExecuteMigrateOptions = [aggregate.app, ...aggregate.extensions]; + const allSpaces: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; const perSpacePlans = new Map(); - // Already-at-head empty-graph members (typically extensions whose + // Already-at-head empty-graph spaces (typically extensions whose // head ref is the empty sentinel, or whose live marker already // matches the target). Kept out of the runner schedule so we don't // write spurious markers for greenfield extensions, but merged back - // into the success envelope so every loaded member is represented. + // into the success envelope so every loaded space is represented. const atHeadResolutions = new Map(); - for (const member of allMembers) { - const isAppMember = member.spaceId === aggregate.app.spaceId; - // The aggregate passed the integrity gate, so every member's head ref + for (const space of allSpaces) { + const isAppSpace = space.spaceId === aggregate.app.spaceId; + // The aggregate passed the integrity gate, so every space's head ref // is resolved (the app's is synthesised from the live contract). - const headRef = requireHeadRef(member); - const memberTargetHash = isAppMember && refHash !== undefined ? refHash : headRef.hash; - const memberRefInvariants = isAppMember && refHash !== undefined ? refInvariants : undefined; - const liveMarker = markerRows.get(member.spaceId) ?? null; + const headRef = requireHeadRef(space); + const spaceTargetHash = isAppSpace && refHash !== undefined ? refHash : headRef.hash; + const spaceRefInvariants = isAppSpace && refHash !== undefined ? refInvariants : undefined; + const liveMarker = markerRows.get(space.spaceId) ?? null; - const outcome = planMemberPath({ - member, + const outcome = planSpacePath({ + space, aggregate, - targetHash: memberTargetHash, - refInvariants: memberRefInvariants, + targetHash: spaceTargetHash, + refInvariants: spaceRefInvariants, liveMarker, - ...(isAppMember ? { refName } : {}), + ...(isAppSpace ? { refName } : {}), }); if (outcome.kind === 'at-head') { - // Empty-graph member whose live marker already matches the target. + // Empty-graph space whose live marker already matches the target. // Kept out of the runner schedule so we don't write spurious markers // for greenfield extensions, but merged back into the success envelope - // so every loaded member is represented. - atHeadResolutions.set(member.spaceId, outcome.plan); + // so every loaded space is represented. + atHeadResolutions.set(space.spaceId, outcome.plan); continue; } if (outcome.kind === 'never-planned') { @@ -197,9 +197,9 @@ export async function executeMigrate() }, ); const structuralPath = @@ -220,14 +220,14 @@ export async function executeMigrate m.spaceId), aggregate.app.spaceId]; const applyOrder = canonicalOrder.filter((spaceId) => perSpacePlans.has(spaceId)); // Short-circuit: nothing pending across any space (no runner-bound - // plans). Surfaces every loaded member — including at-head empty- + // plans). Surfaces every loaded space — including at-head empty- // graph extensions — in `perSpace[]` so the result reflects the // full aggregate, not just the spaces the runner would have touched. const totalPlannedOps = sumPlannedOps(applyOrder, perSpacePlans); @@ -285,7 +285,7 @@ export async function executeMigrate perSpacePlans.has(spaceId) || atHeadResolutions.has(spaceId)) @@ -321,7 +321,7 @@ export async function executeMigrate; readonly targetHash: string; readonly refInvariants: readonly string[] | undefined; readonly liveMarker: ContractMarkerRecordLike | null; readonly refName?: string; -}): MemberPathOutcome { - const isAppMember = member.spaceId === aggregate.app.spaceId; - const headRef = requireHeadRef(member); +}): SpacePathOutcome { + const isAppSpace = space.spaceId === aggregate.app.spaceId; + const headRef = requireHeadRef(space); - if (member.graph().nodes.size === 0) { + if (space.graph().nodes.size === 0) { const liveHash = liveMarker?.storageHash; if (targetHash === liveHash || (liveHash === undefined && targetHash === EMPTY_CONTRACT_HASH)) { return { kind: 'at-head', plan: buildAtHeadResolution({ aggregateTargetId: aggregate.targetId, - member, + space, targetHash, liveMarker, }), }; } - return { kind: 'never-planned', spaceId: member.spaceId, targetHash }; + return { kind: 'never-planned', spaceId: space.spaceId, targetHash }; } const targetInvariants = - isAppMember && refInvariants !== undefined ? refInvariants : headRef.invariants; - const targetMember: ContractSpaceMember = + isAppSpace && refInvariants !== undefined ? refInvariants : headRef.invariants; + const targetSpace: AggregateContractSpace = targetHash === headRef.hash && targetInvariants === headRef.invariants - ? member - : { ...member, headRef: { hash: targetHash, invariants: targetInvariants } }; + ? space + : { ...space, headRef: { hash: targetHash, invariants: targetInvariants } }; const walked = graphWalkStrategy({ aggregateTargetId: aggregate.targetId, - member: targetMember, + space: targetSpace, currentMarker: liveMarker, - ...(isAppMember && refName !== undefined ? { refName } : {}), + ...(isAppSpace && refName !== undefined ? { refName } : {}), }); if (walked.kind === 'unreachable') { - return { kind: 'unreachable', spaceId: member.spaceId, liveMarker, targetHash }; + return { kind: 'unreachable', spaceId: space.spaceId, liveMarker, targetHash }; } if (walked.kind === 'unsatisfiable') { const liveHash = liveMarker?.storageHash ?? EMPTY_CONTRACT_HASH; return { kind: 'unsatisfiable', - spaceId: member.spaceId, - isAppMember, + spaceId: space.spaceId, + isAppSpace, missing: walked.missing, targetInvariants, - targetMember, + targetSpace, liveHash, refName, }; @@ -435,29 +435,29 @@ export function planMemberPath({ /** * Build a zero-op {@link PerSpacePlan} for an empty-graph - * member whose live marker already matches the target. Lets the apply - * pipeline thread the member through `perSpacePlans` -> `applyOrder` + * space whose live marker already matches the target. Lets the apply + * pipeline thread the space through `perSpacePlans` -> `applyOrder` * -> the success envelope's `perSpace[]` block so the result reflects * every loaded space, even when there is nothing to execute. */ function buildAtHeadResolution(args: { readonly aggregateTargetId: string; - readonly member: ContractSpaceMember; + readonly space: AggregateContractSpace; readonly targetHash: string; readonly liveMarker: ContractMarkerRecordLike | null; }): PerSpacePlan { - const { aggregateTargetId, member, targetHash, liveMarker } = args; + const { aggregateTargetId, space, targetHash, liveMarker } = args; return { plan: { targetId: aggregateTargetId, - spaceId: member.spaceId, + spaceId: space.spaceId, origin: liveMarker === null ? null : { storageHash: liveMarker.storageHash }, destination: { storageHash: targetHash }, operations: [], providedInvariants: [], }, displayOps: [], - destinationContract: member.contract(), + destinationContract: space.contract(), strategy: 'graph-walk', migrationEdges: [ buildSynthMigrationEdge({ @@ -494,7 +494,7 @@ interface BuildSuccessArgs { } function buildSuccess(args: BuildSuccessArgs): MigrateSuccess { - // The marker hash surfaced at the top level is the **app member's** + // The marker hash surfaced at the top level is the **app space's** // post-migrate marker (the top-level `markerHash` field). // Per-space markers live on `perSpace[].marker.storageHash`. const appResolution = args.orderedResolutions.find( @@ -586,7 +586,7 @@ export function buildPathNotFoundFailure( const fromHash = marker?.storageHash ?? ''; // The app-case phrasing names the user-visible condition (a // contract has been emitted that no on-disk migration reaches) so - // the error reads naturally for the app member. Extension spaces + // the error reads naturally for the app space. Extension spaces // see the same condition expressed against the offending space. const summary = spaceId === 'app' diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/run-migration.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/run-migration.ts index f64e5692c0..ce07734a21 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/run-migration.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/run-migration.ts @@ -48,7 +48,7 @@ export interface RunMigrationInputs; /** @@ -229,7 +229,7 @@ export function buildPerSpaceBreakdown( /** * Materialise the `applyOrder` ordering into resolved per-space - * entries. Throws if the planner output is missing a member listed + * entries. Throws if the planner output is missing a contract space listed * in `applyOrder` — a wiring bug that should never reach runtime. * * Exported so callers building their own success envelopes after a diff --git a/packages/1-framework/3-tooling/cli/src/control-api/types.ts b/packages/1-framework/3-tooling/cli/src/control-api/types.ts index 7868045335..583af5e200 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/types.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/types.ts @@ -557,9 +557,9 @@ export interface MigrateOptions { /** Migrations root directory (`migrations/` under the project). */ readonly migrationsDir: string; /** - * Optional app-space ref override. When provided, the app member's + * Optional app-space ref override. When provided, the app space's * graph-walk targets this hash instead of `contract.storage.storageHash`. - * Extension members always walk to their own `headRef.hash`. + * Extension spaces always walk to their own `headRef.hash`. */ readonly refHash?: string; /** @@ -631,17 +631,17 @@ export interface MigrateRanEntry { /** * Successful migrate result. Carries both the top-level fields - * (`markerHash` is the **app member's** post-migrate marker) and the + * (`markerHash` is the **app space's** post-migrate marker) and the * per-space breakdown (`perSpace` — markers / operations in canonical * schedule order). */ /** - * Path-decision summary for the **app member** post-migrate. Surfaced + * Path-decision summary for the **app space** post-migrate. Surfaced * at the top level (and consumed by the cli-journeys suite, which * inspects `requiredInvariants`/`satisfiedInvariants`/ * `selectedPath` to validate invariant routing). * - * Per-space path decisions for extension members are not surfaced — + * Per-space path decisions for extension spaces are not surfaced — * extensions own their own ref/invariant control. */ export interface MigratePathDecision { @@ -673,7 +673,7 @@ export interface MigrateSuccess { */ readonly perSpace: ReadonlyArray; /** - * Path-decision data for the app member. Present whenever the + * Path-decision data for the app space. Present whenever the * graph-walk strategy ran for the app (i.e. always for the * aggregate-walking migrate path). Absent only for the no-op * "Already up to date" early return when the app has no plan. @@ -864,7 +864,7 @@ export interface ControlClient { * markers, and (unless `skipSchema` is true) per-space schema * verification with pre-projection (closes F23). * - * @returns Result pattern: per-space schema results on success; + * @returns Result pattern: per-space verify results on success; * structured CLI error on marker / loader failure. * @throws If not connected or infrastructure failure */ diff --git a/packages/1-framework/3-tooling/cli/src/utils/combine-schema-results.ts b/packages/1-framework/3-tooling/cli/src/utils/combine-verify-results.ts similarity index 94% rename from packages/1-framework/3-tooling/cli/src/utils/combine-schema-results.ts rename to packages/1-framework/3-tooling/cli/src/utils/combine-verify-results.ts index 2cef3ba1db..9ea527f7dc 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/combine-schema-results.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/combine-verify-results.ts @@ -5,7 +5,7 @@ import type { VerifyDatabaseSchemaResult } from '@prisma-next/framework-componen * standalone unclaimed-elements list (Part 2), reported once. The CLI renders * both; `unclaimed` is never folded into the combined tree or its issues. */ -export interface CombinedSchemaResult { +export interface CombinedVerifyResult { readonly result: VerifyDatabaseSchemaResult; readonly unclaimed: readonly string[]; } @@ -34,15 +34,17 @@ export interface CombinedSchemaResult { * intact and the envelope internally consistent (`ok: false` ↔ failure * summary). */ -export function combineSchemaResults( +export function combineVerifyResults( perSpace: ReadonlyMap, appSpaceId: string, strict: boolean, unclaimed: readonly string[], -): CombinedSchemaResult { +): CombinedVerifyResult { const appResult = perSpace.get(appSpaceId) ?? perSpace.values().next().value; if (appResult === undefined) { - throw new Error('Aggregate verifier returned no schema results — this is a wiring bug.'); + throw new Error( + 'Aggregate verifier returned no per-space verify results — this is a wiring bug.', + ); } let okAll = true; diff --git a/packages/1-framework/3-tooling/cli/src/utils/extension-pack-inputs.ts b/packages/1-framework/3-tooling/cli/src/utils/extension-pack-inputs.ts index c355d96180..b47c774add 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/extension-pack-inputs.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/extension-pack-inputs.ts @@ -111,7 +111,7 @@ export function toExtensionInputs( * {@link import('./contract-space-aggregate-loader').buildContractSpaceAggregate}. * * Codec-only extensions (no `contractSpace` declaration) are filtered - * out: they are not contract-space members, so the aggregate loader + * out: they contribute no contract space, so the aggregate loader * has nothing to do with them. Filtering happens at this descriptor- * import boundary so the loader stays oblivious to that distinction — * every entry it sees expects an on-disk `migrations//` directory. diff --git a/packages/1-framework/3-tooling/cli/src/utils/plan-resolution.ts b/packages/1-framework/3-tooling/cli/src/utils/plan-resolution.ts index 69a9952346..37cc83100a 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/plan-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/plan-resolution.ts @@ -1,5 +1,5 @@ import type { Contract } from '@prisma-next/contract/types'; -import type { ContractSpaceMember } from '@prisma-next/migration-tools/aggregate'; +import type { AggregateContractSpace } from '@prisma-next/migration-tools/aggregate'; import { MigrationToolsError } from '@prisma-next/migration-tools/errors'; import type { MigrationGraph } from '@prisma-next/migration-tools/graph'; import { @@ -45,11 +45,11 @@ export type FromResolution = export interface ResolveFromForPlanInput { readonly optionsFrom?: string | undefined; - readonly member: ContractSpaceMember; + readonly space: AggregateContractSpace; } -function graphIsEmpty(member: ContractSpaceMember): boolean { - return member.packages.length === 0; +function graphIsEmpty(space: AggregateContractSpace): boolean { + return space.packages.length === 0; } function getReachableRefs( @@ -98,14 +98,14 @@ type RefContractResolution = async function resolveContractRef( parsed: ContractRef, - member: ContractSpaceMember, + space: AggregateContractSpace, options?: { readonly explicitLabel?: string; readonly artifactRole?: 'from' | 'to' }, ): Promise> { const { hash, provenance } = parsed; const refName = provenance.kind === 'ref' ? provenance.refName : undefined; try { - const at = await member.contractAt(hash, refName !== undefined ? { refName } : undefined); + const at = await space.contractAt(hash, refName !== undefined ? { refName } : undefined); if (at.provenance === 'snapshot') { return ok({ @@ -139,7 +139,7 @@ async function resolveFromPolicy( refs: Refs, explicitFromLabel?: string, ): Promise> { - const resolution = await resolveContractRef(parsed, input.member, { + const resolution = await resolveContractRef(parsed, input.space, { ...(explicitFromLabel !== undefined ? { explicitLabel: explicitFromLabel } : {}), artifactRole: 'from', }); @@ -157,7 +157,7 @@ async function resolveFromPolicy( } const { hash, contract, contractJson, contractDts } = resolution.value; - if (graphIsEmpty(input.member)) { + if (graphIsEmpty(input.space)) { return ok({ kind: 'auto-baseline', fromHash: hash, @@ -167,7 +167,7 @@ async function resolveFromPolicy( }); } - const graph = input.member.graph(); + const graph = input.space.graph(); const graphTip = findLatestMigration(graph)?.to ?? null; try { assertFromIsGraphNode(hash, graph, refs, graphTip); @@ -189,9 +189,9 @@ async function resolveFromPolicy( export async function resolveFromForPlan( input: ResolveFromForPlanInput, ): Promise> { - const { optionsFrom, member } = input; - const graph = member.graph(); - const refs = member.refs; + const { optionsFrom, space } = input; + const graph = space.graph(); + const refs = space.refs; if (optionsFrom === undefined) { const dbRef = refs['db']; @@ -208,7 +208,7 @@ export async function resolveFromForPlan( const refResult = parseContractRef(optionsFrom, { graph, refs }); if (!refResult.ok) { if (looksLikeFullHash(optionsFrom)) { - const empty = graphIsEmpty(member); + const empty = graphIsEmpty(space); const graphTip = findLatestMigration(graph)?.to ?? null; if (empty) { return notOk(errorSnapshotMissing(optionsFrom, { viaRef: false })); @@ -222,7 +222,7 @@ export async function resolveFromForPlan( } export interface ResolveToForPlanInput { - readonly member: ContractSpaceMember; + readonly space: AggregateContractSpace; } export interface ResolvedContractRef { @@ -236,16 +236,16 @@ export async function resolveToForPlan( optionsTo: string, input: ResolveToForPlanInput, ): Promise> { - const { member } = input; - const graph = member.graph(); - const refs = member.refs; + const { space } = input; + const graph = space.graph(); + const refs = space.refs; const refResult = parseContractRef(optionsTo, { graph, refs }); if (!refResult.ok) { return notOk(mapRefResolutionError(refResult.failure)); } - const resolution = await resolveContractRef(refResult.value, member, { + const resolution = await resolveContractRef(refResult.value, space, { explicitLabel: optionsTo, artifactRole: 'to', }); diff --git a/packages/1-framework/3-tooling/cli/test/commands/migrate-show.test.ts b/packages/1-framework/3-tooling/cli/test/commands/migrate-show.test.ts index a9cb01cb2b..610618ecd8 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/migrate-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/commands/migrate-show.test.ts @@ -112,14 +112,14 @@ async function buildFixture(): Promise<{ cwd: string; appDir: string }> { /** * Build a fixture where the --to ref carries invariants. Used to verify that - * planMemberPath feeds graphWalkStrategy the ref's invariants as - * member.headRef.invariants, not the contract-derived head ref's empty invariants. + * planSpacePath feeds graphWalkStrategy the ref's invariants as + * space.headRef.invariants, not the contract-derived head ref's empty invariants. * * Graph: EMPTY → C1 → C2 (linear, both migrations provide no invariants). * Named ref `prod` = { hash: C2, invariants: ['inv-a'] }. * - * With the old bug: member.headRef.invariants was always [], so required = []. - * With the fix: member.headRef.invariants is ['inv-a'], so required = ['inv-a'] \ markerInvariants. + * With the old bug: space.headRef.invariants was always [], so required = []. + * With the fix: space.headRef.invariants is ['inv-a'], so required = ['inv-a'] \ markerInvariants. * The graphWalkStrategy call argument differs between the two — this test detects it. */ async function buildInvariantFixture(): Promise<{ cwd: string; appDir: string }> { @@ -229,11 +229,11 @@ describe('migrate --show (read-only + faithfulness)', () => { expect(mocks.graphWalkStrategy).toHaveBeenCalled(); const [firstCall] = mocks.graphWalkStrategy.mock.calls; expect(firstCall).toBeDefined(); - const callArg = firstCall![0] as { currentMarker: unknown; member: { headRef: unknown } }; - // From sha256:empty — marker should be null (planMemberPath treats EMPTY as no-marker). + const callArg = firstCall![0] as { currentMarker: unknown; space: { headRef: unknown } }; + // From sha256:empty — marker should be null (planSpacePath treats EMPTY as no-marker). expect(callArg.currentMarker).toBeNull(); - // App member head ref invariants default to [] (synthesised from contract). - expect((callArg.member.headRef as { invariants: unknown }).invariants).toEqual([]); + // App space head ref invariants default to [] (synthesised from contract). + expect((callArg.space.headRef as { invariants: unknown }).invariants).toEqual([]); }); it('faithfulness: --to ref invariants are passed to graphWalkStrategy (not silently dropped)', async () => { @@ -241,8 +241,8 @@ describe('migrate --show (read-only + faithfulness)', () => { // --to ref's invariants and always using headRef.invariants = [] instead. // // Fixture: EMPTY → C1 (provides 'inv-a') → C2; named ref 'prod' = { hash: C2, invariants: ['inv-a'] }. - // With the old bug: member.headRef.invariants = [] (invariants dropped). - // With the fix: member.headRef.invariants = ['inv-a'] (ref invariants propagated). + // With the old bug: space.headRef.invariants = [] (invariants dropped). + // With the fix: space.headRef.invariants = ['inv-a'] (ref invariants propagated). // // On a graph where all paths from EMPTY satisfy 'inv-a' (EMPTY→C1 provides it), the // walk still succeeds; what changes is the required set fed to findPathWithDecision. @@ -271,12 +271,12 @@ describe('migrate --show (read-only + faithfulness)', () => { expect(mocks.graphWalkStrategy).toHaveBeenCalled(); const [firstCall] = mocks.graphWalkStrategy.mock.calls; expect(firstCall).toBeDefined(); - const callArg = firstCall![0] as { member: { headRef: { hash: string; invariants: unknown } } }; - // The ref 'prod' has invariants: ['inv-a']. planMemberPath must propagate these - // as member.headRef.invariants. The old bug set headRef.invariants = [] here. - expect(callArg.member.headRef.invariants).toEqual(['inv-a']); + const callArg = firstCall![0] as { space: { headRef: { hash: string; invariants: unknown } } }; + // The ref 'prod' has invariants: ['inv-a']. planSpacePath must propagate these + // as space.headRef.invariants. The old bug set headRef.invariants = [] here. + expect(callArg.space.headRef.invariants).toEqual(['inv-a']); // And the target hash must be C2 (the ref's hash). - expect(callArg.member.headRef.hash).toBe(C2); + expect(callArg.space.headRef.hash).toBe(C2); }); it('prints the ordered list of migrations that will run', async () => { @@ -576,11 +576,11 @@ describe('migrate --show (read-only + faithfulness)', () => { it('--from/--to app-space markers do not leak into extension spaces (BUG 1)', async () => { // Repro: with --from --to , extension spaces must NOT receive the - // app --from hash as their marker. If they did, planMemberPath would try to walk + // app --from hash as their marker. If they did, planSpacePath would try to walk // extension-graph from that app hash (which doesn't exist there) and return 'unreachable', // producing "No migration path from sha256:76c1bd5 to sha256:... in space 'pgvector'". // - // Expected behaviour: extension members ignore --from and plan from their own live marker + // Expected behaviour: extension spaces ignore --from and plan from their own live marker // (null / greenfield in offline mode) → their own head — exactly as executeMigrate does. // The extension migration (EMPTY → EXT_C1) must appear in the planned migrations list, // confirming it was planned from greenfield and NOT from the app's --from hash. @@ -608,7 +608,7 @@ describe('migrate --show (read-only + faithfulness)', () => { }); // Extension space (pgvector): EMPTY → EXT_C1. Its own standalone graph; head = EXT_C1. - // The extension marker is absent (offline mode), so planMemberPath should treat it as + // The extension marker is absent (offline mode), so planSpacePath should treat it as // greenfield (null) and plan EMPTY → EXT_C1. const extMigHash = computeMigrationHash( { from: EMPTY, to: EXT_C1, providedInvariants: [], createdAt: '2026-01-01T09:00:00.000Z' }, diff --git a/packages/1-framework/3-tooling/cli/test/commands/migration-plan-command.test.ts b/packages/1-framework/3-tooling/cli/test/commands/migration-plan-command.test.ts index 56396ed5f7..9630213041 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/migration-plan-command.test.ts +++ b/packages/1-framework/3-tooling/cli/test/commands/migration-plan-command.test.ts @@ -2,8 +2,8 @@ import type { Contract } from '@prisma-next/contract/types'; import { errorUnfilledPlaceholder } from '@prisma-next/errors/migration'; import { type ContractAtResult, + createAggregateContractSpace, createContractSpaceAggregate, - createContractSpaceMember, } from '@prisma-next/migration-tools/aggregate'; import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants'; import { MigrationToolsError } from '@prisma-next/migration-tools/errors'; @@ -113,11 +113,11 @@ function sampleSnapshot(storageHash: string) { }; } -function buildResolutionMember( +function buildResolutionSpace( bundles: readonly OnDiskMigrationPackage[], refs: Record = {}, ) { - const member = createContractSpaceMember({ + const space = createAggregateContractSpace({ spaceId: 'app', packages: bundles, refs, @@ -130,7 +130,7 @@ function buildResolutionMember( deserializeContract: (c: unknown) => c as Contract, }); - vi.spyOn(member, 'contractAt').mockImplementation( + vi.spyOn(space, 'contractAt').mockImplementation( async (hash, opts): Promise => { if (opts?.refName !== undefined) { const snap = sampleSnapshot(hash); @@ -186,7 +186,7 @@ function buildResolutionMember( }, ); - return member; + return space; } function setupResolutionAggregate( @@ -197,7 +197,7 @@ function setupResolutionAggregate( ok( createContractSpaceAggregate({ targetId: 'mongo', - app: buildResolutionMember(bundles, refs), + app: buildResolutionSpace(bundles, refs), extensions: [], checkIntegrity: () => [], }), @@ -547,15 +547,15 @@ describe('migration plan command', () => { }, ], }); - // Hand-built aggregate in the new tolerant-member shape: `contract()` + // Hand-built aggregate in the new tolerant-space shape: `contract()` // and `graph()` are lazy methods, not eager values. `plan` only reads - // `app.spaceId` and `app.contract()` here, so the extension member is + // `app.spaceId` and `app.contract()` here, so the extension space is // present for completeness but its facets are never invoked. mocks.buildContractSpaceAggregate.mockResolvedValueOnce( ok( createContractSpaceAggregate({ targetId: 'mongo', - app: createContractSpaceMember({ + app: createAggregateContractSpace({ spaceId: 'app', packages: [], refs: {}, @@ -565,7 +565,7 @@ describe('migration plan command', () => { deserializeContract: (c: unknown) => c as Contract, }), extensions: [ - createContractSpaceMember({ + createAggregateContractSpace({ spaceId: 'cipherstash', packages: [], refs: {}, diff --git a/packages/1-framework/3-tooling/cli/test/commands/migration-read-commands-parity.test.ts b/packages/1-framework/3-tooling/cli/test/commands/migration-read-commands-parity.test.ts index 78c25aab08..cdd839f3fd 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/migration-read-commands-parity.test.ts +++ b/packages/1-framework/3-tooling/cli/test/commands/migration-read-commands-parity.test.ts @@ -319,17 +319,17 @@ describe('migration read commands pretty parity', () => { graph: aggregate.app.graph(), spaces: [], treeSections: listResult.value.spaces.map((spaceEntry) => { - const member = aggregate.space(spaceEntry.space)!; + const space = aggregate.space(spaceEntry.space)!; const tree = spaceEntry.migrations.length === 0 ? '' : renderMigrationGraphSpaceTree({ - graph: member.graph(), + graph: space.graph(), migrations: spaceEntry.migrations, liveContractHash: LIVE_CONTRACT_HASH, glyphMode: 'unicode', colorize: true, - refsByHash: listRefsByContractHash(member), + refsByHash: listRefsByContractHash(space), ...globalWidths, }); return { @@ -379,8 +379,8 @@ describe('migration read commands pretty parity', () => { showSpaceHeadings, ); const treeSections = listResult.value.spaces.map((spaceEntry) => { - const member = aggregate.space(spaceEntry.space)!; - const graph = member.graph(); + const space = aggregate.space(spaceEntry.space)!; + const graph = space.graph(); const targetHash = spaceEntry.space === 'postgis' ? HASH_POSTGIS @@ -403,7 +403,7 @@ describe('migration read commands pretty parity', () => { liveContractHash: LIVE_CONTRACT_HASH, glyphMode: 'unicode', colorize: true, - refsByHash: listRefsByContractHash(member), + refsByHash: listRefsByContractHash(space), statusOverlayByHash: statusOverlay, ...globalWidths, }); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/apply.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/apply.test.ts index 8c8a7a1aac..6ed93c1d12 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/apply.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/apply.test.ts @@ -6,8 +6,8 @@ import type { TargetMigrationsCapability, } from '@prisma-next/framework-components/control'; import type { + AggregateContractSpace, ContractSpaceAggregate, - ContractSpaceMember, PerSpacePlan, } from '@prisma-next/migration-tools/aggregate'; import { @@ -21,10 +21,10 @@ import type { ControlProgressEvent } from '../../src/control-api/types'; const APP_HASH = `sha256:${'a'.repeat(64)}`; -function makeAppMember(): ContractSpaceMember { +function makeAppSpace(): AggregateContractSpace { const contract = { storage: { storageHash: APP_HASH, tables: {}, namespaces: {} }, - } as unknown as ReturnType; + } as unknown as ReturnType; return { spaceId: 'app', packages: [], @@ -44,7 +44,7 @@ function makeAppMember(): ContractSpaceMember { function makeAggregate(): ContractSpaceAggregate { return createContractSpaceAggregate({ targetId: 'postgres', - app: makeAppMember(), + app: makeAppSpace(), extensions: [], checkIntegrity: () => [], }); @@ -62,7 +62,7 @@ function makePerSpacePlan(): PerSpacePlan { return { plan, displayOps: [], - destinationContract: makeAppMember().contract, + destinationContract: makeAppSpace().contract, strategy: 'graph-walk', migrationEdges: [ buildSynthMigrationEdge({ diff --git a/packages/1-framework/3-tooling/cli/test/control-api/db-verify.per-member-verifier.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/db-verify.per-space-verifier.test.ts similarity index 83% rename from packages/1-framework/3-tooling/cli/test/control-api/db-verify.per-member-verifier.test.ts rename to packages/1-framework/3-tooling/cli/test/control-api/db-verify.per-space-verifier.test.ts index 156a32e238..1e1b2d6141 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/db-verify.per-member-verifier.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/db-verify.per-space-verifier.test.ts @@ -1,15 +1,15 @@ import type { Contract } from '@prisma-next/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; -import { createContractSpaceMember } from '@prisma-next/migration-tools/aggregate'; +import { createAggregateContractSpace } from '@prisma-next/migration-tools/aggregate'; import { createSqlContract } from '@prisma-next/test-utils'; import { blindCast } from '@prisma-next/utils/casts'; import { describe, expect, it, vi } from 'vitest'; import { - createPerMemberVerifier, + createPerSpaceVerifier, type ExecuteDbVerifyOptions, } from '../../src/control-api/operations/db-verify'; -describe('createPerMemberVerifier', () => { +describe('createPerSpaceVerifier', () => { it('passes the resolved contract value to verifySchema, not the contract() thunk', () => { const contract = createSqlContract({ target: 'postgres', @@ -22,7 +22,7 @@ describe('createPerMemberVerifier', () => { }, }, }); - const member = createContractSpaceMember({ + const space = createAggregateContractSpace({ spaceId: 'app', packages: [], refs: {}, @@ -55,7 +55,7 @@ describe('createPerMemberVerifier', () => { timings: { total: 0 }, }); - const verifier = createPerMemberVerifier( + const verifier = createPerSpaceVerifier( blindCast, 'minimal verifySchema seam'>({ skipSchema: false, familyInstance: { verifySchema }, @@ -63,13 +63,13 @@ describe('createPerMemberVerifier', () => { }), ); - verifier({}, member, 'strict'); + verifier({}, space, 'strict'); expect(verifySchema).toHaveBeenCalledOnce(); const passedContract = verifySchema.mock.calls[0]![0].contract as Contract; expect(typeof passedContract).toBe('object'); expect(passedContract).toBe(contract); - expect(typeof (member as { contract: unknown }).contract).toBe('function'); - expect(passedContract).not.toBe(member.contract); + expect(typeof (space as { contract: unknown }).contract).toBe('function'); + expect(passedContract).not.toBe(space.contract); }); }); diff --git a/packages/1-framework/3-tooling/cli/test/utils/combine-schema-results.test.ts b/packages/1-framework/3-tooling/cli/test/utils/combine-verify-results.test.ts similarity index 85% rename from packages/1-framework/3-tooling/cli/test/utils/combine-schema-results.test.ts rename to packages/1-framework/3-tooling/cli/test/utils/combine-verify-results.test.ts index 7b8aa491e3..e2d6946b31 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/combine-schema-results.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/combine-verify-results.test.ts @@ -4,7 +4,7 @@ import type { VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; import { describe, expect, it } from 'vitest'; -import { combineSchemaResults } from '../../src/utils/combine-schema-results'; +import { combineVerifyResults } from '../../src/utils/combine-verify-results'; function makeResult(overrides: { spaceId: string; @@ -44,8 +44,8 @@ function makeResult(overrides: { return result; } -describe('combineSchemaResults', () => { - it('preserves the per-family summary when every member passes', () => { +describe('combineVerifyResults', () => { + it('preserves the per-family summary when every space passes', () => { const perSpace = new Map([ [ 'app', @@ -54,7 +54,7 @@ describe('combineSchemaResults', () => { ['cipher', makeResult({ spaceId: 'cipher', ok: true, summary: 'Schema matches contract' })], ]); - const combined = combineSchemaResults(perSpace, 'app', false, []); + const combined = combineVerifyResults(perSpace, 'app', false, []); expect(combined.result).toMatchObject({ ok: true, @@ -63,7 +63,7 @@ describe('combineSchemaResults', () => { expect(combined.unclaimed).toEqual([]); }); - it('preserves the per-family failure summary when every member fails', () => { + it('preserves the per-family failure summary when every space fails', () => { const perSpace = new Map([ [ 'app', @@ -75,7 +75,7 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', false, []); + const combined = combineVerifyResults(perSpace, 'app', false, []); expect(combined.result).toMatchObject({ ok: false, @@ -83,7 +83,7 @@ describe('combineSchemaResults', () => { }); }); - it('falls back to the failing member summary when the app passes but an extension fails', () => { + it('falls back to the failing space summary when the app passes but an extension fails', () => { const perSpace = new Map([ [ 'app', @@ -100,7 +100,7 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', false, []); + const combined = combineVerifyResults(perSpace, 'app', false, []); expect(combined.result).toMatchObject({ ok: false, @@ -110,7 +110,7 @@ describe('combineSchemaResults', () => { }); }); - it('returns a non-`ok` envelope when any member fails, even when the app passes', () => { + it('returns a non-`ok` envelope when any space fails, even when the app passes', () => { const perSpace = new Map([ ['app', makeResult({ spaceId: 'app', ok: true, summary: 'Schema matches contract' })], [ @@ -124,7 +124,7 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', true, []); + const combined = combineVerifyResults(perSpace, 'app', true, []); expect(combined.result.ok).toBe(false); expect(combined.result.summary).not.toContain('matches contract'); @@ -141,7 +141,7 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', true, ['legacy_events']); + const combined = combineVerifyResults(perSpace, 'app', true, ['legacy_events']); expect(combined.result.ok).toBe(false); expect(combined.result.summary).toContain('1 unclaimed element'); @@ -157,7 +157,7 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', false, ['legacy_events', 'old_audit']); + const combined = combineVerifyResults(perSpace, 'app', false, ['legacy_events', 'old_audit']); expect(combined.result.ok).toBe(true); expect(combined.result.summary).toBe('Database schema satisfies contract'); @@ -166,7 +166,7 @@ describe('combineSchemaResults', () => { it('throws a wiring-bug error when the per-space map is empty', () => { const empty = new Map(); - expect(() => combineSchemaResults(empty, 'app', false, [])).toThrow(/wiring bug/); + expect(() => combineVerifyResults(empty, 'app', false, [])).toThrow(/wiring bug/); }); it('falls back to the first iterator value when the app id is absent from the per-space map', () => { @@ -174,7 +174,7 @@ describe('combineSchemaResults', () => { ['cipher', makeResult({ spaceId: 'cipher', ok: true, summary: 'Schema matches contract' })], ]); - const combined = combineSchemaResults(perSpace, 'app', false, []); + const combined = combineVerifyResults(perSpace, 'app', false, []); expect(combined.result).toMatchObject({ ok: true, @@ -183,7 +183,7 @@ describe('combineSchemaResults', () => { }); }); - it('keeps the first failure summary when multiple members fail', () => { + it('keeps the first failure summary when multiple spaces fail', () => { const perSpace = new Map([ [ 'app', @@ -196,7 +196,7 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', false, []); + const combined = combineVerifyResults(perSpace, 'app', false, []); expect(combined.result).toMatchObject({ ok: false, @@ -218,7 +218,7 @@ describe('combineSchemaResults', () => { delete stripped.code; const perSpace = new Map([['app', stripped]]); - const combined = combineSchemaResults(perSpace, 'app', false, []); + const combined = combineVerifyResults(perSpace, 'app', false, []); expect(combined.result).toMatchObject({ ok: false, @@ -226,7 +226,7 @@ describe('combineSchemaResults', () => { }); }); - it('concatenates issues and schemaDiffIssues from all members into the combined result', () => { + it('concatenates issues and schemaDiffIssues from all spaces into the combined result', () => { const appStructuralIssue: SchemaIssue = { kind: 'missing_table', table: 'profiles', @@ -265,7 +265,7 @@ describe('combineSchemaResults', () => { ], ]); - const combined = combineSchemaResults(perSpace, 'app', false, []); + const combined = combineVerifyResults(perSpace, 'app', false, []); expect(combined.result.schema.issues).toEqual([appStructuralIssue]); expect(combined.result.schema.schemaDiffIssues).toEqual([appDiffIssue, extDiffIssue]); diff --git a/packages/1-framework/3-tooling/cli/test/utils/plan-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/utils/plan-resolution.test.ts index e6573dc4ae..37de3bbbba 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/plan-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/plan-resolution.test.ts @@ -1,8 +1,8 @@ import type { Contract } from '@prisma-next/contract/types'; import { CliStructuredError } from '@prisma-next/errors/control'; import { - type ContractSpaceMember, - createContractSpaceMember, + type AggregateContractSpace, + createAggregateContractSpace, } from '@prisma-next/migration-tools/aggregate'; import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants'; import { MigrationToolsError } from '@prisma-next/migration-tools/errors'; @@ -81,12 +81,12 @@ function contractAtResult( }; } -function makeMember( +function makeSpace( packages: readonly OnDiskMigrationPackage[], refs: Refs = {}, contractAtImpl?: ReturnType, ) { - const member = createContractSpaceMember({ + const space = createAggregateContractSpace({ spaceId: 'app', packages, refs, @@ -99,15 +99,15 @@ function makeMember( deserializeContract: (json) => json as Contract, }); if (contractAtImpl) { - vi.spyOn(member, 'contractAt').mockImplementation( - contractAtImpl as ContractSpaceMember['contractAt'], + vi.spyOn(space, 'contractAt').mockImplementation( + contractAtImpl as AggregateContractSpace['contractAt'], ); } - return member; + return space; } function baseInput( - overrides: Partial & Pick, + overrides: Partial & Pick, ): ResolveFromForPlanInput { return { optionsFrom: undefined, @@ -126,8 +126,8 @@ describe('resolveFromForPlan', () => { }); it('returns greenfield when db ref is absent and --from is omitted', async () => { - const member = makeMember([]); - const result = await resolveFromForPlan(baseInput({ member })); + const space = makeSpace([]); + const result = await resolveFromForPlan(baseInput({ space })); expect(result.ok).toBe(true); if (result.ok) { @@ -136,12 +136,12 @@ describe('resolveFromForPlan', () => { }); it('returns auto-baseline when graph is empty and db ref has a paired snapshot', async () => { - const member = makeMember( + const space = makeSpace( [], { db: { hash: HASH_ORPHAN, invariants: [] } }, vi.fn().mockResolvedValue(contractAtResult(HASH_ORPHAN)), ); - const result = await resolveFromForPlan(baseInput({ member })); + const result = await resolveFromForPlan(baseInput({ space })); expect(result.ok).toBe(true); if (result.ok) { @@ -151,16 +151,16 @@ describe('resolveFromForPlan', () => { expect(result.value.contractDts).toContain('Contract'); } } - expect(member.contractAt).toHaveBeenCalledWith(HASH_ORPHAN, { refName: 'db' }); + expect(space.contractAt).toHaveBeenCalledWith(HASH_ORPHAN, { refName: 'db' }); }); it('returns auto-baseline for explicit ref name on an empty graph', async () => { - const member = makeMember( + const space = makeSpace( [], { staging: { hash: HASH_ORPHAN, invariants: [] } }, vi.fn().mockResolvedValue(contractAtResult(HASH_ORPHAN)), ); - const result = await resolveFromForPlan(baseInput({ member, optionsFrom: 'staging' })); + const result = await resolveFromForPlan(baseInput({ space, optionsFrom: 'staging' })); expect(result.ok).toBe(true); if (result.ok) { @@ -170,12 +170,12 @@ describe('resolveFromForPlan', () => { it('returns snapshot for db ref at graph tip with paired snapshot', async () => { const bundles = [makePkg(E, HASH_A, 'm1'), makePkg(HASH_A, HASH_B, 'm2')]; - const member = makeMember( + const space = makeSpace( bundles, { db: { hash: HASH_B, invariants: [] } }, vi.fn().mockResolvedValue(contractAtResult(HASH_B)), ); - const result = await resolveFromForPlan(baseInput({ member })); + const result = await resolveFromForPlan(baseInput({ space })); expect(result.ok).toBe(true); if (result.ok) { @@ -185,12 +185,12 @@ describe('resolveFromForPlan', () => { it('returns snapshot for db ref at a non-tip graph node with paired snapshot', async () => { const bundles = [makePkg(E, HASH_A, 'm1'), makePkg(HASH_A, HASH_B, 'm2')]; - const member = makeMember( + const space = makeSpace( bundles, { db: { hash: HASH_A, invariants: [] } }, vi.fn().mockResolvedValue(contractAtResult(HASH_A)), ); - const result = await resolveFromForPlan(baseInput({ member })); + const result = await resolveFromForPlan(baseInput({ space })); expect(result.ok).toBe(true); if (result.ok) { @@ -200,7 +200,7 @@ describe('resolveFromForPlan', () => { it('refuses forgot-the-flag when db ref hash is not a graph node', async () => { const bundles = [makePkg(E, HASH_A, 'm1'), makePkg(HASH_A, HASH_B, 'm2')]; - const member = makeMember( + const space = makeSpace( bundles, { db: { hash: HASH_ORPHAN, invariants: [] }, @@ -208,7 +208,7 @@ describe('resolveFromForPlan', () => { }, vi.fn().mockResolvedValue(contractAtResult(HASH_ORPHAN)), ); - const result = await resolveFromForPlan(baseInput({ member })); + const result = await resolveFromForPlan(baseInput({ space })); expect(result.ok).toBe(false); if (!result.ok) { @@ -219,12 +219,12 @@ describe('resolveFromForPlan', () => { it('refuses forgot-the-flag for explicit ref name whose hash is not a graph node', async () => { const bundles = [makePkg(E, HASH_A, 'm1')]; - const member = makeMember( + const space = makeSpace( bundles, { staging: { hash: HASH_ORPHAN, invariants: [] } }, vi.fn().mockResolvedValue(contractAtResult(HASH_ORPHAN)), ); - const result = await resolveFromForPlan(baseInput({ member, optionsFrom: 'staging' })); + const result = await resolveFromForPlan(baseInput({ space, optionsFrom: 'staging' })); expect(result.ok).toBe(false); if (!result.ok) { @@ -234,8 +234,8 @@ describe('resolveFromForPlan', () => { it('refuses forgot-the-flag for explicit full hash not in graph on non-empty graph', async () => { const bundles = [makePkg(E, HASH_A, 'm1')]; - const member = makeMember(bundles, { tip: { hash: HASH_A, invariants: [] } }); - const result = await resolveFromForPlan(baseInput({ member, optionsFrom: HASH_ORPHAN })); + const space = makeSpace(bundles, { tip: { hash: HASH_A, invariants: [] } }); + const result = await resolveFromForPlan(baseInput({ space, optionsFrom: HASH_ORPHAN })); expect(result.ok).toBe(false); if (!result.ok) { @@ -245,8 +245,8 @@ describe('resolveFromForPlan', () => { }); it('refuses snapshot-missing for explicit full hash not in graph on empty graph', async () => { - const member = makeMember([]); - const result = await resolveFromForPlan(baseInput({ member, optionsFrom: HASH_ORPHAN })); + const space = makeSpace([]); + const result = await resolveFromForPlan(baseInput({ space, optionsFrom: HASH_ORPHAN })); expect(result.ok).toBe(false); if (!result.ok) { @@ -261,8 +261,8 @@ describe('resolveFromForPlan', () => { .mockResolvedValue( contractAtResult(HASH_A, { provenance: 'graph-node', sourceDir: '/migrations/app/m1' }), ); - const member = makeMember(bundles, {}, contractAt); - const result = await resolveFromForPlan(baseInput({ member, optionsFrom: HASH_A })); + const space = makeSpace(bundles, {}, contractAt); + const result = await resolveFromForPlan(baseInput({ space, optionsFrom: HASH_A })); expect(result.ok).toBe(true); if (result.ok) { @@ -276,7 +276,7 @@ describe('resolveFromForPlan', () => { }); it('refuses snapshot-missing for legacy db ref without snapshot when hash is not a graph node', async () => { - const member = makeMember( + const space = makeSpace( [], { db: { hash: HASH_ORPHAN, invariants: [] } }, vi.fn().mockRejectedValue( @@ -291,7 +291,7 @@ describe('resolveFromForPlan', () => { ), ), ); - const result = await resolveFromForPlan(baseInput({ member })); + const result = await resolveFromForPlan(baseInput({ space })); expect(result.ok).toBe(false); if (!result.ok) { @@ -302,7 +302,7 @@ describe('resolveFromForPlan', () => { it('falls back to graph-node bundle source for legacy db ref without snapshot when hash is in graph', async () => { const bundles = [makePkg(E, HASH_A, 'm1')]; - const member = makeMember( + const space = makeSpace( bundles, { db: { hash: HASH_A, invariants: [] } }, vi @@ -311,7 +311,7 @@ describe('resolveFromForPlan', () => { contractAtResult(HASH_A, { provenance: 'graph-node', sourceDir: '/migrations/app/m1' }), ), ); - const result = await resolveFromForPlan(baseInput({ member })); + const result = await resolveFromForPlan(baseInput({ space })); expect(result.ok).toBe(true); if (result.ok) { @@ -325,7 +325,7 @@ describe('resolveFromForPlan', () => { it('returns graph-node for explicit ref when snapshot is missing but hash is a graph node', async () => { const bundles = [makePkg(E, HASH_A, 'm1')]; - const member = makeMember( + const space = makeSpace( bundles, { staging: { hash: HASH_A, invariants: [] } }, vi @@ -334,7 +334,7 @@ describe('resolveFromForPlan', () => { contractAtResult(HASH_A, { provenance: 'graph-node', sourceDir: '/migrations/app/m1' }), ), ); - const result = await resolveFromForPlan(baseInput({ member, optionsFrom: 'staging' })); + const result = await resolveFromForPlan(baseInput({ space, optionsFrom: 'staging' })); expect(result.ok).toBe(true); if (result.ok) { @@ -344,11 +344,11 @@ describe('resolveFromForPlan', () => { sourceDir: '/migrations/app/m1', }); } - expect(member.contractAt).toHaveBeenCalledWith(HASH_A, { refName: 'staging' }); + expect(space.contractAt).toHaveBeenCalledWith(HASH_A, { refName: 'staging' }); }); it('refuses snapshot-missing for explicit ref name without snapshot when hash is not a graph node', async () => { - const member = makeMember( + const space = makeSpace( [], { staging: { hash: HASH_ORPHAN, invariants: [] } }, vi.fn().mockRejectedValue( @@ -363,7 +363,7 @@ describe('resolveFromForPlan', () => { ), ), ); - const result = await resolveFromForPlan(baseInput({ member, optionsFrom: 'staging' })); + const result = await resolveFromForPlan(baseInput({ space, optionsFrom: 'staging' })); expect(result.ok).toBe(false); if (!result.ok) { @@ -372,7 +372,7 @@ describe('resolveFromForPlan', () => { }); it('surfaces contract validation failure for bad snapshot contract shape', async () => { - const member = makeMember( + const space = makeSpace( [], { db: { hash: HASH_A, invariants: [] } }, vi.fn().mockRejectedValue( @@ -390,7 +390,7 @@ describe('resolveFromForPlan', () => { ), ), ); - const result = await resolveFromForPlan(baseInput({ member })); + const result = await resolveFromForPlan(baseInput({ space })); expect(result.ok).toBe(false); if (!result.ok) { @@ -399,7 +399,7 @@ describe('resolveFromForPlan', () => { }); it('surfaces INVALID_REF_FILE when paired contract.d.ts is missing', async () => { - const member = makeMember( + const space = makeSpace( [], { db: { hash: HASH_A, invariants: [] } }, vi.fn().mockRejectedValue( @@ -409,7 +409,7 @@ describe('resolveFromForPlan', () => { }), ), ); - const result = await resolveFromForPlan(baseInput({ member })); + const result = await resolveFromForPlan(baseInput({ space })); expect(result.ok).toBe(false); if (!result.ok) { @@ -418,13 +418,13 @@ describe('resolveFromForPlan', () => { }); it('treats explicit --from db identically to implicit default', async () => { - const member = makeMember( + const space = makeSpace( [], { db: { hash: HASH_ORPHAN, invariants: [] } }, vi.fn().mockResolvedValue(contractAtResult(HASH_ORPHAN)), ); - const implicit = await resolveFromForPlan(baseInput({ member })); - const explicit = await resolveFromForPlan(baseInput({ member, optionsFrom: 'db' })); + const implicit = await resolveFromForPlan(baseInput({ space })); + const explicit = await resolveFromForPlan(baseInput({ space, optionsFrom: 'db' })); expect(implicit.ok).toBe(true); expect(explicit.ok).toBe(true); @@ -435,7 +435,7 @@ describe('resolveFromForPlan', () => { }); function baseToInput( - overrides: Partial & Pick, + overrides: Partial & Pick, ): ResolveToForPlanInput { return { ...overrides }; } @@ -447,12 +447,12 @@ describe('resolveToForPlan', () => { it('resolves a ref name with a paired snapshot to its materialized contract', async () => { const bundles = [makePkg(E, HASH_A, 'm1'), makePkg(HASH_A, HASH_B, 'm2')]; - const member = makeMember( + const space = makeSpace( bundles, { staging: { hash: HASH_A, invariants: [] } }, vi.fn().mockResolvedValue(contractAtResult(HASH_A)), ); - const result = await resolveToForPlan('staging', baseToInput({ member })); + const result = await resolveToForPlan('staging', baseToInput({ space })); expect(result.ok).toBe(true); if (result.ok) { @@ -469,8 +469,8 @@ describe('resolveToForPlan', () => { .mockResolvedValue( contractAtResult(HASH_A, { provenance: 'graph-node', sourceDir: '/migrations/app/m1' }), ); - const member = makeMember(bundles, {}, contractAt); - const result = await resolveToForPlan(HASH_A, baseToInput({ member })); + const space = makeSpace(bundles, {}, contractAt); + const result = await resolveToForPlan(HASH_A, baseToInput({ space })); expect(result.ok).toBe(true); if (result.ok) { @@ -487,8 +487,8 @@ describe('resolveToForPlan', () => { .mockResolvedValue( contractAtResult(HASH_A, { provenance: 'graph-node', sourceDir: '/migrations/app/m1' }), ); - const member = makeMember(bundles, {}, contractAt); - const result = await resolveToForPlan('m2^', baseToInput({ member })); + const space = makeSpace(bundles, {}, contractAt); + const result = await resolveToForPlan('m2^', baseToInput({ space })); expect(result.ok).toBe(true); if (result.ok) { @@ -499,8 +499,8 @@ describe('resolveToForPlan', () => { it('maps a not-found reference to a structured error', async () => { const bundles = [makePkg(E, HASH_A, 'm1')]; - const member = makeMember(bundles); - const result = await resolveToForPlan('does-not-exist', baseToInput({ member })); + const space = makeSpace(bundles); + const result = await resolveToForPlan('does-not-exist', baseToInput({ space })); expect(result.ok).toBe(false); if (!result.ok) { diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts b/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts index 123db530a0..7e70d7454f 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts @@ -20,10 +20,10 @@ import type { Refs } from '../refs'; import { readRefSnapshot } from '../refs/snapshot'; import type { ContractSpaceHeadRecord } from '../verify-contract-spaces'; import type { + AggregateContractSpace, ContractAtOptions, ContractAtResult, ContractSpaceAggregate, - ContractSpaceMember, } from './types'; function hasErrnoCode(error: unknown, code: string): boolean { @@ -158,24 +158,24 @@ async function resolveGraphNodeContractAt(args: { } /** - * Resolve a member's head ref, asserting it is present. The apply/verify + * Resolve a contract space's head ref, asserting it is present. The apply/verify * engine only runs after `checkIntegrity` has refused on `headRefMissing`, - * so a member reaching the planner / verifier without a head ref is a + * so a space reaching the planner / verifier without a head ref is a * programming error (the integrity gate was skipped), not a user-facing - * state. The app member's head ref is always synthesised, so this only + * state. The app space's head ref is always synthesised, so this only * ever guards an ungated extension space. */ -export function requireHeadRef(member: ContractSpaceMember): ContractSpaceHeadRecord { - if (member.headRef === null) { +export function requireHeadRef(space: AggregateContractSpace): ContractSpaceHeadRecord { + if (space.headRef === null) { throw new Error( - `Contract space "${member.spaceId}" has no head ref; the integrity gate must refuse a missing head ref before planning or verifying.`, + `Contract space "${space.spaceId}" has no head ref; the integrity gate must refuse a missing head ref before planning or verifying.`, ); } - return member.headRef; + return space.headRef; } /** - * Build a {@link ContractSpaceMember} with lazily-memoised `graph()`, + * Build a {@link AggregateContractSpace} with lazily-memoised `graph()`, * `contract()`, and `contractAt()` facets. * * `graph()` reconstructs the migration graph from `packages` on first @@ -187,7 +187,7 @@ export function requireHeadRef(member: ContractSpaceMember): ContractSpaceHeadRe * the same resolution order as plan-time ref resolution: ref snapshot first * (when `opts.refName` is set), else the matching package's `end-contract.*`. */ -export function createContractSpaceMember(args: { +export function createAggregateContractSpace(args: { readonly spaceId: string; readonly packages: readonly OnDiskMigrationPackage[]; readonly refs: Refs; @@ -195,13 +195,13 @@ export function createContractSpaceMember(args: { readonly refsDir: string; readonly resolveContract: () => Contract; readonly deserializeContract: (raw: unknown) => Contract; -}): ContractSpaceMember { +}): AggregateContractSpace { const { spaceId, packages, refs, headRef, refsDir, resolveContract, deserializeContract } = args; let graphMemo: MigrationGraph | undefined; let contractMemo: Contract | undefined; const contractAtMemo = new Map(); - function memberGraph(): MigrationGraph { + function spaceGraph(): MigrationGraph { graphMemo ??= reconstructGraph(packages); return graphMemo; } @@ -211,7 +211,7 @@ export function createContractSpaceMember(args: { packages, refs, headRef, - graph: memberGraph, + graph: spaceGraph, contract() { contractMemo ??= resolveContract(); return contractMemo; @@ -228,7 +228,7 @@ export function createContractSpaceMember(args: { opts, refsDir, packages, - graph: memberGraph(), + graph: spaceGraph(), deserializeContract, }); contractAtMemo.set(key, result); @@ -238,21 +238,21 @@ export function createContractSpaceMember(args: { } /** - * Collect the union of every namespace declared across all members of an + * Collect the union of every namespace declared across all contract spaces of an * aggregate (app + extensions) and return a minimal object with the shape * `{ storage: { namespaces } }` suitable for passing to * `familyInstance.introspect`. * * Callers invoke this after the integrity gate (`buildContractSpaceAggregate` - * with `checkContracts: true`), so every `member.contract()` call is safe — + * with `checkContracts: true`), so every `space.contract()` call is safe — * no try/catch is needed here. */ export function collectAggregateNamespaces(aggregate: ContractSpaceAggregate): { readonly storage: { readonly namespaces: Readonly> }; } { const merged: Record = {}; - for (const member of aggregate.spaces()) { - for (const [key, ns] of Object.entries(member.contract().storage.namespaces)) { + for (const space of aggregate.spaces()) { + for (const [key, ns] of Object.entries(space.contract().storage.namespaces)) { merged[key] = ns; } } @@ -260,7 +260,7 @@ export function collectAggregateNamespaces(aggregate: ContractSpaceAggregate): { } /** - * Assemble a {@link ContractSpaceAggregate} value from its members and a + * Assemble a {@link ContractSpaceAggregate} value from its contract spaces and a * `checkIntegrity` implementation. The query methods (`listSpaces` / * `hasSpace` / `space` / `spaces`) are derived here so every aggregate — * loader-built or test-built — shares one query surface: `app` first, @@ -269,15 +269,15 @@ export function collectAggregateNamespaces(aggregate: ContractSpaceAggregate): { */ export function createContractSpaceAggregate(args: { readonly targetId: string; - readonly app: ContractSpaceMember; - readonly extensions: readonly ContractSpaceMember[]; + readonly app: AggregateContractSpace; + readonly extensions: readonly AggregateContractSpace[]; readonly checkIntegrity: (opts?: IntegrityQueryOptions) => readonly IntegrityViolation[]; }): ContractSpaceAggregate { const { targetId, app, extensions, checkIntegrity } = args; - const ordered: readonly ContractSpaceMember[] = [app, ...extensions]; + const ordered: readonly AggregateContractSpace[] = [app, ...extensions]; const byId = new Map(ordered.map((m) => [m.spaceId, m])); - const spaceDeclares = (member: ContractSpaceMember, entityName: string): boolean => { - for (const coord of elementCoordinates(member.contract().storage)) { + const spaceDeclares = (space: AggregateContractSpace, entityName: string): boolean => { + for (const coord of elementCoordinates(space.contract().storage)) { if (coord.entityName === entityName) return true; } return false; @@ -290,9 +290,9 @@ export function createContractSpaceAggregate(args: { hasSpace: (id) => byId.has(id), space: (id) => byId.get(id), spaces: () => ordered, - declaresEntity: (entityName) => ordered.some((member) => spaceDeclares(member, entityName)), + declaresEntity: (entityName) => ordered.some((space) => spaceDeclares(space, entityName)), declaringSpaces: (entityName) => - ordered.filter((member) => spaceDeclares(member, entityName)).map((m) => m.spaceId), + ordered.filter((space) => spaceDeclares(space, entityName)).map((s) => s.spaceId), checkIntegrity, }; } diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/check-integrity.ts b/packages/1-framework/3-tooling/migration/src/aggregate/check-integrity.ts index e4e6895a96..f7ca30d601 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/check-integrity.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/check-integrity.ts @@ -9,16 +9,16 @@ import type { import type { PackageLoadProblem } from '../io'; import type { OnDiskMigrationPackage } from '../package'; import type { RefLoadProblem } from '../refs'; -import type { ContractSpaceMember } from './types'; +import type { AggregateContractSpace } from './types'; /** * One space's load-time facts that `checkIntegrity` judges: the loaded - * member, the load-time problems `readMigrationsDir` surfaced for it, and + * contract space, the load-time problems `readMigrationsDir` surfaced for it, and * whether it is the app space (the app head ref is synthesised, so the * head-ref checks are skipped for it). */ export interface IntegritySpaceState { - readonly member: ContractSpaceMember; + readonly space: AggregateContractSpace; readonly problems: readonly PackageLoadProblem[]; /** Per-ref problems: a user ref `*.json` that exists but is unparseable. */ readonly refProblems: readonly RefLoadProblem[]; @@ -53,8 +53,8 @@ export function computeIntegrityViolations( ): readonly IntegrityViolation[] { const violations: IntegrityViolation[] = []; - for (const { member, problems, refProblems, headRefProblem, isApp } of input.spaces) { - const { spaceId } = member; + for (const { space, problems, refProblems, headRefProblem, isApp } of input.spaces) { + const { spaceId } = space; for (const problem of problems) { violations.push(loadProblemToViolation(spaceId, problem)); @@ -77,7 +77,7 @@ export function computeIntegrityViolations( }); } - for (const pkg of member.packages) { + for (const pkg of space.packages) { const from = pkg.metadata.from ?? EMPTY_CONTRACT_HASH; const isSelfEdge = from === pkg.metadata.to; const hasDataOp = pkg.ops.some((op) => op.operationClass === 'data'); @@ -86,7 +86,7 @@ export function computeIntegrityViolations( } } - violations.push(...duplicateMigrationHashViolations(spaceId, member.packages)); + violations.push(...duplicateMigrationHashViolations(spaceId, space.packages)); // For non-app spaces: a missing head.json is always an authoring error // (headRefMissing). The graph-reachability check (headRefNotInGraph) only @@ -97,13 +97,10 @@ export function computeIntegrityViolations( // planner emits no DDL for it — the database is treated as already at the // declared state. if (!isApp && headRefProblem === null) { - if (member.headRef === null) { + if (space.headRef === null) { violations.push({ kind: 'headRefMissing', spaceId }); - } else if ( - member.packages.length > 0 && - !headRefPresentInGraph(member, member.headRef.hash) - ) { - violations.push({ kind: 'headRefNotInGraph', spaceId, hash: member.headRef.hash }); + } else if (space.packages.length > 0 && !headRefPresentInGraph(space, space.headRef.hash)) { + violations.push({ kind: 'headRefNotInGraph', spaceId, hash: space.headRef.hash }); } } } @@ -174,8 +171,8 @@ function duplicateMigrationHashViolations( * Whether a space's head-ref hash is present in its reconstructed graph. * An empty graph is reachable only by the empty-contract sentinel. */ -function headRefPresentInGraph(member: ContractSpaceMember, headHash: string): boolean { - const graph = member.graph(); +function headRefPresentInGraph(space: AggregateContractSpace, headHash: string): boolean { + const graph = space.graph(); if (graph.nodes.size === 0) { return headHash === EMPTY_CONTRACT_HASH; } @@ -187,7 +184,7 @@ function layoutViolations( declaredExtensions: readonly DeclaredExtensionEntry[], ): readonly IntegrityViolation[] { const out: IntegrityViolation[] = []; - const extensionSpaceIds = new Set(spaces.filter((s) => !s.isApp).map((s) => s.member.spaceId)); + const extensionSpaceIds = new Set(spaces.filter((s) => !s.isApp).map((s) => s.space.spaceId)); const declaredIds = new Set(declaredExtensions.map((d) => d.id)); for (const id of [...extensionSpaceIds].sort()) { @@ -208,19 +205,19 @@ function contractViolations(input: IntegrityComputationInput): readonly Integrit const elementClaimedBy = new Map(); const elementLabel = new Map(); - for (const { member } of input.spaces) { - let contract: ReturnType; + for (const { space } of input.spaces) { + let contract: ReturnType; try { - contract = member.contract(); + contract = space.contract(); } catch (error) { - out.push({ kind: 'contractUnreadable', spaceId: member.spaceId, detail: detailOf(error) }); + out.push({ kind: 'contractUnreadable', spaceId: space.spaceId, detail: detailOf(error) }); continue; } if (contract.target !== input.targetId) { out.push({ kind: 'targetMismatch', - spaceId: member.spaceId, + spaceId: space.spaceId, expected: input.targetId, actual: contract.target, }); @@ -229,9 +226,9 @@ function contractViolations(input: IntegrityComputationInput): readonly Integrit for (const { namespaceId, entityKind, entityName } of elementCoordinates(contract.storage)) { const key = `${namespaceId}:${entityKind}:${entityName}`; const claimers = elementClaimedBy.get(key); - if (claimers) claimers.push(member.spaceId); + if (claimers) claimers.push(space.spaceId); else { - elementClaimedBy.set(key, [member.spaceId]); + elementClaimedBy.set(key, [space.spaceId]); elementLabel.set(key, `${namespaceId}.${entityName}`); } } diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts b/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts index ffd1f0d4a1..cc2e03f431 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts @@ -12,7 +12,7 @@ import { spaceRefsDirectory, } from '../space-layout'; import { listContractSpaceDirectories } from '../verify-contract-spaces'; -import { createContractSpaceAggregate, createContractSpaceMember } from './aggregate'; +import { createAggregateContractSpace, createContractSpaceAggregate } from './aggregate'; import { computeIntegrityViolations, type IntegritySpaceState } from './check-integrity'; import type { ContractSpaceAggregate } from './types'; @@ -41,7 +41,7 @@ export interface LoadAggregateInput { * Building **never throws on disk content**: a hash- or * invariants-mismatched package is retained, an unparseable package is * omitted, a missing extension head ref leaves `headRef: null`, and an - * unreadable on-disk contract defers its failure to `member.contract()`. + * unreadable on-disk contract defers its failure to `space.contract()`. * Every such problem is judged by {@link ContractSpaceAggregate.checkIntegrity} * rather than aborting the load. The only rejections are catastrophic I/O * (a `migrations/` that exists but is unreadable for reasons other than @@ -65,8 +65,8 @@ export async function loadContractSpaceAggregate( return createContractSpaceAggregate({ targetId, - app: appState.member, - extensions: extensionStates.map((state) => state.member), + app: appState.space, + extensions: extensionStates.map((state) => state.space), checkIntegrity: (opts) => computeIntegrityViolations({ targetId, spaces }, opts), }); } @@ -80,7 +80,7 @@ async function loadAppSpace( const { packages, problems } = await readMigrationsDir(spaceDir); const { refs, problems: refProblems } = await readRefsTolerant(spaceRefsDirectory(spaceDir)); - const member = createContractSpaceMember({ + const space = createAggregateContractSpace({ spaceId: APP_SPACE_ID, packages, refs, @@ -93,7 +93,7 @@ async function loadAppSpace( // The app head ref is synthesised from the live contract, so there is // no on-disk head.json to be missing or corrupt for it. return { - member, + space, problems, refProblems, headRefProblem: null, @@ -131,7 +131,7 @@ async function loadExtensionSpace( const rawContract = await readRawContractDeferred(migrationsDir, spaceId); - const member = createContractSpaceMember({ + const space = createAggregateContractSpace({ spaceId, packages, refs, @@ -141,7 +141,7 @@ async function loadExtensionSpace( deserializeContract, }); - return { member, problems, refProblems, headRefProblem, isApp: false }; + return { space, problems, refProblems, headRefProblem, isApp: false }; } /** diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts b/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts index 97bd2e410e..1f3711837f 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts @@ -18,16 +18,16 @@ import type { ContractSpaceAggregate } from './types'; * Caller-provided policy for {@link planMigration}. Today this carries * just one knob: * - * - `ignoreGraphFor`: `Set`. For listed members, the planner + * - `ignoreGraphFor`: `Set`. For listed contract spaces, the planner * forces the **synth** strategy (synthesise a plan from the contract * IR via `familyInstance.createPlanner(...).plan(...)`) regardless of * whether a graph is available. The CLI's daily-driver `db init` / * `db update` pipelines pass `new Set([aggregate.app.spaceId])` to * keep today's app-space behaviour: the user's authored - * `migrations/` directory is **not** walked for the app member, the - * plan is synthesised on the fly. Extension members are walked. + * `migrations/` directory is **not** walked for the app space, the + * plan is synthesised on the fly. Extension spaces are walked. * - * Listing a member here whose `headRef.invariants` is non-empty is + * Listing a contract space here whose `headRef.invariants` is non-empty is * a `policyConflict` — synth cannot satisfy authored invariants. */ export interface CallerPolicy { @@ -43,8 +43,9 @@ export interface CallerPolicy { * `storageHash` as the graph-walk's `from` node, falling back to * {@link import('../constants').EMPTY_CONTRACT_HASH} when absent. * - `schemaIntrospection`: the family's full live schema IR. Fed into the - * synth strategy in full; the planner scopes the resulting diff to each - * member's own space rather than pruning the schema up front. + * synth strategy in full; the strategy hands the planner a keep-predicate + * that scopes the diff to each contract space's own findings, so no schema + * is pruned up front and the planner holds no ownership logic. * * Callers (CLI commands) gather this via the family's * `readAllMarkers` + `introspect` calls before invoking the planner. @@ -58,7 +59,7 @@ export interface AggregateCurrentDBState { /** * Inputs to {@link planMigration}. * - * The planner is target-agnostic but family-aware: per-member synth + * The planner is target-agnostic but family-aware: per-space synth * delegates to the family's `createPlanner(adapter).plan(...)`, * which is why `adapter`, `migrations` (the * `TargetMigrationsCapability`), and `frameworkComponents` are all @@ -84,7 +85,7 @@ export interface PlannerInput(); - // Iterate in apply order so a per-member error short-circuits the + // Iterate in apply order so a per-space error short-circuits the // walk in the same order the runner would walk inputs. - const orderedMembers: ReadonlyArray = [ + const orderedSpaces: ReadonlyArray = [ ...aggregate.extensions, aggregate.app, ]; - for (const member of orderedMembers) { + for (const space of orderedSpaces) { const declaredByAnotherSpace = (entityName: string): boolean => - aggregate.declaringSpaces(entityName).some((spaceId) => spaceId !== member.spaceId); - const currentMarker = currentDBState.markersBySpaceId.get(member.spaceId) ?? null; - const headRef = requireHeadRef(member); + aggregate.declaringSpaces(entityName).some((spaceId) => spaceId !== space.spaceId); + const currentMarker = currentDBState.markersBySpaceId.get(space.spaceId) ?? null; + const headRef = requireHeadRef(space); - const ignoreGraph = callerPolicy.ignoreGraphFor.has(member.spaceId); + const ignoreGraph = callerPolicy.ignoreGraphFor.has(space.spaceId); const invariantsRequired = headRef.invariants.length > 0; if (ignoreGraph && invariantsRequired) { const conflict: PlannerError = { kind: 'policyConflict', - spaceId: member.spaceId, - detail: `\`callerPolicy.ignoreGraphFor\` requested for space "${member.spaceId}", but the member declares non-empty head-ref invariants (${headRef.invariants.join(', ')}). Synthesising a plan from the contract IR cannot satisfy authored invariants — the graph must be walked. Either remove "${member.spaceId}" from \`ignoreGraphFor\` or amend the on-disk head ref to declare zero invariants.`, + spaceId: space.spaceId, + detail: `\`callerPolicy.ignoreGraphFor\` requested for space "${space.spaceId}", but the contract space declares non-empty head-ref invariants (${headRef.invariants.join(', ')}). Synthesising a plan from the contract IR cannot satisfy authored invariants — the graph must be walked. Either remove "${space.spaceId}" from \`ignoreGraphFor\` or amend the on-disk head ref to declare zero invariants.`, }; return notOk(conflict); } @@ -74,7 +74,7 @@ export async function planMigration 0) { + if (space.graph().nodes.size > 0) { const walked = graphWalkStrategy({ aggregateTargetId: aggregate.targetId, - member, + space, currentMarker, }); if (walked.kind === 'ok') { - perSpace.set(member.spaceId, walked.result); + perSpace.set(space.spaceId, walked.result); continue; } if (walked.kind === 'unreachable') { return notOk({ kind: 'extensionPathUnreachable', - spaceId: member.spaceId, + spaceId: space.spaceId, target: headRef.hash, }); } // unsatisfiable — surface return notOk({ kind: 'extensionPathUnsatisfiable', - spaceId: member.spaceId, + spaceId: space.spaceId, missingInvariants: walked.missing, }); } // Empty graph: synth is the only option, and it can only satisfy - // empty-invariant members. + // empty-invariant contract spaces. if (invariantsRequired) { return notOk({ kind: 'extensionPathUnsatisfiable', - spaceId: member.spaceId, + spaceId: space.spaceId, missingInvariants: [...headRef.invariants].sort(), }); } @@ -133,7 +133,7 @@ export async function planMigration( - member.packages.map((pkg) => [pkg.metadata.migrationHash, pkg]), + space.packages.map((pkg) => [pkg.metadata.migrationHash, pkg]), ); const fromHash = currentMarker?.storageHash ?? EMPTY_CONTRACT_HASH; @@ -85,7 +85,7 @@ export function graphWalkStrategy(input: GraphWalkStrategyInputs): GraphWalkOutc const pkg = packagesByMigrationHash.get(edge.migrationHash); if (!pkg) { throw new Error( - `Migration package missing for edge ${edge.migrationHash} in space "${member.spaceId}". The hydrated migration graph and packagesByMigrationHash map are out of sync — this should be unreachable; report.`, + `Migration package missing for edge ${edge.migrationHash} in space "${space.spaceId}". The hydrated migration graph and packagesByMigrationHash map are out of sync — this should be unreachable; report.`, ); } for (const op of pkg.ops) pathOps.push(op); @@ -101,7 +101,7 @@ export function graphWalkStrategy(input: GraphWalkStrategyInputs): GraphWalkOutc const plan: MigrationPlan = { targetId: aggregateTargetId, - spaceId: member.spaceId, + spaceId: space.spaceId, origin: currentMarker === null ? null : { storageHash: currentMarker.storageHash }, destination: { storageHash: headRef.hash }, operations: pathOps, @@ -113,7 +113,7 @@ export function graphWalkStrategy(input: GraphWalkStrategyInputs): GraphWalkOutc result: { plan, displayOps: pathOps, - destinationContract: member.contract(), + destinationContract: space.contract(), strategy: 'graph-walk', migrationEdges: edgeRefs, pathDecision: outcome.decision, diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts b/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts index 6a579881ad..bbdefea850 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/strategies/synth.ts @@ -13,12 +13,12 @@ import { blindCast } from '@prisma-next/utils/casts'; import type { ContractMarkerRecordLike } from '../marker-types'; import type { PerSpacePlan } from '../planner-types'; import { buildSynthMigrationEdge } from '../synth-migration-edge'; -import type { ContractSpaceMember } from '../types'; +import type { AggregateContractSpace } from '../types'; export interface SynthStrategyInputs { readonly aggregateTargetId: string; readonly currentMarker: ContractMarkerRecordLike | null; - readonly member: ContractSpaceMember; + readonly space: AggregateContractSpace; /** * Ownership query over the passive contract-space aggregate: does a contract * space OTHER than this one declare a storage entity with this bare name? @@ -112,12 +112,12 @@ export async function synthStrategy { const planner = input.migrations.createPlanner(input.adapter); const plannerResult: MigrationPlannerResult = await (planner.plan({ - contract: input.member.contract(), + contract: input.space.contract(), schema: input.schemaIntrospection, policy: input.operationPolicy, fromContract: null, frameworkComponents: input.frameworkComponents, - spaceId: input.member.spaceId, + spaceId: input.space.spaceId, keepDiffIssue: keepIssuesOfThisSpace(input.declaredByAnotherSpace), }) as MaybeAsyncPlannerResult); @@ -156,7 +156,7 @@ export async function synthStrategy 0 ? { warnings: plannerResult.warnings } diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/types.ts b/packages/1-framework/3-tooling/migration/src/aggregate/types.ts index 4b1338bdee..88372dbac8 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/types.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/types.ts @@ -27,10 +27,10 @@ export type ContractAtResult = }; /** - * One contract space — app or extension — as a member of a - * {@link ContractSpaceAggregate}. Every member has the same shape. + * One contract space — app or extension — as the aggregate holds it. + * Every space in a {@link ContractSpaceAggregate} has the same shape. * - * A member is a tolerant snapshot of one space's on-disk state, not a + * A value of this type is a tolerant snapshot of one space's on-disk state, not a * validated value: `packages` is the raw migration-package list as read * from disk (a hash- or invariants-mismatched package is retained here; * a genuinely unparseable one is omitted), and integrity is judged @@ -44,12 +44,12 @@ export type ContractAtResult = * - `headRef`: the system head ref read from * `migrations//refs/head.json`, or `null` when absent * (represented as a `headRefMissing` violation, never fatal). The app - * member's head ref is always synthesised from its live contract's + * space's head ref is always synthesised from its live contract's * storage hash, so it is never `null`. * - `graph()`: the migration graph this space's packages induce — * lazily reconstructed on first call and memoised. Pure structure: a * `from === to` self-edge is represented, not rejected. - * - `contract()`: the deserialized contract for this member — lazily + * - `contract()`: the deserialized contract for this space — lazily * produced on first call and memoised. For the app it is the live * contract the caller supplied; for an extension it is the on-disk * `migrations//contract.json` run through the family's @@ -62,7 +62,7 @@ export type ContractAtResult = * its `end-contract.*`. Lazy per `(hash, refName?)` memoisation; throws * typed {@link MigrationToolsError} values compatible with CLI mappers. */ -export interface ContractSpaceMember { +export interface AggregateContractSpace { readonly spaceId: string; readonly packages: readonly OnDiskMigrationPackage[]; readonly refs: Refs; @@ -75,14 +75,14 @@ export interface ContractSpaceMember { /** * Tolerant, queryable snapshot of a project's on-disk migration state: * the app contract space plus every extension contract space, each a - * {@link ContractSpaceMember}. + * {@link AggregateContractSpace}. * * Produced once per CLI invocation by `loadContractSpaceAggregate`. * Building the aggregate never throws on disk content; every consumer * obtains spaces / packages / refs / graphs from this one value rather * than re-deriving them from disk. * - * - `targetId`: the app contract's target; every member is expected to + * - `targetId`: the app contract's target; every space is expected to * share it (a mismatch surfaces as a `targetMismatch` violation under * `checkContracts`). * - `app` / `extensions`: retained as fields for the existing planner / @@ -103,12 +103,12 @@ export interface ContractSpaceMember { */ export interface ContractSpaceAggregate { readonly targetId: string; - readonly app: ContractSpaceMember; - readonly extensions: readonly ContractSpaceMember[]; + readonly app: AggregateContractSpace; + readonly extensions: readonly AggregateContractSpace[]; listSpaces(): readonly string[]; hasSpace(id: string): boolean; - space(id: string): ContractSpaceMember | undefined; - spaces(): readonly ContractSpaceMember[]; + space(id: string): AggregateContractSpace | undefined; + spaces(): readonly AggregateContractSpace[]; declaresEntity(entityName: string): boolean; declaringSpaces(entityName: string): readonly string[]; checkIntegrity(opts?: IntegrityQueryOptions): readonly IntegrityViolation[]; diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts b/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts index cd7c9c9ea5..5bc59fe4eb 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/verifier.ts @@ -3,7 +3,7 @@ import type { Result } from '@prisma-next/utils/result'; import { notOk, ok } from '@prisma-next/utils/result'; import { requireHeadRef } from './aggregate'; import type { ContractMarkerRecordLike } from './marker-types'; -import type { ContractSpaceAggregate, ContractSpaceMember } from './types'; +import type { AggregateContractSpace, ContractSpaceAggregate } from './types'; import { collectExtraElementNames, stripExtraFindings } from './unclaimed-elements'; /** @@ -24,15 +24,15 @@ export interface VerifierInput { * contract-satisfaction view (extras stripped) and one deduplicated list of * live elements no contract space declares. It touches no storage shape. */ - readonly verifySchemaForMember: ( + readonly verifySchemaForSpace: ( schema: unknown, - member: ContractSpaceMember, + space: AggregateContractSpace, mode: 'strict' | 'lenient', ) => VerifyDatabaseSchemaResult; } /** - * Marker-check result per member. Mirrors the four cases the + * Marker-check result per contract space. Mirrors the four cases the * `verifyContractSpaces` primitive surfaces today, plus an `'absent'` * case for greenfield spaces (no marker row written yet — `db init` * not run). @@ -89,8 +89,8 @@ export type VerifierOutput = Result; * Verify a {@link ContractSpaceAggregate} against the live database * state. Bundles two checks: * - * - `markerCheck` per member: compare the live marker row against the - * member's `headRef.hash` + `headRef.invariants`. Absence is a + * - `markerCheck` per contract space: compare the live marker row against the + * space's `headRef.hash` + `headRef.invariants`. Absence is a * distinct kind, not an error (callers — `db verify` strict vs * `db init` precondition — choose how to interpret it). * - `schemaCheck`: two distinct outputs from the per-space diffs. @@ -103,7 +103,7 @@ export type VerifierOutput = Result; * once for the database. No schema is pruned before verifying. * * `markerCheck.orphanMarkers` lists every marker row whose `space` is - * not a member of the aggregate. `db verify` callers reject orphans; + * not a contract space of the aggregate. `db verify` callers reject orphans; * future tooling may not. * * Pure synchronous function; no I/O. The caller (CLI) gathers @@ -121,21 +121,21 @@ export function verifyMigration(input: VerifierInput): VerifierOutput { } function runVerifyMigration(input: VerifierInput): VerifierOutput { - const { aggregate, markersBySpaceId, schemaIntrospection, mode, verifySchemaForMember } = input; - const allMembers: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; - const memberSpaceIds = new Set(allMembers.map((m) => m.spaceId)); + const { aggregate, markersBySpaceId, schemaIntrospection, mode, verifySchemaForSpace } = input; + const allSpaces: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; + const aggregateSpaceIds = new Set(allSpaces.map((m) => m.spaceId)); - // Marker check per member. + // Marker check per contract space. const markerPerSpace = new Map(); - for (const member of allMembers) { - const marker = markersBySpaceId.get(member.spaceId) ?? null; + for (const space of allSpaces) { + const marker = markersBySpaceId.get(space.spaceId) ?? null; if (marker === null) { - markerPerSpace.set(member.spaceId, { kind: 'absent' }); + markerPerSpace.set(space.spaceId, { kind: 'absent' }); continue; } - const headRef = requireHeadRef(member); + const headRef = requireHeadRef(space); if (marker.storageHash !== headRef.hash) { - markerPerSpace.set(member.spaceId, { + markerPerSpace.set(space.spaceId, { kind: 'hashMismatch', markerHash: marker.storageHash, expected: headRef.hash, @@ -145,20 +145,20 @@ function runVerifyMigration(input: VerifierInput): VerifierOutput { const markerInvariants = new Set(marker.invariants); const missing = headRef.invariants.filter((id) => !markerInvariants.has(id)); if (missing.length > 0) { - markerPerSpace.set(member.spaceId, { + markerPerSpace.set(space.spaceId, { kind: 'missingInvariants', missing: [...missing].sort(), }); continue; } - markerPerSpace.set(member.spaceId, { kind: 'ok' }); + markerPerSpace.set(space.spaceId, { kind: 'ok' }); } // Orphan markers: entries in markersBySpaceId whose spaceId is not a - // member of the aggregate. + // contract space of the aggregate. const orphanMarkers: { spaceId: string; row: ContractMarkerRecordLike }[] = []; for (const [spaceId, row] of markersBySpaceId) { - if (row !== null && !memberSpaceIds.has(spaceId)) { + if (row !== null && !aggregateSpaceIds.has(spaceId)) { orphanMarkers.push({ spaceId, row }); } } @@ -170,9 +170,9 @@ function runVerifyMigration(input: VerifierInput): VerifierOutput { // only when no contract space declares it. const schemaPerSpace = new Map(); const extraNames = new Set(); - for (const member of allMembers) { - const result = verifySchemaForMember(schemaIntrospection, member, mode); - schemaPerSpace.set(member.spaceId, stripExtraFindings(result)); + for (const space of allSpaces) { + const result = verifySchemaForSpace(schemaIntrospection, space, mode); + schemaPerSpace.set(space.spaceId, stripExtraFindings(result)); for (const name of collectExtraElementNames(result)) extraNames.add(name); } const unclaimed = [...extraNames] diff --git a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts index e3d6e4f99e..24252d3c86 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts @@ -1,7 +1,7 @@ export { collectAggregateNamespaces, + createAggregateContractSpace, createContractSpaceAggregate, - createContractSpaceMember, requireHeadRef, } from '../aggregate/aggregate'; export { @@ -30,10 +30,10 @@ export { } from '../aggregate/strategies/graph-walk'; export { buildSynthMigrationEdge } from '../aggregate/synth-migration-edge'; export type { + AggregateContractSpace, ContractAtOptions, ContractAtResult, ContractSpaceAggregate, - ContractSpaceMember, } from '../aggregate/types'; export { type MarkerCheckResult, diff --git a/packages/1-framework/3-tooling/migration/src/gather-disk-contract-space-state.ts b/packages/1-framework/3-tooling/migration/src/gather-disk-contract-space-state.ts index 4805cc4e66..109ff16998 100644 --- a/packages/1-framework/3-tooling/migration/src/gather-disk-contract-space-state.ts +++ b/packages/1-framework/3-tooling/migration/src/gather-disk-contract-space-state.ts @@ -41,7 +41,7 @@ export async function gatherDiskContractSpaceState(args: { /** * Set of space ids the project declares: `'app'` plus each entry in * `extensionPacks` whose descriptor exposes a `contractSpace`. The - * helper reads on-disk head data only for the extension members. + * helper reads on-disk head data only for the extension spaces. */ readonly loadedSpaceIds: ReadonlySet; }): Promise { diff --git a/packages/1-framework/3-tooling/migration/src/integrity-violation.ts b/packages/1-framework/3-tooling/migration/src/integrity-violation.ts index 808f75f27a..31d31e88bb 100644 --- a/packages/1-framework/3-tooling/migration/src/integrity-violation.ts +++ b/packages/1-framework/3-tooling/migration/src/integrity-violation.ts @@ -65,7 +65,7 @@ export type IntegrityViolation = readonly claimedBy: readonly string[]; } | { readonly kind: 'contractUnreadable'; readonly spaceId: string; readonly detail: string } - // genuinely unloadable — package omitted from member.packages + // genuinely unloadable — package omitted from space.packages | { readonly kind: 'packageUnloadable'; readonly spaceId: string; diff --git a/packages/1-framework/3-tooling/migration/src/verify-contract-spaces.ts b/packages/1-framework/3-tooling/migration/src/verify-contract-spaces.ts index 568df593ab..ffb38a1041 100644 --- a/packages/1-framework/3-tooling/migration/src/verify-contract-spaces.ts +++ b/packages/1-framework/3-tooling/migration/src/verify-contract-spaces.ts @@ -157,7 +157,7 @@ export type VerifyContractSpacesResult = * Algorithm: * * - For every extension space declared in `loadedSpaces` (`'app'` - * excluded — the per-space verifier is scoped to extension members; + * excluded — the per-space verifier is scoped to extension spaces; * the app is verified through the aggregate path): * - If no contract-space dir on disk → `declaredButUnmigrated`. * - Else if `markerRowsBySpace` lacks an entry → no violation here; diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/check-integrity.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/check-integrity.test.ts index a2c99f7c2d..fdad07d638 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/check-integrity.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/check-integrity.test.ts @@ -2,7 +2,7 @@ import type { Contract } from '@prisma-next/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { createSqlContract } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; -import { createContractSpaceMember } from '../../src/aggregate/aggregate'; +import { createAggregateContractSpace } from '../../src/aggregate/aggregate'; import type { IntegritySpaceState } from '../../src/aggregate/check-integrity'; import { computeIntegrityViolations } from '../../src/aggregate/check-integrity'; import { createAttestedPackage } from '../fixtures'; @@ -23,7 +23,7 @@ function contractWithTables( } function makeSpaceState(spaceId: string, contract: Contract, isApp = false): IntegritySpaceState { - const member = createContractSpaceMember({ + const space = createAggregateContractSpace({ spaceId, packages: [], refs: {}, @@ -32,7 +32,7 @@ function makeSpaceState(spaceId: string, contract: Contract, isApp = false): Int resolveContract: () => contract, deserializeContract: (raw) => raw as Contract, }); - return { member, problems: [], refProblems: [], headRefProblem: null, isApp }; + return { space, problems: [], refProblems: [], headRefProblem: null, isApp }; } describe('computeIntegrityViolations', () => { @@ -48,7 +48,7 @@ describe('computeIntegrityViolations', () => { { ...second, metadata: { ...second.metadata, migrationHash: sharedHash } }, ]; - const member = createContractSpaceMember({ + const space = createAggregateContractSpace({ spaceId: 'app', packages, refs: {}, @@ -61,7 +61,7 @@ describe('computeIntegrityViolations', () => { }); const state: IntegritySpaceState = { - member, + space, problems: [], refProblems: [], headRefProblem: null, @@ -75,7 +75,7 @@ describe('computeIntegrityViolations', () => { migrationHash: sharedHash, dirNames: ['20260101T0000_first', '20260101T0000_second'], }); - expect(() => member.graph()).not.toThrow(); + expect(() => space.graph()).not.toThrow(); }); describe('disjointness (checkContracts)', () => { @@ -133,10 +133,10 @@ describe('computeIntegrityViolations', () => { }); it('rethrows when graph() fails for an unexpected reason', () => { - // Give the member a package so packages.length > 0, which triggers the + // Give the space a package so packages.length > 0, which triggers the // graph-reachability check and therefore the graph() call. const pkg = createAttestedPackage('20260101T0000_init', { from: null, to: 'sha256:head' }); - const member = createContractSpaceMember({ + const space = createAggregateContractSpace({ spaceId: 'ext', packages: [pkg], refs: {}, @@ -147,15 +147,15 @@ describe('computeIntegrityViolations', () => { }, deserializeContract: (raw) => raw as Contract, }); - const faultyMember = { - ...member, + const faultySpace = { + ...space, graph() { throw new Error('engine fault'); }, }; const state: IntegritySpaceState = { - member: faultyMember, + space: faultySpace, problems: [], refProblems: [], headRefProblem: null, diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/contract-at.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/contract-at.test.ts index 1873467079..213a4328c6 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/contract-at.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/contract-at.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import type { Contract } from '@prisma-next/contract/types'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createContractSpaceMember } from '../../src/aggregate/aggregate'; +import { createAggregateContractSpace } from '../../src/aggregate/aggregate'; import { writeRefSnapshot } from '../../src/refs/snapshot'; import { createAttestedPackage, createTestContract, writeTestPackage } from '../fixtures'; @@ -55,7 +55,7 @@ async function writeEndContract( await writeFile(join(packageDir, 'end-contract.d.ts'), sampleContractDts(dtsLabel)); } -describe('ContractSpaceMember.contractAt', () => { +describe('AggregateContractSpace.contractAt', () => { let workDir: string; let refsDir: string; let packageDir: string; @@ -75,11 +75,11 @@ describe('ContractSpaceMember.contractAt', () => { await rm(workDir, { recursive: true, force: true }); }); - function memberWithPackages( + function spaceWithPackages( packages: ReturnType[], deserialize: (raw: unknown) => Contract = identityDeserialize, ) { - return createContractSpaceMember({ + return createAggregateContractSpace({ spaceId: 'app', packages: packages.map((pkg) => ({ ...pkg, dirPath: packageDir })), refs: {}, @@ -96,11 +96,11 @@ describe('ContractSpaceMember.contractAt', () => { contractDts: sampleContractDts('snapshot'), }); - const member = memberWithPackages([ + const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_A }), ]); - const result = await member.contractAt(HASH_A, { refName: 'staging' }); + const result = await space.contractAt(HASH_A, { refName: 'staging' }); expect(result.hash).toBe(HASH_A); expect(result.provenance).toBe('snapshot'); @@ -111,11 +111,11 @@ describe('ContractSpaceMember.contractAt', () => { }); it('reads end-contract from the matching graph-node package without refName', async () => { - const member = memberWithPackages([ + const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), ]); - const result = await member.contractAt(HASH_B); + const result = await space.contractAt(HASH_B); expect(result.hash).toBe(HASH_B); expect(result.provenance).toBe('graph-node'); @@ -128,11 +128,11 @@ describe('ContractSpaceMember.contractAt', () => { }); it('falls back to the graph-node bundle when the ref snapshot is absent', async () => { - const member = memberWithPackages([ + const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), ]); - const result = await member.contractAt(HASH_B, { refName: 'staging' }); + const result = await space.contractAt(HASH_B, { refName: 'staging' }); expect(result.provenance).toBe('graph-node'); if (result.provenance !== 'graph-node') throw new Error('expected graph-node provenance'); @@ -141,11 +141,11 @@ describe('ContractSpaceMember.contractAt', () => { }); it('throws when the hash is a graph node but no bundle ends at that hash', async () => { - const member = memberWithPackages([ + const space = spaceWithPackages([ createAttestedPackage('20260101T0000_second', { from: HASH_A, to: HASH_B }), ]); - await expect(member.contractAt(HASH_A)).rejects.toMatchObject({ + await expect(space.contractAt(HASH_A)).rejects.toMatchObject({ code: 'MIGRATION.BUNDLE_NOT_FOUND_FOR_GRAPH_NODE', }); }); @@ -153,11 +153,11 @@ describe('ContractSpaceMember.contractAt', () => { it('throws when the matching bundle is missing end-contract.json', async () => { await rm(join(packageDir, 'end-contract.json')); - const member = memberWithPackages([ + const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), ]); - await expect(member.contractAt(HASH_B)).rejects.toMatchObject({ + await expect(space.contractAt(HASH_B)).rejects.toMatchObject({ code: 'MIGRATION.FILE_MISSING', details: { file: 'end-contract.json', dir: packageDir }, }); @@ -166,57 +166,57 @@ describe('ContractSpaceMember.contractAt', () => { it('throws when end-contract.json is invalid JSON', async () => { await writeFile(join(packageDir, 'end-contract.json'), '{not json'); - const member = memberWithPackages([ + const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), ]); - await expect(member.contractAt(HASH_B)).rejects.toMatchObject({ + await expect(space.contractAt(HASH_B)).rejects.toMatchObject({ code: 'MIGRATION.INVALID_JSON', }); }); it('throws when deserializeContract rejects the parsed end-contract', async () => { - const member = memberWithPackages( + const space = spaceWithPackages( [createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B })], () => { throw new Error('bad contract shape'); }, ); - await expect(member.contractAt(HASH_B)).rejects.toMatchObject({ + await expect(space.contractAt(HASH_B)).rejects.toMatchObject({ code: 'MIGRATION.CONTRACT_DESERIALIZATION_FAILED', }); }); it('throws snapshot missing when refName is set and hash is not a graph node', async () => { - const member = memberWithPackages([ + const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), ]); - await expect(member.contractAt(HASH_A, { refName: 'staging' })).rejects.toMatchObject({ + await expect(space.contractAt(HASH_A, { refName: 'staging' })).rejects.toMatchObject({ code: 'MIGRATION.SNAPSHOT_MISSING', details: { refName: 'staging' }, }); }); it('throws hash not in graph when refName is omitted and hash is not a graph node', async () => { - const member = memberWithPackages([ + const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), ]); - await expect(member.contractAt(HASH_A)).rejects.toMatchObject({ + await expect(space.contractAt(HASH_A)).rejects.toMatchObject({ code: 'MIGRATION.HASH_NOT_IN_GRAPH', details: { hash: HASH_A }, }); }); it('memoises successful resolutions per hash and refName', async () => { - const member = memberWithPackages([ + const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), ]); - const first = await member.contractAt(HASH_B); - const second = await member.contractAt(HASH_B); + const first = await space.contractAt(HASH_B); + const second = await space.contractAt(HASH_B); expect(second).toBe(first); }); @@ -226,12 +226,12 @@ describe('ContractSpaceMember.contractAt', () => { contractDts: sampleContractDts('snapshot'), }); - const member = memberWithPackages([ + const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), ]); - const fromSnapshot = await member.contractAt(HASH_B, { refName: 'staging' }); - const fromBundle = await member.contractAt(HASH_B); + const fromSnapshot = await space.contractAt(HASH_B, { refName: 'staging' }); + const fromBundle = await space.contractAt(HASH_B); expect(fromSnapshot.contractDts).toBe(sampleContractDts('snapshot')); expect(fromBundle.contractDts).toBe(sampleContractDts('bundle')); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/loader.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/loader.test.ts index 60446e0379..2069a7bd40 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/loader.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/loader.test.ts @@ -128,10 +128,10 @@ describe('loadContractSpaceAggregate', () => { await writeHeadRef('supabase', { hash: headHash, invariants: [] }); const aggregate = await load(); - const member = aggregate.space('supabase'); - expect(member).toBeDefined(); - expect(member?.packages).toHaveLength(0); - expect(member?.headRef).toEqual({ hash: headHash, invariants: [] }); + const space = aggregate.space('supabase'); + expect(space).toBeDefined(); + expect(space?.packages).toHaveLength(0); + expect(space?.headRef).toEqual({ hash: headHash, invariants: [] }); }); it('produces no headRefNotInGraph violation when packages is empty and head.json is on disk', async () => { @@ -184,7 +184,7 @@ describe('loadContractSpaceAggregate', () => { }); }); - describe('app member', () => { + describe('app space', () => { it('synthesises the app head ref from the live contract storage hash', async () => { const aggregate = await load(); expect(aggregate.app.headRef).toEqual({ @@ -219,9 +219,9 @@ describe('loadContractSpaceAggregate', () => { await writeContractJson('cipherstash', extContract); const aggregate = await load(); - const member = aggregate.space('cipherstash'); - const first = member?.contract(); - expect(first).toBe(member?.contract()); + const space = aggregate.space('cipherstash'); + const first = space?.contract(); + expect(first).toBe(space?.contract()); expect(first?.target).toBe('postgres'); }); }); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts index cd17f74f45..cc4f2bd981 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts @@ -12,22 +12,22 @@ import { createSqlContract } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createContractSpaceAggregate } from '../../src/aggregate/aggregate'; import { planMigration } from '../../src/aggregate/planner'; -import type { ContractSpaceAggregate, ContractSpaceMember } from '../../src/aggregate/types'; +import type { AggregateContractSpace, ContractSpaceAggregate } from '../../src/aggregate/types'; import { EMPTY_CONTRACT_HASH } from '../../src/constants'; import type { OnDiskMigrationPackage } from '../../src/package'; -import { createAttestedPackage, makeContractSpaceMember } from '../fixtures'; +import { createAttestedPackage, makeAggregateContractSpace } from '../fixtures'; const POLICY: MigrationOperationPolicy = { allowedOperationClasses: ['additive', 'widening'], }; -function makeMember(args: { +function makeSpace(args: { spaceId: string; contract?: Contract; headRef?: { hash: string; invariants: readonly string[] }; packages?: readonly OnDiskMigrationPackage[]; -}): ContractSpaceMember { - return makeContractSpaceMember({ +}): AggregateContractSpace { + return makeAggregateContractSpace({ spaceId: args.spaceId, contract: args.contract ?? createSqlContract({ target: 'postgres' }), headRef: args.headRef ?? { hash: EMPTY_CONTRACT_HASH, invariants: [] }, @@ -36,8 +36,8 @@ function makeMember(args: { } function makeAggregate(args: { - app: ContractSpaceMember; - extensions?: ContractSpaceMember[]; + app: AggregateContractSpace; + extensions?: AggregateContractSpace[]; targetId?: string; }): ContractSpaceAggregate { return createContractSpaceAggregate({ @@ -90,9 +90,9 @@ function makeSyntheticPlan(targetId: string): MigrationPlanWithAuthoringSurface } describe('planMigration', () => { - it('selects synth for the app member when callerPolicy.ignoreGraphFor includes its spaceId', async () => { + it('selects synth for the app space when callerPolicy.ignoreGraphFor includes its spaceId', async () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app' }), + app: makeSpace({ spaceId: 'app' }), }); const stubPlan = makeSyntheticPlan('placeholder-target-id-from-stub'); const planner = makeStubPlanner({ kind: 'success', plan: stubPlan }); @@ -118,16 +118,16 @@ describe('planMigration', () => { expect(success.perSpace.get('app')?.plan.targetId).toBe('postgres'); }); - it('selects graph-walk for an extension member with a non-empty graph reaching its head ref', async () => { + it('selects graph-walk for an extension space with a non-empty graph reaching its head ref', async () => { const headHash = 'sha256:cipher-head'; const cipherPkg = createAttestedPackage('20260101T0000_init', { from: null, to: headHash }); - const extension = makeMember({ + const extension = makeSpace({ spaceId: 'cipherstash', headRef: { hash: headHash, invariants: [] }, packages: [cipherPkg], }); const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app' }), + app: makeSpace({ spaceId: 'app' }), extensions: [extension], }); @@ -160,12 +160,12 @@ describe('planMigration', () => { }); it('falls back to synth when an extension graph is empty and no invariants are required', async () => { - const extension = makeMember({ + const extension = makeSpace({ spaceId: 'cipherstash', headRef: { hash: EMPTY_CONTRACT_HASH, invariants: [] }, }); const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app' }), + app: makeSpace({ spaceId: 'app' }), extensions: [extension], }); @@ -189,13 +189,13 @@ describe('planMigration', () => { expect(result.assertOk().perSpace.get('cipherstash')?.strategy).toBe('synth'); }); - it('rejects with policyConflict when ignoreGraphFor covers a member that declares non-empty invariants', async () => { - const extension = makeMember({ + it('rejects with policyConflict when ignoreGraphFor covers a space that declares non-empty invariants', async () => { + const extension = makeSpace({ spaceId: 'cipherstash', headRef: { hash: EMPTY_CONTRACT_HASH, invariants: ['cipher:create-v1'] }, }); const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app' }), + app: makeSpace({ spaceId: 'app' }), extensions: [extension], }); @@ -227,13 +227,13 @@ describe('planMigration', () => { expect(failure.detail).toContain('cipher:create-v1'); }); - it('rejects with extensionPathUnsatisfiable when the empty-graph member declares non-empty invariants', async () => { - const extension = makeMember({ + it('rejects with extensionPathUnsatisfiable when the empty-graph space declares non-empty invariants', async () => { + const extension = makeSpace({ spaceId: 'cipherstash', headRef: { hash: EMPTY_CONTRACT_HASH, invariants: ['cipher:create-v1'] }, }); const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app' }), + app: makeSpace({ spaceId: 'app' }), extensions: [extension], }); @@ -267,7 +267,7 @@ describe('planMigration', () => { it('forwards synth-strategy planner failures as appSynthFailure', async () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app' }), + app: makeSpace({ spaceId: 'app' }), }); const failingPlanner = makeStubPlanner({ kind: 'failure', diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/graph-walk.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/graph-walk.test.ts index 42fecd9d37..a3c6270715 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/graph-walk.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/graph-walk.test.ts @@ -1,17 +1,17 @@ import { createSqlContract } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { graphWalkStrategy } from '../../../src/aggregate/strategies/graph-walk'; -import type { ContractSpaceMember } from '../../../src/aggregate/types'; +import type { AggregateContractSpace } from '../../../src/aggregate/types'; import { EMPTY_CONTRACT_HASH } from '../../../src/constants'; import type { OnDiskMigrationPackage } from '../../../src/package'; -import { createAttestedPackage, makeContractSpaceMember } from '../../fixtures'; +import { createAttestedPackage, makeAggregateContractSpace } from '../../fixtures'; -function makeMember( +function makeSpace( packages: readonly OnDiskMigrationPackage[], headHash: string, invariants: readonly string[] = [], -): ContractSpaceMember { - return makeContractSpaceMember({ +): AggregateContractSpace { + return makeAggregateContractSpace({ spaceId: 'cipherstash', contract: createSqlContract({ target: 'postgres' }), headRef: { hash: headHash, invariants }, @@ -26,7 +26,7 @@ describe('graphWalkStrategy', () => { const outcome = graphWalkStrategy({ aggregateTargetId: 'postgres', - member: makeMember([pkg], headHash), + space: makeSpace([pkg], headHash), currentMarker: null, }); @@ -49,7 +49,7 @@ describe('graphWalkStrategy', () => { const outcome = graphWalkStrategy({ aggregateTargetId: 'postgres', - member: makeMember([pkg], headHash), + space: makeSpace([pkg], headHash), currentMarker: null, }); @@ -60,11 +60,11 @@ describe('graphWalkStrategy', () => { // A package walking baseline → headHash but providing zero invariants. const headHash = 'sha256:cipher-head'; const pkg = createAttestedPackage('20260101T0000_init', { from: null, to: headHash }); - const member = makeMember([pkg], headHash, ['cipher:create-v1']); + const space = makeSpace([pkg], headHash, ['cipher:create-v1']); const outcome = graphWalkStrategy({ aggregateTargetId: 'postgres', - member, + space, currentMarker: null, }); @@ -79,7 +79,7 @@ describe('graphWalkStrategy', () => { const outcome = graphWalkStrategy({ aggregateTargetId: 'postgres', - member: makeMember([pkg], headHash), + space: makeSpace([pkg], headHash), currentMarker: null, refName: 'prod', }); @@ -95,7 +95,7 @@ describe('graphWalkStrategy', () => { const outcome = graphWalkStrategy({ aggregateTargetId: 'postgres', - member: makeMember([pkg], headHash), + space: makeSpace([pkg], headHash), currentMarker: null, }); @@ -110,7 +110,7 @@ describe('graphWalkStrategy', () => { const outcome = graphWalkStrategy({ aggregateTargetId: 'postgres', - member: makeMember([pkg], headHash), + space: makeSpace([pkg], headHash), currentMarker: { storageHash: headHash, invariants: [] }, }); @@ -126,7 +126,7 @@ describe('graphWalkStrategy', () => { // with an empty path because fromHash === toHash. const outcome = graphWalkStrategy({ aggregateTargetId: 'postgres', - member: makeMember([], EMPTY_CONTRACT_HASH), + space: makeSpace([], EMPTY_CONTRACT_HASH), currentMarker: null, }); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts index 136882cdab..5aff729de4 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/synth.test.ts @@ -11,8 +11,8 @@ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { createSqlContract } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { synthStrategy } from '../../../src/aggregate/strategies/synth'; -import type { ContractSpaceMember } from '../../../src/aggregate/types'; -import { makeContractSpaceMember } from '../../fixtures'; +import type { AggregateContractSpace } from '../../../src/aggregate/types'; +import { makeAggregateContractSpace } from '../../fixtures'; const POLICY: MigrationOperationPolicy = { allowedOperationClasses: ['additive', 'widening'], @@ -21,8 +21,8 @@ const POLICY: MigrationOperationPolicy = { const STUB_ADAPTER: ControlAdapterInstance<'sql', 'postgres'> = {} as unknown as ControlAdapterInstance<'sql', 'postgres'>; -function makeMember(spaceId: string, tables: Record): ContractSpaceMember { - return makeContractSpaceMember({ +function makeSpace(spaceId: string, tables: Record): AggregateContractSpace { + return makeAggregateContractSpace({ spaceId, contract: createSqlContract({ target: 'postgres', @@ -71,7 +71,7 @@ describe('synthStrategy', () => { contractToSchema: () => ({ tables: {} }), }; - const appMember = makeMember('app', { app_user: {} }); + const appSpace = makeSpace('app', { app_user: {} }); const liveSchema = { tables: { @@ -84,7 +84,7 @@ describe('synthStrategy', () => { const outcome = await synthStrategy({ aggregateTargetId: 'postgres', currentMarker: null, - member: appMember, + space: appSpace, declaredByAnotherSpace: (name) => name === 'cipher_state', schemaIntrospection: liveSchema, adapter: STUB_ADAPTER, @@ -181,7 +181,7 @@ describe('synthStrategy', () => { const outcome = await synthStrategy({ aggregateTargetId: 'postgres', currentMarker: null, - member: makeMember('app', {}), + space: makeSpace('app', {}), declaredByAnotherSpace: () => false, schemaIntrospection: { tables: {} }, adapter: STUB_ADAPTER, diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts index e38f93abdc..d884d0a65a 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/verifier.test.ts @@ -8,16 +8,16 @@ import { createSqlContract } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createContractSpaceAggregate } from '../../src/aggregate/aggregate'; import type { ContractMarkerRecordLike } from '../../src/aggregate/marker-types'; -import type { ContractSpaceAggregate, ContractSpaceMember } from '../../src/aggregate/types'; +import type { AggregateContractSpace, ContractSpaceAggregate } from '../../src/aggregate/types'; import { verifyMigration } from '../../src/aggregate/verifier'; -import { makeContractSpaceMember } from '../fixtures'; +import { makeAggregateContractSpace } from '../fixtures'; -function makeMember(args: { +function makeSpace(args: { spaceId: string; headHash: string; invariants?: readonly string[]; tables?: Record; -}): ContractSpaceMember { +}): AggregateContractSpace { const tables = args.tables ?? {}; const contract = createSqlContract({ target: 'postgres', @@ -27,7 +27,7 @@ function makeMember(args: { }, }, }); - return makeContractSpaceMember({ + return makeAggregateContractSpace({ spaceId: args.spaceId, contract: contract as Contract, headRef: { hash: args.headHash, invariants: args.invariants ?? [] }, @@ -35,8 +35,8 @@ function makeMember(args: { } function makeAggregate(args: { - app: ContractSpaceMember; - extensions?: ContractSpaceMember[]; + app: AggregateContractSpace; + extensions?: AggregateContractSpace[]; }): ContractSpaceAggregate { return createContractSpaceAggregate({ targetId: 'postgres', @@ -61,19 +61,19 @@ function extraTableNode(name: string): SchemaVerificationNode { } /** - * A per-member verifier standing in for a family's: it verifies the member's + * A per-space verifier standing in for a family's: it verifies the space's * contract against the **full** live schema and flags every live table the - * member does not declare as an `extra_table` warning — exactly the shape the + * space does not declare as an `extra_table` warning — exactly the shape the * real family verify produces before the aggregate verifier scopes it. */ const FULL_SCHEMA_VERIFY = ( schema: unknown, - member: ContractSpaceMember, + space: AggregateContractSpace, _mode: 'strict' | 'lenient', ): VerifyDatabaseSchemaResult => { const liveTables = Object.keys((schema as { tables?: Record })?.tables ?? {}); const declared = new Set( - Object.keys(member.contract().storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries['table'] ?? {}), + Object.keys(space.contract().storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries['table'] ?? {}), ); const extras = liveTables.filter((name) => !declared.has(name)); const children = extras.map(extraTableNode); @@ -121,16 +121,16 @@ function extraNodeNames(result: VerifyDatabaseSchemaResult | undefined): string[ describe('verifyMigration', () => { describe('markerCheck', () => { - it('reports `absent` when the member has no marker row', () => { + it('reports `absent` when the space has no marker row', () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:app-head' }), + app: makeSpace({ spaceId: 'app', headHash: 'sha256:app-head' }), }); const result = verifyMigration({ aggregate, markersBySpaceId: new Map(), schemaIntrospection: { tables: {} }, mode: 'strict', - verifySchemaForMember: FULL_SCHEMA_VERIFY, + verifySchemaForSpace: FULL_SCHEMA_VERIFY, }); expect(result.ok).toBe(true); expect(result.assertOk().markerCheck.perSpace.get('app')).toEqual({ kind: 'absent' }); @@ -138,7 +138,7 @@ describe('verifyMigration', () => { it('reports `ok` when marker hash + invariants match the head ref', () => { const aggregate = makeAggregate({ - app: makeMember({ + app: makeSpace({ spaceId: 'app', headHash: 'sha256:app-head', invariants: ['inv-1'], @@ -152,14 +152,14 @@ describe('verifyMigration', () => { markersBySpaceId: markers, schemaIntrospection: { tables: {} }, mode: 'strict', - verifySchemaForMember: FULL_SCHEMA_VERIFY, + verifySchemaForSpace: FULL_SCHEMA_VERIFY, }); expect(result.assertOk().markerCheck.perSpace.get('app')).toEqual({ kind: 'ok' }); }); it('reports `hashMismatch` when marker hash differs from head ref', () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:expected' }), + app: makeSpace({ spaceId: 'app', headHash: 'sha256:expected' }), }); const markers = new Map([ ['app', { storageHash: 'sha256:actual', invariants: [] }], @@ -169,7 +169,7 @@ describe('verifyMigration', () => { markersBySpaceId: markers, schemaIntrospection: { tables: {} }, mode: 'strict', - verifySchemaForMember: FULL_SCHEMA_VERIFY, + verifySchemaForSpace: FULL_SCHEMA_VERIFY, }); expect(result.assertOk().markerCheck.perSpace.get('app')).toEqual({ kind: 'hashMismatch', @@ -180,9 +180,9 @@ describe('verifyMigration', () => { it('reports `missingInvariants` when the head ref declares invariants the marker lacks', () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:h' }), + app: makeSpace({ spaceId: 'app', headHash: 'sha256:h' }), extensions: [ - makeMember({ + makeSpace({ spaceId: 'cipher', headHash: 'sha256:cipher', invariants: ['cipher:create-v1', 'cipher:rotate-v1'], @@ -197,7 +197,7 @@ describe('verifyMigration', () => { markersBySpaceId: markers, schemaIntrospection: { tables: {} }, mode: 'strict', - verifySchemaForMember: FULL_SCHEMA_VERIFY, + verifySchemaForSpace: FULL_SCHEMA_VERIFY, }); expect(result.assertOk().markerCheck.perSpace.get('cipher')).toEqual({ kind: 'missingInvariants', @@ -205,9 +205,9 @@ describe('verifyMigration', () => { }); }); - it('lists orphan markers (rows for non-aggregate members)', () => { + it('lists orphan markers (rows for non-aggregate spaces)', () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:h' }), + app: makeSpace({ spaceId: 'app', headHash: 'sha256:h' }), }); const markers = new Map([ ['app', { storageHash: 'sha256:h', invariants: [] }], @@ -219,7 +219,7 @@ describe('verifyMigration', () => { markersBySpaceId: markers, schemaIntrospection: { tables: {} }, mode: 'strict', - verifySchemaForMember: FULL_SCHEMA_VERIFY, + verifySchemaForSpace: FULL_SCHEMA_VERIFY, }); expect(result.assertOk().markerCheck.orphanMarkers.map((o) => o.spaceId)).toEqual([ 'cipher', @@ -231,9 +231,9 @@ describe('verifyMigration', () => { describe('schemaCheck', () => { it('Part 1: each space view shows its declared nodes only, no extras', () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), + app: makeSpace({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), extensions: [ - makeMember({ + makeSpace({ spaceId: 'cipher', headHash: 'sha256:cipher', tables: { cipher_state: {} }, @@ -253,7 +253,7 @@ describe('verifyMigration', () => { markersBySpaceId: new Map(), schemaIntrospection: liveSchema, mode: 'strict', - verifySchemaForMember: FULL_SCHEMA_VERIFY, + verifySchemaForSpace: FULL_SCHEMA_VERIFY, }); const schemaCheck = result.assertOk().schemaCheck; @@ -267,9 +267,9 @@ describe('verifyMigration', () => { it('Part 2: reports a table no space declares once in the unclaimed list', () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), + app: makeSpace({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), extensions: [ - makeMember({ + makeSpace({ spaceId: 'cipher', headHash: 'sha256:cipher', tables: { cipher_state: {} }, @@ -289,7 +289,7 @@ describe('verifyMigration', () => { markersBySpaceId: new Map(), schemaIntrospection: liveSchema, mode: 'strict', - verifySchemaForMember: FULL_SCHEMA_VERIFY, + verifySchemaForSpace: FULL_SCHEMA_VERIFY, }); // `orphan_table` is declared by no space, so it appears exactly once — @@ -299,9 +299,9 @@ describe('verifyMigration', () => { it('Part 2: deduplicates and sorts multiple undeclared tables into one list', () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), + app: makeSpace({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), extensions: [ - makeMember({ + makeSpace({ spaceId: 'cipher', headHash: 'sha256:cipher', tables: { cipher_state: {} }, @@ -322,7 +322,7 @@ describe('verifyMigration', () => { markersBySpaceId: new Map(), schemaIntrospection: liveSchema, mode: 'lenient', - verifySchemaForMember: FULL_SCHEMA_VERIFY, + verifySchemaForSpace: FULL_SCHEMA_VERIFY, }); expect(result.assertOk().schemaCheck.unclaimed).toEqual(['another_orphan', 'mystery_table']); @@ -330,7 +330,7 @@ describe('verifyMigration', () => { it('single-space: an undeclared table is unclaimed, not a node in the space view', () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), + app: makeSpace({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), }); const liveSchema = { tables: { user: { columns: {} }, legacy_events: { columns: {} } }, @@ -341,7 +341,7 @@ describe('verifyMigration', () => { markersBySpaceId: new Map(), schemaIntrospection: liveSchema, mode: 'strict', - verifySchemaForMember: FULL_SCHEMA_VERIFY, + verifySchemaForSpace: FULL_SCHEMA_VERIFY, }); const schemaCheck = result.assertOk().schemaCheck; @@ -352,9 +352,9 @@ describe('verifyMigration', () => { it('leaves the unclaimed list empty when every live table is declared by some space', () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), + app: makeSpace({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), extensions: [ - makeMember({ + makeSpace({ spaceId: 'cipher', headHash: 'sha256:cipher', tables: { cipher_state: {} }, @@ -372,7 +372,7 @@ describe('verifyMigration', () => { }, }, mode: 'strict', - verifySchemaForMember: FULL_SCHEMA_VERIFY, + verifySchemaForSpace: FULL_SCHEMA_VERIFY, }); const schemaCheck = result.assertOk().schemaCheck; @@ -381,9 +381,9 @@ describe('verifyMigration', () => { expect(schemaCheck.unclaimed).toEqual([]); }); - it('returns notOk(introspectionFailure) when verifySchemaForMember throws', () => { + it('returns notOk(introspectionFailure) when verifySchemaForSpace throws', () => { const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), + app: makeSpace({ spaceId: 'app', headHash: 'sha256:h', tables: { user: {} } }), }); const result = verifyMigration({ @@ -391,7 +391,7 @@ describe('verifyMigration', () => { markersBySpaceId: new Map(), schemaIntrospection: { tables: { user: { columns: {} } } }, mode: 'strict', - verifySchemaForMember: () => { + verifySchemaForSpace: () => { throw new Error('introspection broke'); }, }); @@ -403,10 +403,10 @@ describe('verifyMigration', () => { }); }); - it('threads the verifier mode (strict / lenient) to the per-member callback verbatim', () => { + it('threads the verifier mode (strict / lenient) to the per-space callback verbatim', () => { let observedMode: 'strict' | 'lenient' | undefined; const aggregate = makeAggregate({ - app: makeMember({ spaceId: 'app', headHash: 'sha256:h' }), + app: makeSpace({ spaceId: 'app', headHash: 'sha256:h' }), }); verifyMigration({ @@ -414,9 +414,9 @@ describe('verifyMigration', () => { markersBySpaceId: new Map(), schemaIntrospection: { tables: {} }, mode: 'lenient', - verifySchemaForMember: (schema, member, mode) => { + verifySchemaForSpace: (schema, space, mode) => { observedMode = mode; - return FULL_SCHEMA_VERIFY(schema, member, mode); + return FULL_SCHEMA_VERIFY(schema, space, mode); }, }); diff --git a/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts b/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts index ac3b0a18b7..7296f14bd7 100644 --- a/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts +++ b/packages/1-framework/3-tooling/migration/test/deletable-node-modules.test.ts @@ -283,7 +283,7 @@ describe('aggregate pipeline (loader → planner → verifier) against deleted n markersBySpaceId: new Map(), schemaIntrospection: { tables: { user: { columns: {} }, test_box: { columns: {} } } }, mode: 'lenient', - verifySchemaForMember: () => ({ + verifySchemaForSpace: () => ({ ok: true, summary: 'Database schema satisfies contract', contract: { storageHash: 'sha256:test' }, diff --git a/packages/1-framework/3-tooling/migration/test/fixtures.ts b/packages/1-framework/3-tooling/migration/test/fixtures.ts index a0dd10fe22..9e887b9341 100644 --- a/packages/1-framework/3-tooling/migration/test/fixtures.ts +++ b/packages/1-framework/3-tooling/migration/test/fixtures.ts @@ -1,8 +1,8 @@ import type { Contract } from '@prisma-next/contract/types'; import type { MigrationPlanOperation } from '@prisma-next/framework-components/control'; import { createContract } from '@prisma-next/test-utils'; -import { createContractSpaceMember } from '../src/aggregate/aggregate'; -import type { ContractSpaceMember } from '../src/aggregate/types'; +import { createAggregateContractSpace } from '../src/aggregate/aggregate'; +import type { AggregateContractSpace } from '../src/aggregate/types'; import { EMPTY_CONTRACT_HASH } from '../src/constants'; import { computeMigrationHash } from '../src/hash'; import { deriveProvidedInvariants } from '../src/invariants'; @@ -60,13 +60,13 @@ export function createAttestedPackage( } /** - * Build a {@link ContractSpaceMember} for engine tests from the fields a + * Build a {@link AggregateContractSpace} for engine tests from the fields a * test cares about. `graph()` is reconstructed from `packages` and * `contract()` returns the supplied (already-deserialized) contract. * Defaults: empty packages / refs, an empty-contract head ref, and a * blank SQL/postgres contract. */ -export function makeContractSpaceMember(args: { +export function makeAggregateContractSpace(args: { spaceId: string; contract?: Contract; headRef?: ContractSpaceHeadRecord | null; @@ -74,10 +74,10 @@ export function makeContractSpaceMember(args: { refs?: Refs; refsDir?: string; deserializeContract?: (raw: unknown) => Contract; -}): ContractSpaceMember { +}): AggregateContractSpace { const contract = args.contract ?? createContract(); const deserializeContract = args.deserializeContract ?? ((raw: unknown) => raw as Contract); - return createContractSpaceMember({ + return createAggregateContractSpace({ spaceId: args.spaceId, packages: args.packages ?? [], refs: args.refs ?? {}, diff --git a/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir-node.ts b/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir-node.ts index 745a4fe92c..a3635fe56c 100644 --- a/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir-node.ts +++ b/packages/2-sql/1-core/schema-ir/src/ir/sql-schema-ir-node.ts @@ -25,9 +25,9 @@ export abstract class SqlSchemaIRNode extends IRNodeBase { /** * Enumerable discriminant identifying which node this is (database / * namespace / table / policy / role). Target concretions set a unique value; - * the `.is`/`.assert`/`.ensure` guards compare against it. Unlike `kind`, it - * is enumerable, so it survives the `projectSchemaToSpace` spread that - * flattens the tree into plain objects. + * the `.is`/`.assert` guards compare against it. Unlike `kind`, it is + * enumerable, so it survives a spread that flattens a node into a plain + * object. */ readonly nodeKind?: string; diff --git a/packages/3-extensions/supabase/test/classification.e2e.test.ts b/packages/3-extensions/supabase/test/classification.e2e.test.ts index 146b414672..a4410e07b8 100644 --- a/packages/3-extensions/supabase/test/classification.e2e.test.ts +++ b/packages/3-extensions/supabase/test/classification.e2e.test.ts @@ -228,7 +228,7 @@ describe('supabase external-schema classification (db init + db verify)', () => ).toBe(true); if (verifyResult.ok) { - // Every space (app + supabase) must have a passing schema result. + // Every space (app + supabase) must have a passing verify result. for (const [spaceId, schemaResult] of verifyResult.value.schemaResults) { expect( schemaResult.ok, @@ -236,7 +236,7 @@ describe('supabase external-schema classification (db init + db verify)', () => ).toBe(true); } - // The supabase space's schema result must reflect external-present + // The supabase space's verify result must reflect external-present // status: it passes, meaning auth.* / storage.* are confirmed present // and were not flagged as missing owned tables. const supabaseVerifyResult = verifyResult.value.schemaResults.get('supabase');