@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.
- package/dist/control-policy-DUFgy9jO.mjs +1103 -0
- package/dist/control-policy-DUFgy9jO.mjs.map +1 -0
- package/dist/control.d.mts.map +1 -1
- package/dist/control.mjs +19 -40
- package/dist/control.mjs.map +1 -1
- package/dist/default-normalizer.mjs +247 -1
- package/dist/default-normalizer.mjs.map +1 -0
- package/dist/diff-database-schema-BmSmWcaY.mjs +313 -0
- package/dist/diff-database-schema-BmSmWcaY.mjs.map +1 -0
- package/dist/diff-database-schema.d.mts +46 -0
- package/dist/diff-database-schema.d.mts.map +1 -0
- package/dist/diff-database-schema.mjs +2 -0
- package/dist/issue-planner.d.mts +34 -10
- package/dist/issue-planner.d.mts.map +1 -1
- package/dist/issue-planner.mjs +2 -2
- package/dist/migration.d.mts +2 -2
- package/dist/migration.mjs +2 -2
- package/dist/native-type-normalizer.mjs +37 -1
- package/dist/native-type-normalizer.mjs.map +1 -0
- package/dist/{op-factory-call-BuoGT5UI.mjs → op-factory-call-BKRnMgrB.mjs} +2 -2
- package/dist/op-factory-call-BKRnMgrB.mjs.map +1 -0
- package/dist/{op-factory-call-Caqus-Qg.d.mts → op-factory-call-Dt-Jx5JS.d.mts} +2 -2
- package/dist/{op-factory-call-Caqus-Qg.d.mts.map → op-factory-call-Dt-Jx5JS.d.mts.map} +1 -1
- package/dist/op-factory-call.d.mts +2 -2
- package/dist/op-factory-call.mjs +2 -2
- package/dist/planner-BMBRPzsv.mjs +367 -0
- package/dist/planner-BMBRPzsv.mjs.map +1 -0
- package/dist/planner-ddl-builders-B8Nn6nHN.mjs.map +1 -1
- package/dist/planner-ddl-builders.d.mts +2 -2
- package/dist/planner-ddl-builders.d.mts.map +1 -1
- package/dist/planner-identity-values-CJPha2Sz.mjs.map +1 -1
- package/dist/planner-identity-values.d.mts +7 -1
- package/dist/planner-identity-values.d.mts.map +1 -1
- package/dist/planner-identity-values.mjs +2 -2
- package/dist/{planner-produced-postgres-migration-CtJi4sgS.d.mts → planner-produced-postgres-migration-C0a7I1-e.d.mts} +2 -2
- package/dist/{planner-produced-postgres-migration-CtJi4sgS.d.mts.map → planner-produced-postgres-migration-C0a7I1-e.d.mts.map} +1 -1
- package/dist/{planner-produced-postgres-migration-C2_H6Vvl.mjs → planner-produced-postgres-migration-Dp6IO_gj.mjs} +2 -2
- package/dist/{planner-produced-postgres-migration-C2_H6Vvl.mjs.map → planner-produced-postgres-migration-Dp6IO_gj.mjs.map} +1 -1
- package/dist/planner-produced-postgres-migration.d.mts +1 -1
- package/dist/planner-produced-postgres-migration.mjs +1 -1
- package/dist/planner-sql-checks-Cze3vMfe.mjs.map +1 -1
- package/dist/planner-sql-checks.d.mts +1 -1
- package/dist/planner-sql-checks.d.mts.map +1 -1
- package/dist/planner.d.mts +27 -67
- package/dist/planner.d.mts.map +1 -1
- package/dist/planner.mjs +3 -2
- package/dist/{postgres-database-schema-node-os8ogEap.d.mts → postgres-database-schema-node-C9bTuMrd.d.mts} +14 -7
- package/dist/postgres-database-schema-node-C9bTuMrd.d.mts.map +1 -0
- package/dist/{postgres-migration-D0QIVE-p.mjs → postgres-migration-D0jwzYDT.mjs} +2 -2
- package/dist/{postgres-migration-D0QIVE-p.mjs.map → postgres-migration-D0jwzYDT.mjs.map} +1 -1
- package/dist/{postgres-migration-CfyKDT7b.d.mts → postgres-migration-DFaxuRyI.d.mts} +2 -2
- package/dist/{postgres-migration-CfyKDT7b.d.mts.map → postgres-migration-DFaxuRyI.d.mts.map} +1 -1
- package/dist/{postgres-table-schema-node-Clei_xel.mjs → postgres-table-schema-node-BgaSrxYN.mjs} +30 -29
- package/dist/postgres-table-schema-node-BgaSrxYN.mjs.map +1 -0
- package/dist/schema-node-kinds-ClScchhi.mjs +76 -0
- package/dist/schema-node-kinds-ClScchhi.mjs.map +1 -0
- package/dist/types.d.mts +1 -1
- package/dist/types.mjs +1 -1
- package/package.json +21 -20
- package/src/core/migrations/column-ddl-rendering.ts +117 -0
- package/src/core/migrations/contract-to-postgres-database-schema-node.ts +25 -1
- package/src/core/migrations/control-policy.ts +68 -48
- package/src/core/migrations/diff-database-schema.ts +242 -153
- package/src/core/migrations/issue-planner.ts +621 -667
- package/src/core/migrations/operations/constraints.ts +1 -1
- package/src/core/migrations/planner-ddl-builders.ts +5 -2
- package/src/core/migrations/planner-identity-values.ts +1 -1
- package/src/core/migrations/planner-sql-checks.ts +1 -1
- package/src/core/migrations/planner-strategies.ts +248 -208
- package/src/core/migrations/planner.ts +180 -74
- package/src/core/migrations/runner.ts +1 -4
- package/src/core/migrations/verify-postgres-namespaces.ts +26 -15
- package/src/core/postgres-schema-verifier.ts +10 -7
- package/src/core/psl-infer/infer-psl-contract.ts +5 -10
- package/src/core/schema-ir/postgres-database-schema-node.ts +3 -6
- package/src/core/schema-ir/postgres-namespace-schema-node.ts +8 -9
- package/src/core/schema-ir/postgres-policy-schema-node.ts +3 -6
- package/src/core/schema-ir/postgres-role-schema-node.ts +3 -6
- package/src/core/schema-ir/postgres-table-schema-node.ts +20 -9
- package/src/core/schema-ir/schema-node-kinds.ts +80 -2
- package/src/exports/control.ts +10 -43
- package/src/exports/diff-database-schema.ts +7 -0
- package/src/exports/issue-planner.ts +1 -1
- package/src/exports/op-factory-call.ts +1 -0
- package/src/exports/planner-identity-values.ts +4 -1
- package/src/exports/planner.ts +0 -1
- package/dist/default-normalizer-Dug8Fez9.mjs +0 -248
- package/dist/default-normalizer-Dug8Fez9.mjs.map +0 -1
- package/dist/issue-planner-Cbh-xTEr.mjs +0 -849
- package/dist/issue-planner-Cbh-xTEr.mjs.map +0 -1
- package/dist/native-type-normalizer-Bc9XJzWC.mjs +0 -38
- package/dist/native-type-normalizer-Bc9XJzWC.mjs.map +0 -1
- package/dist/op-factory-call-BuoGT5UI.mjs.map +0 -1
- package/dist/planner-Bq0Z2sVO.mjs +0 -641
- package/dist/planner-Bq0Z2sVO.mjs.map +0 -1
- package/dist/postgres-database-schema-node-os8ogEap.d.mts.map +0 -1
- package/dist/postgres-table-schema-node-Clei_xel.mjs.map +0 -1
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
import { r as isPostgresSchema } from "./postgres-schema-CajYAwny.mjs";
|
|
2
|
+
import { i as quoteIdentifier } from "./sql-utils-DcfMz4MQ.mjs";
|
|
3
|
+
import { t as PostgresSchemaNodeKind } from "./schema-node-kinds-ClScchhi.mjs";
|
|
4
|
+
import { D as postgresDefaultToDdlColumnDefault, E as SetNotNullCall, O as buildTargetDetails, S as DropTableCall, T as SetDefaultCall, _ as DropConstraintCall, a as AddNotNullColumnWithTempDefaultCall, b as DropNotNullCall, c as AlterColumnTypeCall, f as CreateSchemaCall, g as DropColumnCall, h as DropCheckConstraintCall, i as AddNotNullColumnDirectCall, m as DataTransformCall, n as AddColumnCall, o as AddPrimaryKeyCall, p as CreateTableCall, r as AddForeignKeyCall, s as AddUniqueCall, t as AddCheckConstraintCall, u as CreateIndexCall, v as DropDefaultCall, w as RawSqlCall, y as DropIndexCall } from "./op-factory-call-BKRnMgrB.mjs";
|
|
5
|
+
import { t as buildExpectedFormatType } from "./planner-sql-checks-Cze3vMfe.mjs";
|
|
6
|
+
import { n as buildColumnTypeSql, t as buildColumnDefaultSql } from "./planner-ddl-builders-B8Nn6nHN.mjs";
|
|
7
|
+
import { n as resolveIdentityValue } from "./planner-identity-values-CJPha2Sz.mjs";
|
|
8
|
+
import { i as hasUniqueConstraint, n as hasForeignKey, t as buildSchemaLookupMap } from "./planner-schema-lookup-CiVaAQP-.mjs";
|
|
9
|
+
import { UNBOUND_NAMESPACE_ID, entityAt } from "@prisma-next/framework-components/ir";
|
|
10
|
+
import { blindCast } from "@prisma-next/utils/casts";
|
|
11
|
+
import { StorageTable } from "@prisma-next/sql-contract/types";
|
|
12
|
+
import { resolveValueSetValues } from "@prisma-next/family-sql/control";
|
|
13
|
+
import { ifDefined } from "@prisma-next/utils/defined";
|
|
14
|
+
import * as contractFree from "@prisma-next/sql-relational-core/contract-free";
|
|
15
|
+
import { RelationalSchemaNodeKind, SqlSchemaIR } from "@prisma-next/sql-schema-ir/types";
|
|
16
|
+
import { defaultIndexName } from "@prisma-next/sql-schema-ir/naming";
|
|
17
|
+
import { notOk, ok } from "@prisma-next/utils/result";
|
|
18
|
+
//#region src/core/migrations/column-ddl-rendering.ts
|
|
19
|
+
/**
|
|
20
|
+
* Reconstructs the `StorageColumn`-shaped fields the DDL builder functions
|
|
21
|
+
* (`buildColumnTypeSql`, `buildExpectedFormatType`, `resolveIdentityValue`)
|
|
22
|
+
* expect, from a column node's own stamped codec identity (`codecRef` /
|
|
23
|
+
* `codecBaseNativeType` / `codecNamedType`, Decision 5) — never the
|
|
24
|
+
* contract. The builders were written against `StorageColumn` and are
|
|
25
|
+
* unchanged here; only the shape feeding them moves from the contract to
|
|
26
|
+
* the node. An empty `storageTypes` catalog is passed alongside: the
|
|
27
|
+
* node's fields are already resolved past any `typeRef` indirection, so no
|
|
28
|
+
* live lookup is needed, and passing a non-empty catalog would risk a
|
|
29
|
+
* false `typeRef` hit against an unrelated storage type.
|
|
30
|
+
*/
|
|
31
|
+
function columnLike(column) {
|
|
32
|
+
if (column.codecRef === void 0 || column.codecBaseNativeType === void 0) throw new Error(`columnLike: expected column "${column.name}" carries no codec identity — the expected tree must be derived via contractToSchemaIR for planning`);
|
|
33
|
+
return {
|
|
34
|
+
nativeType: column.codecBaseNativeType,
|
|
35
|
+
codecId: column.codecRef.codecId,
|
|
36
|
+
nullable: column.nullable,
|
|
37
|
+
...ifDefined("many", column.many ?? column.codecRef.many),
|
|
38
|
+
...ifDefined("typeParams", column.codecRef.typeParams !== void 0 ? blindCast(column.codecRef.typeParams) : void 0),
|
|
39
|
+
...column.codecNamedType ? { typeRef: "<resolved>" } : {},
|
|
40
|
+
...ifDefined("default", column.resolvedDefault)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Builds the `CREATE TABLE` / `ADD COLUMN` DDL column for an expected column
|
|
45
|
+
* node, resolving type rendering from the node's codec identity against the
|
|
46
|
+
* codec hooks the caller holds — the same builder the pre-`plan(start, end)`
|
|
47
|
+
* op-path called, so the output is byte-identical.
|
|
48
|
+
*/
|
|
49
|
+
function renderColumnDdl(name, column, codecHooks) {
|
|
50
|
+
const like = columnLike(column);
|
|
51
|
+
const typeSql = buildColumnTypeSql(like, codecHooks, {});
|
|
52
|
+
const ddlDefault = postgresDefaultToDdlColumnDefault(like.default);
|
|
53
|
+
return contractFree.col(name, typeSql, {
|
|
54
|
+
...!column.nullable ? { notNull: true } : {},
|
|
55
|
+
...ifDefined("default", ddlDefault),
|
|
56
|
+
...ifDefined("codecRef", column.codecRef)
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Builds the `ALTER COLUMN … TYPE` operands for an expected column node.
|
|
61
|
+
*/
|
|
62
|
+
function renderColumnAlterType(column, codecHooks) {
|
|
63
|
+
const like = columnLike(column);
|
|
64
|
+
return {
|
|
65
|
+
qualifiedTargetType: buildColumnTypeSql(like, codecHooks, {}, false),
|
|
66
|
+
formatTypeExpected: buildExpectedFormatType(like, codecHooks, {})
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolves the identity value (monoid neutral element) SQL literal used as
|
|
71
|
+
* the temporary default when adding a NOT-NULL column with no contract
|
|
72
|
+
* default (`notNullAddColumnCallStrategy`'s shared-temp-default backfill).
|
|
73
|
+
* `null` when the column's type has no built-in/codec-provided identity
|
|
74
|
+
* value.
|
|
75
|
+
*/
|
|
76
|
+
function resolveColumnTemporaryDefault(column, codecHooks) {
|
|
77
|
+
return resolveIdentityValue(columnLike(column), codecHooks, {});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The column's `SET DEFAULT` clause SQL, resolved from a column-default
|
|
81
|
+
* diff node. `''` when the node carries no resolved default.
|
|
82
|
+
*/
|
|
83
|
+
function renderColumnDefaultSql(defaultNode) {
|
|
84
|
+
if (defaultNode.resolved === void 0) return "";
|
|
85
|
+
return buildColumnDefaultSql(defaultNode.resolved, {
|
|
86
|
+
nativeType: defaultNode.nativeTypeContext ?? "",
|
|
87
|
+
...ifDefined("many", defaultNode.many)
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/core/migrations/planner-strategies.ts
|
|
92
|
+
/**
|
|
93
|
+
* Look up a storage table by its explicit namespace coordinate. Returns
|
|
94
|
+
* `undefined` when the namespace has no table by that name (or no such
|
|
95
|
+
* namespace exists). Callers that get `undefined` MUST treat it as an
|
|
96
|
+
* explicit conflict — never silently fall back to a global default
|
|
97
|
+
* schema or a name-only walk, because that footgun would resolve a
|
|
98
|
+
* stale or duplicate table name to whichever namespace the iteration
|
|
99
|
+
* order surfaced first (a real data-loss hazard in multi-namespace
|
|
100
|
+
* contracts where two namespaces can carry the same table name).
|
|
101
|
+
*/
|
|
102
|
+
function tableAt(storage, namespaceId, tableName) {
|
|
103
|
+
const ns = storage.namespaces[namespaceId];
|
|
104
|
+
if (ns === void 0) return void 0;
|
|
105
|
+
return ns.entries.table?.[tableName];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the DDL schema name for a namespace coordinate. Postgres-aware
|
|
109
|
+
* namespaces dispatch to their polymorphic `ddlSchemaName` override —
|
|
110
|
+
* named schemas return their own id; the unbound singleton returns
|
|
111
|
+
* `UNBOUND_NAMESPACE_ID`. Legacy single-namespace contracts whose
|
|
112
|
+
* `__unbound__` slot is the framework-default `SqlUnboundNamespace`
|
|
113
|
+
* (rather than the Postgres-aware `PostgresUnboundSchema`) flow the
|
|
114
|
+
* coordinate through unchanged so downstream `qualifyTableName`
|
|
115
|
+
* resolves polymorphically.
|
|
116
|
+
*/
|
|
117
|
+
function resolveDdlSchemaForNamespace(ctx, namespaceId) {
|
|
118
|
+
const namespace = ctx.toContract.storage.namespaces[namespaceId];
|
|
119
|
+
if (isPostgresSchema(namespace)) return namespace.ddlSchemaName(ctx.toContract.storage);
|
|
120
|
+
return namespaceId;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Recovers the contract namespace id for a DDL schema name embedded in a
|
|
124
|
+
* diff-issue path (`path[1]`). The strategies need the CONTRACT column/table
|
|
125
|
+
* (for codec-hook reads and eligibility probes the retained subsystems still
|
|
126
|
+
* run) even though the issue itself only carries the resolved DDL schema —
|
|
127
|
+
* this is the reverse of `resolveDdlSchemaForNamespace`.
|
|
128
|
+
*/
|
|
129
|
+
function namespaceIdForDdlSchema(ctx, ddlSchemaName) {
|
|
130
|
+
return resolveNamespaceIdForDdlSchema(ctx.toContract, ddlSchemaName);
|
|
131
|
+
}
|
|
132
|
+
/** A `not-equal` column issue whose node pair is narrowed to `SqlColumnIR`. */
|
|
133
|
+
function columnNodePair(issue) {
|
|
134
|
+
const node = issueNode(issue);
|
|
135
|
+
if (node === void 0 || node.nodeKind !== RelationalSchemaNodeKind.column) return void 0;
|
|
136
|
+
if (issue.expected === void 0 || issue.actual === void 0) return void 0;
|
|
137
|
+
return {
|
|
138
|
+
expected: blindCast(issue.expected),
|
|
139
|
+
actual: blindCast(issue.actual)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const notNullBackfillCallStrategy = (issues, ctx) => {
|
|
143
|
+
if (!ctx.policy.allowedOperationClasses.includes("data")) return { kind: "no_match" };
|
|
144
|
+
const matched = [];
|
|
145
|
+
const calls = [];
|
|
146
|
+
for (const issue of issues) {
|
|
147
|
+
if (issue.reason !== "not-found") continue;
|
|
148
|
+
const node = issueNode(issue);
|
|
149
|
+
if (node === void 0 || node.nodeKind !== RelationalSchemaNodeKind.column) continue;
|
|
150
|
+
const expected = blindCast(issue.expected);
|
|
151
|
+
if (expected.nullable !== false || expected.resolvedDefault !== void 0) continue;
|
|
152
|
+
const ddlSchemaName = issueSchemaName(issue);
|
|
153
|
+
const tableName = issueTableName(issue);
|
|
154
|
+
if (ddlSchemaName === void 0 || tableName === void 0) continue;
|
|
155
|
+
const schemaName = emissionSchemaName(ctx, ddlSchemaName);
|
|
156
|
+
matched.push(issue);
|
|
157
|
+
const ddl = renderColumnDdl(expected.name, expected, ctx.codecHooks);
|
|
158
|
+
const nullableSpec = contractFree.col(ddl.name, ddl.type, { ...ddl.codecRef !== void 0 ? { codecRef: ddl.codecRef } : {} });
|
|
159
|
+
calls.push(new AddColumnCall(schemaName, tableName, nullableSpec), new DataTransformCall(`backfill-${tableName}-${expected.name}`, `backfill-${tableName}-${expected.name}:check`, `backfill-${tableName}-${expected.name}:run`), new SetNotNullCall(schemaName, tableName, expected.name));
|
|
160
|
+
}
|
|
161
|
+
if (matched.length === 0) return { kind: "no_match" };
|
|
162
|
+
return {
|
|
163
|
+
kind: "match",
|
|
164
|
+
issues: issues.filter((i) => !matched.includes(i)),
|
|
165
|
+
calls,
|
|
166
|
+
recipe: true
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
const SAFE_WIDENINGS = new Set([
|
|
170
|
+
"int2→int4",
|
|
171
|
+
"int2→int8",
|
|
172
|
+
"int4→int8",
|
|
173
|
+
"float4→float8"
|
|
174
|
+
]);
|
|
175
|
+
/**
|
|
176
|
+
* Handles `not-equal` column issues whose TYPE differs. `fromContract` is
|
|
177
|
+
* only supplied by `migration plan` — for reconciliation (`db update` /
|
|
178
|
+
* `db init`, `fromContract === null`) this strategy never fires, mirroring
|
|
179
|
+
* the legacy `typeChangeCallStrategy`'s requirement of a prior contract:
|
|
180
|
+
* `mapNodeIssueToCall`'s in-place ALTER covers reconciliation directly.
|
|
181
|
+
*
|
|
182
|
+
* A single node issue can carry BOTH type and nullability drift (Postgres
|
|
183
|
+
* alters in place, so the differ emits one `not-equal` column issue where
|
|
184
|
+
* the legacy coordinate walk emitted two: `type_mismatch` +
|
|
185
|
+
* `nullability_mismatch`). When this strategy consumes the issue for its
|
|
186
|
+
* type portion, it also emits whatever nullability delta the same node pair
|
|
187
|
+
* carries — using the same construction `nullableTighteningCallStrategy` /
|
|
188
|
+
* the mapper's direct dispatch would have used — so the issue is never
|
|
189
|
+
* partially handled.
|
|
190
|
+
*/
|
|
191
|
+
const typeChangeCallStrategy = (issues, ctx) => {
|
|
192
|
+
if (ctx.fromContract === null) return { kind: "no_match" };
|
|
193
|
+
const dataAllowed = ctx.policy.allowedOperationClasses.includes("data");
|
|
194
|
+
const matched = [];
|
|
195
|
+
const calls = [];
|
|
196
|
+
for (const issue of issues) {
|
|
197
|
+
if (issue.reason !== "not-equal") continue;
|
|
198
|
+
const pair = columnNodePair(issue);
|
|
199
|
+
if (pair === void 0) continue;
|
|
200
|
+
const { expected, actual } = pair;
|
|
201
|
+
if (!columnTypeChangedNativeOnly(expected, actual)) continue;
|
|
202
|
+
const fromType = actual.nativeType;
|
|
203
|
+
const toType = expected.nativeType;
|
|
204
|
+
const isSafeWidening = SAFE_WIDENINGS.has(`${fromType}→${toType}`);
|
|
205
|
+
if (!isSafeWidening && !dataAllowed) continue;
|
|
206
|
+
const ddlSchemaName = issueSchemaName(issue);
|
|
207
|
+
const tableName = issueTableName(issue);
|
|
208
|
+
if (ddlSchemaName === void 0 || tableName === void 0) continue;
|
|
209
|
+
const schemaName = emissionSchemaName(ctx, ddlSchemaName);
|
|
210
|
+
matched.push(issue);
|
|
211
|
+
const { qualifiedTargetType, formatTypeExpected } = renderColumnAlterType(expected, ctx.codecHooks);
|
|
212
|
+
const alterOpts = {
|
|
213
|
+
qualifiedTargetType,
|
|
214
|
+
formatTypeExpected,
|
|
215
|
+
rawTargetTypeForLabel: qualifiedTargetType
|
|
216
|
+
};
|
|
217
|
+
if (isSafeWidening) calls.push(new AlterColumnTypeCall(schemaName, tableName, expected.name, alterOpts));
|
|
218
|
+
else calls.push(new DataTransformCall(`typechange-${tableName}-${expected.name}`, `typechange-${tableName}-${expected.name}:check`, `typechange-${tableName}-${expected.name}:run`), new AlterColumnTypeCall(schemaName, tableName, expected.name, alterOpts));
|
|
219
|
+
if (expected.nullable !== actual.nullable) if (expected.nullable) calls.push(new DropNotNullCall(schemaName, tableName, expected.name));
|
|
220
|
+
else if (dataAllowed) calls.push(new DataTransformCall(`handle-nulls-${tableName}-${expected.name}`, `handle-nulls-${tableName}-${expected.name}:check`, `handle-nulls-${tableName}-${expected.name}:run`), new SetNotNullCall(schemaName, tableName, expected.name));
|
|
221
|
+
else calls.push(new SetNotNullCall(schemaName, tableName, expected.name));
|
|
222
|
+
}
|
|
223
|
+
if (matched.length === 0) return { kind: "no_match" };
|
|
224
|
+
return {
|
|
225
|
+
kind: "match",
|
|
226
|
+
issues: issues.filter((i) => !matched.includes(i)),
|
|
227
|
+
calls,
|
|
228
|
+
recipe: true
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Whether the raw (unresolved) native type differs — the SAME comparison
|
|
233
|
+
* `typeChangeCallStrategy` always used (`fromColumn.nativeType !==
|
|
234
|
+
* toColumn.nativeType`), which for the widenable numeric/float types
|
|
235
|
+
* `SAFE_WIDENINGS` lists is identical to the resolved comparison
|
|
236
|
+
* `columnTypeChanged` (in `issue-planner.ts`) performs.
|
|
237
|
+
*/
|
|
238
|
+
function columnTypeChangedNativeOnly(expected, actual) {
|
|
239
|
+
return expected.nativeType !== actual.nativeType;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Handles `not-equal` column issues whose type did NOT change but
|
|
243
|
+
* nullability tightened (contract requires NOT NULL, live column is
|
|
244
|
+
* nullable). A type-changed issue's nullability delta (if any) is already
|
|
245
|
+
* handled by `typeChangeCallStrategy`, which runs first — this strategy
|
|
246
|
+
* only ever sees issues that strategy left behind.
|
|
247
|
+
*/
|
|
248
|
+
const nullableTighteningCallStrategy = (issues, ctx) => {
|
|
249
|
+
if (!ctx.policy.allowedOperationClasses.includes("data")) return { kind: "no_match" };
|
|
250
|
+
const matched = [];
|
|
251
|
+
const calls = [];
|
|
252
|
+
for (const issue of issues) {
|
|
253
|
+
if (issue.reason !== "not-equal") continue;
|
|
254
|
+
const pair = columnNodePair(issue);
|
|
255
|
+
if (pair === void 0) continue;
|
|
256
|
+
const { expected, actual } = pair;
|
|
257
|
+
if (columnTypeChangedNativeOnly(expected, actual)) continue;
|
|
258
|
+
if (expected.nullable !== false || actual.nullable !== true) continue;
|
|
259
|
+
const ddlSchemaName = issueSchemaName(issue);
|
|
260
|
+
const tableName = issueTableName(issue);
|
|
261
|
+
if (ddlSchemaName === void 0 || tableName === void 0) continue;
|
|
262
|
+
const schemaName = emissionSchemaName(ctx, ddlSchemaName);
|
|
263
|
+
matched.push(issue);
|
|
264
|
+
calls.push(new DataTransformCall(`handle-nulls-${tableName}-${expected.name}`, `handle-nulls-${tableName}-${expected.name}:check`, `handle-nulls-${tableName}-${expected.name}:run`), new SetNotNullCall(schemaName, tableName, expected.name));
|
|
265
|
+
}
|
|
266
|
+
if (matched.length === 0) return { kind: "no_match" };
|
|
267
|
+
return {
|
|
268
|
+
kind: "match",
|
|
269
|
+
issues: issues.filter((i) => !matched.includes(i)),
|
|
270
|
+
calls,
|
|
271
|
+
recipe: true
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* Collects every check constraint from a table in the contract storage.
|
|
276
|
+
* Returns an empty array when the table has no checks or the table is absent.
|
|
277
|
+
*/
|
|
278
|
+
function collectContractChecks(storage, namespaceId, tableName) {
|
|
279
|
+
const ns = storage.namespaces[namespaceId];
|
|
280
|
+
const tableRaw = ns !== void 0 ? ns.entries.table?.[tableName] : void 0;
|
|
281
|
+
if (!(tableRaw instanceof StorageTable)) return [];
|
|
282
|
+
const checks = tableRaw.checks;
|
|
283
|
+
if (!checks || checks.length === 0) return [];
|
|
284
|
+
return checks.map((c) => ({
|
|
285
|
+
name: c.name,
|
|
286
|
+
column: c.column,
|
|
287
|
+
permittedValues: resolveValueSetValues(c.valueSet, storage, `check "${c.name}" on "${tableName}"`)
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Compares two value arrays as unordered sets.
|
|
292
|
+
*/
|
|
293
|
+
function checkValueSetsEqual(a, b) {
|
|
294
|
+
if (a.length !== b.length) return false;
|
|
295
|
+
const bSet = new Set(b);
|
|
296
|
+
return a.every((v) => bSet.has(v));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Plans check-constraint migrations for `enumType`-authored columns.
|
|
300
|
+
*
|
|
301
|
+
* Walks every namespace's tables in the target contract (the check nodes'
|
|
302
|
+
* resolved `permittedValues` are ultimately sourced from the same
|
|
303
|
+
* contract-declared value sets, so walking the contract directly is the
|
|
304
|
+
* simplest faithful port — the strategy's decisions never depend on which
|
|
305
|
+
* ISSUES happen to be in the input list, only on the contract + live schema
|
|
306
|
+
* shapes). For each table that carries `checks`, diffs the contract-expected
|
|
307
|
+
* checks against the live schema's checks:
|
|
308
|
+
*
|
|
309
|
+
* - Check in contract, absent from live DB → `AddCheckConstraintCall`.
|
|
310
|
+
* - Check in live DB, absent from contract → `DropCheckConstraintCall`.
|
|
311
|
+
* - Check on both sides but value sets differ → `DropCheckConstraintCall`
|
|
312
|
+
* then `AddCheckConstraintCall` (drop + recreate; a check predicate cannot
|
|
313
|
+
* be altered in place).
|
|
314
|
+
*
|
|
315
|
+
* Consumes every `sql-check-constraint` issue on a table this walk handles
|
|
316
|
+
* (not-found/not-expected/not-equal), leaving check issues on tables with
|
|
317
|
+
* NO contract checks to `mapNodeIssueToCall`'s `not-expected` fallback.
|
|
318
|
+
*/
|
|
319
|
+
const checkConstraintPlanCallStrategy = (issues, ctx) => {
|
|
320
|
+
const calls = [];
|
|
321
|
+
const handledIssueKeys = /* @__PURE__ */ new Set();
|
|
322
|
+
for (const [namespaceId, ns] of Object.entries(ctx.toContract.storage.namespaces)) for (const tableName of Object.keys(ns.entries.table ?? {})) {
|
|
323
|
+
const contractChecks = collectContractChecks(ctx.toContract.storage, namespaceId, tableName);
|
|
324
|
+
if (contractChecks.length === 0) continue;
|
|
325
|
+
const liveChecks = ctx.schema.tables[tableName]?.checks ?? [];
|
|
326
|
+
const ddlSchema = resolveDdlSchemaForNamespace(ctx, namespaceId);
|
|
327
|
+
for (const contractCheck of contractChecks) {
|
|
328
|
+
const liveCheck = liveChecks.find((c) => c.name === contractCheck.name);
|
|
329
|
+
const issueKey = `${tableName} ${contractCheck.name}`;
|
|
330
|
+
if (!liveCheck) {
|
|
331
|
+
calls.push(new AddCheckConstraintCall(ddlSchema, tableName, contractCheck.name, contractCheck.column, contractCheck.permittedValues));
|
|
332
|
+
handledIssueKeys.add(issueKey);
|
|
333
|
+
} else if (!checkValueSetsEqual(contractCheck.permittedValues, liveCheck.permittedValues)) {
|
|
334
|
+
calls.push(new DropCheckConstraintCall(ddlSchema, tableName, contractCheck.name), new AddCheckConstraintCall(ddlSchema, tableName, contractCheck.name, contractCheck.column, contractCheck.permittedValues));
|
|
335
|
+
handledIssueKeys.add(issueKey);
|
|
336
|
+
} else handledIssueKeys.add(issueKey);
|
|
337
|
+
}
|
|
338
|
+
for (const liveCheck of liveChecks) if (!contractChecks.some((c) => c.name === liveCheck.name)) {
|
|
339
|
+
const issueKey = `${tableName} ${liveCheck.name}`;
|
|
340
|
+
calls.push(new DropCheckConstraintCall(ddlSchema, tableName, liveCheck.name));
|
|
341
|
+
handledIssueKeys.add(issueKey);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (calls.length === 0 && handledIssueKeys.size === 0) return { kind: "no_match" };
|
|
345
|
+
return {
|
|
346
|
+
kind: "match",
|
|
347
|
+
issues: issues.filter((issue) => {
|
|
348
|
+
const node = issueNode(issue);
|
|
349
|
+
if (node === void 0 || node.nodeKind !== RelationalSchemaNodeKind.check) return true;
|
|
350
|
+
const tableName = issueTableName(issue);
|
|
351
|
+
if (tableName === void 0) return true;
|
|
352
|
+
const checkName = blindCast(node).name;
|
|
353
|
+
return !handledIssueKeys.has(`${tableName} ${checkName}`);
|
|
354
|
+
}),
|
|
355
|
+
calls
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* Dispatches codec-typed storage types through their codec's
|
|
360
|
+
* `planTypeOperations` hook (the authoritative source for codec-driven DDL
|
|
361
|
+
* such as custom type creation). Codec extension/type ops are not modeled as
|
|
362
|
+
* diff nodes — this strategy drives entirely off `ctx.toContract.storage.types`
|
|
363
|
+
* + codec hooks, consuming nothing from the node issue list (there is no
|
|
364
|
+
* node-vocabulary equivalent of `type_missing` / `enum_values_changed`).
|
|
365
|
+
*/
|
|
366
|
+
const storageTypePlanCallStrategy = (issues, ctx) => {
|
|
367
|
+
const storageTypes = ctx.toContract.storage.types ?? {};
|
|
368
|
+
if (Object.keys(storageTypes).length === 0) return { kind: "no_match" };
|
|
369
|
+
const calls = [];
|
|
370
|
+
for (const [typeName, typeInstance] of Object.entries(storageTypes).sort(([a], [b]) => a.localeCompare(b))) {
|
|
371
|
+
const codecInstance = typeInstance;
|
|
372
|
+
const hook = ctx.codecHooks.get(codecInstance.codecId);
|
|
373
|
+
if (!hook?.planTypeOperations) continue;
|
|
374
|
+
const planResult = hook.planTypeOperations({
|
|
375
|
+
typeName,
|
|
376
|
+
typeInstance: codecInstance,
|
|
377
|
+
contract: ctx.toContract,
|
|
378
|
+
schema: ctx.schema,
|
|
379
|
+
schemaName: ctx.schemaName,
|
|
380
|
+
policy: ctx.policy
|
|
381
|
+
});
|
|
382
|
+
if (!planResult) continue;
|
|
383
|
+
for (const op of planResult.operations) calls.push(new RawSqlCall({
|
|
384
|
+
...op,
|
|
385
|
+
target: {
|
|
386
|
+
id: op.target.id,
|
|
387
|
+
details: buildTargetDetails("type", typeName, ctx.schemaName)
|
|
388
|
+
}
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
if (calls.length === 0) return { kind: "no_match" };
|
|
392
|
+
return {
|
|
393
|
+
kind: "match",
|
|
394
|
+
issues,
|
|
395
|
+
calls
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
/**
|
|
399
|
+
* Handles `not-found` column issues for NOT NULL columns without a contract
|
|
400
|
+
* default. Replaces the legacy `buildAddColumnItem` non-default branches.
|
|
401
|
+
*
|
|
402
|
+
* Two shapes:
|
|
403
|
+
* - Shared-temp-default safe: emit a single atomic composite op (add
|
|
404
|
+
* nullable → backfill identity value → `SET NOT NULL` → `DROP DEFAULT`).
|
|
405
|
+
* The temp-default value is resolved from the column node's `codecRef`
|
|
406
|
+
* (`resolveColumnTemporaryDefault`, wrapping `resolveIdentityValue`).
|
|
407
|
+
* - Empty-table guarded: emit a hand-built op with a `tableIsEmptyCheck`
|
|
408
|
+
* precheck so the failure message is "table is not empty" rather than the
|
|
409
|
+
* raw PG NOT NULL violation.
|
|
410
|
+
*
|
|
411
|
+
* "Normal" not-found column cases (nullable or has a contract default) are
|
|
412
|
+
* left for `mapNodeIssueToCall`'s default `AddColumnCall` emission.
|
|
413
|
+
*/
|
|
414
|
+
const notNullAddColumnCallStrategy = (issues, ctx) => {
|
|
415
|
+
const matched = [];
|
|
416
|
+
const calls = [];
|
|
417
|
+
const schemaLookups = buildSchemaLookupMap(ctx.schema);
|
|
418
|
+
const mutableCodecHooks = ctx.codecHooks;
|
|
419
|
+
const mutableStorageTypes = ctx.storageTypes;
|
|
420
|
+
for (const issue of issues) {
|
|
421
|
+
if (issue.reason !== "not-found") continue;
|
|
422
|
+
const node = issueNode(issue);
|
|
423
|
+
if (node === void 0 || node.nodeKind !== RelationalSchemaNodeKind.column) continue;
|
|
424
|
+
const expected = blindCast(issue.expected);
|
|
425
|
+
if (expected.nullable !== false || expected.resolvedDefault !== void 0) continue;
|
|
426
|
+
const ddlSchemaName = issueSchemaName(issue);
|
|
427
|
+
const tableName = issueTableName(issue);
|
|
428
|
+
if (ddlSchemaName === void 0 || tableName === void 0) continue;
|
|
429
|
+
const namespaceId = namespaceIdForDdlSchema(ctx, ddlSchemaName);
|
|
430
|
+
const schemaName = namespaceId === UNBOUND_NAMESPACE_ID ? UNBOUND_NAMESPACE_ID : ddlSchemaName;
|
|
431
|
+
const contractTable = tableAt(ctx.toContract.storage, namespaceId, tableName);
|
|
432
|
+
const column = contractTable?.columns[expected.name];
|
|
433
|
+
if (!contractTable || !column) continue;
|
|
434
|
+
const schemaTable = ctx.schema.tables[tableName];
|
|
435
|
+
if (!schemaTable) continue;
|
|
436
|
+
const temporaryDefault = resolveColumnTemporaryDefault(expected, ctx.codecHooks);
|
|
437
|
+
const schemaLookup = schemaLookups.get(tableName);
|
|
438
|
+
const canUseSharedTempDefault = temporaryDefault !== null && canUseSharedTemporaryDefaultStrategy({
|
|
439
|
+
table: contractTable,
|
|
440
|
+
schemaTable,
|
|
441
|
+
schemaLookup,
|
|
442
|
+
columnName: expected.name
|
|
443
|
+
});
|
|
444
|
+
matched.push(issue);
|
|
445
|
+
if (canUseSharedTempDefault && temporaryDefault !== null) {
|
|
446
|
+
calls.push(new AddNotNullColumnWithTempDefaultCall({
|
|
447
|
+
schemaName,
|
|
448
|
+
tableName,
|
|
449
|
+
columnName: expected.name,
|
|
450
|
+
column,
|
|
451
|
+
codecHooks: mutableCodecHooks,
|
|
452
|
+
storageTypes: mutableStorageTypes,
|
|
453
|
+
temporaryDefault
|
|
454
|
+
}));
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
calls.push(new AddNotNullColumnDirectCall(schemaName, tableName, expected.name, renderColumnDdl(expected.name, expected, ctx.codecHooks)));
|
|
458
|
+
}
|
|
459
|
+
if (matched.length === 0) return { kind: "no_match" };
|
|
460
|
+
return {
|
|
461
|
+
kind: "match",
|
|
462
|
+
issues: issues.filter((i) => !matched.includes(i)),
|
|
463
|
+
calls
|
|
464
|
+
};
|
|
465
|
+
};
|
|
466
|
+
function canUseSharedTemporaryDefaultStrategy(options) {
|
|
467
|
+
const { table, schemaTable, schemaLookup, columnName } = options;
|
|
468
|
+
if (table.primaryKey?.columns.includes(columnName) && !schemaTable.primaryKey) return false;
|
|
469
|
+
for (const unique of table.uniques) {
|
|
470
|
+
if (!unique.columns.includes(columnName)) continue;
|
|
471
|
+
if (!schemaLookup || !hasUniqueConstraint(schemaLookup, unique.columns)) return false;
|
|
472
|
+
}
|
|
473
|
+
for (const foreignKey of table.foreignKeys) {
|
|
474
|
+
if (foreignKey.constraint === false || !foreignKey.source.columns.includes(columnName)) continue;
|
|
475
|
+
if (!schemaLookup || !hasForeignKey(schemaLookup, foreignKey)) return false;
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Ordered list of Postgres planner strategies, shared by `migration plan`
|
|
481
|
+
* and `db update` / `db init`. The issue planner runs each strategy in
|
|
482
|
+
* order, letting it consume any issues it handles, and routes whatever's
|
|
483
|
+
* left through `mapNodeIssueToCall`. Behavior diverges purely on
|
|
484
|
+
* `policy.allowedOperationClasses`:
|
|
485
|
+
*
|
|
486
|
+
* - When `'data'` is allowed (`migration plan`), the data-safe strategies
|
|
487
|
+
* (`notNullBackfillCallStrategy`, `typeChangeCallStrategy`,
|
|
488
|
+
* `nullableTighteningCallStrategy`) consume their matching issues and emit
|
|
489
|
+
* `DataTransformCall` placeholders or recipe ops.
|
|
490
|
+
*
|
|
491
|
+
* - When `'data'` is not allowed (`db update` / `db init`), the
|
|
492
|
+
* placeholder-emitting strategies short-circuit to `no_match`, leaving
|
|
493
|
+
* the issue for the downstream strategies (`storageTypePlanCallStrategy`,
|
|
494
|
+
* `notNullAddColumnCallStrategy`) or the `mapNodeIssueToCall` default to
|
|
495
|
+
* handle with direct DDL.
|
|
496
|
+
*
|
|
497
|
+
* Codec-typed storage type entries are dispatched through
|
|
498
|
+
* `storageTypePlanCallStrategy`.
|
|
499
|
+
*/
|
|
500
|
+
const postgresPlannerStrategies = [
|
|
501
|
+
notNullBackfillCallStrategy,
|
|
502
|
+
typeChangeCallStrategy,
|
|
503
|
+
nullableTighteningCallStrategy,
|
|
504
|
+
checkConstraintPlanCallStrategy,
|
|
505
|
+
storageTypePlanCallStrategy,
|
|
506
|
+
notNullAddColumnCallStrategy
|
|
507
|
+
];
|
|
508
|
+
//#endregion
|
|
509
|
+
//#region src/core/migrations/issue-planner.ts
|
|
510
|
+
/**
|
|
511
|
+
* Deterministic name for the element-non-null CHECK constraint on a scalar-array
|
|
512
|
+
* column. Distinct `_elem_not_null` suffix avoids collision with the enum
|
|
513
|
+
* value-set `_check` constraints. Re-emitting the same schema produces the same
|
|
514
|
+
* name, so `pg_get_constraintdef`-based verify sees no drift.
|
|
515
|
+
*/
|
|
516
|
+
function elementNonNullCheckName(tableName, columnName) {
|
|
517
|
+
return `${tableName}_${columnName}_elem_not_null`;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Predicate enforcing that a scalar-array column carries no NULL element. The
|
|
521
|
+
* array column itself may be NULL (container nullability is the column's NOT NULL
|
|
522
|
+
* clause); `array_position` over a NULL array yields NULL, which a CHECK treats
|
|
523
|
+
* as satisfied, so a nullable array column is unaffected.
|
|
524
|
+
*/
|
|
525
|
+
function elementNonNullCheckExpression(columnName) {
|
|
526
|
+
return `array_position(${quoteIdentifier(columnName)}, NULL) IS NULL`;
|
|
527
|
+
}
|
|
528
|
+
function issueConflict(kind, summary, location) {
|
|
529
|
+
return {
|
|
530
|
+
kind,
|
|
531
|
+
summary,
|
|
532
|
+
why: "Use `migration new` to author a custom migration for this change.",
|
|
533
|
+
...location ? { location } : {}
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Classifies calls into DDL sequencing buckets. The order matches the
|
|
538
|
+
* legacy walk-schema planner's emission order so `db init` and `db update`
|
|
539
|
+
* produce byte-identical plans for the shared shape (deps → drops → tables
|
|
540
|
+
* → columns → alters → PKs → uniques → indexes → FKs).
|
|
541
|
+
*/
|
|
542
|
+
function classifyCall(call) {
|
|
543
|
+
switch (call.factoryName) {
|
|
544
|
+
case "createExtension":
|
|
545
|
+
case "createSchema": return "dep";
|
|
546
|
+
case "dropTable":
|
|
547
|
+
case "dropColumn":
|
|
548
|
+
case "dropConstraint":
|
|
549
|
+
case "dropCheckConstraint":
|
|
550
|
+
case "dropIndex":
|
|
551
|
+
case "dropDefault": return "drop";
|
|
552
|
+
case "addCheckConstraint": return "unique";
|
|
553
|
+
case "createTable": return "table";
|
|
554
|
+
case "enableRowLevelSecurity": return "rlsEnable";
|
|
555
|
+
case "createRlsPolicy": return "rlsPolicy";
|
|
556
|
+
case "dropRlsPolicy": return "drop";
|
|
557
|
+
case "addColumn": return "column";
|
|
558
|
+
case "alterColumnType":
|
|
559
|
+
case "setNotNull":
|
|
560
|
+
case "dropNotNull":
|
|
561
|
+
case "setDefault": return "alter";
|
|
562
|
+
case "addPrimaryKey": return "primaryKey";
|
|
563
|
+
case "addUnique": return "unique";
|
|
564
|
+
case "createIndex": return "index";
|
|
565
|
+
case "addForeignKey": return "foreignKey";
|
|
566
|
+
case "rawSql":
|
|
567
|
+
if (call.op?.target?.details?.objectType === "type") return "dep";
|
|
568
|
+
return "alter";
|
|
569
|
+
default: return "alter";
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const DEFAULT_POLICY = { allowedOperationClasses: [
|
|
573
|
+
"additive",
|
|
574
|
+
"widening",
|
|
575
|
+
"destructive",
|
|
576
|
+
"data"
|
|
577
|
+
] };
|
|
578
|
+
function emptySchemaIR() {
|
|
579
|
+
return new SqlSchemaIR({ tables: {} });
|
|
580
|
+
}
|
|
581
|
+
function conflictKindForCall(call) {
|
|
582
|
+
switch (call.factoryName) {
|
|
583
|
+
case "alterColumnType": return "typeMismatch";
|
|
584
|
+
case "setNotNull":
|
|
585
|
+
case "dropNotNull": return "nullabilityConflict";
|
|
586
|
+
case "addForeignKey":
|
|
587
|
+
case "dropConstraint": return "foreignKeyConflict";
|
|
588
|
+
case "createIndex":
|
|
589
|
+
case "dropIndex": return "indexIncompatible";
|
|
590
|
+
default: return "missingButNonAdditive";
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
function locationForCall(call) {
|
|
594
|
+
const anyCall = call;
|
|
595
|
+
const location = {};
|
|
596
|
+
if (anyCall.tableName) location.table = anyCall.tableName;
|
|
597
|
+
if (anyCall.columnName) location.column = anyCall.columnName;
|
|
598
|
+
if (anyCall.indexName) location.index = anyCall.indexName;
|
|
599
|
+
if (anyCall.constraintName) location.constraint = anyCall.constraintName;
|
|
600
|
+
if (anyCall.typeName) location.type = anyCall.typeName;
|
|
601
|
+
return Object.keys(location).length > 0 ? location : void 0;
|
|
602
|
+
}
|
|
603
|
+
function conflictForDisallowedCall(call, allowed) {
|
|
604
|
+
const summary = `Operation "${call.label}" requires class "${call.operationClass}", but policy allows only: ${allowed.join(", ")}`;
|
|
605
|
+
const location = locationForCall(call);
|
|
606
|
+
return {
|
|
607
|
+
kind: conflictKindForCall(call),
|
|
608
|
+
summary,
|
|
609
|
+
why: "Use `migration new` to author a custom migration for this change.",
|
|
610
|
+
...location ? { location } : {}
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
/** The diff node an issue concerns — expected when present, else the actual (extra) node. */
|
|
614
|
+
function issueNode(issue) {
|
|
615
|
+
const node = issue.expected ?? issue.actual;
|
|
616
|
+
if (node === void 0) return void 0;
|
|
617
|
+
return blindCast(node);
|
|
618
|
+
}
|
|
619
|
+
/** DDL schema segment of a table-or-descendant issue path: `[database, ddlSchema, table, …]`. */
|
|
620
|
+
function issueSchemaName(issue) {
|
|
621
|
+
return issue.path[1];
|
|
622
|
+
}
|
|
623
|
+
/** Table segment of a table-or-descendant issue path: `[database, ddlSchema, table, …]`. */
|
|
624
|
+
function issueTableName(issue) {
|
|
625
|
+
return issue.path[2];
|
|
626
|
+
}
|
|
627
|
+
/** Column name embedded in a column/default issue path segment (`column:<name>`). */
|
|
628
|
+
function issueColumnName(issue) {
|
|
629
|
+
const segment = issue.path[3];
|
|
630
|
+
if (segment === void 0 || !segment.startsWith("column:")) return void 0;
|
|
631
|
+
return segment.slice(7);
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* The DDL schema name to use when EMITTING an op against `ddlSchemaName` (the
|
|
635
|
+
* diff tree's resolved physical schema, `issueSchemaName(issue)`). The
|
|
636
|
+
* unbound namespace's diff-tree identity resolves to `public` (a concrete
|
|
637
|
+
* physical default the differ needs in order to compare its tree against
|
|
638
|
+
* introspection — `resolveDdlSchemaForNamespaceStorage`), but DDL EMISSION
|
|
639
|
+
* must stay unqualified so the live connection's `search_path` resolves it
|
|
640
|
+
* at runtime (`boundSchema`). Recovers the logical namespace id via the
|
|
641
|
+
* contract and substitutes the unbound sentinel back in when it resolves
|
|
642
|
+
* there; every other namespace's `ddlSchemaName` already agrees between the
|
|
643
|
+
* two resolution paths, so it passes through unchanged.
|
|
644
|
+
*/
|
|
645
|
+
function emissionSchemaName(ctx, ddlSchemaName) {
|
|
646
|
+
return resolveNamespaceIdForDdlSchema(ctx.toContract, ddlSchemaName) === UNBOUND_NAMESPACE_ID ? UNBOUND_NAMESPACE_ID : ddlSchemaName;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Whether a column node is a scalar-array (`many: true`) column. The family
|
|
650
|
+
* converter (`contractToSchemaIR`'s `convertColumn`) never stamps `many` on
|
|
651
|
+
* the derived node — array-ness is folded into the `[]` suffix on
|
|
652
|
+
* `nativeType` instead — so the node-derived check reads the suffix; `.many`
|
|
653
|
+
* is still checked first for nodes a caller stamps directly (e.g. hand-built
|
|
654
|
+
* test fixtures, or an adapter that populates it at introspection).
|
|
655
|
+
*/
|
|
656
|
+
function isManyColumn(column) {
|
|
657
|
+
return column.many === true || column.nativeType.endsWith("[]");
|
|
658
|
+
}
|
|
659
|
+
/** Whether the expected/actual native type (resolved, or raw+many fallback) differs — mirrors `SqlColumnIR.isEqualTo`'s type comparison. */
|
|
660
|
+
function columnTypeChanged(expected, actual) {
|
|
661
|
+
if (expected.resolvedNativeType !== void 0 && actual.resolvedNativeType !== void 0) return expected.resolvedNativeType !== actual.resolvedNativeType;
|
|
662
|
+
return expected.nativeType !== actual.nativeType || Boolean(expected.many) !== Boolean(actual.many);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Re-keys the legacy `ISSUE_KIND_ORDER` on `(nodeKind, reason)`, numbers
|
|
666
|
+
* preserved so the dependency intent stays legible. Final emission order is
|
|
667
|
+
* fixed downstream by `classifyCall` bucketing (dep → drop → table → column →
|
|
668
|
+
* recipe → alter → primaryKey → unique → index → foreignKey), so this only
|
|
669
|
+
* breaks ties within a bucket.
|
|
670
|
+
*/
|
|
671
|
+
function nodeIssueOrder(issue) {
|
|
672
|
+
const node = issueNode(issue);
|
|
673
|
+
if (node === void 0) return 99;
|
|
674
|
+
switch (node.nodeKind) {
|
|
675
|
+
case PostgresSchemaNodeKind.namespace: return 1;
|
|
676
|
+
case RelationalSchemaNodeKind.foreignKey: return issue.reason === "not-expected" ? 10 : 60;
|
|
677
|
+
case RelationalSchemaNodeKind.unique: return issue.reason === "not-expected" ? 11 : 51;
|
|
678
|
+
case RelationalSchemaNodeKind.primaryKey: return issue.reason === "not-expected" ? 12 : 50;
|
|
679
|
+
case RelationalSchemaNodeKind.index: return issue.reason === "not-expected" ? 13 : 52;
|
|
680
|
+
case RelationalSchemaNodeKind.columnDefault:
|
|
681
|
+
if (issue.reason === "not-expected") return 14;
|
|
682
|
+
return issue.reason === "not-found" ? 42 : 43;
|
|
683
|
+
case RelationalSchemaNodeKind.column:
|
|
684
|
+
if (issue.reason === "not-expected") return 15;
|
|
685
|
+
return issue.reason === "not-found" ? 30 : 40;
|
|
686
|
+
case PostgresSchemaNodeKind.table: return issue.reason === "not-expected" ? 16 : 20;
|
|
687
|
+
case RelationalSchemaNodeKind.check:
|
|
688
|
+
if (issue.reason === "not-found") return 53;
|
|
689
|
+
return issue.reason === "not-expected" ? 55 : 54;
|
|
690
|
+
default: return 99;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/** Deterministic tiebreak within an order bucket: the diff path already encodes schema → table → child. */
|
|
694
|
+
function nodeIssueKey(issue) {
|
|
695
|
+
return issue.path.join(" ");
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* The generic differ is total: a missing/extra table (or column) emits an
|
|
699
|
+
* issue for itself AND for every node in its subtree. `CreateTable`/`DropTable`
|
|
700
|
+
* and `AddColumn`/`DropColumn` already account for the whole subtree, so the
|
|
701
|
+
* nested issues are redundant — coalescing drops any issue whose path is a
|
|
702
|
+
* strict descendant of a `not-found`/`not-expected` issue's path. Run over the
|
|
703
|
+
* relational subset ONLY (policy issues and synthesized namespace issues are
|
|
704
|
+
* handled on their own paths, never coalesced against tables).
|
|
705
|
+
*/
|
|
706
|
+
function coalesceSubtreeIssues(issues) {
|
|
707
|
+
const collapsingPaths = issues.filter((issue) => issue.reason === "not-found" || issue.reason === "not-expected").map((issue) => issue.path);
|
|
708
|
+
if (collapsingPaths.length === 0) return issues;
|
|
709
|
+
return issues.filter((issue) => !collapsingPaths.some((ancestor) => isStrictDescendantPath(issue.path, ancestor)));
|
|
710
|
+
}
|
|
711
|
+
function isStrictDescendantPath(path, ancestor) {
|
|
712
|
+
if (path.length <= ancestor.length) return false;
|
|
713
|
+
for (let i = 0; i < ancestor.length; i += 1) if (path[i] !== ancestor[i]) return false;
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
function fkSpecFromNode(fk, tableName) {
|
|
717
|
+
return {
|
|
718
|
+
name: fk.name ?? `${tableName}_${fk.columns.join("_")}_fkey`,
|
|
719
|
+
columns: [...fk.columns],
|
|
720
|
+
references: {
|
|
721
|
+
schema: fk.referencedSchema ?? "",
|
|
722
|
+
table: fk.referencedTable,
|
|
723
|
+
columns: [...fk.referencedColumns]
|
|
724
|
+
},
|
|
725
|
+
...ifDefined("onDelete", fk.onDelete),
|
|
726
|
+
...ifDefined("onUpdate", fk.onUpdate)
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Builds the `CreateTable` + child `CreateIndex` / `AddForeignKey` / `AddUnique`
|
|
731
|
+
* calls for a newly-expected table, reading only the table node's children. The
|
|
732
|
+
* PK and element-non-null CHECKs go inline as table constraints; indexes
|
|
733
|
+
* (declared + FK-backing, already merged and ordered at derivation) and the
|
|
734
|
+
* FK / unique constraints are separate calls (re-bucketed downstream). Every
|
|
735
|
+
* column's DDL is resolved from its `codecRef` via `renderColumnDdl`.
|
|
736
|
+
*/
|
|
737
|
+
function buildCreateTableCallsFromNode(schemaName, table, codecHooks) {
|
|
738
|
+
const ddlColumns = Object.values(table.columns).map((c) => renderColumnDdl(c.name, c, codecHooks));
|
|
739
|
+
const primaryKeyConstraints = table.primaryKey ? [contractFree.primaryKey([...table.primaryKey.columns], { ...ifDefined("name", table.primaryKey.name) })] : [];
|
|
740
|
+
const elementNonNullChecks = Object.values(table.columns).filter((c) => isManyColumn(c)).map((c) => contractFree.checkExpression(elementNonNullCheckName(table.name, c.name), elementNonNullCheckExpression(c.name)));
|
|
741
|
+
const allTableConstraints = [...primaryKeyConstraints, ...elementNonNullChecks];
|
|
742
|
+
const calls = [new CreateTableCall(schemaName, table.name, ddlColumns, allTableConstraints.length > 0 ? allTableConstraints : void 0)];
|
|
743
|
+
for (const index of table.indexes) {
|
|
744
|
+
const indexName = index.name ?? defaultIndexName(table.name, index.columns);
|
|
745
|
+
const extras = {};
|
|
746
|
+
if (index.type !== void 0) extras.type = index.type;
|
|
747
|
+
if (index.options !== void 0) extras.options = index.options;
|
|
748
|
+
calls.push(new CreateIndexCall(schemaName, table.name, indexName, [...index.columns], extras));
|
|
749
|
+
}
|
|
750
|
+
for (const fk of table.foreignKeys) calls.push(new AddForeignKeyCall(schemaName, table.name, fkSpecFromNode(fk, table.name)));
|
|
751
|
+
for (const unique of table.uniques) {
|
|
752
|
+
const constraintName = unique.name ?? `${table.name}_${unique.columns.join("_")}_key`;
|
|
753
|
+
calls.push(new AddUniqueCall(schemaName, table.name, constraintName, [...unique.columns]));
|
|
754
|
+
}
|
|
755
|
+
return calls;
|
|
756
|
+
}
|
|
757
|
+
function nodeConflict(kind, message) {
|
|
758
|
+
return issueConflict(kind, message);
|
|
759
|
+
}
|
|
760
|
+
function mapTableNodeIssue(issue, schemaName, codecHooks) {
|
|
761
|
+
if (issue.reason === "not-found") return ok(buildCreateTableCallsFromNode(schemaName, blindCast(issue.expected), codecHooks));
|
|
762
|
+
if (issue.reason === "not-expected") return ok([new DropTableCall(schemaName, blindCast(issue.actual).name)]);
|
|
763
|
+
return notOk(nodeConflict("unsupportedOperation", `Unexpected table drift: ${issue.message}`));
|
|
764
|
+
}
|
|
765
|
+
function mapColumnNodeIssue(issue, schemaName, tableName, codecHooks) {
|
|
766
|
+
if (issue.reason === "not-found") {
|
|
767
|
+
const column = blindCast(issue.expected);
|
|
768
|
+
return ok([new AddColumnCall(schemaName, tableName, renderColumnDdl(column.name, column, codecHooks))]);
|
|
769
|
+
}
|
|
770
|
+
if (issue.reason === "not-expected") return ok([new DropColumnCall(schemaName, tableName, blindCast(issue.actual).name)]);
|
|
771
|
+
const expected = blindCast(issue.expected);
|
|
772
|
+
const actual = blindCast(issue.actual);
|
|
773
|
+
const calls = [];
|
|
774
|
+
if (columnTypeChanged(expected, actual)) {
|
|
775
|
+
const { qualifiedTargetType, formatTypeExpected } = renderColumnAlterType(expected, codecHooks);
|
|
776
|
+
calls.push(new AlterColumnTypeCall(schemaName, tableName, expected.name, {
|
|
777
|
+
qualifiedTargetType,
|
|
778
|
+
formatTypeExpected,
|
|
779
|
+
rawTargetTypeForLabel: qualifiedTargetType
|
|
780
|
+
}));
|
|
781
|
+
}
|
|
782
|
+
if (expected.nullable !== actual.nullable) calls.push(expected.nullable ? new DropNotNullCall(schemaName, tableName, expected.name) : new SetNotNullCall(schemaName, tableName, expected.name));
|
|
783
|
+
return ok(calls);
|
|
784
|
+
}
|
|
785
|
+
function mapColumnDefaultNodeIssue(issue, schemaName, tableName, columnName) {
|
|
786
|
+
if (issue.reason === "not-expected") return ok([new DropDefaultCall(schemaName, tableName, columnName)]);
|
|
787
|
+
if (issue.expected === void 0) return ok([]);
|
|
788
|
+
const defaultSql = renderColumnDefaultSql(blindCast(issue.expected));
|
|
789
|
+
if (!defaultSql) return ok([]);
|
|
790
|
+
return ok([new SetDefaultCall(schemaName, tableName, columnName, defaultSql, issue.reason === "not-equal" ? "widening" : "additive")]);
|
|
791
|
+
}
|
|
792
|
+
function mapPrimaryKeyNodeIssue(issue, schemaName, tableName) {
|
|
793
|
+
if (issue.reason === "not-found") {
|
|
794
|
+
const pk = blindCast(issue.expected);
|
|
795
|
+
return ok([new AddPrimaryKeyCall(schemaName, tableName, pk.name ?? `${tableName}_pkey`, [...pk.columns])]);
|
|
796
|
+
}
|
|
797
|
+
if (issue.reason === "not-expected") return ok([new DropConstraintCall(schemaName, tableName, blindCast(issue.actual).name ?? `${tableName}_pkey`, "primaryKey")]);
|
|
798
|
+
return notOk(nodeConflict("indexIncompatible", issue.message));
|
|
799
|
+
}
|
|
800
|
+
function mapForeignKeyNodeIssue(issue, schemaName, tableName) {
|
|
801
|
+
if (issue.reason === "not-found") return ok([new AddForeignKeyCall(schemaName, tableName, fkSpecFromNode(blindCast(issue.expected), tableName))]);
|
|
802
|
+
if (issue.reason === "not-expected") {
|
|
803
|
+
const fk = blindCast(issue.actual);
|
|
804
|
+
return ok([new DropConstraintCall(schemaName, tableName, fk.name ?? `${tableName}_${fk.columns.join("_")}_fkey`, "foreignKey")]);
|
|
805
|
+
}
|
|
806
|
+
return notOk(nodeConflict("foreignKeyConflict", issue.message));
|
|
807
|
+
}
|
|
808
|
+
function mapUniqueNodeIssue(issue, schemaName, tableName) {
|
|
809
|
+
if (issue.reason === "not-found") {
|
|
810
|
+
const unique = blindCast(issue.expected);
|
|
811
|
+
return ok([new AddUniqueCall(schemaName, tableName, unique.name ?? `${tableName}_${unique.columns.join("_")}_key`, [...unique.columns])]);
|
|
812
|
+
}
|
|
813
|
+
if (issue.reason === "not-expected") {
|
|
814
|
+
const unique = blindCast(issue.actual);
|
|
815
|
+
return ok([new DropConstraintCall(schemaName, tableName, unique.name ?? `${tableName}_${unique.columns.join("_")}_key`, "unique")]);
|
|
816
|
+
}
|
|
817
|
+
return notOk(nodeConflict("indexIncompatible", issue.message));
|
|
818
|
+
}
|
|
819
|
+
function mapIndexNodeIssue(issue, schemaName, tableName) {
|
|
820
|
+
if (issue.reason === "not-found") {
|
|
821
|
+
const index = blindCast(issue.expected);
|
|
822
|
+
const indexName = index.name ?? defaultIndexName(tableName, index.columns);
|
|
823
|
+
const extras = {};
|
|
824
|
+
if (index.type !== void 0) extras.type = index.type;
|
|
825
|
+
if (index.options !== void 0) extras.options = index.options;
|
|
826
|
+
return ok([new CreateIndexCall(schemaName, tableName, indexName, [...index.columns], extras)]);
|
|
827
|
+
}
|
|
828
|
+
if (issue.reason === "not-expected") {
|
|
829
|
+
const index = blindCast(issue.actual);
|
|
830
|
+
return ok([new DropIndexCall(schemaName, tableName, index.name ?? defaultIndexName(tableName, index.columns))]);
|
|
831
|
+
}
|
|
832
|
+
return notOk(nodeConflict("indexIncompatible", issue.message));
|
|
833
|
+
}
|
|
834
|
+
function mapCheckNodeIssue(issue, schemaName, tableName) {
|
|
835
|
+
if (issue.reason === "not-expected") return ok([new DropCheckConstraintCall(schemaName, tableName, blindCast(issue.actual).name)]);
|
|
836
|
+
return notOk(nodeConflict("unsupportedOperation", `Check constraint drift on "${tableName}" — handled by checkConstraintPlanCallStrategy: ${issue.message}`));
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Maps one node-typed diff issue to its migration call(s), dispatching on the
|
|
840
|
+
* node's `nodeKind` + `issue.reason`, reading nodes and resolving column DDL
|
|
841
|
+
* from `codecRef` via `column-ddl-rendering.ts`.
|
|
842
|
+
*/
|
|
843
|
+
function mapNodeIssueToCall(issue, ctx) {
|
|
844
|
+
const node = issueNode(issue);
|
|
845
|
+
if (node === void 0) return notOk(nodeConflict("unsupportedOperation", `Issue carries neither an expected nor an actual node: ${issue.message}`));
|
|
846
|
+
if (node.nodeKind === PostgresSchemaNodeKind.namespace) {
|
|
847
|
+
if (issue.reason !== "not-found") return notOk(nodeConflict("unsupportedOperation", `Unexpected namespace drift: ${issue.message}`));
|
|
848
|
+
return ok([new CreateSchemaCall(blindCast(issue.expected).schemaName)]);
|
|
849
|
+
}
|
|
850
|
+
const ddlSchemaName = issueSchemaName(issue);
|
|
851
|
+
const tableName = issueTableName(issue);
|
|
852
|
+
if (ddlSchemaName === void 0 || tableName === void 0) return notOk(nodeConflict("unsupportedOperation", `Issue has no schema/table in its path: ${issue.message}`));
|
|
853
|
+
const schemaName = emissionSchemaName(ctx, ddlSchemaName);
|
|
854
|
+
switch (node.nodeKind) {
|
|
855
|
+
case PostgresSchemaNodeKind.table: return mapTableNodeIssue(issue, schemaName, ctx.codecHooks);
|
|
856
|
+
case RelationalSchemaNodeKind.column: return mapColumnNodeIssue(issue, schemaName, tableName, ctx.codecHooks);
|
|
857
|
+
case RelationalSchemaNodeKind.columnDefault: {
|
|
858
|
+
const columnName = issueColumnName(issue);
|
|
859
|
+
if (columnName === void 0) return notOk(nodeConflict("unsupportedOperation", `Default issue has no column in its path: ${issue.message}`));
|
|
860
|
+
return mapColumnDefaultNodeIssue(issue, schemaName, tableName, columnName);
|
|
861
|
+
}
|
|
862
|
+
case RelationalSchemaNodeKind.primaryKey: return mapPrimaryKeyNodeIssue(issue, schemaName, tableName);
|
|
863
|
+
case RelationalSchemaNodeKind.foreignKey: return mapForeignKeyNodeIssue(issue, schemaName, tableName);
|
|
864
|
+
case RelationalSchemaNodeKind.unique: return mapUniqueNodeIssue(issue, schemaName, tableName);
|
|
865
|
+
case RelationalSchemaNodeKind.index: return mapIndexNodeIssue(issue, schemaName, tableName);
|
|
866
|
+
case RelationalSchemaNodeKind.check: return mapCheckNodeIssue(issue, schemaName, tableName);
|
|
867
|
+
default: return notOk(nodeConflict("unsupportedOperation", `Unhandled node kind: ${node.nodeKind}`));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Runs the ordered strategy list over the node-typed diff issues, maps
|
|
872
|
+
* leftover issues via {@link mapNodeIssueToCall}, applies operation-class
|
|
873
|
+
* policy gating, and buckets calls into the fixed DDL emission order (dep →
|
|
874
|
+
* drop → table → column → recipe → alter → primaryKey → unique → index →
|
|
875
|
+
* foreignKey).
|
|
876
|
+
*/
|
|
877
|
+
function planIssues(options) {
|
|
878
|
+
const policyProvided = options.policy !== void 0;
|
|
879
|
+
const policy = options.policy ?? DEFAULT_POLICY;
|
|
880
|
+
const schema = options.schema ?? emptySchemaIR();
|
|
881
|
+
const frameworkComponents = options.frameworkComponents ?? [];
|
|
882
|
+
const context = {
|
|
883
|
+
toContract: options.toContract,
|
|
884
|
+
fromContract: options.fromContract,
|
|
885
|
+
schemaName: options.schemaName,
|
|
886
|
+
codecHooks: options.codecHooks,
|
|
887
|
+
storageTypes: options.storageTypes,
|
|
888
|
+
schema,
|
|
889
|
+
policy,
|
|
890
|
+
frameworkComponents
|
|
891
|
+
};
|
|
892
|
+
const strategies = options.strategies ?? postgresPlannerStrategies;
|
|
893
|
+
let remaining = options.issues;
|
|
894
|
+
const recipeCalls = [];
|
|
895
|
+
const bucketablePatternCalls = [];
|
|
896
|
+
for (const strategy of strategies) {
|
|
897
|
+
const result = strategy(remaining, context);
|
|
898
|
+
if (result.kind === "match") {
|
|
899
|
+
remaining = result.issues;
|
|
900
|
+
if (result.recipe) recipeCalls.push(...result.calls);
|
|
901
|
+
else bucketablePatternCalls.push(...result.calls);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
const sorted = [...remaining].sort((a, b) => {
|
|
905
|
+
const kindDelta = nodeIssueOrder(a) - nodeIssueOrder(b);
|
|
906
|
+
if (kindDelta !== 0) return kindDelta;
|
|
907
|
+
const keyA = nodeIssueKey(a);
|
|
908
|
+
const keyB = nodeIssueKey(b);
|
|
909
|
+
return keyA < keyB ? -1 : keyA > keyB ? 1 : 0;
|
|
910
|
+
});
|
|
911
|
+
const defaultCalls = [];
|
|
912
|
+
const conflicts = [];
|
|
913
|
+
for (const issue of sorted) {
|
|
914
|
+
const result = mapNodeIssueToCall(issue, context);
|
|
915
|
+
if (result.ok) defaultCalls.push(...result.value);
|
|
916
|
+
else conflicts.push(result.failure);
|
|
917
|
+
}
|
|
918
|
+
const allowed = policy.allowedOperationClasses;
|
|
919
|
+
let gatedDefault = defaultCalls;
|
|
920
|
+
let gatedRecipe = recipeCalls;
|
|
921
|
+
let gatedBucketable = bucketablePatternCalls;
|
|
922
|
+
if (policyProvided) {
|
|
923
|
+
const keepIfAllowed = (bucket) => (call) => {
|
|
924
|
+
if (allowed.includes(call.operationClass)) {
|
|
925
|
+
bucket.push(call);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
conflicts.push(conflictForDisallowedCall(call, allowed));
|
|
929
|
+
};
|
|
930
|
+
const gatedDefaultBucket = [];
|
|
931
|
+
const gatedRecipeBucket = [];
|
|
932
|
+
const gatedBucketableBucket = [];
|
|
933
|
+
defaultCalls.forEach(keepIfAllowed(gatedDefaultBucket));
|
|
934
|
+
recipeCalls.forEach(keepIfAllowed(gatedRecipeBucket));
|
|
935
|
+
bucketablePatternCalls.forEach(keepIfAllowed(gatedBucketableBucket));
|
|
936
|
+
gatedDefault = gatedDefaultBucket;
|
|
937
|
+
gatedRecipe = gatedRecipeBucket;
|
|
938
|
+
gatedBucketable = gatedBucketableBucket;
|
|
939
|
+
}
|
|
940
|
+
if (conflicts.length > 0) return notOk(conflicts);
|
|
941
|
+
const combinedBucketable = [...gatedDefault, ...gatedBucketable];
|
|
942
|
+
const byCategory = (cat) => combinedBucketable.filter((c) => classifyCall(c) === cat);
|
|
943
|
+
return ok({ calls: [
|
|
944
|
+
...byCategory("dep"),
|
|
945
|
+
...byCategory("drop"),
|
|
946
|
+
...byCategory("table"),
|
|
947
|
+
...byCategory("column"),
|
|
948
|
+
...gatedRecipe,
|
|
949
|
+
...byCategory("alter"),
|
|
950
|
+
...byCategory("primaryKey"),
|
|
951
|
+
...byCategory("unique"),
|
|
952
|
+
...byCategory("index"),
|
|
953
|
+
...byCategory("foreignKey")
|
|
954
|
+
] });
|
|
955
|
+
}
|
|
956
|
+
//#endregion
|
|
957
|
+
//#region src/core/migrations/control-policy.ts
|
|
958
|
+
/**
|
|
959
|
+
* Factory calls that create a whole, previously-absent top-level storage
|
|
960
|
+
* object. Used to decide whether `tolerated` permits a call (it only allows
|
|
961
|
+
* creating absent objects, never modifying existing ones).
|
|
962
|
+
*
|
|
963
|
+
* Deliberately an explicit, closed set rather than a `factoryName`
|
|
964
|
+
* create/alter/drop classification: it answers exactly one yes/no question
|
|
965
|
+
* and is fail-closed. Any call not listed here — including future or
|
|
966
|
+
* extension-contributed factories — is treated as NOT object-creation, so it
|
|
967
|
+
* is suppressed under `tolerated` rather than permissively emitted.
|
|
968
|
+
*/
|
|
969
|
+
const OBJECT_CREATION_FACTORIES = new Set([
|
|
970
|
+
"createTable",
|
|
971
|
+
"createSchema",
|
|
972
|
+
"createRlsPolicy",
|
|
973
|
+
"enableRowLevelSecurity"
|
|
974
|
+
]);
|
|
975
|
+
function createsNewTopLevelObject(call) {
|
|
976
|
+
return OBJECT_CREATION_FACTORIES.has(call.factoryName);
|
|
977
|
+
}
|
|
978
|
+
function ddlSchemaNameForNamespace(contract, namespaceId) {
|
|
979
|
+
const namespace = contract.storage.namespaces[namespaceId];
|
|
980
|
+
return isPostgresSchema(namespace) ? namespace.ddlSchemaName(contract.storage) : namespaceId;
|
|
981
|
+
}
|
|
982
|
+
function resolveNamespaceIdForTable(contract, tableName, ddlSchemaName) {
|
|
983
|
+
for (const namespaceId of Object.keys(contract.storage.namespaces)) {
|
|
984
|
+
if (!entityAt(contract.storage, {
|
|
985
|
+
namespaceId,
|
|
986
|
+
entityKind: "table",
|
|
987
|
+
entityName: tableName
|
|
988
|
+
})) continue;
|
|
989
|
+
if (ddlSchemaName === void 0 || ddlSchemaNameForNamespace(contract, namespaceId) === ddlSchemaName) return namespaceId;
|
|
990
|
+
}
|
|
991
|
+
return UNBOUND_NAMESPACE_ID;
|
|
992
|
+
}
|
|
993
|
+
function resolveNamespaceIdForDdlSchema(contract, ddlSchemaName) {
|
|
994
|
+
for (const namespaceId of Object.keys(contract.storage.namespaces)) {
|
|
995
|
+
const ns = contract.storage.namespaces[namespaceId];
|
|
996
|
+
if (isPostgresSchema(ns) && ns.ddlSchemaName(contract.storage) === ddlSchemaName) return namespaceId;
|
|
997
|
+
if (namespaceId === ddlSchemaName) return namespaceId;
|
|
998
|
+
}
|
|
999
|
+
return UNBOUND_NAMESPACE_ID;
|
|
1000
|
+
}
|
|
1001
|
+
function postgresCallFields(call) {
|
|
1002
|
+
return {
|
|
1003
|
+
...ifDefined("schemaName", "schemaName" in call ? call.schemaName : void 0),
|
|
1004
|
+
...ifDefined("tableName", "tableName" in call ? call.tableName : void 0),
|
|
1005
|
+
...ifDefined("columnName", "columnName" in call ? call.columnName : void 0)
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
function formatPostgresControlPolicySubjectLabel(factoryName, subject, contract) {
|
|
1009
|
+
if (subject?.table) return `${factoryName}(${ddlSchemaNameForNamespace(contract, subject.namespaceId)}.${subject.table})`;
|
|
1010
|
+
return factoryName;
|
|
1011
|
+
}
|
|
1012
|
+
function resolvePostgresCallControlPolicySubject(call, contract) {
|
|
1013
|
+
const callFields = postgresCallFields(call);
|
|
1014
|
+
const createsNewObject = createsNewTopLevelObject(call);
|
|
1015
|
+
if (call.factoryName === "createSchema" && callFields.schemaName) return {
|
|
1016
|
+
namespaceId: resolveNamespaceIdForDdlSchema(contract, callFields.schemaName),
|
|
1017
|
+
createsNewObject
|
|
1018
|
+
};
|
|
1019
|
+
if (callFields.tableName) {
|
|
1020
|
+
const namespaceId = resolveNamespaceIdForTable(contract, callFields.tableName, callFields.schemaName);
|
|
1021
|
+
const tableControlPolicy = entityAt(contract.storage, {
|
|
1022
|
+
namespaceId,
|
|
1023
|
+
entityKind: "table",
|
|
1024
|
+
entityName: callFields.tableName
|
|
1025
|
+
})?.control;
|
|
1026
|
+
return {
|
|
1027
|
+
namespaceId,
|
|
1028
|
+
...ifDefined("explicitNodeControlPolicy", tableControlPolicy),
|
|
1029
|
+
table: callFields.tableName,
|
|
1030
|
+
...ifDefined("column", callFields.columnName),
|
|
1031
|
+
createsNewObject
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
if (callFields.schemaName) return {
|
|
1035
|
+
namespaceId: resolveNamespaceIdForDdlSchema(contract, callFields.schemaName),
|
|
1036
|
+
createsNewObject
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Node kinds that describe the absence of a whole, top-level Postgres
|
|
1041
|
+
* object — the same objects `createsNewTopLevelObject` recognises for calls.
|
|
1042
|
+
* Used by {@link resolvePostgresNodeIssueCreationFactoryName} to decide
|
|
1043
|
+
* whether a `tolerated` subject permits the issue to flow into the planner
|
|
1044
|
+
* (create-if-absent) and to seed the suppressed-subject warning's
|
|
1045
|
+
* `factoryName` when the planner is skipped. RLS policy creation is not
|
|
1046
|
+
* listed here — policy issues never reach this issue-based partition (they
|
|
1047
|
+
* are routed to `planPostgresSchemaDiff` and gated via the call-based
|
|
1048
|
+
* {@link resolvePostgresCallControlPolicySubject} instead).
|
|
1049
|
+
*/
|
|
1050
|
+
const POSTGRES_NODE_CREATION_FACTORY = Object.freeze({
|
|
1051
|
+
[PostgresSchemaNodeKind.namespace]: "createSchema",
|
|
1052
|
+
[PostgresSchemaNodeKind.table]: "createTable"
|
|
1053
|
+
});
|
|
1054
|
+
function resolvePostgresNodeIssueCreationFactoryName(issue) {
|
|
1055
|
+
if (issue.reason !== "not-found") return void 0;
|
|
1056
|
+
const node = issue.expected ?? issue.actual;
|
|
1057
|
+
if (node === void 0) return void 0;
|
|
1058
|
+
return POSTGRES_NODE_CREATION_FACTORY[node.nodeKind];
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Resolve the control-policy subject coordinate for a single node-typed
|
|
1062
|
+
* {@link SchemaDiffIssue}. Mirrors the resolution
|
|
1063
|
+
* `resolvePostgresCallControlPolicySubject` performs for a generated DDL
|
|
1064
|
+
* call, but works *off the issue* — so the planner can partition issues by
|
|
1065
|
+
* effective policy before the diff engine runs. `createsNewObject` is
|
|
1066
|
+
* derived from the node kind + reason: a `not-found` schema/table describes
|
|
1067
|
+
* a brand-new top-level object; everything else touches an existing object.
|
|
1068
|
+
*
|
|
1069
|
+
* A `not-expected` table carries no contract namespace coordinate (the live
|
|
1070
|
+
* table isn't claimed by any contract namespace), so the subject's
|
|
1071
|
+
* `namespaceId` falls back to {@link UNBOUND_NAMESPACE_ID} via
|
|
1072
|
+
* `resolveNamespaceIdForTable`'s own fallback — the call-side resolver does
|
|
1073
|
+
* the same for the `DropTableCall` it produces.
|
|
1074
|
+
*/
|
|
1075
|
+
function resolvePostgresNodeIssueControlPolicySubject(issue, contract) {
|
|
1076
|
+
const node = issue.expected ?? issue.actual;
|
|
1077
|
+
if (node === void 0) return void 0;
|
|
1078
|
+
if (node.nodeKind === PostgresSchemaNodeKind.namespace) return {
|
|
1079
|
+
namespaceId: resolveNamespaceIdForDdlSchema(contract, blindCast(node).schemaName),
|
|
1080
|
+
createsNewObject: issue.reason === "not-found"
|
|
1081
|
+
};
|
|
1082
|
+
const tableName = issue.path[2];
|
|
1083
|
+
if (tableName === void 0) return void 0;
|
|
1084
|
+
const ddlSchemaName = issue.path[1];
|
|
1085
|
+
const namespaceId = resolveNamespaceIdForTable(contract, tableName, ddlSchemaName);
|
|
1086
|
+
const table = entityAt(contract.storage, {
|
|
1087
|
+
namespaceId,
|
|
1088
|
+
entityKind: "table",
|
|
1089
|
+
entityName: tableName
|
|
1090
|
+
});
|
|
1091
|
+
const createsNewObject = node.nodeKind === PostgresSchemaNodeKind.table && issue.reason === "not-found";
|
|
1092
|
+
return {
|
|
1093
|
+
namespaceId,
|
|
1094
|
+
...ifDefined("explicitNodeControlPolicy", table?.control),
|
|
1095
|
+
table: tableName,
|
|
1096
|
+
...ifDefined("column", issueColumnName(issue)),
|
|
1097
|
+
createsNewObject
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
//#endregion
|
|
1101
|
+
export { resolvePostgresNodeIssueCreationFactoryName as a, issueSchemaName as c, postgresPlannerStrategies as d, resolvePostgresNodeIssueControlPolicySubject as i, issueTableName as l, resolveNamespaceIdForDdlSchema as n, coalesceSubtreeIssues as o, resolvePostgresCallControlPolicySubject as r, issueNode as s, formatPostgresControlPolicySubjectLabel as t, planIssues as u };
|
|
1102
|
+
|
|
1103
|
+
//# sourceMappingURL=control-policy-DUFgy9jO.mjs.map
|