@prisma-next/target-postgres 0.14.0 → 0.15.0-dev.10

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