Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
58b4a5a
docs(postgres-rls): slice 2 schema-node-tree-restructure — spec + design
wmadden-electric Jun 29, 2026
17148dd
refactor(postgres): leaf split — RLS policy/role contract entities vs…
wmadden-electric Jun 29, 2026
6705391
refactor(postgres): rename PostgresTableIR → PostgresTableSchemaNode
wmadden-electric Jun 29, 2026
c446373
feat(postgres): namespace + database-root schema nodes (units 3+4)
wmadden-electric Jun 29, 2026
87ee213
docs(postgres-rls): pin CF-1 — existingSchemas consumer rewire as a u…
wmadden-electric Jun 29, 2026
e3524e1
docs(postgres-rls): rewrite plan as a concise scannable overview
wmadden-electric Jun 29, 2026
519b8f9
refactor(postgres): producers build the schema-node tree (unit 5)
wmadden-electric Jun 29, 2026
912b2f0
wip(postgres): unit 6 — family consumers walk the introspected node
wmadden-electric Jun 29, 2026
5e11ab3
wip(postgres): unit 6 — differ/planner walk the root; retire Postgres…
wmadden-electric Jun 29, 2026
9096215
wip(migration): unit 6 — SQLite + aggregate consumers walk the node
wmadden-electric Jun 29, 2026
50692f4
wip(postgres): unit 6 — rebuild consumer tests for the schema-node tree
wmadden-electric Jun 29, 2026
c4d939b
wip(migration): unit 6 — F3 remove bare casts in aggregate consumers
wmadden-electric Jun 29, 2026
1e0f676
fix(sql-family): verify pairs each contract namespace to its actual node
wmadden-electric Jun 29, 2026
03271fe
refactor(sql-family): one shared per-namespace-paired schema diff for…
wmadden-electric Jun 30, 2026
b3fe123
refactor(postgres): one diffDatabaseSchema for planner + verify
wmadden-electric Jun 30, 2026
722d873
feat(postgres): unit 7 — database→PSL inference moves to the target (R7)
wmadden-electric Jun 30, 2026
a834a40
fix(postgres): runner post-apply verify uses the shared per-namespace…
wmadden-electric Jun 30, 2026
a43576b
fix(postgres): project expected tree per-namespace, no whole-contract…
wmadden-electric Jun 30, 2026
91248f6
docs(postgres-rls): mark slice 2 in review (#894); record landed deci…
wmadden-electric Jun 30, 2026
a491335
docs(postgres-rls): D1 residual → TML-2958 (drop invented namespaced-…
wmadden-electric Jul 1, 2026
e12408f
fix(postgres): PSL inference throws on cross-schema table-name collision
wmadden-electric Jul 1, 2026
b696c21
refactor(sql): move database-schema diff to a required target-descrip…
wmadden-electric Jul 1, 2026
349b1e4
refactor(sql): decouple the schema view and SQLite verify from the di…
wmadden-electric Jul 1, 2026
fb34d8c
refactor(postgres): planner reads its own database tree, not the diff…
wmadden-electric Jul 1, 2026
92a4a6b
refactor(postgres): schema-diff nodes carry a unique nodeKind; guards…
wmadden-electric Jul 1, 2026
7489e4f
refactor(migration): aggregate verifier/planner take family schema-sh…
wmadden-electric Jul 1, 2026
3c3a96f
refactor(postgres): mechanical cleanups from the schema-node-tree review
wmadden-electric Jul 1, 2026
833e054
docs(postgres-rls): schema diff/verify rework design (PR #894 review …
wmadden-electric Jul 1, 2026
e9ed2aa
docs(postgres-rls): SchemaDiffer/SchemaDiff diff-verify design (round 2)
wmadden-electric Jul 2, 2026
973000e
refactor(postgres): SchemaDiffer SPI returns SchemaDiff instead of Ve…
wmadden-electric Jul 2, 2026
4e256f8
refactor(migration): contract-space by issue-filtering; delete the sc…
wmadden-electric Jul 2, 2026
0627b8b
fix(migration): scope the verify tree by top-level tables only, never…
wmadden-electric Jul 2, 2026
4db45b5
fix(migration): only entity nodes are droppable from the scoped verif…
wmadden-electric Jul 2, 2026
a836ec6
refactor(postgres): consistent static node guards; delete ensure() an…
wmadden-electric Jul 2, 2026
0bc6c36
docs(postgres-rls): reconcile diff-verify design to bare-name ownersh…
wmadden-electric Jul 2, 2026
70345d4
refactor(postgres): one diff module, extract diff-SPI types, drop a d…
wmadden-electric Jul 2, 2026
d4040d3
refactor(postgres): planner comment/code-quality cleanups, drop trans…
wmadden-electric Jul 2, 2026
2ae324e
docs(postgres-rls): correct diff-verify design §14 to V4 findings
wmadden-electric Jul 2, 2026
b85526c
fix(migration): scope verify counts family-agnostically, unbreak Mong…
wmadden-electric Jul 2, 2026
e5c51a0
docs(postgres-rls): collapse diff-verify design to passive-aggregate,…
wmadden-electric Jul 2, 2026
5e36733
refactor(diff): node-type the diff issues; drop the planner per-issue…
wmadden-electric Jul 2, 2026
689f650
docs(postgres-rls): verifier output is two parts — contract satisfact…
wmadden-electric Jul 2, 2026
a61c3f1
docs(postgres-rls): the two-part verify split lives in the aggregate …
wmadden-electric Jul 2, 2026
9e2e80f
refactor(migration): split db verify into per-space satisfaction + on…
wmadden-electric Jul 2, 2026
8c3d4a1
fix(migration): strip only top-level extras, recompute stripped count…
wmadden-electric Jul 2, 2026
9309fd0
fix(migration): re-fold schemaDiffIssues into the recomputed strip ve…
wmadden-electric Jul 2, 2026
14f5b7a
refactor(migration): planner takes a diff keep-predicate, drop the cr…
wmadden-electric Jul 2, 2026
5fc61c8
docs(postgres-rls): reconcile plan seam to the keep-predicate; record…
wmadden-electric Jul 2, 2026
9d56e8e
refactor(migration): contract-space naming across the aggregate, reti…
wmadden-electric Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture docs/subsystems/7. Migration System.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,13 @@ export interface ControlFamilyInstance<TFamilyId extends string, TSchemaIR>
}): Promise<VerifyDatabaseResult>;

/**
* 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -407,6 +408,16 @@ export interface MigrationPlanner<
* per-extension callers pass the extension's space id.
*/
readonly spaceId: string;
/**
* 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 keepDiffIssue?: (issue: DiffIssue) => boolean;
Comment on lines +411 to +420

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a weird inversion of control. Why not just give it the list of issues?

}): MigrationPlannerResult;

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { SchemaIssue } from './control-result-types';

export type SchemaDiffOutcome = 'missing' | 'extra' | 'mismatch';

export interface SchemaDiffIssue {
export interface SchemaDiffIssue<TNode extends DiffableNode = DiffableNode> {
/** 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;
}

/**
Expand Down Expand Up @@ -130,3 +132,49 @@ function diffChildren(

return issues;
}

/**
* 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<TNode extends DiffableNode = DiffableNode> =
| SchemaIssue
| SchemaDiffIssue<TNode>;

/**
* 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.
*
* `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<TNode extends DiffableNode = DiffableNode> {
readonly issues: readonly SchemaIssue[];
readonly schemaDiffIssues: readonly SchemaDiffIssue<TNode>[];

constructor(issues: readonly SchemaIssue[], schemaDiffIssues: readonly SchemaDiffIssue<TNode>[]) {
this.issues = issues;
this.schemaDiffIssues = schemaDiffIssues;
}

/** Fans `keep` across both issue lists, returning a new `SchemaDiff` narrowed to the survivors. */
filter(keep: (issue: DiffIssue<TNode>) => boolean): SchemaDiff<TNode> {
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<TInput> {
diff(input: TInput): SchemaDiff;
Comment thread
wmadden-electric marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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([]);
});
});
42 changes: 23 additions & 19 deletions packages/1-framework/3-tooling/cli/src/commands/db-verify.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -29,7 +26,7 @@ import {
errorTargetMismatch,
errorUnexpected,
} from '../utils/cli-errors';
import { combineSchemaResults } from '../utils/combine-schema-results';
import { type CombinedVerifyResult, combineVerifyResults } from '../utils/combine-verify-results';
import {
addGlobalOptions,
maskConnectionUrl,
Expand Down Expand Up @@ -103,7 +100,7 @@ function mapVerifyFailure(verifyResult: VerifyDatabaseResult): CliStructuredErro
return errorRuntime(verifyResult.summary);
}

