@prisma-next/target-postgres 0.14.0-dev.49 → 0.14.0-dev.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/control-policy-DUFgy9jO.mjs +1103 -0
  2. package/dist/control-policy-DUFgy9jO.mjs.map +1 -0
  3. package/dist/control.d.mts.map +1 -1
  4. package/dist/control.mjs +19 -40
  5. package/dist/control.mjs.map +1 -1
  6. package/dist/default-normalizer.mjs +247 -1
  7. package/dist/default-normalizer.mjs.map +1 -0
  8. package/dist/diff-database-schema-BmSmWcaY.mjs +313 -0
  9. package/dist/diff-database-schema-BmSmWcaY.mjs.map +1 -0
  10. package/dist/diff-database-schema.d.mts +46 -0
  11. package/dist/diff-database-schema.d.mts.map +1 -0
  12. package/dist/diff-database-schema.mjs +2 -0
  13. package/dist/issue-planner.d.mts +34 -10
  14. package/dist/issue-planner.d.mts.map +1 -1
  15. package/dist/issue-planner.mjs +2 -2
  16. package/dist/migration.d.mts +2 -2
  17. package/dist/migration.mjs +2 -2
  18. package/dist/native-type-normalizer.mjs +37 -1
  19. package/dist/native-type-normalizer.mjs.map +1 -0
  20. package/dist/{op-factory-call-BuoGT5UI.mjs → op-factory-call-BKRnMgrB.mjs} +2 -2
  21. package/dist/op-factory-call-BKRnMgrB.mjs.map +1 -0
  22. package/dist/{op-factory-call-Caqus-Qg.d.mts → op-factory-call-Dt-Jx5JS.d.mts} +2 -2
  23. package/dist/{op-factory-call-Caqus-Qg.d.mts.map → op-factory-call-Dt-Jx5JS.d.mts.map} +1 -1
  24. package/dist/op-factory-call.d.mts +2 -2
  25. package/dist/op-factory-call.mjs +2 -2
  26. package/dist/planner-BMBRPzsv.mjs +367 -0
  27. package/dist/planner-BMBRPzsv.mjs.map +1 -0
  28. package/dist/planner-ddl-builders-B8Nn6nHN.mjs.map +1 -1
  29. package/dist/planner-ddl-builders.d.mts +2 -2
  30. package/dist/planner-ddl-builders.d.mts.map +1 -1
  31. package/dist/planner-identity-values-CJPha2Sz.mjs.map +1 -1
  32. package/dist/planner-identity-values.d.mts +7 -1
  33. package/dist/planner-identity-values.d.mts.map +1 -1
  34. package/dist/planner-identity-values.mjs +2 -2
  35. package/dist/{planner-produced-postgres-migration-CtJi4sgS.d.mts → planner-produced-postgres-migration-C0a7I1-e.d.mts} +2 -2
  36. package/dist/{planner-produced-postgres-migration-CtJi4sgS.d.mts.map → planner-produced-postgres-migration-C0a7I1-e.d.mts.map} +1 -1
  37. package/dist/{planner-produced-postgres-migration-C2_H6Vvl.mjs → planner-produced-postgres-migration-Dp6IO_gj.mjs} +2 -2
  38. package/dist/{planner-produced-postgres-migration-C2_H6Vvl.mjs.map → planner-produced-postgres-migration-Dp6IO_gj.mjs.map} +1 -1
  39. package/dist/planner-produced-postgres-migration.d.mts +1 -1
  40. package/dist/planner-produced-postgres-migration.mjs +1 -1
  41. package/dist/planner-sql-checks-Cze3vMfe.mjs.map +1 -1
  42. package/dist/planner-sql-checks.d.mts +1 -1
  43. package/dist/planner-sql-checks.d.mts.map +1 -1
  44. package/dist/planner.d.mts +27 -67
  45. package/dist/planner.d.mts.map +1 -1
  46. package/dist/planner.mjs +3 -2
  47. package/dist/{postgres-database-schema-node-os8ogEap.d.mts → postgres-database-schema-node-C9bTuMrd.d.mts} +14 -7
  48. package/dist/postgres-database-schema-node-C9bTuMrd.d.mts.map +1 -0
  49. package/dist/{postgres-migration-D0QIVE-p.mjs → postgres-migration-D0jwzYDT.mjs} +2 -2
  50. package/dist/{postgres-migration-D0QIVE-p.mjs.map → postgres-migration-D0jwzYDT.mjs.map} +1 -1
  51. package/dist/{postgres-migration-CfyKDT7b.d.mts → postgres-migration-DFaxuRyI.d.mts} +2 -2
  52. package/dist/{postgres-migration-CfyKDT7b.d.mts.map → postgres-migration-DFaxuRyI.d.mts.map} +1 -1
  53. package/dist/{postgres-table-schema-node-Clei_xel.mjs → postgres-table-schema-node-BgaSrxYN.mjs} +30 -29
  54. package/dist/postgres-table-schema-node-BgaSrxYN.mjs.map +1 -0
  55. package/dist/schema-node-kinds-ClScchhi.mjs +76 -0
  56. package/dist/schema-node-kinds-ClScchhi.mjs.map +1 -0
  57. package/dist/types.d.mts +1 -1
  58. package/dist/types.mjs +1 -1
  59. package/package.json +21 -20
  60. package/src/core/migrations/column-ddl-rendering.ts +117 -0
  61. package/src/core/migrations/contract-to-postgres-database-schema-node.ts +25 -1
  62. package/src/core/migrations/control-policy.ts +68 -48
  63. package/src/core/migrations/diff-database-schema.ts +242 -153
  64. package/src/core/migrations/issue-planner.ts +621 -667
  65. package/src/core/migrations/operations/constraints.ts +1 -1
  66. package/src/core/migrations/planner-ddl-builders.ts +5 -2
  67. package/src/core/migrations/planner-identity-values.ts +1 -1
  68. package/src/core/migrations/planner-sql-checks.ts +1 -1
  69. package/src/core/migrations/planner-strategies.ts +248 -208
  70. package/src/core/migrations/planner.ts +180 -74
  71. package/src/core/migrations/runner.ts +1 -4
  72. package/src/core/migrations/verify-postgres-namespaces.ts +26 -15
  73. package/src/core/postgres-schema-verifier.ts +10 -7
  74. package/src/core/psl-infer/infer-psl-contract.ts +5 -10
  75. package/src/core/schema-ir/postgres-database-schema-node.ts +3 -6
  76. package/src/core/schema-ir/postgres-namespace-schema-node.ts +8 -9
  77. package/src/core/schema-ir/postgres-policy-schema-node.ts +3 -6
  78. package/src/core/schema-ir/postgres-role-schema-node.ts +3 -6
  79. package/src/core/schema-ir/postgres-table-schema-node.ts +20 -9
  80. package/src/core/schema-ir/schema-node-kinds.ts +80 -2
  81. package/src/exports/control.ts +10 -43
  82. package/src/exports/diff-database-schema.ts +7 -0
  83. package/src/exports/issue-planner.ts +1 -1
  84. package/src/exports/op-factory-call.ts +1 -0
  85. package/src/exports/planner-identity-values.ts +4 -1
  86. package/src/exports/planner.ts +0 -1
  87. package/dist/default-normalizer-Dug8Fez9.mjs +0 -248
  88. package/dist/default-normalizer-Dug8Fez9.mjs.map +0 -1
  89. package/dist/issue-planner-Cbh-xTEr.mjs +0 -849
  90. package/dist/issue-planner-Cbh-xTEr.mjs.map +0 -1
  91. package/dist/native-type-normalizer-Bc9XJzWC.mjs +0 -38
  92. package/dist/native-type-normalizer-Bc9XJzWC.mjs.map +0 -1
  93. package/dist/op-factory-call-BuoGT5UI.mjs.map +0 -1
  94. package/dist/planner-Bq0Z2sVO.mjs +0 -641
  95. package/dist/planner-Bq0Z2sVO.mjs.map +0 -1
  96. package/dist/postgres-database-schema-node-os8ogEap.d.mts.map +0 -1
  97. package/dist/postgres-table-schema-node-Clei_xel.mjs.map +0 -1
