@prisma-next/target-postgres 0.14.0-dev.7 → 0.14.0-dev.71
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/authoring-Bi9aa8zG.mjs +574 -0
- package/dist/authoring-Bi9aa8zG.mjs.map +1 -0
- package/dist/canonicalize-CAbXtVtB.d.mts +37 -0
- package/dist/canonicalize-CAbXtVtB.d.mts.map +1 -0
- package/dist/{codec-ids-CnXu9Qy3.d.mts → codec-ids-BzrFF-I4.d.mts} +3 -2
- package/dist/codec-ids-BzrFF-I4.d.mts.map +1 -0
- package/dist/{codec-ids-BvytN2P8.mjs → codec-ids-C_-Hj6bL.mjs} +3 -2
- package/dist/codec-ids-C_-Hj6bL.mjs.map +1 -0
- package/dist/codec-ids.d.mts +2 -2
- package/dist/codec-ids.mjs +2 -2
- package/dist/{codec-types-DHCkwPKE.d.mts → codec-types-BiIWziRO.d.mts} +3 -2
- package/dist/codec-types-BiIWziRO.d.mts.map +1 -0
- package/dist/codec-types.d.mts +1 -1
- package/dist/codecs-Cl_hQHg-.mjs +913 -0
- package/dist/codecs-Cl_hQHg-.mjs.map +1 -0
- package/dist/{codecs--0A5_4Bq.d.mts → codecs-Cm-nS4mQ.d.mts} +111 -4
- package/dist/codecs-Cm-nS4mQ.d.mts.map +1 -0
- package/dist/codecs.d.mts +2 -2
- package/dist/codecs.mjs +3 -757
- package/dist/codecs.mjs.map +1 -1
- package/dist/contract-free.d.mts +33 -3
- package/dist/contract-free.d.mts.map +1 -1
- package/dist/contract-free.mjs +23 -7
- package/dist/contract-free.mjs.map +1 -1
- package/dist/control-policy-CuUhjWnn.mjs +1258 -0
- package/dist/control-policy-CuUhjWnn.mjs.map +1 -0
- package/dist/control.d.mts +2 -2
- package/dist/control.d.mts.map +1 -1
- package/dist/control.mjs +866 -36
- package/dist/control.mjs.map +1 -1
- package/dist/data-transform-BOWpliq8.mjs.map +1 -1
- package/dist/{data-transform-DDgWdB5o.d.mts → data-transform-DPzwBMhW.d.mts} +3 -3
- package/dist/{data-transform-DDgWdB5o.d.mts.map → data-transform-DPzwBMhW.d.mts.map} +1 -1
- package/dist/data-transform.d.mts +1 -1
- package/dist/{ddl-QDyOSeLc.mjs → ddl-Dqgs-bDc.mjs} +93 -6
- package/dist/ddl-Dqgs-bDc.mjs.map +1 -0
- package/dist/ddl.d.mts +3 -2
- package/dist/ddl.mjs +2 -2
- package/dist/default-normalizer.d.mts +1 -1
- package/dist/default-normalizer.d.mts.map +1 -1
- package/dist/default-normalizer.mjs +247 -1
- package/dist/default-normalizer.mjs.map +1 -0
- package/dist/descriptor-meta-UugzVM-U.mjs +22 -0
- package/dist/descriptor-meta-UugzVM-U.mjs.map +1 -0
- package/dist/descriptor-meta-runtime-UwYdV8fD.mjs +12 -0
- package/dist/descriptor-meta-runtime-UwYdV8fD.mjs.map +1 -0
- package/dist/diff-database-schema-ClN3bkRS.mjs +305 -0
- package/dist/diff-database-schema-ClN3bkRS.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/errors-DO-w0EUS.mjs +35 -0
- package/dist/errors-DO-w0EUS.mjs.map +1 -0
- package/dist/errors.d.mts +11 -9
- package/dist/errors.d.mts.map +1 -1
- package/dist/errors.mjs +1 -1
- package/dist/issue-planner.d.mts +35 -11
- package/dist/issue-planner.d.mts.map +1 -1
- package/dist/issue-planner.mjs +2 -2
- package/dist/migration.d.mts +5 -5
- package/dist/migration.d.mts.map +1 -1
- package/dist/migration.mjs +4 -4
- package/dist/namespace-ids-xtp_ZY86.mjs +7 -0
- package/dist/namespace-ids-xtp_ZY86.mjs.map +1 -0
- package/dist/native-type-normalizer.mjs +37 -1
- package/dist/native-type-normalizer.mjs.map +1 -0
- package/dist/nodes-Bc2N3tMy.d.mts +170 -0
- package/dist/nodes-Bc2N3tMy.d.mts.map +1 -0
- package/dist/nodes-DJC5B8Id.mjs +192 -0
- package/dist/nodes-DJC5B8Id.mjs.map +1 -0
- package/dist/{op-factory-call-DmQEc3XV.d.mts → op-factory-call-CSIg5XDu.d.mts} +91 -15
- package/dist/op-factory-call-CSIg5XDu.d.mts.map +1 -0
- package/dist/{op-factory-call-D_p5vxwt.mjs → op-factory-call-DtVSP_7Y.mjs} +400 -32
- package/dist/op-factory-call-DtVSP_7Y.mjs.map +1 -0
- package/dist/op-factory-call.d.mts +2 -2
- package/dist/op-factory-call.mjs +2 -2
- package/dist/pack.d.mts +300 -3
- package/dist/pack.d.mts.map +1 -1
- package/dist/pack.mjs +1 -1
- package/dist/planner-ChVasl04.mjs +390 -0
- package/dist/planner-ChVasl04.mjs.map +1 -0
- package/dist/{planner-ddl-builders-B2wOwLqI.mjs → planner-ddl-builders-BKlmGDJl.mjs} +17 -28
- package/dist/planner-ddl-builders-BKlmGDJl.mjs.map +1 -0
- package/dist/planner-ddl-builders.d.mts +4 -6
- package/dist/planner-ddl-builders.d.mts.map +1 -1
- package/dist/planner-ddl-builders.mjs +2 -2
- package/dist/planner-identity-values-CJPha2Sz.mjs.map +1 -1
- package/dist/planner-identity-values.d.mts +8 -2
- package/dist/planner-identity-values.d.mts.map +1 -1
- package/dist/planner-identity-values.mjs +2 -2
- package/dist/{planner-produced-postgres-migration-QqHa2C2l.d.mts → planner-produced-postgres-migration-CXue-6M9.d.mts} +4 -4
- package/dist/{planner-produced-postgres-migration-QqHa2C2l.d.mts.map → planner-produced-postgres-migration-CXue-6M9.d.mts.map} +1 -1
- package/dist/{planner-produced-postgres-migration-Cji5vxUf.mjs → planner-produced-postgres-migration-qpU05c__.mjs} +3 -3
- package/dist/{planner-produced-postgres-migration-Cji5vxUf.mjs.map → planner-produced-postgres-migration-qpU05c__.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-jqUUGyQR.mjs → planner-sql-checks-CDXn2uaS.mjs} +5 -5
- package/dist/{planner-sql-checks-jqUUGyQR.mjs.map → planner-sql-checks-CDXn2uaS.mjs.map} +1 -1
- package/dist/planner-sql-checks.d.mts +2 -2
- package/dist/planner-sql-checks.d.mts.map +1 -1
- package/dist/planner-sql-checks.mjs +1 -1
- package/dist/{planner-target-details-CIY6tLeo.d.mts → planner-target-details-HkP4RrRO.d.mts} +2 -2
- package/dist/planner-target-details-HkP4RrRO.d.mts.map +1 -0
- package/dist/planner-target-details.d.mts +1 -1
- package/dist/planner.d.mts +72 -17
- package/dist/planner.d.mts.map +1 -1
- package/dist/planner.mjs +3 -2
- package/dist/{postgres-contract-serializer-k3TAcPMY.mjs → postgres-contract-view-C3LPCLP_.mjs} +45 -41
- package/dist/postgres-contract-view-C3LPCLP_.mjs.map +1 -0
- package/dist/postgres-contract-view-C9j5cqP0.d.mts +34 -0
- package/dist/postgres-contract-view-C9j5cqP0.d.mts.map +1 -0
- package/dist/postgres-database-schema-node-CJv5bfbs.d.mts +262 -0
- package/dist/postgres-database-schema-node-CJv5bfbs.d.mts.map +1 -0
- package/dist/{postgres-migration-Y4YBJqkS.d.mts → postgres-migration-DkmgGNcv.d.mts} +47 -9
- package/dist/postgres-migration-DkmgGNcv.d.mts.map +1 -0
- package/dist/{postgres-migration-B5jKrXv3.mjs → postgres-migration-fndp3Mbm.mjs} +69 -45
- package/dist/postgres-migration-fndp3Mbm.mjs.map +1 -0
- package/dist/postgres-native-enum-BwVNf0Ku.mjs +45 -0
- package/dist/postgres-native-enum-BwVNf0Ku.mjs.map +1 -0
- package/dist/postgres-rls-policy--CSddvMR.d.mts +52 -0
- package/dist/postgres-rls-policy--CSddvMR.d.mts.map +1 -0
- package/dist/postgres-role-schema-node-Cm-j0ZGM.mjs +190 -0
- package/dist/postgres-role-schema-node-Cm-j0ZGM.mjs.map +1 -0
- package/dist/postgres-schema-DKq6s1P3.d.mts +269 -0
- package/dist/postgres-schema-DKq6s1P3.d.mts.map +1 -0
- package/dist/{postgres-schema-COGZ1ark.mjs → postgres-schema-DYATroNx.mjs} +200 -11
- package/dist/postgres-schema-DYATroNx.mjs.map +1 -0
- package/dist/postgres-table-schema-node-D6LvInCe.mjs +216 -0
- package/dist/postgres-table-schema-node-D6LvInCe.mjs.map +1 -0
- package/dist/render-ops.d.mts +1 -1
- package/dist/{render-typescript-KMgosran.mjs → render-typescript-7yqILcwr.mjs} +45 -16
- package/dist/render-typescript-7yqILcwr.mjs.map +1 -0
- package/dist/render-typescript.mjs +1 -1
- package/dist/resolve-ddl-schema-DpGte50u.mjs +22 -0
- package/dist/resolve-ddl-schema-DpGte50u.mjs.map +1 -0
- package/dist/rls-canonicalize.d.mts +20 -0
- package/dist/rls-canonicalize.d.mts.map +1 -0
- package/dist/rls-canonicalize.mjs +2 -0
- package/dist/runtime.d.mts +6 -8
- package/dist/runtime.d.mts.map +1 -1
- package/dist/runtime.mjs +3 -3
- package/dist/runtime.mjs.map +1 -1
- package/dist/schema-ir-annotations.d.mts +15 -0
- package/dist/schema-ir-annotations.d.mts.map +1 -0
- package/dist/schema-ir-annotations.mjs +2 -0
- package/dist/{sql-utils-DcfMz4MQ.mjs → sql-utils-SU4FDvIV.mjs} +12 -3
- package/dist/{sql-utils-DcfMz4MQ.mjs.map → sql-utils-SU4FDvIV.mjs.map} +1 -1
- package/dist/sql-utils.d.mts +8 -1
- package/dist/sql-utils.d.mts.map +1 -1
- package/dist/sql-utils.mjs +2 -2
- package/dist/types.d.mts +4 -140
- package/dist/types.mjs +5 -2
- package/dist/wire-name-rHHtr1h0.mjs +75 -0
- package/dist/wire-name-rHHtr1h0.mjs.map +1 -0
- package/package.json +26 -20
- package/src/contract-free/checks.ts +73 -0
- package/src/contract-free/control-bootstrap.ts +20 -3
- package/src/contract-free/ddl.ts +98 -1
- package/src/core/authoring.ts +509 -2
- package/src/core/codec-ids.ts +1 -0
- package/src/core/codec-type-map.ts +2 -0
- package/src/core/codecs.ts +180 -1
- package/src/core/ddl/nodes.ts +185 -2
- package/src/core/default-normalizer.ts +122 -2
- package/src/core/descriptor-meta.ts +10 -1
- package/src/core/entity-kinds.ts +41 -0
- package/src/core/errors.ts +14 -12
- package/src/core/migrations/bound-schema.ts +10 -0
- package/src/core/migrations/column-ddl-rendering.ts +117 -0
- package/src/core/migrations/contract-to-postgres-database-schema-node.ts +189 -0
- package/src/core/migrations/control-policy.ts +217 -65
- package/src/core/migrations/diff-database-schema.ts +252 -0
- package/src/core/migrations/issue-planner.ts +769 -656
- package/src/core/migrations/op-factory-call.ts +273 -21
- package/src/core/migrations/operations/columns.ts +24 -12
- package/src/core/migrations/operations/constraints.ts +1 -1
- package/src/core/migrations/operations/data-transform.ts +6 -0
- package/src/core/migrations/operations/dependencies.ts +2 -1
- package/src/core/migrations/operations/native-enum-types.ts +62 -0
- package/src/core/migrations/operations/rls.ts +187 -0
- package/src/core/migrations/operations/shared.ts +0 -30
- package/src/core/migrations/planner-ddl-builders.ts +31 -59
- package/src/core/migrations/planner-identity-values.ts +1 -1
- package/src/core/migrations/planner-recipes.ts +31 -13
- package/src/core/migrations/planner-sql-checks.ts +1 -1
- package/src/core/migrations/planner-strategies.ts +248 -216
- package/src/core/migrations/planner-target-details.ts +3 -1
- package/src/core/migrations/planner.ts +357 -67
- package/src/core/migrations/postgres-migration.ts +101 -86
- package/src/core/migrations/render-typescript.ts +42 -15
- package/src/core/migrations/resolve-ddl-schema.ts +26 -0
- package/src/core/migrations/runner.ts +11 -16
- package/src/core/migrations/verify-postgres-namespaces.ts +46 -31
- package/src/core/namespace-ids.ts +3 -0
- package/src/core/postgres-contract-serializer.ts +46 -71
- package/src/core/postgres-contract-view.ts +48 -0
- package/src/core/postgres-native-enum.ts +61 -0
- package/src/core/postgres-rls-enablement.ts +34 -0
- package/src/core/postgres-rls-policy.ts +64 -0
- package/src/core/postgres-role.ts +59 -0
- package/src/core/postgres-schema-verifier.ts +10 -7
- package/src/core/postgres-schema.ts +85 -15
- package/src/core/postgres-validators.ts +34 -0
- package/src/core/psl-infer/infer-psl-contract.ts +1273 -0
- package/src/core/psl-infer/postgres-default-mapping.ts +16 -0
- package/src/core/psl-infer/postgres-type-map.ts +128 -0
- package/src/core/rls/canonicalize.ts +48 -0
- package/src/core/rls/wire-name.ts +33 -0
- package/src/core/schema-ir/node-storage-coordinate.ts +26 -0
- package/src/core/schema-ir/postgres-database-schema-node.ts +61 -0
- package/src/core/schema-ir/postgres-namespace-schema-node.ts +70 -0
- package/src/core/schema-ir/postgres-native-enum-schema-node.ts +83 -0
- package/src/core/schema-ir/postgres-policy-schema-node.ts +90 -0
- package/src/core/schema-ir/postgres-role-schema-node.ts +65 -0
- package/src/core/schema-ir/postgres-table-schema-node.ts +140 -0
- package/src/core/schema-ir/schema-node-kinds.ts +122 -0
- package/src/core/sql-utils.ts +15 -2
- package/src/exports/codecs.ts +3 -0
- package/src/exports/contract-free.ts +11 -1
- package/src/exports/control.ts +27 -33
- package/src/exports/ddl.ts +8 -0
- package/src/exports/diff-database-schema.ts +7 -0
- package/src/exports/issue-planner.ts +1 -1
- package/src/exports/migration.ts +1 -0
- package/src/exports/op-factory-call.ts +3 -0
- package/src/exports/planner-ddl-builders.ts +0 -2
- package/src/exports/planner-identity-values.ts +4 -1
- package/src/exports/planner.ts +1 -0
- package/src/exports/rls-canonicalize.ts +7 -0
- package/src/exports/runtime.ts +1 -0
- package/src/exports/schema-ir-annotations.ts +1 -0
- package/src/exports/sql-utils.ts +1 -0
- package/src/exports/types.ts +37 -0
- package/dist/codec-ids-BvytN2P8.mjs.map +0 -1
- package/dist/codec-ids-CnXu9Qy3.d.mts.map +0 -1
- package/dist/codec-types-DHCkwPKE.d.mts.map +0 -1
- package/dist/codecs--0A5_4Bq.d.mts.map +0 -1
- package/dist/ddl-QDyOSeLc.mjs.map +0 -1
- package/dist/default-normalizer-DyyCHQWs.mjs +0 -131
- package/dist/default-normalizer-DyyCHQWs.mjs.map +0 -1
- package/dist/descriptor-meta-CpGygXpI.mjs +0 -140
- package/dist/descriptor-meta-CpGygXpI.mjs.map +0 -1
- package/dist/errors-CUk87ByX.mjs +0 -33
- package/dist/errors-CUk87ByX.mjs.map +0 -1
- package/dist/issue-planner-DL6g3CmE.mjs +0 -824
- package/dist/issue-planner-DL6g3CmE.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/nodes-Bbhs2rwj.mjs +0 -76
- package/dist/nodes-Bbhs2rwj.mjs.map +0 -1
- package/dist/nodes-pLeLgdis.d.mts +0 -67
- package/dist/nodes-pLeLgdis.d.mts.map +0 -1
- package/dist/op-factory-call-D_p5vxwt.mjs.map +0 -1
- package/dist/op-factory-call-DmQEc3XV.d.mts.map +0 -1
- package/dist/planner-Bs_baQax.mjs +0 -324
- package/dist/planner-Bs_baQax.mjs.map +0 -1
- package/dist/planner-ddl-builders-B2wOwLqI.mjs.map +0 -1
- package/dist/planner-target-details-CIY6tLeo.d.mts.map +0 -1
- package/dist/postgres-contract-serializer-k3TAcPMY.mjs.map +0 -1
- package/dist/postgres-migration-B5jKrXv3.mjs.map +0 -1
- package/dist/postgres-migration-Y4YBJqkS.d.mts.map +0 -1
- package/dist/postgres-schema-COGZ1ark.mjs.map +0 -1
- package/dist/render-typescript-KMgosran.mjs.map +0 -1
- package/dist/types.d.mts.map +0 -1
- package/src/core/postgres-enum-type-schema.ts +0 -17
|
@@ -1,36 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Postgres migration planner.
|
|
2
|
+
* Postgres migration issue planner.
|
|
3
3
|
*
|
|
4
|
-
* Takes schema issues (from
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Takes node-typed schema-diff issues (from the one differ — see
|
|
5
|
+
* `buildPostgresPlanDiff` in `diff-database-schema.ts`) and emits migration
|
|
6
|
+
* IR (`PostgresOpFactoryCall[]`). Strategies consume issues they recognize
|
|
7
|
+
* and produce specialized call sequences (e.g. NOT NULL backfill →
|
|
7
8
|
* addColumn(nullable) + dataTransform + setNotNull); remaining issues flow
|
|
8
|
-
* through `
|
|
9
|
+
* through `mapNodeIssueToCall` for the default case.
|
|
10
|
+
*
|
|
11
|
+
* Structural op-render (column type/default DDL) resolves the column node's
|
|
12
|
+
* `codecRef` against the codec hooks the caller holds (`column-ddl-
|
|
13
|
+
* rendering.ts`) — never re-derived from the contract. The retained
|
|
14
|
+
* subsystems (codec type-operations, the NOT-NULL temp-default deferred DDL,
|
|
15
|
+
* control-policy disposition) still read the contract via the strategy
|
|
16
|
+
* context, per the slice's scope.
|
|
9
17
|
*/
|
|
10
18
|
|
|
11
|
-
import type { Contract
|
|
19
|
+
import type { Contract } from '@prisma-next/contract/types';
|
|
12
20
|
import type {
|
|
13
21
|
CodecControlHooks,
|
|
14
22
|
MigrationOperationPolicy,
|
|
15
23
|
SqlPlannerConflict,
|
|
16
24
|
SqlPlannerConflictLocation,
|
|
17
25
|
} from '@prisma-next/family-sql/control';
|
|
18
|
-
import { arraysEqual } from '@prisma-next/family-sql/schema-verify';
|
|
19
26
|
import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
|
|
20
|
-
import type {
|
|
21
|
-
import
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
StorageTable,
|
|
25
|
-
StorageTypeInstance,
|
|
26
|
-
} from '@prisma-next/sql-contract/types';
|
|
27
|
-
import type { CodecRef, DdlColumn, DdlTableConstraint } from '@prisma-next/sql-relational-core/ast';
|
|
27
|
+
import type { DiffableNode, SchemaDiffIssue } from '@prisma-next/framework-components/control';
|
|
28
|
+
import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
|
|
29
|
+
import type { SqlStorage, StorageTypeInstance } from '@prisma-next/sql-contract/types';
|
|
30
|
+
import type { DdlTableConstraint } from '@prisma-next/sql-relational-core/ast';
|
|
28
31
|
import * as contractFree from '@prisma-next/sql-relational-core/contract-free';
|
|
29
|
-
import
|
|
32
|
+
import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
|
|
33
|
+
import {
|
|
34
|
+
RelationalSchemaNodeKind,
|
|
35
|
+
type SqlColumnDefaultIR,
|
|
36
|
+
type SqlColumnIR,
|
|
37
|
+
type SqlForeignKeyIR,
|
|
38
|
+
type SqlIndexIR,
|
|
39
|
+
SqlSchemaIR,
|
|
40
|
+
type SqlSchemaIRNode,
|
|
41
|
+
type SqlUniqueIR,
|
|
42
|
+
} from '@prisma-next/sql-schema-ir/types';
|
|
30
43
|
import { blindCast } from '@prisma-next/utils/casts';
|
|
31
44
|
import { ifDefined } from '@prisma-next/utils/defined';
|
|
32
45
|
import type { Result } from '@prisma-next/utils/result';
|
|
33
46
|
import { notOk, ok } from '@prisma-next/utils/result';
|
|
47
|
+
import type { PostgresNamespaceSchemaNode } from '../schema-ir/postgres-namespace-schema-node';
|
|
48
|
+
import type { PostgresNativeEnumSchemaNode } from '../schema-ir/postgres-native-enum-schema-node';
|
|
49
|
+
import type { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node';
|
|
50
|
+
import { PostgresSchemaNodeKind } from '../schema-ir/schema-node-kinds';
|
|
51
|
+
import { quoteIdentifier } from '../sql-utils';
|
|
52
|
+
import {
|
|
53
|
+
renderColumnAlterType,
|
|
54
|
+
renderColumnDdl,
|
|
55
|
+
renderColumnDefaultSql,
|
|
56
|
+
} from './column-ddl-rendering';
|
|
57
|
+
import { resolveNamespaceIdForDdlSchema } from './control-policy';
|
|
34
58
|
import {
|
|
35
59
|
AddColumnCall,
|
|
36
60
|
AddForeignKeyCall,
|
|
@@ -38,85 +62,50 @@ import {
|
|
|
38
62
|
AddUniqueCall,
|
|
39
63
|
AlterColumnTypeCall,
|
|
40
64
|
CreateIndexCall,
|
|
65
|
+
CreateNativeEnumTypeCall,
|
|
41
66
|
CreateSchemaCall,
|
|
42
67
|
CreateTableCall,
|
|
68
|
+
DisableRowLevelSecurityCall,
|
|
43
69
|
DropCheckConstraintCall,
|
|
44
70
|
DropColumnCall,
|
|
45
71
|
DropConstraintCall,
|
|
46
72
|
DropDefaultCall,
|
|
47
73
|
DropIndexCall,
|
|
74
|
+
DropNativeEnumTypeCall,
|
|
48
75
|
DropNotNullCall,
|
|
49
76
|
DropTableCall,
|
|
77
|
+
EnableRowLevelSecurityCall,
|
|
50
78
|
type PostgresOpFactoryCall,
|
|
51
|
-
postgresDefaultToDdlColumnDefault,
|
|
52
79
|
SetDefaultCall,
|
|
53
80
|
SetNotNullCall,
|
|
54
81
|
} from './op-factory-call';
|
|
55
82
|
import type { ForeignKeySpec } from './operations/shared';
|
|
56
|
-
import { buildColumnDefaultSql, buildColumnTypeSql } from './planner-ddl-builders';
|
|
57
|
-
import { buildExpectedFormatType } from './planner-sql-checks';
|
|
58
83
|
import {
|
|
59
84
|
type CallMigrationStrategy,
|
|
60
85
|
postgresPlannerStrategies,
|
|
61
|
-
resolveDdlSchemaForNamespace,
|
|
62
|
-
resolveNamespaceIdForIssue,
|
|
63
86
|
type StrategyContext,
|
|
64
|
-
tableAt,
|
|
65
87
|
} from './planner-strategies';
|
|
66
|
-
import { resolveColumnTypeMetadata } from './planner-type-resolution';
|
|
67
88
|
|
|
68
89
|
export type { CallMigrationStrategy, StrategyContext };
|
|
69
90
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// Types next
|
|
80
|
-
type_missing: 2,
|
|
81
|
-
type_values_mismatch: 3,
|
|
82
|
-
enum_values_changed: 3,
|
|
83
|
-
|
|
84
|
-
// Drops (reconciliation — clear the way for creates)
|
|
85
|
-
// FKs dropped first (they depend on other constraints)
|
|
86
|
-
extra_foreign_key: 10,
|
|
87
|
-
extra_unique_constraint: 11,
|
|
88
|
-
extra_primary_key: 12,
|
|
89
|
-
extra_index: 13,
|
|
90
|
-
extra_default: 14,
|
|
91
|
-
extra_column: 15,
|
|
92
|
-
extra_table: 16,
|
|
93
|
-
|
|
94
|
-
// Tables before columns
|
|
95
|
-
missing_table: 20,
|
|
96
|
-
|
|
97
|
-
// Columns before constraints
|
|
98
|
-
missing_column: 30,
|
|
99
|
-
|
|
100
|
-
// Reconciliation alters (on existing objects)
|
|
101
|
-
type_mismatch: 40,
|
|
102
|
-
nullability_mismatch: 41,
|
|
103
|
-
default_missing: 42,
|
|
104
|
-
default_mismatch: 43,
|
|
105
|
-
|
|
106
|
-
// Constraints after columns exist
|
|
107
|
-
primary_key_mismatch: 50,
|
|
108
|
-
unique_constraint_mismatch: 51,
|
|
109
|
-
index_mismatch: 52,
|
|
110
|
-
foreign_key_mismatch: 60,
|
|
111
|
-
|
|
112
|
-
// Check constraints
|
|
113
|
-
check_missing: 53,
|
|
114
|
-
check_mismatch: 54,
|
|
115
|
-
check_removed: 55,
|
|
116
|
-
};
|
|
91
|
+
/**
|
|
92
|
+
* Deterministic name for the element-non-null CHECK constraint on a scalar-array
|
|
93
|
+
* column. Distinct `_elem_not_null` suffix avoids collision with the enum
|
|
94
|
+
* value-set `_check` constraints. Re-emitting the same schema produces the same
|
|
95
|
+
* name, so `pg_get_constraintdef`-based verify sees no drift.
|
|
96
|
+
*/
|
|
97
|
+
function elementNonNullCheckName(tableName: string, columnName: string): string {
|
|
98
|
+
return `${tableName}_${columnName}_elem_not_null`;
|
|
99
|
+
}
|
|
117
100
|
|
|
118
|
-
|
|
119
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Predicate enforcing that a scalar-array column carries no NULL element. The
|
|
103
|
+
* array column itself may be NULL (container nullability is the column's NOT NULL
|
|
104
|
+
* clause); `array_position` over a NULL array yields NULL, which a CHECK treats
|
|
105
|
+
* as satisfied, so a nullable array column is unaffected.
|
|
106
|
+
*/
|
|
107
|
+
function elementNonNullCheckExpression(columnName: string): string {
|
|
108
|
+
return `array_position(${quoteIdentifier(columnName)}, NULL) IS NULL`;
|
|
120
109
|
}
|
|
121
110
|
|
|
122
111
|
// ============================================================================
|
|
@@ -136,558 +125,10 @@ function issueConflict(
|
|
|
136
125
|
};
|
|
137
126
|
}
|
|
138
127
|
|
|
139
|
-
function isMissing(issue: SchemaIssue): boolean {
|
|
140
|
-
if (issue.kind === 'enum_values_changed') return false;
|
|
141
|
-
return issue.actual === undefined;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ============================================================================
|
|
145
|
-
// Issue planner
|
|
146
|
-
// ============================================================================
|
|
147
|
-
|
|
148
|
-
export interface IssuePlannerOptions {
|
|
149
|
-
readonly issues: readonly SchemaIssue[];
|
|
150
|
-
readonly toContract: Contract<SqlStorage>;
|
|
151
|
-
readonly fromContract: Contract<SqlStorage> | null;
|
|
152
|
-
readonly schemaName: string;
|
|
153
|
-
readonly codecHooks: ReadonlyMap<string, CodecControlHooks>;
|
|
154
|
-
readonly storageTypes: Readonly<Record<string, StorageTypeInstance>>;
|
|
155
|
-
/**
|
|
156
|
-
* Current database schema IR. Strategies read this to detect whether a
|
|
157
|
-
* structure already exists (e.g. `buildSchemaLookupMap` for shared-temp-
|
|
158
|
-
* default safety, extension dependency checks). Defaults to an empty schema
|
|
159
|
-
* when omitted so the planner can still run over "fresh DB" contract
|
|
160
|
-
* snapshots.
|
|
161
|
-
*/
|
|
162
|
-
readonly schema?: SqlSchemaIR;
|
|
163
|
-
/**
|
|
164
|
-
* Operation-class policy. `planIssues` filters calls whose `operationClass`
|
|
165
|
-
* is not in `policy.allowedOperationClasses` and surfaces them as conflicts
|
|
166
|
-
* instead of emitting disallowed DDL. Defaults to additive-only.
|
|
167
|
-
*/
|
|
168
|
-
readonly policy?: MigrationOperationPolicy;
|
|
169
|
-
/**
|
|
170
|
-
* Framework components participating in this composition. Available to
|
|
171
|
-
* future strategies that may consult component metadata at plan time.
|
|
172
|
-
*/
|
|
173
|
-
readonly frameworkComponents?: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
|
|
174
|
-
readonly strategies?: readonly CallMigrationStrategy[];
|
|
175
|
-
}
|
|
176
|
-
|
|
177
128
|
export interface IssuePlannerValue {
|
|
178
129
|
readonly calls: readonly PostgresOpFactoryCall[];
|
|
179
130
|
}
|
|
180
131
|
|
|
181
|
-
function toDdlColumn(
|
|
182
|
-
name: string,
|
|
183
|
-
column: StorageColumn,
|
|
184
|
-
codecHooks: ReadonlyMap<string, CodecControlHooks>,
|
|
185
|
-
storageTypes: Readonly<Record<string, StorageTypeInstance>>,
|
|
186
|
-
): DdlColumn {
|
|
187
|
-
const typeSql = buildColumnTypeSql(column, codecHooks, storageTypes);
|
|
188
|
-
const ddlDefault = postgresDefaultToDdlColumnDefault(column.default);
|
|
189
|
-
const resolved = resolveColumnTypeMetadata(
|
|
190
|
-
column,
|
|
191
|
-
storageTypes as Record<string, StorageTypeInstance>,
|
|
192
|
-
);
|
|
193
|
-
const codecRef: CodecRef | undefined = resolved.codecId
|
|
194
|
-
? {
|
|
195
|
-
codecId: resolved.codecId,
|
|
196
|
-
...(resolved.typeParams !== undefined
|
|
197
|
-
? {
|
|
198
|
-
typeParams: blindCast<
|
|
199
|
-
JsonValue,
|
|
200
|
-
'resolved.typeParams is JsonValue-shaped storage metadata; the narrowed (non-undefined) value lands in CodecRef.typeParams which is JsonValue'
|
|
201
|
-
>(resolved.typeParams),
|
|
202
|
-
}
|
|
203
|
-
: {}),
|
|
204
|
-
}
|
|
205
|
-
: undefined;
|
|
206
|
-
return contractFree.col(name, typeSql, {
|
|
207
|
-
...(!column.nullable ? { notNull: true } : {}),
|
|
208
|
-
...ifDefined('default', ddlDefault),
|
|
209
|
-
...ifDefined('codecRef', codecRef),
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function mapIssueToCall(
|
|
214
|
-
issue: SchemaIssue,
|
|
215
|
-
ctx: StrategyContext,
|
|
216
|
-
): Result<readonly PostgresOpFactoryCall[], SqlPlannerConflict> {
|
|
217
|
-
const { schemaName, codecHooks, storageTypes } = ctx;
|
|
218
|
-
// Per-table effective schema. `extra_table` issues intentionally
|
|
219
|
-
// omit `namespaceId` — the live DB carries a table that
|
|
220
|
-
// is not claimed by any contract namespace, so there is no contract
|
|
221
|
-
// coordinate to project from. Those issues fall back to the planner's
|
|
222
|
-
// global `ctx.schemaName`; every other issue dispatches through the
|
|
223
|
-
// resolved namespace's polymorphic `ddlSchemaName`.
|
|
224
|
-
const tableSchema = (issue: SchemaIssue): string => {
|
|
225
|
-
if (issue.kind === 'extra_table') return schemaName;
|
|
226
|
-
if (!('table' in issue) || !issue.table) return schemaName;
|
|
227
|
-
return resolveDdlSchemaForNamespace(ctx, resolveNamespaceIdForIssue(issue));
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
switch (issue.kind) {
|
|
231
|
-
case 'missing_schema': {
|
|
232
|
-
const namespaceId = issue.namespaceId;
|
|
233
|
-
if (!namespaceId)
|
|
234
|
-
return notOk(
|
|
235
|
-
issueConflict('unsupportedOperation', 'Missing schema issue has no namespaceId'),
|
|
236
|
-
);
|
|
237
|
-
const ddlSchemaName = resolveDdlSchemaForNamespace(ctx, namespaceId);
|
|
238
|
-
return ok([new CreateSchemaCall(ddlSchemaName)]);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
case 'missing_table': {
|
|
242
|
-
if (!issue.table)
|
|
243
|
-
return notOk(
|
|
244
|
-
issueConflict('unsupportedOperation', 'Missing table issue has no table name'),
|
|
245
|
-
);
|
|
246
|
-
const namespaceId = resolveNamespaceIdForIssue(issue);
|
|
247
|
-
const contractTable = tableAt(ctx.toContract.storage, namespaceId, issue.table);
|
|
248
|
-
if (!contractTable) {
|
|
249
|
-
return notOk(
|
|
250
|
-
issueConflict(
|
|
251
|
-
'unsupportedOperation',
|
|
252
|
-
`Table "${issue.table}" in namespace "${namespaceId}" reported missing but not found in destination contract`,
|
|
253
|
-
),
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
const schemaForTable = tableSchema(issue);
|
|
257
|
-
const ddlColumns: DdlColumn[] = Object.entries(contractTable.columns).map(([name, column]) =>
|
|
258
|
-
toDdlColumn(name, column, codecHooks, storageTypes),
|
|
259
|
-
);
|
|
260
|
-
const ddlConstraints: DdlTableConstraint[] | undefined = contractTable.primaryKey
|
|
261
|
-
? [
|
|
262
|
-
contractFree.primaryKey(contractTable.primaryKey.columns, {
|
|
263
|
-
...(contractTable.primaryKey.name ? { name: contractTable.primaryKey.name } : {}),
|
|
264
|
-
}),
|
|
265
|
-
]
|
|
266
|
-
: undefined;
|
|
267
|
-
const calls: PostgresOpFactoryCall[] = [
|
|
268
|
-
new CreateTableCall(schemaForTable, issue.table, ddlColumns, ddlConstraints),
|
|
269
|
-
];
|
|
270
|
-
for (const index of contractTable.indexes) {
|
|
271
|
-
const indexName = index.name ?? `${issue.table}_${index.columns.join('_')}_idx`;
|
|
272
|
-
const extras: { type?: string; options?: Record<string, unknown> } = {};
|
|
273
|
-
if (index.type !== undefined) extras.type = index.type;
|
|
274
|
-
if (index.options !== undefined) extras.options = index.options;
|
|
275
|
-
calls.push(
|
|
276
|
-
new CreateIndexCall(schemaForTable, issue.table, indexName, [...index.columns], extras),
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
const explicitIndexColumnSets = new Set(
|
|
280
|
-
contractTable.indexes.map((idx) => idx.columns.join(',')),
|
|
281
|
-
);
|
|
282
|
-
for (const fk of contractTable.foreignKeys) {
|
|
283
|
-
if (fk.constraint) {
|
|
284
|
-
const fkName = fk.name ?? `${issue.table}_${fk.source.columns.join('_')}_fkey`;
|
|
285
|
-
const fkSpec: ForeignKeySpec = {
|
|
286
|
-
name: fkName,
|
|
287
|
-
columns: fk.source.columns,
|
|
288
|
-
references: {
|
|
289
|
-
schema: fk.target.namespaceId,
|
|
290
|
-
table: fk.target.tableName,
|
|
291
|
-
columns: fk.target.columns,
|
|
292
|
-
},
|
|
293
|
-
...(fk.onDelete !== undefined && { onDelete: fk.onDelete }),
|
|
294
|
-
...(fk.onUpdate !== undefined && { onUpdate: fk.onUpdate }),
|
|
295
|
-
};
|
|
296
|
-
calls.push(new AddForeignKeyCall(schemaForTable, issue.table, fkSpec));
|
|
297
|
-
}
|
|
298
|
-
if (fk.index && !explicitIndexColumnSets.has(fk.source.columns.join(','))) {
|
|
299
|
-
const indexName = `${issue.table}_${fk.source.columns.join('_')}_idx`;
|
|
300
|
-
calls.push(
|
|
301
|
-
new CreateIndexCall(schemaForTable, issue.table, indexName, [...fk.source.columns]),
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
for (const unique of contractTable.uniques) {
|
|
306
|
-
const constraintName = unique.name ?? `${issue.table}_${unique.columns.join('_')}_key`;
|
|
307
|
-
calls.push(
|
|
308
|
-
new AddUniqueCall(schemaForTable, issue.table, constraintName, [...unique.columns]),
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
return ok(calls);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
case 'missing_column':
|
|
315
|
-
if (!issue.table || !issue.column)
|
|
316
|
-
return notOk(
|
|
317
|
-
issueConflict('unsupportedOperation', 'Missing column issue has no table/column name'),
|
|
318
|
-
);
|
|
319
|
-
{
|
|
320
|
-
const namespaceId = resolveNamespaceIdForIssue(issue);
|
|
321
|
-
const column = tableAt(ctx.toContract.storage, namespaceId, issue.table)?.columns[
|
|
322
|
-
issue.column
|
|
323
|
-
];
|
|
324
|
-
if (!column)
|
|
325
|
-
return notOk(
|
|
326
|
-
issueConflict(
|
|
327
|
-
'unsupportedOperation',
|
|
328
|
-
`Column "${issue.table}"."${issue.column}" not in destination contract`,
|
|
329
|
-
),
|
|
330
|
-
);
|
|
331
|
-
return ok([
|
|
332
|
-
new AddColumnCall(
|
|
333
|
-
tableSchema(issue),
|
|
334
|
-
issue.table,
|
|
335
|
-
toDdlColumn(issue.column, column, codecHooks, storageTypes),
|
|
336
|
-
),
|
|
337
|
-
]);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
case 'default_missing':
|
|
341
|
-
if (!issue.table || !issue.column)
|
|
342
|
-
return notOk(
|
|
343
|
-
issueConflict('unsupportedOperation', 'Default missing issue has no table/column name'),
|
|
344
|
-
);
|
|
345
|
-
{
|
|
346
|
-
const namespaceId = resolveNamespaceIdForIssue(issue);
|
|
347
|
-
const column = tableAt(ctx.toContract.storage, namespaceId, issue.table)?.columns[
|
|
348
|
-
issue.column
|
|
349
|
-
];
|
|
350
|
-
if (!column?.default) {
|
|
351
|
-
return notOk(
|
|
352
|
-
issueConflict(
|
|
353
|
-
'unsupportedOperation',
|
|
354
|
-
`Column "${issue.table}"."${issue.column}" has no default in contract`,
|
|
355
|
-
),
|
|
356
|
-
);
|
|
357
|
-
}
|
|
358
|
-
const defaultSql = buildColumnDefaultSql(column.default, column);
|
|
359
|
-
if (!defaultSql) return ok([]);
|
|
360
|
-
return ok([new SetDefaultCall(tableSchema(issue), issue.table, issue.column, defaultSql)]);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
case 'extra_table':
|
|
364
|
-
if (!issue.table)
|
|
365
|
-
return notOk(issueConflict('unsupportedOperation', 'Extra table issue has no table name'));
|
|
366
|
-
return ok([new DropTableCall(tableSchema(issue), issue.table)]);
|
|
367
|
-
|
|
368
|
-
case 'extra_column':
|
|
369
|
-
if (!issue.table || !issue.column)
|
|
370
|
-
return notOk(
|
|
371
|
-
issueConflict('unsupportedOperation', 'Extra column issue has no table/column name'),
|
|
372
|
-
);
|
|
373
|
-
return ok([new DropColumnCall(tableSchema(issue), issue.table, issue.column)]);
|
|
374
|
-
|
|
375
|
-
case 'extra_index':
|
|
376
|
-
if (!issue.table || !issue.indexOrConstraint)
|
|
377
|
-
return notOk(
|
|
378
|
-
issueConflict('unsupportedOperation', 'Extra index issue has no table/index name'),
|
|
379
|
-
);
|
|
380
|
-
return ok([new DropIndexCall(tableSchema(issue), issue.table, issue.indexOrConstraint)]);
|
|
381
|
-
|
|
382
|
-
case 'extra_unique_constraint':
|
|
383
|
-
case 'extra_foreign_key':
|
|
384
|
-
case 'extra_primary_key': {
|
|
385
|
-
if (!issue.table)
|
|
386
|
-
return notOk(
|
|
387
|
-
issueConflict(
|
|
388
|
-
'unsupportedOperation',
|
|
389
|
-
'Extra constraint issue has no table/constraint name',
|
|
390
|
-
),
|
|
391
|
-
);
|
|
392
|
-
// `extra_primary_key` issues don't carry a constraint name — the
|
|
393
|
-
// verifier only has the table. Fall back to `<table>_pkey`, matching
|
|
394
|
-
// Postgres' default PK constraint naming and the old reconciliation
|
|
395
|
-
// planner's behavior.
|
|
396
|
-
const constraintName =
|
|
397
|
-
issue.indexOrConstraint ??
|
|
398
|
-
(issue.kind === 'extra_primary_key' ? `${issue.table}_pkey` : undefined);
|
|
399
|
-
if (!constraintName)
|
|
400
|
-
return notOk(
|
|
401
|
-
issueConflict(
|
|
402
|
-
'unsupportedOperation',
|
|
403
|
-
'Extra constraint issue has no table/constraint name',
|
|
404
|
-
),
|
|
405
|
-
);
|
|
406
|
-
const kindMap = {
|
|
407
|
-
extra_unique_constraint: 'unique' as const,
|
|
408
|
-
extra_foreign_key: 'foreignKey' as const,
|
|
409
|
-
extra_primary_key: 'primaryKey' as const,
|
|
410
|
-
};
|
|
411
|
-
return ok([
|
|
412
|
-
new DropConstraintCall(
|
|
413
|
-
tableSchema(issue),
|
|
414
|
-
issue.table,
|
|
415
|
-
constraintName,
|
|
416
|
-
kindMap[issue.kind],
|
|
417
|
-
),
|
|
418
|
-
]);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
case 'extra_default':
|
|
422
|
-
if (!issue.table || !issue.column)
|
|
423
|
-
return notOk(
|
|
424
|
-
issueConflict('unsupportedOperation', 'Extra default issue has no table/column name'),
|
|
425
|
-
);
|
|
426
|
-
return ok([new DropDefaultCall(tableSchema(issue), issue.table, issue.column)]);
|
|
427
|
-
|
|
428
|
-
case 'nullability_mismatch': {
|
|
429
|
-
if (!issue.table || !issue.column)
|
|
430
|
-
return notOk(
|
|
431
|
-
issueConflict('nullabilityConflict', 'Nullability mismatch has no table/column name'),
|
|
432
|
-
);
|
|
433
|
-
const namespaceId = resolveNamespaceIdForIssue(issue);
|
|
434
|
-
const column = tableAt(ctx.toContract.storage, namespaceId, issue.table)?.columns[
|
|
435
|
-
issue.column
|
|
436
|
-
];
|
|
437
|
-
if (!column)
|
|
438
|
-
return notOk(
|
|
439
|
-
issueConflict(
|
|
440
|
-
'nullabilityConflict',
|
|
441
|
-
`Column "${issue.table}"."${issue.column}" not found in destination contract`,
|
|
442
|
-
),
|
|
443
|
-
);
|
|
444
|
-
const schemaForTable = tableSchema(issue);
|
|
445
|
-
return ok(
|
|
446
|
-
column.nullable
|
|
447
|
-
? [new DropNotNullCall(schemaForTable, issue.table, issue.column)]
|
|
448
|
-
: [new SetNotNullCall(schemaForTable, issue.table, issue.column)],
|
|
449
|
-
);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
case 'type_mismatch':
|
|
453
|
-
if (!issue.table || !issue.column)
|
|
454
|
-
return notOk(issueConflict('typeMismatch', 'Type mismatch has no table/column name'));
|
|
455
|
-
{
|
|
456
|
-
const namespaceId = resolveNamespaceIdForIssue(issue);
|
|
457
|
-
const column = tableAt(ctx.toContract.storage, namespaceId, issue.table)?.columns[
|
|
458
|
-
issue.column
|
|
459
|
-
];
|
|
460
|
-
if (!column)
|
|
461
|
-
return notOk(
|
|
462
|
-
issueConflict(
|
|
463
|
-
'typeMismatch',
|
|
464
|
-
`Column "${issue.table}"."${issue.column}" not in destination contract`,
|
|
465
|
-
),
|
|
466
|
-
);
|
|
467
|
-
const hooksMap = codecHooks as Map<string, CodecControlHooks>;
|
|
468
|
-
const typesMap = storageTypes as Record<string, StorageTypeInstance>;
|
|
469
|
-
const qualifiedTargetType = buildColumnTypeSql(column, hooksMap, typesMap, false);
|
|
470
|
-
const formatTypeExpected = buildExpectedFormatType(column, hooksMap, typesMap);
|
|
471
|
-
return ok([
|
|
472
|
-
new AlterColumnTypeCall(tableSchema(issue), issue.table, issue.column, {
|
|
473
|
-
qualifiedTargetType,
|
|
474
|
-
formatTypeExpected,
|
|
475
|
-
rawTargetTypeForLabel: qualifiedTargetType,
|
|
476
|
-
}),
|
|
477
|
-
]);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
case 'default_mismatch':
|
|
481
|
-
if (!issue.table || !issue.column)
|
|
482
|
-
return notOk(
|
|
483
|
-
issueConflict('unsupportedOperation', 'Default mismatch has no table/column name'),
|
|
484
|
-
);
|
|
485
|
-
{
|
|
486
|
-
const namespaceId = resolveNamespaceIdForIssue(issue);
|
|
487
|
-
const column = tableAt(ctx.toContract.storage, namespaceId, issue.table)?.columns[
|
|
488
|
-
issue.column
|
|
489
|
-
];
|
|
490
|
-
if (!column?.default) return ok([]);
|
|
491
|
-
const defaultSql = buildColumnDefaultSql(column.default, column);
|
|
492
|
-
if (!defaultSql) return ok([]);
|
|
493
|
-
return ok([
|
|
494
|
-
new SetDefaultCall(tableSchema(issue), issue.table, issue.column, defaultSql, 'widening'),
|
|
495
|
-
]);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
case 'primary_key_mismatch':
|
|
499
|
-
if (!issue.table)
|
|
500
|
-
return notOk(issueConflict('indexIncompatible', 'Primary key issue has no table name'));
|
|
501
|
-
if (isMissing(issue)) {
|
|
502
|
-
const namespaceId = resolveNamespaceIdForIssue(issue);
|
|
503
|
-
const pk = tableAt(ctx.toContract.storage, namespaceId, issue.table)?.primaryKey;
|
|
504
|
-
if (!pk)
|
|
505
|
-
return notOk(
|
|
506
|
-
issueConflict('indexIncompatible', `No primary key in contract for "${issue.table}"`),
|
|
507
|
-
);
|
|
508
|
-
const constraintName = pk.name ?? `${issue.table}_pkey`;
|
|
509
|
-
return ok([
|
|
510
|
-
new AddPrimaryKeyCall(tableSchema(issue), issue.table, constraintName, pk.columns),
|
|
511
|
-
]);
|
|
512
|
-
}
|
|
513
|
-
return notOk(
|
|
514
|
-
issueConflict(
|
|
515
|
-
'indexIncompatible',
|
|
516
|
-
`Primary key on "${issue.table}" has different columns (expected: ${issue.expected}, actual: ${issue.actual})`,
|
|
517
|
-
{ table: issue.table },
|
|
518
|
-
),
|
|
519
|
-
);
|
|
520
|
-
|
|
521
|
-
case 'unique_constraint_mismatch':
|
|
522
|
-
if (!issue.table)
|
|
523
|
-
return notOk(
|
|
524
|
-
issueConflict('indexIncompatible', 'Unique constraint issue has no table name'),
|
|
525
|
-
);
|
|
526
|
-
if (isMissing(issue) && issue.expected) {
|
|
527
|
-
const columns = issue.expected.split(', ');
|
|
528
|
-
const constraintName = `${issue.table}_${columns.join('_')}_key`;
|
|
529
|
-
return ok([new AddUniqueCall(tableSchema(issue), issue.table, constraintName, columns)]);
|
|
530
|
-
}
|
|
531
|
-
return notOk(
|
|
532
|
-
issueConflict(
|
|
533
|
-
'indexIncompatible',
|
|
534
|
-
`Unique constraint on "${issue.table}" differs (expected: ${issue.expected}, actual: ${issue.actual})`,
|
|
535
|
-
{ table: issue.table },
|
|
536
|
-
),
|
|
537
|
-
);
|
|
538
|
-
|
|
539
|
-
case 'index_mismatch':
|
|
540
|
-
if (!issue.table)
|
|
541
|
-
return notOk(issueConflict('indexIncompatible', 'Index issue has no table name'));
|
|
542
|
-
if (isMissing(issue) && issue.expected) {
|
|
543
|
-
const namespaceId = resolveNamespaceIdForIssue(issue);
|
|
544
|
-
const columns = issue.expected.split(', ');
|
|
545
|
-
const contractIndex = tableAt(
|
|
546
|
-
ctx.toContract.storage,
|
|
547
|
-
namespaceId,
|
|
548
|
-
issue.table,
|
|
549
|
-
)?.indexes.find((idx: StorageTable['indexes'][number]) =>
|
|
550
|
-
arraysEqual(idx.columns, columns),
|
|
551
|
-
);
|
|
552
|
-
const indexName = contractIndex?.name ?? `${issue.table}_${columns.join('_')}_idx`;
|
|
553
|
-
const extras: { type?: string; options?: Record<string, unknown> } = {};
|
|
554
|
-
if (contractIndex?.type !== undefined) extras.type = contractIndex.type;
|
|
555
|
-
if (contractIndex?.options !== undefined) extras.options = contractIndex.options;
|
|
556
|
-
return ok([
|
|
557
|
-
new CreateIndexCall(tableSchema(issue), issue.table, indexName, columns, extras),
|
|
558
|
-
]);
|
|
559
|
-
}
|
|
560
|
-
return notOk(
|
|
561
|
-
issueConflict(
|
|
562
|
-
'indexIncompatible',
|
|
563
|
-
`Index on "${issue.table}" differs (expected: ${issue.expected}, actual: ${issue.actual})`,
|
|
564
|
-
{ table: issue.table },
|
|
565
|
-
),
|
|
566
|
-
);
|
|
567
|
-
|
|
568
|
-
case 'check_missing': {
|
|
569
|
-
if (!issue.table || !issue.indexOrConstraint)
|
|
570
|
-
return notOk(
|
|
571
|
-
issueConflict('unsupportedOperation', 'Check missing issue has no table/constraint name'),
|
|
572
|
-
);
|
|
573
|
-
// check_missing is normally consumed by checkConstraintPlanCallStrategy.
|
|
574
|
-
// This case handles any that arrive here (e.g. in tests that invoke
|
|
575
|
-
// mapIssueToCall directly or skip the strategy).
|
|
576
|
-
return notOk(
|
|
577
|
-
issueConflict(
|
|
578
|
-
'unsupportedOperation',
|
|
579
|
-
`Check constraint "${issue.indexOrConstraint}" missing on "${issue.table}" — handled by checkConstraintPlanCallStrategy`,
|
|
580
|
-
),
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
case 'check_mismatch': {
|
|
585
|
-
if (!issue.table || !issue.indexOrConstraint)
|
|
586
|
-
return notOk(
|
|
587
|
-
issueConflict(
|
|
588
|
-
'unsupportedOperation',
|
|
589
|
-
'Check mismatch issue has no table/constraint name',
|
|
590
|
-
),
|
|
591
|
-
);
|
|
592
|
-
return notOk(
|
|
593
|
-
issueConflict(
|
|
594
|
-
'unsupportedOperation',
|
|
595
|
-
`Check constraint "${issue.indexOrConstraint}" values mismatch on "${issue.table}" — handled by checkConstraintPlanCallStrategy`,
|
|
596
|
-
),
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
case 'check_removed': {
|
|
601
|
-
if (!issue.table || !issue.indexOrConstraint)
|
|
602
|
-
return notOk(
|
|
603
|
-
issueConflict('unsupportedOperation', 'Check removed issue has no table/constraint name'),
|
|
604
|
-
);
|
|
605
|
-
return ok([
|
|
606
|
-
new DropCheckConstraintCall(tableSchema(issue), issue.table, issue.indexOrConstraint),
|
|
607
|
-
]);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
case 'foreign_key_mismatch':
|
|
611
|
-
if (!issue.table)
|
|
612
|
-
return notOk(issueConflict('foreignKeyConflict', 'Foreign key issue has no table name'));
|
|
613
|
-
if (isMissing(issue) && issue.expected) {
|
|
614
|
-
const arrowIdx = issue.expected.indexOf(' -> ');
|
|
615
|
-
if (arrowIdx >= 0) {
|
|
616
|
-
const namespaceId = resolveNamespaceIdForIssue(issue);
|
|
617
|
-
const columns = issue.expected.slice(0, arrowIdx).split(', ');
|
|
618
|
-
const fkName = `${issue.table}_${columns.join('_')}_fkey`;
|
|
619
|
-
const fk = tableAt(ctx.toContract.storage, namespaceId, issue.table)?.foreignKeys.find(
|
|
620
|
-
(k) => k.source.columns.join(', ') === columns.join(', '),
|
|
621
|
-
);
|
|
622
|
-
if (fk) {
|
|
623
|
-
const fkSpec: ForeignKeySpec = {
|
|
624
|
-
name: fkName,
|
|
625
|
-
columns: fk.source.columns,
|
|
626
|
-
references: {
|
|
627
|
-
schema: fk.target.namespaceId,
|
|
628
|
-
table: fk.target.tableName,
|
|
629
|
-
columns: fk.target.columns,
|
|
630
|
-
},
|
|
631
|
-
...(fk.onDelete !== undefined && { onDelete: fk.onDelete }),
|
|
632
|
-
...(fk.onUpdate !== undefined && { onUpdate: fk.onUpdate }),
|
|
633
|
-
};
|
|
634
|
-
return ok([new AddForeignKeyCall(tableSchema(issue), issue.table, fkSpec)]);
|
|
635
|
-
}
|
|
636
|
-
return notOk(
|
|
637
|
-
issueConflict(
|
|
638
|
-
'foreignKeyConflict',
|
|
639
|
-
`Foreign key on "${issue.table}" (${columns.join(', ')}) not found in destination contract`,
|
|
640
|
-
{ table: issue.table },
|
|
641
|
-
),
|
|
642
|
-
);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
return notOk(
|
|
646
|
-
issueConflict(
|
|
647
|
-
'foreignKeyConflict',
|
|
648
|
-
`Foreign key on "${issue.table}" differs (expected: ${issue.expected}, actual: ${issue.actual})`,
|
|
649
|
-
{ table: issue.table },
|
|
650
|
-
),
|
|
651
|
-
);
|
|
652
|
-
|
|
653
|
-
case 'type_missing': {
|
|
654
|
-
if (!issue.typeName)
|
|
655
|
-
return notOk(issueConflict('unsupportedOperation', 'Type missing issue has no typeName'));
|
|
656
|
-
const typeInstance = ctx.toContract.storage.types?.[issue.typeName];
|
|
657
|
-
if (!typeInstance) {
|
|
658
|
-
return notOk(
|
|
659
|
-
issueConflict(
|
|
660
|
-
'unsupportedOperation',
|
|
661
|
-
`Type "${issue.typeName}" reported missing but not found in destination contract`,
|
|
662
|
-
),
|
|
663
|
-
);
|
|
664
|
-
}
|
|
665
|
-
return notOk(
|
|
666
|
-
issueConflict(
|
|
667
|
-
'unsupportedOperation',
|
|
668
|
-
`Type "${issue.typeName}" uses codec "${typeInstance.codecId}" — only value-set types are supported`,
|
|
669
|
-
),
|
|
670
|
-
);
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
case 'type_values_mismatch':
|
|
674
|
-
return notOk(
|
|
675
|
-
issueConflict(
|
|
676
|
-
'unsupportedOperation',
|
|
677
|
-
`Type "${issue.typeName ?? 'unknown'}" values differ — type alteration not yet supported`,
|
|
678
|
-
),
|
|
679
|
-
);
|
|
680
|
-
|
|
681
|
-
default:
|
|
682
|
-
return notOk(
|
|
683
|
-
issueConflict(
|
|
684
|
-
'unsupportedOperation',
|
|
685
|
-
`Unhandled issue kind: ${(issue as SchemaIssue).kind}`,
|
|
686
|
-
),
|
|
687
|
-
);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
132
|
/**
|
|
692
133
|
* Classifies calls into dependency order categories for correct DDL sequencing.
|
|
693
134
|
*/
|
|
@@ -695,6 +136,8 @@ type CallCategory =
|
|
|
695
136
|
| 'dep'
|
|
696
137
|
| 'drop'
|
|
697
138
|
| 'table'
|
|
139
|
+
| 'rlsEnable'
|
|
140
|
+
| 'rlsPolicy'
|
|
698
141
|
| 'column'
|
|
699
142
|
| 'alter'
|
|
700
143
|
| 'primaryKey'
|
|
@@ -712,8 +155,10 @@ function classifyCall(call: PostgresOpFactoryCall): CallCategory {
|
|
|
712
155
|
switch (call.factoryName) {
|
|
713
156
|
case 'createExtension':
|
|
714
157
|
case 'createSchema':
|
|
158
|
+
case 'createNativeEnumType':
|
|
715
159
|
return 'dep';
|
|
716
160
|
case 'dropTable':
|
|
161
|
+
case 'dropNativeEnumType':
|
|
717
162
|
case 'dropColumn':
|
|
718
163
|
case 'dropConstraint':
|
|
719
164
|
case 'dropCheckConstraint':
|
|
@@ -724,6 +169,13 @@ function classifyCall(call: PostgresOpFactoryCall): CallCategory {
|
|
|
724
169
|
return 'unique'; // after uniques, before indexes
|
|
725
170
|
case 'createTable':
|
|
726
171
|
return 'table';
|
|
172
|
+
case 'enableRowLevelSecurity':
|
|
173
|
+
case 'disableRowLevelSecurity':
|
|
174
|
+
return 'rlsEnable';
|
|
175
|
+
case 'createRlsPolicy':
|
|
176
|
+
return 'rlsPolicy';
|
|
177
|
+
case 'dropRlsPolicy':
|
|
178
|
+
return 'drop';
|
|
727
179
|
case 'addColumn':
|
|
728
180
|
return 'column';
|
|
729
181
|
case 'alterColumnType':
|
|
@@ -760,17 +212,6 @@ function classifyCall(call: PostgresOpFactoryCall): CallCategory {
|
|
|
760
212
|
}
|
|
761
213
|
}
|
|
762
214
|
|
|
763
|
-
/** Stable lexical key used to order issues within the same kind bucket. */
|
|
764
|
-
function issueKey(issue: SchemaIssue): string {
|
|
765
|
-
const table = 'table' in issue && typeof issue.table === 'string' ? issue.table : '';
|
|
766
|
-
const column = 'column' in issue && typeof issue.column === 'string' ? issue.column : '';
|
|
767
|
-
const name =
|
|
768
|
-
'indexOrConstraint' in issue && typeof issue.indexOrConstraint === 'string'
|
|
769
|
-
? issue.indexOrConstraint
|
|
770
|
-
: '';
|
|
771
|
-
return `${table}\u0000${column}\u0000${name}`;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
215
|
// When no policy is explicitly supplied (test-only path; production callers
|
|
775
216
|
// always pass one), allow every class so strategies that gate on
|
|
776
217
|
// `'data'` (data-safe placeholders) still fire — the test is treated as
|
|
@@ -781,7 +222,7 @@ const DEFAULT_POLICY: MigrationOperationPolicy = {
|
|
|
781
222
|
};
|
|
782
223
|
|
|
783
224
|
function emptySchemaIR(): SqlSchemaIR {
|
|
784
|
-
return { tables: {} };
|
|
225
|
+
return new SqlSchemaIR({ tables: {} });
|
|
785
226
|
}
|
|
786
227
|
|
|
787
228
|
function conflictKindForCall(call: PostgresOpFactoryCall): SqlPlannerConflict['kind'] {
|
|
@@ -814,17 +255,22 @@ function locationForCall(call: PostgresOpFactoryCall): SqlPlannerConflict['locat
|
|
|
814
255
|
typeName?: string;
|
|
815
256
|
};
|
|
816
257
|
const location: {
|
|
817
|
-
|
|
258
|
+
entityKind?: string;
|
|
259
|
+
entityName?: string;
|
|
818
260
|
column?: string;
|
|
819
261
|
index?: string;
|
|
820
262
|
constraint?: string;
|
|
821
|
-
type?: string;
|
|
822
263
|
} = {};
|
|
823
|
-
if (anyCall.tableName)
|
|
264
|
+
if (anyCall.tableName) {
|
|
265
|
+
location.entityKind = 'table';
|
|
266
|
+
location.entityName = anyCall.tableName;
|
|
267
|
+
} else if (anyCall.typeName) {
|
|
268
|
+
location.entityKind = 'native_enum';
|
|
269
|
+
location.entityName = anyCall.typeName;
|
|
270
|
+
}
|
|
824
271
|
if (anyCall.columnName) location.column = anyCall.columnName;
|
|
825
272
|
if (anyCall.indexName) location.index = anyCall.indexName;
|
|
826
273
|
if (anyCall.constraintName) location.constraint = anyCall.constraintName;
|
|
827
|
-
if (anyCall.typeName) location.type = anyCall.typeName;
|
|
828
274
|
return Object.keys(location).length > 0 ? (location as SqlPlannerConflictLocation) : undefined;
|
|
829
275
|
}
|
|
830
276
|
|
|
@@ -842,13 +288,688 @@ function conflictForDisallowedCall(
|
|
|
842
288
|
};
|
|
843
289
|
}
|
|
844
290
|
|
|
291
|
+
// ============================================================================
|
|
292
|
+
// Node-based issue planner
|
|
293
|
+
// ============================================================================
|
|
294
|
+
//
|
|
295
|
+
// Consumes node-typed `SchemaDiffIssue`s (from the one differ —
|
|
296
|
+
// `buildPostgresPlanDiff`) and reads the diff node each issue carries
|
|
297
|
+
// (`issue.expected` / `issue.actual`). Column DDL (type/default SQL) resolves
|
|
298
|
+
// from the column node's `codecRef` against the codec hooks the caller holds
|
|
299
|
+
// (`column-ddl-rendering.ts`), never the contract. The retained subsystems —
|
|
300
|
+
// codec type-operations, field-lifecycle hooks, the NOT-NULL temp-default
|
|
301
|
+
// deferred DDL, control-policy disposition — keep the contract via the
|
|
302
|
+
// strategy context, per the slice's scope.
|
|
303
|
+
|
|
304
|
+
/** The diff node an issue concerns — expected when present, else the actual (extra) node. */
|
|
305
|
+
export function issueNode(issue: SchemaDiffIssue): SqlSchemaIRNode | undefined {
|
|
306
|
+
const node = issue.expected ?? issue.actual;
|
|
307
|
+
if (node === undefined) return undefined;
|
|
308
|
+
return blindCast<
|
|
309
|
+
SqlSchemaIRNode,
|
|
310
|
+
'every node in a Postgres schema diff tree is a SqlSchemaIRNode; nodeKind is its required discriminant'
|
|
311
|
+
>(node);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** DDL schema segment of a table-or-descendant issue path: `[database, ddlSchema, table, …]`. */
|
|
315
|
+
export function issueSchemaName(issue: SchemaDiffIssue): string | undefined {
|
|
316
|
+
return issue.path[1];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Table segment of a table-or-descendant issue path: `[database, ddlSchema, table, …]`. */
|
|
320
|
+
export function issueTableName(issue: SchemaDiffIssue): string | undefined {
|
|
321
|
+
return issue.path[2];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Column name embedded in a column/default issue path segment (`column:<name>`). */
|
|
325
|
+
export function issueColumnName(issue: SchemaDiffIssue): string | undefined {
|
|
326
|
+
const segment = issue.path[3];
|
|
327
|
+
if (segment === undefined || !segment.startsWith('column:')) return undefined;
|
|
328
|
+
return segment.slice('column:'.length);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The DDL schema name to use when EMITTING an op against `ddlSchemaName` (the
|
|
333
|
+
* diff tree's resolved physical schema, `issueSchemaName(issue)`). The
|
|
334
|
+
* unbound namespace's diff-tree identity resolves to `public` (a concrete
|
|
335
|
+
* physical default the differ needs in order to compare its tree against
|
|
336
|
+
* introspection — `resolveDdlSchemaForNamespaceStorage`), but DDL EMISSION
|
|
337
|
+
* must stay unqualified so the live connection's `search_path` resolves it
|
|
338
|
+
* at runtime (`boundSchema`). Recovers the logical namespace id via the
|
|
339
|
+
* contract and substitutes the unbound sentinel back in when it resolves
|
|
340
|
+
* there; every other namespace's `ddlSchemaName` already agrees between the
|
|
341
|
+
* two resolution paths, so it passes through unchanged.
|
|
342
|
+
*/
|
|
343
|
+
export function emissionSchemaName(ctx: StrategyContext, ddlSchemaName: string): string {
|
|
344
|
+
const namespaceId = resolveNamespaceIdForDdlSchema(ctx.toContract, ddlSchemaName);
|
|
345
|
+
return namespaceId === UNBOUND_NAMESPACE_ID ? UNBOUND_NAMESPACE_ID : ddlSchemaName;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Whether a column node is a scalar-array (`many: true`) column. The family
|
|
350
|
+
* converter (`contractToSchemaIR`'s `convertColumn`) never stamps `many` on
|
|
351
|
+
* the derived node — array-ness is folded into the `[]` suffix on
|
|
352
|
+
* `nativeType` instead — so the node-derived check reads the suffix; `.many`
|
|
353
|
+
* is still checked first for nodes a caller stamps directly (e.g. hand-built
|
|
354
|
+
* test fixtures, or an adapter that populates it at introspection).
|
|
355
|
+
*/
|
|
356
|
+
function isManyColumn(column: SqlColumnIR): boolean {
|
|
357
|
+
return column.many === true || column.nativeType.endsWith('[]');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Whether the expected/actual native type (resolved, or raw+many fallback) differs — mirrors `SqlColumnIR.isEqualTo`'s type comparison. */
|
|
361
|
+
export function columnTypeChanged(expected: SqlColumnIR, actual: SqlColumnIR): boolean {
|
|
362
|
+
if (expected.resolvedNativeType !== undefined && actual.resolvedNativeType !== undefined) {
|
|
363
|
+
return expected.resolvedNativeType !== actual.resolvedNativeType;
|
|
364
|
+
}
|
|
365
|
+
return (
|
|
366
|
+
expected.nativeType !== actual.nativeType || Boolean(expected.many) !== Boolean(actual.many)
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ----------------------------------------------------------------------------
|
|
371
|
+
// Node-keyed issue ordering (re-keys ISSUE_KIND_ORDER on nodeKind + reason)
|
|
372
|
+
// ----------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Re-keys the legacy `ISSUE_KIND_ORDER` on `(nodeKind, reason)`, numbers
|
|
376
|
+
* preserved so the dependency intent stays legible. Final emission order is
|
|
377
|
+
* fixed downstream by `classifyCall` bucketing (dep → drop → table → column →
|
|
378
|
+
* recipe → alter → primaryKey → unique → index → foreignKey), so this only
|
|
379
|
+
* breaks ties within a bucket.
|
|
380
|
+
*/
|
|
381
|
+
export function nodeIssueOrder(issue: SchemaDiffIssue): number {
|
|
382
|
+
const node = issueNode(issue);
|
|
383
|
+
if (node === undefined) return 99;
|
|
384
|
+
switch (node.nodeKind) {
|
|
385
|
+
case PostgresSchemaNodeKind.namespace:
|
|
386
|
+
return 1;
|
|
387
|
+
case PostgresSchemaNodeKind.nativeEnum:
|
|
388
|
+
// Creates order right after namespace creates within the 'dep' bucket
|
|
389
|
+
// (CREATE SCHEMA before CREATE TYPE); drops order after table drops
|
|
390
|
+
// within the 'drop' bucket (DROP TYPE only after its dependents left).
|
|
391
|
+
return issue.reason === 'not-expected' ? 17 : 2;
|
|
392
|
+
case RelationalSchemaNodeKind.foreignKey:
|
|
393
|
+
return issue.reason === 'not-expected' ? 10 : 60;
|
|
394
|
+
case RelationalSchemaNodeKind.unique:
|
|
395
|
+
return issue.reason === 'not-expected' ? 11 : 51;
|
|
396
|
+
case RelationalSchemaNodeKind.primaryKey:
|
|
397
|
+
return issue.reason === 'not-expected' ? 12 : 50;
|
|
398
|
+
case RelationalSchemaNodeKind.index:
|
|
399
|
+
return issue.reason === 'not-expected' ? 13 : 52;
|
|
400
|
+
case RelationalSchemaNodeKind.columnDefault:
|
|
401
|
+
if (issue.reason === 'not-expected') return 14;
|
|
402
|
+
return issue.reason === 'not-found' ? 42 : 43;
|
|
403
|
+
case RelationalSchemaNodeKind.column:
|
|
404
|
+
if (issue.reason === 'not-expected') return 15;
|
|
405
|
+
return issue.reason === 'not-found' ? 30 : 40;
|
|
406
|
+
case PostgresSchemaNodeKind.table:
|
|
407
|
+
return issue.reason === 'not-expected' ? 16 : 20;
|
|
408
|
+
case RelationalSchemaNodeKind.check:
|
|
409
|
+
if (issue.reason === 'not-found') return 53;
|
|
410
|
+
return issue.reason === 'not-expected' ? 55 : 54;
|
|
411
|
+
default:
|
|
412
|
+
return 99;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Deterministic tiebreak within an order bucket: the diff path already encodes schema → table → child. */
|
|
417
|
+
export function nodeIssueKey(issue: SchemaDiffIssue): string {
|
|
418
|
+
return issue.path.join(' ');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ----------------------------------------------------------------------------
|
|
422
|
+
// Subtree coalescing (the planner's responsibility per the differ's contract)
|
|
423
|
+
// ----------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The generic differ is total: a missing/extra table (or column) emits an
|
|
427
|
+
* issue for itself AND for every node in its subtree. `CreateTable`/`DropTable`
|
|
428
|
+
* and `AddColumn`/`DropColumn` already account for the whole subtree, so the
|
|
429
|
+
* nested issues are redundant — coalescing drops any issue whose path is a
|
|
430
|
+
* strict descendant of a `not-found`/`not-expected` issue's path. Run over the
|
|
431
|
+
* relational subset ONLY (policy issues and synthesized namespace issues are
|
|
432
|
+
* handled on their own paths, never coalesced against tables).
|
|
433
|
+
*/
|
|
434
|
+
export function coalesceSubtreeIssues<TNode extends DiffableNode = DiffableNode>(
|
|
435
|
+
issues: readonly SchemaDiffIssue<TNode>[],
|
|
436
|
+
): readonly SchemaDiffIssue<TNode>[] {
|
|
437
|
+
const collapsingPaths = issues
|
|
438
|
+
.filter((issue) => issue.reason === 'not-found' || issue.reason === 'not-expected')
|
|
439
|
+
.map((issue) => issue.path);
|
|
440
|
+
if (collapsingPaths.length === 0) return issues;
|
|
441
|
+
return issues.filter(
|
|
442
|
+
(issue) => !collapsingPaths.some((ancestor) => isStrictDescendantPath(issue.path, ancestor)),
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function isStrictDescendantPath(path: readonly string[], ancestor: readonly string[]): boolean {
|
|
447
|
+
if (path.length <= ancestor.length) return false;
|
|
448
|
+
for (let i = 0; i < ancestor.length; i += 1) {
|
|
449
|
+
if (path[i] !== ancestor[i]) return false;
|
|
450
|
+
}
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ----------------------------------------------------------------------------
|
|
455
|
+
// Node → call construction
|
|
456
|
+
// ----------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
function fkSpecFromNode(fk: SqlForeignKeyIR, tableName: string): ForeignKeySpec {
|
|
459
|
+
const name = fk.name ?? `${tableName}_${fk.columns.join('_')}_fkey`;
|
|
460
|
+
return {
|
|
461
|
+
name,
|
|
462
|
+
columns: [...fk.columns],
|
|
463
|
+
references: {
|
|
464
|
+
// The raw target namespace coordinate, matching the retired coordinate
|
|
465
|
+
// path's `references.schema: fk.target.namespaceId` (the FK node stamps
|
|
466
|
+
// it verbatim). The op renderer qualifies the REFERENCES clause from it.
|
|
467
|
+
schema: fk.referencedSchema ?? '',
|
|
468
|
+
table: fk.referencedTable,
|
|
469
|
+
columns: [...fk.referencedColumns],
|
|
470
|
+
},
|
|
471
|
+
...ifDefined('onDelete', fk.onDelete),
|
|
472
|
+
...ifDefined('onUpdate', fk.onUpdate),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Builds the `CreateTable` + child `CreateIndex` / `AddForeignKey` / `AddUnique`
|
|
478
|
+
* calls for a newly-expected table, reading only the table node's children. The
|
|
479
|
+
* PK and element-non-null CHECKs go inline as table constraints; indexes
|
|
480
|
+
* (declared + FK-backing, already merged and ordered at derivation) and the
|
|
481
|
+
* FK / unique constraints are separate calls (re-bucketed downstream). Every
|
|
482
|
+
* column's DDL is resolved from its `codecRef` via `renderColumnDdl`.
|
|
483
|
+
*/
|
|
484
|
+
function buildCreateTableCallsFromNode(
|
|
485
|
+
schemaName: string,
|
|
486
|
+
ddlSchemaName: string,
|
|
487
|
+
table: PostgresTableSchemaNode,
|
|
488
|
+
codecHooks: ReadonlyMap<string, CodecControlHooks>,
|
|
489
|
+
): PostgresOpFactoryCall[] {
|
|
490
|
+
const ddlColumns = Object.values(table.columns).map((c) =>
|
|
491
|
+
renderColumnDdl(c.name, c, codecHooks),
|
|
492
|
+
);
|
|
493
|
+
const primaryKeyConstraints: DdlTableConstraint[] = table.primaryKey
|
|
494
|
+
? [
|
|
495
|
+
contractFree.primaryKey([...table.primaryKey.columns], {
|
|
496
|
+
...ifDefined('name', table.primaryKey.name),
|
|
497
|
+
}),
|
|
498
|
+
]
|
|
499
|
+
: [];
|
|
500
|
+
const elementNonNullChecks: DdlTableConstraint[] = Object.values(table.columns)
|
|
501
|
+
.filter((c) => isManyColumn(c))
|
|
502
|
+
.map((c) =>
|
|
503
|
+
contractFree.checkExpression(
|
|
504
|
+
elementNonNullCheckName(table.name, c.name),
|
|
505
|
+
elementNonNullCheckExpression(c.name),
|
|
506
|
+
),
|
|
507
|
+
);
|
|
508
|
+
const allTableConstraints = [...primaryKeyConstraints, ...elementNonNullChecks];
|
|
509
|
+
const calls: PostgresOpFactoryCall[] = [
|
|
510
|
+
new CreateTableCall(
|
|
511
|
+
schemaName,
|
|
512
|
+
table.name,
|
|
513
|
+
ddlColumns,
|
|
514
|
+
allTableConstraints.length > 0 ? allTableConstraints : undefined,
|
|
515
|
+
),
|
|
516
|
+
];
|
|
517
|
+
for (const index of table.indexes) {
|
|
518
|
+
const indexName = index.name ?? defaultIndexName(table.name, index.columns);
|
|
519
|
+
const extras: { type?: string; options?: Record<string, unknown> } = {};
|
|
520
|
+
if (index.type !== undefined) extras.type = index.type;
|
|
521
|
+
if (index.options !== undefined) extras.options = index.options;
|
|
522
|
+
calls.push(new CreateIndexCall(schemaName, table.name, indexName, [...index.columns], extras));
|
|
523
|
+
}
|
|
524
|
+
for (const fk of table.foreignKeys) {
|
|
525
|
+
calls.push(new AddForeignKeyCall(schemaName, table.name, fkSpecFromNode(fk, table.name)));
|
|
526
|
+
}
|
|
527
|
+
for (const unique of table.uniques) {
|
|
528
|
+
const constraintName = unique.name ?? `${table.name}_${unique.columns.join('_')}_key`;
|
|
529
|
+
calls.push(new AddUniqueCall(schemaName, table.name, constraintName, [...unique.columns]));
|
|
530
|
+
}
|
|
531
|
+
// Marker-driven: a newly-created table that is RLS-controlled enables RLS
|
|
532
|
+
// as part of its creation bundle. The policy set never decides this. The
|
|
533
|
+
// resolved schema (not the emission sentinel) binds into the op's
|
|
534
|
+
// relrowsecurity checks.
|
|
535
|
+
if (table.rlsEnabled) {
|
|
536
|
+
calls.push(new EnableRowLevelSecurityCall(ddlSchemaName, table.name));
|
|
537
|
+
}
|
|
538
|
+
return calls;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function nodeConflict(kind: SqlPlannerConflict['kind'], message: string): SqlPlannerConflict {
|
|
542
|
+
return issueConflict(kind, message);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Managed native-enum issue -> op lowering. A missing declared type creates
|
|
547
|
+
* it; an unclaimed live type drops it (ownership-scoped upstream by
|
|
548
|
+
* `retainUnownedExtras`, destructiveness gated by the operation-class
|
|
549
|
+
* policy); a paired member-value mismatch is a NAMED unsupported diagnostic
|
|
550
|
+
* — never a silent no-op and never a drop-and-recreate. Slice B replaces the
|
|
551
|
+
* diagnostic with order-aware `ALTER TYPE ... ADD VALUE` / refusal semantics.
|
|
552
|
+
*/
|
|
553
|
+
function mapNativeEnumNodeIssue(
|
|
554
|
+
issue: SchemaDiffIssue,
|
|
555
|
+
ctx: StrategyContext,
|
|
556
|
+
): Result<readonly PostgresOpFactoryCall[], SqlPlannerConflict> {
|
|
557
|
+
const ddlSchemaName = issueSchemaName(issue);
|
|
558
|
+
if (ddlSchemaName === undefined) {
|
|
559
|
+
return notOk(
|
|
560
|
+
nodeConflict(
|
|
561
|
+
'unsupportedOperation',
|
|
562
|
+
`Enum issue has no schema in its path: ${issue.path.join('/')}`,
|
|
563
|
+
),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
const schemaName = emissionSchemaName(ctx, ddlSchemaName);
|
|
567
|
+
if (issue.reason === 'not-found') {
|
|
568
|
+
const expected = blindCast<
|
|
569
|
+
PostgresNativeEnumSchemaNode,
|
|
570
|
+
'a not-found native-enum issue always carries the expected PostgresNativeEnumSchemaNode'
|
|
571
|
+
>(issue.expected);
|
|
572
|
+
return ok([new CreateNativeEnumTypeCall(schemaName, expected.typeName, expected.members)]);
|
|
573
|
+
}
|
|
574
|
+
if (issue.reason === 'not-expected') {
|
|
575
|
+
const actual = blindCast<
|
|
576
|
+
PostgresNativeEnumSchemaNode,
|
|
577
|
+
'a not-expected native-enum issue always carries the actual PostgresNativeEnumSchemaNode'
|
|
578
|
+
>(issue.actual);
|
|
579
|
+
return ok([new DropNativeEnumTypeCall(schemaName, actual.typeName)]);
|
|
580
|
+
}
|
|
581
|
+
const expected = blindCast<
|
|
582
|
+
PostgresNativeEnumSchemaNode,
|
|
583
|
+
'a not-equal native-enum issue carries both sides; the expected node names the type'
|
|
584
|
+
>(issue.expected);
|
|
585
|
+
const actualMembers = blindCast<
|
|
586
|
+
PostgresNativeEnumSchemaNode,
|
|
587
|
+
'a not-equal native-enum issue carries both sides'
|
|
588
|
+
>(issue.actual).members;
|
|
589
|
+
return notOk(
|
|
590
|
+
nodeConflict(
|
|
591
|
+
'unsupportedOperation',
|
|
592
|
+
`Native enum type "${ddlSchemaName}"."${expected.typeName}" member values changed ` +
|
|
593
|
+
`(contract declares [${expected.members.join(', ')}], database has [${actualMembers.join(', ')}]); ` +
|
|
594
|
+
'enum value changes are not auto-migrated yet. Author the change manually with `migration new`.',
|
|
595
|
+
),
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function mapTableNodeIssue(
|
|
600
|
+
issue: SchemaDiffIssue,
|
|
601
|
+
schemaName: string,
|
|
602
|
+
// The diff tree's RESOLVED physical schema. Enablement ops bind it into
|
|
603
|
+
// their `pg_class`/`pg_namespace` checks (the emission sentinel would
|
|
604
|
+
// never match a live nspname), mirroring the schema the retired
|
|
605
|
+
// policy-half enable resolved via `resolveDdlSchemaForNamespaceStorage`.
|
|
606
|
+
ddlSchemaName: string,
|
|
607
|
+
codecHooks: ReadonlyMap<string, CodecControlHooks>,
|
|
608
|
+
): Result<readonly PostgresOpFactoryCall[], SqlPlannerConflict> {
|
|
609
|
+
if (issue.reason === 'not-found') {
|
|
610
|
+
const table = blindCast<
|
|
611
|
+
PostgresTableSchemaNode,
|
|
612
|
+
'a not-found table issue always carries the expected PostgresTableSchemaNode'
|
|
613
|
+
>(issue.expected);
|
|
614
|
+
return ok(buildCreateTableCallsFromNode(schemaName, ddlSchemaName, table, codecHooks));
|
|
615
|
+
}
|
|
616
|
+
if (issue.reason === 'not-expected') {
|
|
617
|
+
const table = blindCast<
|
|
618
|
+
PostgresTableSchemaNode,
|
|
619
|
+
'a not-expected table issue always carries the actual PostgresTableSchemaNode'
|
|
620
|
+
>(issue.actual);
|
|
621
|
+
return ok([new DropTableCall(schemaName, table.name)]);
|
|
622
|
+
}
|
|
623
|
+
// A paired table `not-equal` means enablement drift TODAY, because
|
|
624
|
+
// `isEqualTo` compares only name + `rlsEnabled`. Key on the actual
|
|
625
|
+
// expected-vs-actual `rlsEnabled` delta rather than assuming it: emit ENABLE
|
|
626
|
+
// when it flipped on, DISABLE when it flipped off, and fail loud when
|
|
627
|
+
// `rlsEnabled` matches on both sides — that means a second table attribute
|
|
628
|
+
// drifted into `isEqualTo` and this mapper does not yet handle it. The
|
|
629
|
+
// expected side is authoritative (marker-driven, never the policy set).
|
|
630
|
+
const expected = blindCast<
|
|
631
|
+
PostgresTableSchemaNode,
|
|
632
|
+
'a not-equal table issue always carries the expected PostgresTableSchemaNode'
|
|
633
|
+
>(issue.expected);
|
|
634
|
+
const actual = blindCast<
|
|
635
|
+
PostgresTableSchemaNode,
|
|
636
|
+
'a not-equal table issue always carries the actual PostgresTableSchemaNode'
|
|
637
|
+
>(issue.actual);
|
|
638
|
+
if (expected.rlsEnabled && !actual.rlsEnabled) {
|
|
639
|
+
return ok([new EnableRowLevelSecurityCall(ddlSchemaName, expected.name)]);
|
|
640
|
+
}
|
|
641
|
+
if (!expected.rlsEnabled && actual.rlsEnabled) {
|
|
642
|
+
return ok([new DisableRowLevelSecurityCall(ddlSchemaName, expected.name)]);
|
|
643
|
+
}
|
|
644
|
+
return notOk(
|
|
645
|
+
nodeConflict(
|
|
646
|
+
'unsupportedOperation',
|
|
647
|
+
`unhandled table-attribute drift on "${expected.name}": table not-equal with no rlsEnabled delta (a second table attribute drifted)`,
|
|
648
|
+
),
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function mapColumnNodeIssue(
|
|
653
|
+
issue: SchemaDiffIssue,
|
|
654
|
+
schemaName: string,
|
|
655
|
+
tableName: string,
|
|
656
|
+
codecHooks: ReadonlyMap<string, CodecControlHooks>,
|
|
657
|
+
): Result<readonly PostgresOpFactoryCall[], SqlPlannerConflict> {
|
|
658
|
+
if (issue.reason === 'not-found') {
|
|
659
|
+
const column = blindCast<
|
|
660
|
+
SqlColumnIR,
|
|
661
|
+
'a not-found column issue always carries the expected column node'
|
|
662
|
+
>(issue.expected);
|
|
663
|
+
return ok([
|
|
664
|
+
new AddColumnCall(schemaName, tableName, renderColumnDdl(column.name, column, codecHooks)),
|
|
665
|
+
]);
|
|
666
|
+
}
|
|
667
|
+
if (issue.reason === 'not-expected') {
|
|
668
|
+
const column = blindCast<
|
|
669
|
+
SqlColumnIR,
|
|
670
|
+
'a not-expected column issue always carries the actual column node'
|
|
671
|
+
>(issue.actual);
|
|
672
|
+
return ok([new DropColumnCall(schemaName, tableName, column.name)]);
|
|
673
|
+
}
|
|
674
|
+
// not-equal: Postgres alters in place — type drift and/or nullability drift.
|
|
675
|
+
const expected = blindCast<
|
|
676
|
+
SqlColumnIR,
|
|
677
|
+
'a not-equal column issue always carries the expected column node'
|
|
678
|
+
>(issue.expected);
|
|
679
|
+
const actual = blindCast<
|
|
680
|
+
SqlColumnIR,
|
|
681
|
+
'a not-equal column issue always carries the actual column node'
|
|
682
|
+
>(issue.actual);
|
|
683
|
+
const calls: PostgresOpFactoryCall[] = [];
|
|
684
|
+
if (columnTypeChanged(expected, actual)) {
|
|
685
|
+
const { qualifiedTargetType, formatTypeExpected } = renderColumnAlterType(expected, codecHooks);
|
|
686
|
+
calls.push(
|
|
687
|
+
new AlterColumnTypeCall(schemaName, tableName, expected.name, {
|
|
688
|
+
qualifiedTargetType,
|
|
689
|
+
formatTypeExpected,
|
|
690
|
+
rawTargetTypeForLabel: qualifiedTargetType,
|
|
691
|
+
}),
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
if (expected.nullable !== actual.nullable) {
|
|
695
|
+
calls.push(
|
|
696
|
+
expected.nullable
|
|
697
|
+
? new DropNotNullCall(schemaName, tableName, expected.name)
|
|
698
|
+
: new SetNotNullCall(schemaName, tableName, expected.name),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
return ok(calls);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function mapColumnDefaultNodeIssue(
|
|
705
|
+
issue: SchemaDiffIssue,
|
|
706
|
+
schemaName: string,
|
|
707
|
+
tableName: string,
|
|
708
|
+
columnName: string,
|
|
709
|
+
): Result<readonly PostgresOpFactoryCall[], SqlPlannerConflict> {
|
|
710
|
+
if (issue.reason === 'not-expected') {
|
|
711
|
+
return ok([new DropDefaultCall(schemaName, tableName, columnName)]);
|
|
712
|
+
}
|
|
713
|
+
// not-found (SET DEFAULT, additive) or not-equal (SET DEFAULT, widening).
|
|
714
|
+
if (issue.expected === undefined) return ok([]);
|
|
715
|
+
const defaultNode = blindCast<
|
|
716
|
+
SqlColumnDefaultIR,
|
|
717
|
+
'a not-found/not-equal column-default issue always carries the expected default node'
|
|
718
|
+
>(issue.expected);
|
|
719
|
+
const defaultSql = renderColumnDefaultSql(defaultNode);
|
|
720
|
+
if (!defaultSql) return ok([]);
|
|
721
|
+
return ok([
|
|
722
|
+
new SetDefaultCall(
|
|
723
|
+
schemaName,
|
|
724
|
+
tableName,
|
|
725
|
+
columnName,
|
|
726
|
+
defaultSql,
|
|
727
|
+
issue.reason === 'not-equal' ? 'widening' : 'additive',
|
|
728
|
+
),
|
|
729
|
+
]);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function mapPrimaryKeyNodeIssue(
|
|
733
|
+
issue: SchemaDiffIssue,
|
|
734
|
+
schemaName: string,
|
|
735
|
+
tableName: string,
|
|
736
|
+
): Result<readonly PostgresOpFactoryCall[], SqlPlannerConflict> {
|
|
737
|
+
if (issue.reason === 'not-found') {
|
|
738
|
+
const pk = blindCast<
|
|
739
|
+
{ readonly columns: readonly string[]; readonly name?: string },
|
|
740
|
+
'a not-found primary-key issue always carries the expected PrimaryKey node'
|
|
741
|
+
>(issue.expected);
|
|
742
|
+
const constraintName = pk.name ?? `${tableName}_pkey`;
|
|
743
|
+
return ok([new AddPrimaryKeyCall(schemaName, tableName, constraintName, [...pk.columns])]);
|
|
744
|
+
}
|
|
745
|
+
if (issue.reason === 'not-expected') {
|
|
746
|
+
const pk = blindCast<
|
|
747
|
+
{ readonly name?: string },
|
|
748
|
+
'a not-expected primary-key issue always carries the actual PrimaryKey node'
|
|
749
|
+
>(issue.actual);
|
|
750
|
+
return ok([
|
|
751
|
+
new DropConstraintCall(schemaName, tableName, pk.name ?? `${tableName}_pkey`, 'primaryKey'),
|
|
752
|
+
]);
|
|
753
|
+
}
|
|
754
|
+
return notOk(nodeConflict('indexIncompatible', issue.path.join('/')));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function mapForeignKeyNodeIssue(
|
|
758
|
+
issue: SchemaDiffIssue,
|
|
759
|
+
schemaName: string,
|
|
760
|
+
tableName: string,
|
|
761
|
+
): Result<readonly PostgresOpFactoryCall[], SqlPlannerConflict> {
|
|
762
|
+
if (issue.reason === 'not-found') {
|
|
763
|
+
const fk = blindCast<
|
|
764
|
+
SqlForeignKeyIR,
|
|
765
|
+
'a not-found foreign-key issue always carries the expected foreign-key node'
|
|
766
|
+
>(issue.expected);
|
|
767
|
+
return ok([new AddForeignKeyCall(schemaName, tableName, fkSpecFromNode(fk, tableName))]);
|
|
768
|
+
}
|
|
769
|
+
if (issue.reason === 'not-expected') {
|
|
770
|
+
const fk = blindCast<
|
|
771
|
+
SqlForeignKeyIR,
|
|
772
|
+
'a not-expected foreign-key issue always carries the actual foreign-key node'
|
|
773
|
+
>(issue.actual);
|
|
774
|
+
const name = fk.name ?? `${tableName}_${fk.columns.join('_')}_fkey`;
|
|
775
|
+
return ok([new DropConstraintCall(schemaName, tableName, name, 'foreignKey')]);
|
|
776
|
+
}
|
|
777
|
+
return notOk(nodeConflict('foreignKeyConflict', issue.path.join('/')));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function mapUniqueNodeIssue(
|
|
781
|
+
issue: SchemaDiffIssue,
|
|
782
|
+
schemaName: string,
|
|
783
|
+
tableName: string,
|
|
784
|
+
): Result<readonly PostgresOpFactoryCall[], SqlPlannerConflict> {
|
|
785
|
+
if (issue.reason === 'not-found') {
|
|
786
|
+
const unique = blindCast<
|
|
787
|
+
SqlUniqueIR,
|
|
788
|
+
'a not-found unique issue always carries the expected unique node'
|
|
789
|
+
>(issue.expected);
|
|
790
|
+
const name = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`;
|
|
791
|
+
return ok([new AddUniqueCall(schemaName, tableName, name, [...unique.columns])]);
|
|
792
|
+
}
|
|
793
|
+
if (issue.reason === 'not-expected') {
|
|
794
|
+
const unique = blindCast<
|
|
795
|
+
SqlUniqueIR,
|
|
796
|
+
'a not-expected unique issue always carries the actual unique node'
|
|
797
|
+
>(issue.actual);
|
|
798
|
+
const name = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`;
|
|
799
|
+
return ok([new DropConstraintCall(schemaName, tableName, name, 'unique')]);
|
|
800
|
+
}
|
|
801
|
+
return notOk(nodeConflict('indexIncompatible', issue.path.join('/')));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function mapIndexNodeIssue(
|
|
805
|
+
issue: SchemaDiffIssue,
|
|
806
|
+
schemaName: string,
|
|
807
|
+
tableName: string,
|
|
808
|
+
): Result<readonly PostgresOpFactoryCall[], SqlPlannerConflict> {
|
|
809
|
+
if (issue.reason === 'not-found') {
|
|
810
|
+
const index = blindCast<
|
|
811
|
+
SqlIndexIR,
|
|
812
|
+
'a not-found index issue always carries the expected index node'
|
|
813
|
+
>(issue.expected);
|
|
814
|
+
const indexName = index.name ?? defaultIndexName(tableName, index.columns);
|
|
815
|
+
const extras: { type?: string; options?: Record<string, unknown> } = {};
|
|
816
|
+
if (index.type !== undefined) extras.type = index.type;
|
|
817
|
+
if (index.options !== undefined) extras.options = index.options;
|
|
818
|
+
return ok([new CreateIndexCall(schemaName, tableName, indexName, [...index.columns], extras)]);
|
|
819
|
+
}
|
|
820
|
+
if (issue.reason === 'not-expected') {
|
|
821
|
+
const index = blindCast<
|
|
822
|
+
SqlIndexIR,
|
|
823
|
+
'a not-expected index issue always carries the actual index node'
|
|
824
|
+
>(issue.actual);
|
|
825
|
+
const indexName = index.name ?? defaultIndexName(tableName, index.columns);
|
|
826
|
+
return ok([new DropIndexCall(schemaName, tableName, indexName)]);
|
|
827
|
+
}
|
|
828
|
+
return notOk(nodeConflict('indexIncompatible', issue.path.join('/')));
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function mapCheckNodeIssue(
|
|
832
|
+
issue: SchemaDiffIssue,
|
|
833
|
+
schemaName: string,
|
|
834
|
+
tableName: string,
|
|
835
|
+
): Result<readonly PostgresOpFactoryCall[], SqlPlannerConflict> {
|
|
836
|
+
// check_removed (extra live check not in contract) is the only check drift
|
|
837
|
+
// the default mapper handles directly; check_missing / check_mismatch are
|
|
838
|
+
// consumed by `checkConstraintPlanCallStrategy` (drop+recreate), so reaching
|
|
839
|
+
// here for them means the strategy did not run — a conflict.
|
|
840
|
+
if (issue.reason === 'not-expected') {
|
|
841
|
+
const check = blindCast<
|
|
842
|
+
{ readonly name: string },
|
|
843
|
+
'a not-expected check issue always carries the actual check node'
|
|
844
|
+
>(issue.actual);
|
|
845
|
+
return ok([new DropCheckConstraintCall(schemaName, tableName, check.name)]);
|
|
846
|
+
}
|
|
847
|
+
return notOk(
|
|
848
|
+
nodeConflict(
|
|
849
|
+
'unsupportedOperation',
|
|
850
|
+
`Check constraint drift on "${tableName}" — handled by checkConstraintPlanCallStrategy: ${issue.path.join('/')}`,
|
|
851
|
+
),
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Maps one node-typed diff issue to its migration call(s), dispatching on the
|
|
857
|
+
* node's `nodeKind` + `issue.reason`, reading nodes and resolving column DDL
|
|
858
|
+
* from `codecRef` via `column-ddl-rendering.ts`.
|
|
859
|
+
*/
|
|
860
|
+
export function mapNodeIssueToCall(
|
|
861
|
+
issue: SchemaDiffIssue,
|
|
862
|
+
ctx: StrategyContext,
|
|
863
|
+
): Result<readonly PostgresOpFactoryCall[], SqlPlannerConflict> {
|
|
864
|
+
const node = issueNode(issue);
|
|
865
|
+
if (node === undefined) {
|
|
866
|
+
return notOk(
|
|
867
|
+
nodeConflict(
|
|
868
|
+
'unsupportedOperation',
|
|
869
|
+
`Issue carries neither an expected nor an actual node: ${issue.path.join('/')}`,
|
|
870
|
+
),
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
if (node.nodeKind === PostgresSchemaNodeKind.namespace) {
|
|
874
|
+
if (issue.reason !== 'not-found') {
|
|
875
|
+
return notOk(
|
|
876
|
+
nodeConflict('unsupportedOperation', `Unexpected namespace drift: ${issue.path.join('/')}`),
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
const namespace = blindCast<
|
|
880
|
+
PostgresNamespaceSchemaNode,
|
|
881
|
+
'a namespace-presence issue always carries a PostgresNamespaceSchemaNode'
|
|
882
|
+
>(issue.expected);
|
|
883
|
+
return ok([new CreateSchemaCall(namespace.schemaName)]);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (node.nodeKind === PostgresSchemaNodeKind.nativeEnum) {
|
|
887
|
+
return mapNativeEnumNodeIssue(issue, ctx);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const ddlSchemaName = issueSchemaName(issue);
|
|
891
|
+
const tableName = issueTableName(issue);
|
|
892
|
+
if (ddlSchemaName === undefined || tableName === undefined) {
|
|
893
|
+
return notOk(
|
|
894
|
+
nodeConflict(
|
|
895
|
+
'unsupportedOperation',
|
|
896
|
+
`Issue has no schema/table in its path: ${issue.path.join('/')}`,
|
|
897
|
+
),
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
const schemaName = emissionSchemaName(ctx, ddlSchemaName);
|
|
901
|
+
|
|
902
|
+
switch (node.nodeKind) {
|
|
903
|
+
case PostgresSchemaNodeKind.table:
|
|
904
|
+
return mapTableNodeIssue(issue, schemaName, ddlSchemaName, ctx.codecHooks);
|
|
905
|
+
case RelationalSchemaNodeKind.column:
|
|
906
|
+
return mapColumnNodeIssue(issue, schemaName, tableName, ctx.codecHooks);
|
|
907
|
+
case RelationalSchemaNodeKind.columnDefault: {
|
|
908
|
+
const columnName = issueColumnName(issue);
|
|
909
|
+
if (columnName === undefined) {
|
|
910
|
+
return notOk(
|
|
911
|
+
nodeConflict(
|
|
912
|
+
'unsupportedOperation',
|
|
913
|
+
`Default issue has no column in its path: ${issue.path.join('/')}`,
|
|
914
|
+
),
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
return mapColumnDefaultNodeIssue(issue, schemaName, tableName, columnName);
|
|
918
|
+
}
|
|
919
|
+
case RelationalSchemaNodeKind.primaryKey:
|
|
920
|
+
return mapPrimaryKeyNodeIssue(issue, schemaName, tableName);
|
|
921
|
+
case RelationalSchemaNodeKind.foreignKey:
|
|
922
|
+
return mapForeignKeyNodeIssue(issue, schemaName, tableName);
|
|
923
|
+
case RelationalSchemaNodeKind.unique:
|
|
924
|
+
return mapUniqueNodeIssue(issue, schemaName, tableName);
|
|
925
|
+
case RelationalSchemaNodeKind.index:
|
|
926
|
+
return mapIndexNodeIssue(issue, schemaName, tableName);
|
|
927
|
+
case RelationalSchemaNodeKind.check:
|
|
928
|
+
return mapCheckNodeIssue(issue, schemaName, tableName);
|
|
929
|
+
default:
|
|
930
|
+
return notOk(nodeConflict('unsupportedOperation', `Unhandled node kind: ${node.nodeKind}`));
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export interface IssuePlannerOptions {
|
|
935
|
+
readonly issues: readonly SchemaDiffIssue[];
|
|
936
|
+
readonly toContract: Contract<SqlStorage>;
|
|
937
|
+
readonly fromContract: Contract<SqlStorage> | null;
|
|
938
|
+
readonly schemaName: string;
|
|
939
|
+
readonly codecHooks: ReadonlyMap<string, CodecControlHooks>;
|
|
940
|
+
readonly storageTypes: Readonly<Record<string, StorageTypeInstance>>;
|
|
941
|
+
/**
|
|
942
|
+
* Current database schema IR. Strategies read this to detect whether a
|
|
943
|
+
* structure already exists (e.g. `buildSchemaLookupMap` for shared-temp-
|
|
944
|
+
* default safety, extension dependency checks). Defaults to an empty schema
|
|
945
|
+
* when omitted so the planner can still run over "fresh DB" contract
|
|
946
|
+
* snapshots.
|
|
947
|
+
*/
|
|
948
|
+
readonly schema?: SqlSchemaIR;
|
|
949
|
+
/**
|
|
950
|
+
* Operation-class policy. `planIssues` filters calls whose `operationClass`
|
|
951
|
+
* is not in `policy.allowedOperationClasses` and surfaces them as conflicts
|
|
952
|
+
* instead of emitting disallowed DDL. Defaults to additive-only.
|
|
953
|
+
*/
|
|
954
|
+
readonly policy?: MigrationOperationPolicy;
|
|
955
|
+
/**
|
|
956
|
+
* Framework components participating in this composition. Available to
|
|
957
|
+
* future strategies that may consult component metadata at plan time.
|
|
958
|
+
*/
|
|
959
|
+
readonly frameworkComponents?: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
|
|
960
|
+
readonly strategies?: readonly CallMigrationStrategy[];
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Runs the ordered strategy list over the node-typed diff issues, maps
|
|
965
|
+
* leftover issues via {@link mapNodeIssueToCall}, applies operation-class
|
|
966
|
+
* policy gating, and buckets calls into the fixed DDL emission order (dep →
|
|
967
|
+
* drop → table → column → recipe → alter → primaryKey → unique → index →
|
|
968
|
+
* foreignKey).
|
|
969
|
+
*/
|
|
845
970
|
export function planIssues(
|
|
846
971
|
options: IssuePlannerOptions,
|
|
847
972
|
): Result<IssuePlannerValue, readonly SqlPlannerConflict[]> {
|
|
848
|
-
// When no policy is supplied, `planIssues` treats the call as trusted (the
|
|
849
|
-
// caller — typically a test — has already vetted the issues). Only explicit
|
|
850
|
-
// policies gate operation classes into conflicts.
|
|
851
|
-
// `PostgresMigrationPlanner` always passes an explicit policy.
|
|
852
973
|
const policyProvided = options.policy !== undefined;
|
|
853
974
|
const policy = options.policy ?? DEFAULT_POLICY;
|
|
854
975
|
const schema = options.schema ?? emptySchemaIR();
|
|
@@ -884,10 +1005,10 @@ export function planIssues(
|
|
|
884
1005
|
}
|
|
885
1006
|
|
|
886
1007
|
const sorted = [...remaining].sort((a, b) => {
|
|
887
|
-
const kindDelta =
|
|
1008
|
+
const kindDelta = nodeIssueOrder(a) - nodeIssueOrder(b);
|
|
888
1009
|
if (kindDelta !== 0) return kindDelta;
|
|
889
|
-
const keyA =
|
|
890
|
-
const keyB =
|
|
1010
|
+
const keyA = nodeIssueKey(a);
|
|
1011
|
+
const keyB = nodeIssueKey(b);
|
|
891
1012
|
return keyA < keyB ? -1 : keyA > keyB ? 1 : 0;
|
|
892
1013
|
});
|
|
893
1014
|
|
|
@@ -895,7 +1016,7 @@ export function planIssues(
|
|
|
895
1016
|
const conflicts: SqlPlannerConflict[] = [];
|
|
896
1017
|
|
|
897
1018
|
for (const issue of sorted) {
|
|
898
|
-
const result =
|
|
1019
|
+
const result = mapNodeIssueToCall(issue, context);
|
|
899
1020
|
if (result.ok) {
|
|
900
1021
|
defaultCalls.push(...result.value);
|
|
901
1022
|
} else {
|
|
@@ -903,11 +1024,6 @@ export function planIssues(
|
|
|
903
1024
|
}
|
|
904
1025
|
}
|
|
905
1026
|
|
|
906
|
-
// Policy gating: drop calls whose operation class is not allowed and
|
|
907
|
-
// surface a conflict describing the disallowed op. Applies to both strategy
|
|
908
|
-
// output and default-mapped output. Only active when the caller explicitly
|
|
909
|
-
// supplied a policy — direct unit-test invocations (which pass no policy)
|
|
910
|
-
// stay as pass-through and keep destructive recipe steps intact.
|
|
911
1027
|
const allowed = policy.allowedOperationClasses;
|
|
912
1028
|
let gatedDefault = defaultCalls;
|
|
913
1029
|
let gatedRecipe = recipeCalls;
|
|
@@ -935,13 +1051,6 @@ export function planIssues(
|
|
|
935
1051
|
return notOk(conflicts);
|
|
936
1052
|
}
|
|
937
1053
|
|
|
938
|
-
// Recipe strategies (`notNullBackfillCallStrategy`,
|
|
939
|
-
// `nullableTighteningCallStrategy`, etc.) emit a cohesive sequence that must
|
|
940
|
-
// stay contiguous. They are inserted at a single pattern slot. Non-recipe
|
|
941
|
-
// pattern strategies (`checkConstraintPlanCallStrategy`,
|
|
942
|
-
// `storageTypePlanCallStrategy`, `notNullAddColumnCallStrategy`) produce
|
|
943
|
-
// individually classifiable calls that slot into DDL buckets alongside
|
|
944
|
-
// default-mapped calls.
|
|
945
1054
|
const combinedBucketable = [...gatedDefault, ...gatedBucketable];
|
|
946
1055
|
const byCategory = (cat: CallCategory) =>
|
|
947
1056
|
combinedBucketable.filter((c) => classifyCall(c) === cat);
|
|
@@ -957,6 +1066,10 @@ export function planIssues(
|
|
|
957
1066
|
...byCategory('unique'),
|
|
958
1067
|
...byCategory('index'),
|
|
959
1068
|
...byCategory('foreignKey'),
|
|
1069
|
+
// Enablement changes run after all relational DDL (the table must exist)
|
|
1070
|
+
// and before the policy calls the planner appends after `planIssues` —
|
|
1071
|
+
// the same position the retired imperative enable-on-first-policy used.
|
|
1072
|
+
...byCategory('rlsEnable'),
|
|
960
1073
|
];
|
|
961
1074
|
|
|
962
1075
|
return ok({ calls });
|