type DbVerifyFailure = CliStructuredError | VerifyDatabaseSchemaResult;
type DbVerifyFailure = CliStructuredError | CombinedVerifyResult;

function errorInvalidVerifyMode(options: {
readonly why: string;
Expand Down Expand Up @@ -419,12 +416,13 @@ async function executeDbVerifyCommand(
});
}

const combined = combineSchemaResults(
const combined = combineVerifyResults(
aggregateResult.value.schemaResults,
aggregateResult.value.appSpaceId,
options.strict ?? false,
aggregateResult.value.unclaimed,
);
if (!combined.ok) {
if (!combined.result.ok) {
return notOk(combined);
}

Expand All @@ -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',
Expand All @@ -459,7 +458,7 @@ async function executeDbSchemaOnlyVerifyCommand(
options: DbVerifyOptions,
flags: GlobalFlags,
ui: TerminalUI,
): Promise<Result<VerifyDatabaseSchemaResult, CliStructuredError>> {
): Promise<Result<CombinedVerifyResult, CliStructuredError>> {
const paths = await resolveVerifyPaths(options);
renderVerifyHeader(paths, options, 'schema-only', flags, ui);

Expand All @@ -484,10 +483,11 @@ async function executeDbSchemaOnlyVerifyCommand(
if (!aggregateResult.ok) return notOk(aggregateResult.failure);

return ok(
combineSchemaResults(
combineVerifyResults(
aggregateResult.value.schemaResults,
aggregateResult.value.appSpaceId,
options.strict ?? false,
aggregateResult.value.unclaimed,
),
);
} catch (error) {
Expand Down Expand Up @@ -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) {
Comment on lines +545 to +556

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Schema-only failures can exit silently under --quiet.

Unlike the full-mode failure branch below (Lines 583-591) which explicitly forces { ...flags, quiet: false } so that "exiting 1 without diagnostics is unhelpful" never happens, this schema-only success-callback passes flags unmodified into formatSchemaVerifyOutput. Since that formatter returns '' immediately when flags.quiet is true, running db verify --schema-only --quiet against a database that fails schema verification will print nothing and simply exit with code 1 (via the result.ok && !result.value.result.ok check just below), leaving the user with no diagnostic output.

The line-range change summary for this segment describes "a forced quiet: false flags object," but the actual code doesn't apply that override here — only the full-mode failure path does.

🐛 Proposed fix to force diagnostics on schema-only failures
         const exitCode = handleResult(result, flags, ui, (combined) => {
           if (flags.json) {
             ui.output(formatSchemaVerifyJson(combined.result, combined.unclaimed));
           } else {
-            const output = formatSchemaVerifyOutput(combined.result, flags, combined.unclaimed);
+            const outputFlags = combined.result.ok ? flags : { ...flags, quiet: false };
+            const output = formatSchemaVerifyOutput(combined.result, outputFlags, combined.unclaimed);
             if (output) {
               ui.log(output);
             }
           }
         });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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) {
const exitCode = handleResult(result, flags, ui, (combined) => {
if (flags.json) {
ui.output(formatSchemaVerifyJson(combined.result, combined.unclaimed));
} else {
const outputFlags = combined.result.ok ? flags : { ...flags, quiet: false };
const output = formatSchemaVerifyOutput(combined.result, outputFlags, combined.unclaimed);
if (output) {
ui.log(output);
}
}
});
if (result.ok && !result.value.result.ok) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/1-framework/3-tooling/cli/src/commands/db-verify.ts` around lines
545 - 556, Schema-only verification can exit with no diagnostics when `--quiet`
is set because `handleResult`’s callback in `db-verify.ts` passes `flags`
straight into `formatSchemaVerifyOutput`, which suppresses output for quiet
mode. Update the schema-only success path to mirror the full-mode failure branch
by forcing a non-quiet flags object before calling `formatSchemaVerifyOutput`,
using the existing `flags`, `handleResult`, and `formatSchemaVerifyOutput` flow
so `--schema-only --quiet` still prints the failure details.

process.exit(1);
}

Expand All @@ -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);
}
Expand Down
Loading
Loading