@@ -1,191 +1,280 @@
1
- import type { Contract } from '@prisma-next/contract/types';
2
- import { verifySqlSchemaTree } from '@prisma-next/family-sql/diff';
1
+ import type { Contract, ControlPolicy } from '@prisma-next/contract/types';
2
+ import type { SqlSchemaDiffResult } from '@prisma-next/family-sql/control';
3
+ import { buildNativeTypeExpander } from '@prisma-next/family-sql/control';
4
+ import {
5
+ classifyDiffSubjectGranularity,
6
+ resolveSemanticSatisfaction,
7
+ } from '@prisma-next/family-sql/diff';
3
8
  import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
4
- import type {
5
- SchemaDiffIssue,
6
- VerifyDatabaseSchemaResult,
7
- } from '@prisma-next/framework-components/control';
8
- import { diffSchemas, SchemaDiff } from '@prisma-next/framework-components/control';
9
+ import type { SchemaDiffIssue } from '@prisma-next/framework-components/control';
10
+ import { diffSchemas } from '@prisma-next/framework-components/control';
9
11
  import type { SqlStorage } from '@prisma-next/sql-contract/types';
10
12
  import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types';
11
13
  import { blindCast } from '@prisma-next/utils/casts';
12
- import { parsePostgresDefault } from '../default-normalizer';
13
- import { normalizeSchemaNativeType } from '../native-type-normalizer';
14
+ import { ifDefined } from '@prisma-next/utils/defined';
14
15
  import type { PostgresContract } from '../postgres-schema';
15
16
  import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node';
16
- import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node';
17
- import type { SqlSchemaDiffNode } from '../schema-ir/schema-node-kinds';
17
+ import { PostgresNamespaceSchemaNode } from '../schema-ir/postgres-namespace-schema-node';
18
+ import { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node';
19
+ import {
20
+ postgresDiffSubjectGranularity,
21
+ type SqlSchemaDiffNode,
22
+ } from '../schema-ir/schema-node-kinds';
18
23
  import { contractToPostgresDatabaseSchemaNode } from './contract-to-postgres-database-schema-node';
24
+ import { resolvePostgresNodeIssueControlPolicySubject } from './control-policy';
19
25
 
20
- interface PostgresDiffDatabaseSchemaInput {
21
- readonly contract: Contract<SqlStorage>;
22
- readonly actualSchema: SqlSchemaIRNode;
23
- readonly strict: boolean;
24
- readonly typeMetadataRegistry: ReadonlyMap<string, { readonly nativeType?: string }>;
25
- readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
26
+ function ownedSchemaNames(expected: PostgresDatabaseSchemaNode): ReadonlySet<string> {
27
+ const policyNamespaces = Object.values(expected.namespaces).flatMap((ns) =>
28
+ Object.values(ns.tables).flatMap((t) => t.policies.map((p) => p.namespaceId)),
29
+ );
30
+ return new Set([...policyNamespaces, ...expected.existingSchemas]);
26
31
  }
27
32
 
28
33
  /**
29
- * The single combined database-schema comparison the one computation the
30
- * migration planner and the family schema verify both consume. Composes,
31
- * once each:
32
- *
33
- * - the per-namespace-paired relational diff (`verifySqlSchemaTree`) → table /
34
- * column / constraint findings as framework `SchemaIssue`s (with the
35
- * verification-tree `root` and pass/warn/fail counts);
36
- * - the policy diff (`diffPostgresSchema` over the two trees) → RLS policy
37
- * presence as `SchemaDiffIssue`s, ownership-filtered to the contract's owned
38
- * schemas.
39
- *
40
- * `diffPostgresDatabaseSchema` and `verifyPostgresDatabaseSchema` both read
41
- * this single result, so the relational walk runs once per caller — never
42
- * once for the diff and again for the verify tree.
34
+ * Applies the family's semantic-satisfaction normalization across a Postgres
35
+ * tree pair: every actual table with an expected counterpart (paired by
36
+ * namespace id, then table id) gets its unique/index child lists adjusted;
37
+ * everything else passes through untouched.
43
38
  */
44
- function computePostgresSchemaComparison(input: PostgresDiffDatabaseSchemaInput): {
45
- readonly relational: VerifyDatabaseSchemaResult;
46
- readonly schemaDiffIssues: readonly SchemaDiffIssue<SqlSchemaDiffNode>[];
47
- } {
48
- const postgresContract = blindCast<
49
- PostgresContract,
50
- 'diffPostgresDatabaseSchema is only called with a postgres contract'
51
- >(input.contract);
52
-
53
- // Relational diff: per-namespace-paired so a multi-schema database checks each
54
- // contract namespace against its own actual node.
55
- const relational = verifySqlSchemaTree({
56
- contract: input.contract,
57
- actualSchema: input.actualSchema,
58
- buildExpectedSchema: (scopedContract) =>
59
- contractToPostgresDatabaseSchemaNode(
60
- blindCast<
61
- PostgresContract | null,
62
- 'the relational pairing projects a scoped postgres contract'
63
- >(scopedContract),
64
- { annotationNamespace: 'pg' },
65
- ),
66
- strict: input.strict,
67
- typeMetadataRegistry: input.typeMetadataRegistry,
68
- frameworkComponents: input.frameworkComponents,
69
- normalizeDefault: parsePostgresDefault,
70
- normalizeNativeType: normalizeSchemaNativeType,
39
+ export function normalizePostgresActualForDiff(
40
+ expected: PostgresDatabaseSchemaNode,
41
+ actual: PostgresDatabaseSchemaNode,
42
+ ): PostgresDatabaseSchemaNode {
43
+ const namespaces: Record<string, PostgresNamespaceSchemaNode> = {};
44
+ for (const [nsId, actualNs] of Object.entries(actual.namespaces)) {
45
+ const expectedNs = expected.namespaces[nsId];
46
+ if (expectedNs === undefined) {
47
+ namespaces[nsId] = actualNs;
48
+ continue;
49
+ }
50
+ const tables: Record<string, PostgresTableSchemaNode> = {};
51
+ for (const [tableName, actualTable] of Object.entries(actualNs.tables)) {
52
+ const expectedTable = expectedNs.tables[tableName];
53
+ if (expectedTable === undefined) {
54
+ tables[tableName] = actualTable;
55
+ continue;
56
+ }
57
+ const adjusted = resolveSemanticSatisfaction({
58
+ expectedUniques: expectedTable.uniques,
59
+ expectedIndexes: expectedTable.indexes,
60
+ actualUniques: actualTable.uniques,
61
+ actualIndexes: actualTable.indexes,
62
+ });
63
+ tables[tableName] = new PostgresTableSchemaNode({
64
+ name: actualTable.name,
65
+ columns: actualTable.columns,
66
+ foreignKeys: actualTable.foreignKeys,
67
+ uniques: adjusted.actualUniques,
68
+ indexes: adjusted.actualIndexes,
69
+ ...ifDefined('primaryKey', actualTable.primaryKey),
70
+ ...ifDefined('annotations', actualTable.annotations),
71
+ ...ifDefined('checks', actualTable.checks),
72
+ policies: [...actualTable.policies],
73
+ });
74
+ }
75
+ namespaces[nsId] = new PostgresNamespaceSchemaNode({
76
+ schemaName: actualNs.schemaName,
77
+ tables,
78
+ nativeEnumTypeNames: actualNs.nativeEnumTypeNames,
79
+ });
80
+ }
81
+ return new PostgresDatabaseSchemaNode({
82
+ namespaces,
83
+ roles: [...actual.roles],
84
+ existingSchemas: [...actual.existingSchemas],
85
+ pgVersion: actual.pgVersion,
71
86
  });
87
+ }
72
88
 
73
- // Policy diff: the generic node differ over the expected/actual policy trees,
74
- // ownership-filtered to the schemas the contract owns (so unowned-namespace
75
- // policies are not reported as extras). The actual schema is always the
76
- // Postgres database root in production assert it, matching the prior
77
- // `collectSchemaDiffIssues` / `planPostgresSchemaDiff` behaviour.
78
- PostgresDatabaseSchemaNode.assert(input.actualSchema);
79
- const expected = contractToPostgresDatabaseSchemaNode(postgresContract, {
80
- annotationNamespace: 'pg',
81
- });
82
- const schemaDiffIssues = filterIssuesByOwnership(
83
- diffPostgresSchema(expected, input.actualSchema),
84
- ownedSchemaNames(expected),
89
+ /**
90
+ * Drops contract namespaces that declare no tables from the verdict-diff
91
+ * expected tree and the relational owned-schema set. The legacy relational
92
+ * walk skipped a table-less namespace (e.g. an enums-only schema) before
93
+ * pairing, so neither its DDL schema's absence nor that schema's live
94
+ * relational contents ever reached the verdict — the pruned tree
95
+ * reproduces that. The prune loses no expected policies: policies attach
96
+ * to tables, so a table-less namespace carries none (the projection
97
+ * throws on a policy referencing an absent table). Live policies in a
98
+ * pruned schema remain governed via the full owned set (see
99
+ * {@link diffPostgresSchema}).
100
+ */
101
+ function pruneTableLessNamespaces(
102
+ expected: PostgresDatabaseSchemaNode,
103
+ ): PostgresDatabaseSchemaNode {
104
+ const namespaces = Object.fromEntries(
105
+ Object.entries(expected.namespaces).filter(([, ns]) => Object.keys(ns.tables).length > 0),
85
106
  );
86
-
87
- return { relational, schemaDiffIssues };
107
+ return new PostgresDatabaseSchemaNode({
108
+ namespaces,
109
+ roles: [...expected.roles],
110
+ existingSchemas: expected.existingSchemas.filter((s) => namespaces[s] !== undefined),
111
+ pgVersion: expected.pgVersion,
112
+ });
88
113
  }
89
114
 
90
115
  /**
91
- * The `SchemaDiffer` for Postgres: the target's black-box comparison,
92
- * projected to the two issue lists. Namespace presence (`missing_schema`
93
- * `CREATE SCHEMA`) is intentionally NOT composed here: it is a planner-only
94
- * op-generation concern (verify rejects on the relational `missing_table` a
95
- * missing schema already produces), so the planner stitches it in around this
96
- * diff. Control-policy suppression of the policy issues is likewise a
97
- * per-consumer post-step (verify filters the issues; the planner filters the
98
- * calls).
116
+ * Resolves a verdict-diff issue's subject table's declared control policy
117
+ * directly from the contract, by delegating to the same node-typed resolver
118
+ * ({@link resolvePostgresNodeIssueControlPolicySubject}) the planner uses to
119
+ * gate DDL calls. `undefined` when the issue resolves to no contract table.
99
120
  */
100
- export function diffPostgresDatabaseSchema(
101
- input: PostgresDiffDatabaseSchemaInput,
102
- ): SchemaDiff<SqlSchemaDiffNode> {
103
- const { relational, schemaDiffIssues } = computePostgresSchemaComparison(input);
104
- return new SchemaDiff(relational.schema.issues, schemaDiffIssues);
121
+ function resolveControlPolicy(
122
+ issue: SchemaDiffIssue,
123
+ contract: Contract<SqlStorage>,
124
+ ): ControlPolicy | undefined {
125
+ const nodeIssue = blindCast<
126
+ SchemaDiffIssue<SqlSchemaDiffNode>,
127
+ 'every node in a Postgres schema diff tree is a SqlSchemaDiffNode'
128
+ >(issue);
129
+ return resolvePostgresNodeIssueControlPolicySubject(nodeIssue, contract)
130
+ ?.explicitNodeControlPolicy;
105
131
  }
106
132
 
107
133
  /**
108
- * The same combined comparison as {@link diffPostgresDatabaseSchema}, wrapped
109
- * in the verify envelope (`ok`/`summary`/`code`/`target`/`timings`) plus the
110
- * pass/warn/fail tree the CLI renders i.e. exactly the existing verify-result
111
- * schema shape, so nothing downstream changes.
134
+ * The Postgres full-tree node diff for the family verify verdict: derive
135
+ * the expected tree (resolved leaf values, expander threaded, FK schemas
136
+ * resolved, table-less namespaces pruned), normalize the actual tree for
137
+ * semantic satisfaction, run the generic
138
+ * differ, and scope out `not-expected` findings under namespaces the
139
+ * contract does not own. Ownership is role-aware, mirroring the legacy
140
+ * decomposition: relational extras check the PRUNED owned set (the legacy
141
+ * per-namespace walk never visited a table-less namespace, so its live
142
+ * relational contents are invisible), while `structural` extras (RLS
143
+ * policies) check the FULL owned set (the legacy policy diff governed
144
+ * every contract schema regardless of tables — RLS governance does not
145
+ * shrink because a namespace declares no tables). The codec `verifyType`
146
+ * hooks run once per contract namespace with tables against that
147
+ * namespace's paired actual node (the hooks read namespace-scoped state
148
+ * such as `nativeEnumTypeNames`).
112
149
  */
113
- export function verifyPostgresDatabaseSchema(
114
- input: PostgresDiffDatabaseSchemaInput,
115
- ): VerifyDatabaseSchemaResult {
116
- const { relational, schemaDiffIssues } = computePostgresSchemaComparison(input);
150
+ export function diffPostgresSchema(input: {
151
+ readonly contract: Contract<SqlStorage>;
152
+ readonly schema: SqlSchemaIRNode;
153
+ readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
154
+ }): SqlSchemaDiffResult {
155
+ const postgresContract = blindCast<
156
+ PostgresContract,
157
+ 'diffPostgresSchema is only called with a postgres contract'
158
+ >(input.contract);
159
+ PostgresDatabaseSchemaNode.assert(input.schema);
160
+ const actual = input.schema;
161
+ const expandNativeType = buildNativeTypeExpander(input.frameworkComponents);
162
+ const fullExpected = contractToPostgresDatabaseSchemaNode(postgresContract, {
163
+ annotationNamespace: 'pg',
164
+ ...ifDefined('expandNativeType', expandNativeType),
165
+ });
166
+ const expected = pruneTableLessNamespaces(fullExpected);
167
+ const normalizedActual = normalizePostgresActualForDiff(expected, actual);
168
+ const relationalOwned = ownedSchemaNames(expected);
169
+ const structuralOwned = ownedSchemaNames(fullExpected);
170
+ const issues = diffSchemas(expected, normalizedActual).filter((issue) => {
171
+ if (issue.reason !== 'not-expected') return true;
172
+ const namespaceSegment = issue.path[1];
173
+ if (namespaceSegment === undefined) return true;
174
+ const granularity = classifyDiffSubjectGranularity(issue, postgresDiffSubjectGranularity);
175
+ const owned = granularity === 'structural' ? structuralOwned : relationalOwned;
176
+ return owned.has(namespaceSegment);
177
+ });
178
+ const namespacePairs = Object.values(expected.namespaces).map((ns) => ({
179
+ actual: actual.namespaces[ns.schemaName],
180
+ }));
117
181
  return {
118
- ...relational,
119
- schema: { ...relational.schema, schemaDiffIssues },
182
+ issues,
183
+ resolveControlPolicy: (issue) => resolveControlPolicy(issue, postgresContract),
184
+ namespacePairs,
120
185
  };
121
186
  }
122
187
 
123
- function ownedSchemaNames(expected: PostgresDatabaseSchemaNode): ReadonlySet<string> {
124
- const policyNamespaces = Object.values(expected.namespaces).flatMap((ns) =>
125
- Object.values(ns.tables).flatMap((t) => t.policies.map((p) => p.namespaceId)),
126
- );
127
- return new Set([...policyNamespaces, ...expected.existingSchemas]);
128
- }
129
-
130
- // Renders a display-only reference string for the diff message. If policy
131
- // rendering grows, route it through the adapter's SQL renderer so the message
132
- // can't diverge from the emitted policy SQL.
133
- function renderPostgresPolicyReference(policy: PostgresPolicySchemaNode): string {
134
- return `policy "${policy.name}" on "${policy.namespaceId}"."${policy.tableName}"`;
135
- }
136
-
137
188
  /**
138
- * The policy node-diff the structural half of the combined comparison above.
139
- * Computes RLS-policy drift between two derived schema trees:
140
- *
141
- * 1. Runs the framework total diff over the two `PostgresDatabaseSchemaNode`
142
- * roots (database namespace table policy).
143
- * 2. Filters to policy-subject issues only this is transitional: the generic
144
- * differ walks the whole tree, but the legacy relational verifier still owns
145
- * table/column drift, so non-policy issues are dropped here.
146
- * 3. Remaps the message to a human-readable policy reference.
147
- *
148
- * Both trees are `PostgresDatabaseSchemaNode`s, so every issue node is a
149
- * `SqlSchemaDiffNode` — narrow the framework's `SchemaDiffIssue<DiffableNode>`
150
- * output once here (the single boundary cast), so every downstream consumer
151
- * (the ownership filter, the planner) reads the concrete node with no cast.
152
- *
153
- * Ownership filtering (dropping `extra` issues in namespaces a contract doesn't
154
- * own) is the caller's responsibility — use `filterIssuesByOwnership`.
189
+ * Adds an empty namespace node to the actual tree for every expected namespace
190
+ * absent from it. The relational plan diff pairs on namespace: a contract
191
+ * namespace whose live schema does not exist yet must surface each of its
192
+ * tables as `not-found` (→ `CREATE TABLE`), NOT as a single namespace
193
+ * `not-found` that subtree-coalescing would collapse (leaving `CREATE SCHEMA`
194
+ * with no tables). Padding makes the namespaces pair, so only table/column/
195
+ * policy drift surfaces; `CREATE SCHEMA` comes separately from the synthesized
196
+ * namespace-presence stitch (`verifyPostgresNamespacePresence`), never from the
197
+ * tree diff matching the retired per-namespace-paired relational walk, which
198
+ * paired a missing schema against an empty namespace node.
155
199
  */
156
- export function diffPostgresSchema(
200
+ function padActualNamespaces(
157
201
  expected: PostgresDatabaseSchemaNode,
158
202
  actual: PostgresDatabaseSchemaNode,
159
- ): readonly SchemaDiffIssue<SqlSchemaDiffNode>[] {
160
- const issues = blindCast<
161
- readonly SchemaDiffIssue<SqlSchemaDiffNode>[],
162
- 'both trees are PostgresDatabaseSchemaNodes, so every diff-issue node is a SqlSchemaDiffNode'
163
- >(diffSchemas(expected, actual));
203
+ ): PostgresDatabaseSchemaNode {
204
+ const namespaces: Record<string, PostgresNamespaceSchemaNode> = { ...actual.namespaces };
205
+ let padded = false;
206
+ for (const schemaName of Object.keys(expected.namespaces)) {
207
+ if (namespaces[schemaName] === undefined) {
208
+ namespaces[schemaName] = new PostgresNamespaceSchemaNode({
209
+ schemaName,
210
+ tables: {},
211
+ nativeEnumTypeNames: [],
212
+ });
213
+ padded = true;
214
+ }
215
+ }
216
+ if (!padded) return actual;
217
+ return new PostgresDatabaseSchemaNode({
218
+ namespaces,
219
+ roles: [...actual.roles],
220
+ existingSchemas: [...actual.existingSchemas],
221
+ pgVersion: actual.pgVersion,
222
+ });
223
+ }
164
224
 
165
- return issues
166
- .filter((i) => {
167
- const node = i.expected ?? i.actual;
168
- return node !== undefined && PostgresPolicySchemaNode.is(node);
169
- })
170
- .map((i) => {
171
- const node = i.expected ?? i.actual;
172
- if (node === undefined || !PostgresPolicySchemaNode.is(node)) return i;
173
- return { ...i, message: `${i.outcome}: ${renderPostgresPolicyReference(node)}` };
174
- });
225
+ export interface PostgresPlanDiff {
226
+ /** The desired ("end") tree — resolved leaf values (incl. `codecRef`) on every column, table-less namespaces pruned. */
227
+ readonly expected: PostgresDatabaseSchemaNode;
228
+ /** The live ("start") tree, padded with empty namespaces and normalized for semantic satisfaction against `expected`. */
229
+ readonly actual: PostgresDatabaseSchemaNode;
230
+ /** The one node diff over the two trees: relational + policy drift, role-aware ownership filtered. */
231
+ readonly issues: readonly SchemaDiffIssue<SqlSchemaDiffNode>[];
175
232
  }
176
233
 
177
234
  /**
178
- * Filters `extra` policy issues to those in owned namespaces. Call after
179
- * `diffPostgresSchema` with the union of namespace ids from the expected tree's
180
- * policies and its `existingSchemas`.
235
+ * The Postgres planner's diff input: the SAME tree-building
236
+ * `diffPostgresSchema` uses (expander threaded, FK schemas resolved,
237
+ * table-less namespaces pruned, actual normalized for semantic satisfaction,
238
+ * role-aware ownership filter) plus actual namespace padding (so a missing
239
+ * schema's tables surface as `not-found` instead of a swallowed namespace
240
+ * `not-found`). One differ drives both verify and plan; this is the
241
+ * plan-side derivation. The single issue list covers tables / columns /
242
+ * constraints / indexes / defaults AND policies — the caller splits it
243
+ * (relational → `mapNodeIssueToCall`; policy → RLS ops) and stitches in
244
+ * `CREATE SCHEMA` separately.
181
245
  */
182
- export function filterIssuesByOwnership(
183
- issues: readonly SchemaDiffIssue<SqlSchemaDiffNode>[],
184
- ownedSchemaNameSet: ReadonlySet<string>,
185
- ): readonly SchemaDiffIssue<SqlSchemaDiffNode>[] {
186
- return issues.filter((i) => {
187
- if (i.outcome !== 'extra') return true;
188
- if (i.actual === undefined) return false;
189
- return PostgresPolicySchemaNode.is(i.actual) && ownedSchemaNameSet.has(i.actual.namespaceId);
246
+ export function buildPostgresPlanDiff(input: {
247
+ readonly contract: Contract<SqlStorage>;
248
+ readonly actualSchema: SqlSchemaIRNode;
249
+ readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
250
+ }): PostgresPlanDiff {
251
+ const postgresContract = blindCast<
252
+ PostgresContract,
253
+ 'buildPostgresPlanDiff is only called with a postgres contract'
254
+ >(input.contract);
255
+ PostgresDatabaseSchemaNode.assert(input.actualSchema);
256
+ const actual = input.actualSchema;
257
+ const expandNativeType = buildNativeTypeExpander(input.frameworkComponents);
258
+ const projectionOptions = {
259
+ annotationNamespace: 'pg',
260
+ ...ifDefined('expandNativeType', expandNativeType),
261
+ };
262
+ const fullExpected = contractToPostgresDatabaseSchemaNode(postgresContract, projectionOptions);
263
+ const expected = pruneTableLessNamespaces(fullExpected);
264
+ const paddedActual = padActualNamespaces(expected, actual);
265
+ const normalizedActual = normalizePostgresActualForDiff(expected, paddedActual);
266
+ const relationalOwned = ownedSchemaNames(expected);
267
+ const structuralOwned = ownedSchemaNames(fullExpected);
268
+ const issues = blindCast<
269
+ readonly SchemaDiffIssue<SqlSchemaDiffNode>[],
270
+ 'both trees are PostgresDatabaseSchemaNodes, so every diff-issue node is a SqlSchemaDiffNode'
271
+ >(diffSchemas(expected, normalizedActual)).filter((issue) => {
272
+ if (issue.reason !== 'not-expected') return true;
273
+ const namespaceSegment = issue.path[1];
274
+ if (namespaceSegment === undefined) return true;
275
+ const granularity = classifyDiffSubjectGranularity(issue, postgresDiffSubjectGranularity);
276
+ const owned = granularity === 'structural' ? structuralOwned : relationalOwned;
277
+ return owned.has(namespaceSegment);
190
278
  });
279
+ return { expected, actual: normalizedActual, issues };
191
280
  }