@intentius/chant-lexicon-sql 0.124.46 → 0.124.48

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 (360) hide show
  1. package/dist/change-set.d.ts +1 -1
  2. package/dist/change-set.d.ts.map +1 -1
  3. package/dist/change-set.js +2 -1
  4. package/dist/clickhouse/apply/statements.d.ts +9 -0
  5. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  6. package/dist/clickhouse/apply/statements.js +24 -0
  7. package/dist/clickhouse/import/live-export.d.ts +7 -3
  8. package/dist/clickhouse/import/live-export.d.ts.map +1 -1
  9. package/dist/clickhouse/import/live-export.js +21 -5
  10. package/dist/clickhouse/live/named-collections.d.ts +27 -0
  11. package/dist/clickhouse/live/named-collections.d.ts.map +1 -0
  12. package/dist/clickhouse/live/named-collections.js +53 -0
  13. package/dist/clickhouse/live/undeclarable.d.ts +15 -3
  14. package/dist/clickhouse/live/undeclarable.d.ts.map +1 -1
  15. package/dist/clickhouse/live/undeclarable.js +18 -4
  16. package/dist/clickhouse/named-collections.d.ts +83 -0
  17. package/dist/clickhouse/named-collections.d.ts.map +1 -0
  18. package/dist/clickhouse/named-collections.js +203 -0
  19. package/dist/clickhouse/parser.d.ts.map +1 -1
  20. package/dist/clickhouse/parser.js +1 -1
  21. package/dist/clickhouse/plan/commands.d.ts +2 -1
  22. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  23. package/dist/clickhouse/plan/commands.js +17 -2
  24. package/dist/clickhouse/plan/diff.d.ts +15 -0
  25. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  26. package/dist/clickhouse/plan/diff.js +43 -1
  27. package/dist/clickhouse/plan/disruption.d.ts.map +1 -1
  28. package/dist/clickhouse/plan/disruption.js +5 -0
  29. package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
  30. package/dist/clickhouse/plan/rebuild-handoff.js +4 -0
  31. package/dist/clickhouse/plan/report.d.ts.map +1 -1
  32. package/dist/clickhouse/plan/report.js +6 -1
  33. package/dist/clickhouse/plan/rules.d.ts +5 -1
  34. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  35. package/dist/clickhouse/plan/rules.js +7 -3
  36. package/dist/clickhouse/plan/stream.d.ts +40 -0
  37. package/dist/clickhouse/plan/stream.d.ts.map +1 -0
  38. package/dist/clickhouse/plan/stream.js +107 -0
  39. package/dist/clickhouse/rebuild/observe.d.ts +3 -1
  40. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  41. package/dist/clickhouse/rebuild/observe.js +14 -1
  42. package/dist/clickhouse/review-catalog.d.ts +213 -0
  43. package/dist/clickhouse/review-catalog.d.ts.map +1 -0
  44. package/dist/clickhouse/review-catalog.js +330 -0
  45. package/dist/codegen/classifier-page.d.ts.map +1 -1
  46. package/dist/codegen/classifier-page.js +21 -4
  47. package/dist/codegen/clickhouse-module.d.ts.map +1 -1
  48. package/dist/codegen/clickhouse-module.js +3 -0
  49. package/dist/codegen/coverage-page.d.ts.map +1 -1
  50. package/dist/codegen/coverage-page.js +5 -3
  51. package/dist/codegen/docs.js +1 -1
  52. package/dist/codegen/postgres-classifier-page.js +1 -1
  53. package/dist/codegen/registry.d.ts.map +1 -1
  54. package/dist/codegen/registry.js +5 -0
  55. package/dist/composites/catalog.d.ts.map +1 -1
  56. package/dist/composites/catalog.js +100 -0
  57. package/dist/composites/clickhouse.d.ts +2 -0
  58. package/dist/composites/clickhouse.d.ts.map +1 -1
  59. package/dist/composites/clickhouse.js +2 -0
  60. package/dist/composites/otel-tables.d.ts +100 -0
  61. package/dist/composites/otel-tables.d.ts.map +1 -0
  62. package/dist/composites/otel-tables.js +282 -0
  63. package/dist/composites/stream-ingest.d.ts +63 -0
  64. package/dist/composites/stream-ingest.d.ts.map +1 -0
  65. package/dist/composites/stream-ingest.js +39 -0
  66. package/dist/coverage/clickhouse.json +1 -1
  67. package/dist/coverage/postgres.json +7 -7
  68. package/dist/coverage.d.ts +16 -6
  69. package/dist/coverage.d.ts.map +1 -1
  70. package/dist/coverage.js +10 -5
  71. package/dist/drizzle-command.d.ts +17 -0
  72. package/dist/drizzle-command.d.ts.map +1 -0
  73. package/dist/drizzle-command.js +91 -0
  74. package/dist/drizzle.d.ts +70 -0
  75. package/dist/drizzle.d.ts.map +1 -0
  76. package/dist/drizzle.js +684 -0
  77. package/dist/files/clickhouse.d.ts.map +1 -1
  78. package/dist/files/clickhouse.js +8 -1
  79. package/dist/files/common.d.ts +13 -0
  80. package/dist/files/common.d.ts.map +1 -1
  81. package/dist/files/common.js +5 -0
  82. package/dist/files/postgres.js +12 -2
  83. package/dist/generated/clickhouse.d.ts +2 -0
  84. package/dist/generated/clickhouse.d.ts.map +1 -1
  85. package/dist/generated/clickhouse.js +2 -0
  86. package/dist/import-parser.d.ts +21 -1
  87. package/dist/import-parser.d.ts.map +1 -1
  88. package/dist/import-parser.js +79 -5
  89. package/dist/index.d.ts +1 -1
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +1 -1
  92. package/dist/integrity.json +6 -5
  93. package/dist/lint/post-synth/audit-entries.d.ts.map +1 -1
  94. package/dist/lint/post-synth/audit-entries.js +1 -0
  95. package/dist/lint/post-synth/index.d.ts.map +1 -1
  96. package/dist/lint/post-synth/index.js +2 -0
  97. package/dist/lint/post-synth/sqlch128.d.ts +18 -0
  98. package/dist/lint/post-synth/sqlch128.d.ts.map +1 -0
  99. package/dist/lint/post-synth/sqlch128.js +222 -0
  100. package/dist/lint/rules/postgres-templates.js +2 -2
  101. package/dist/lsp/postgres-template.d.ts.map +1 -1
  102. package/dist/lsp/postgres-template.js +11 -0
  103. package/dist/manifest.json +1 -1
  104. package/dist/mcp/postgres.js +1 -1
  105. package/dist/meta.json +25 -0
  106. package/dist/migrate/from-postgres/detect.d.ts +4 -0
  107. package/dist/migrate/from-postgres/detect.d.ts.map +1 -0
  108. package/dist/migrate/from-postgres/detect.js +7 -0
  109. package/dist/migrate/from-postgres/emit.d.ts +26 -0
  110. package/dist/migrate/from-postgres/emit.d.ts.map +1 -0
  111. package/dist/migrate/from-postgres/emit.js +86 -0
  112. package/dist/migrate/from-postgres/index.d.ts +51 -0
  113. package/dist/migrate/from-postgres/index.d.ts.map +1 -0
  114. package/dist/migrate/from-postgres/index.js +115 -0
  115. package/dist/migrate/from-postgres/translate.d.ts +73 -0
  116. package/dist/migrate/from-postgres/translate.d.ts.map +1 -0
  117. package/dist/migrate/from-postgres/translate.js +261 -0
  118. package/dist/migrate/from-postgres/types.d.ts +51 -0
  119. package/dist/migrate/from-postgres/types.d.ts.map +1 -0
  120. package/dist/migrate/from-postgres/types.js +211 -0
  121. package/dist/migration-replay.d.ts +73 -0
  122. package/dist/migration-replay.d.ts.map +1 -0
  123. package/dist/migration-replay.js +294 -0
  124. package/dist/okf/index.md +6 -0
  125. package/dist/okf/rules/SQLCH128.md +11 -0
  126. package/dist/okf/types/PostgresForeignDataWrapper.md +9 -0
  127. package/dist/okf/types/PostgresForeignServer.md +9 -0
  128. package/dist/okf/types/PostgresOperator.md +9 -0
  129. package/dist/okf/types/PostgresPublication.md +9 -0
  130. package/dist/okf/types/PostgresStatistics.md +9 -0
  131. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  132. package/dist/op/activities/postgres-migration.js +4 -0
  133. package/dist/op/activity-contracts.d.ts +20 -0
  134. package/dist/op/activity-contracts.d.ts.map +1 -1
  135. package/dist/op/activity-contracts.js +2 -0
  136. package/dist/plugin.d.ts.map +1 -1
  137. package/dist/plugin.js +36 -2
  138. package/dist/postgres/apply/apply.js +8 -1
  139. package/dist/postgres/apply/statements.d.ts.map +1 -1
  140. package/dist/postgres/apply/statements.js +87 -2
  141. package/dist/postgres/constraint-names.d.ts +30 -0
  142. package/dist/postgres/constraint-names.d.ts.map +1 -0
  143. package/dist/postgres/constraint-names.js +72 -0
  144. package/dist/postgres/entities.d.ts +110 -1
  145. package/dist/postgres/entities.d.ts.map +1 -1
  146. package/dist/postgres/entities.js +146 -0
  147. package/dist/postgres/entity-types.d.ts +5 -0
  148. package/dist/postgres/entity-types.d.ts.map +1 -1
  149. package/dist/postgres/entity-types.js +5 -0
  150. package/dist/postgres/import/generator.d.ts.map +1 -1
  151. package/dist/postgres/import/generator.js +50 -2
  152. package/dist/postgres/import/live-export.d.ts.map +1 -1
  153. package/dist/postgres/import/live-export.js +2 -0
  154. package/dist/postgres/live/catalog-kinds.d.ts +14 -0
  155. package/dist/postgres/live/catalog-kinds.d.ts.map +1 -1
  156. package/dist/postgres/live/catalog-kinds.js +206 -0
  157. package/dist/postgres/live/catalog.d.ts +2 -0
  158. package/dist/postgres/live/catalog.d.ts.map +1 -1
  159. package/dist/postgres/live/catalog.js +3 -1
  160. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  161. package/dist/postgres/live/describe-resources.js +10 -2
  162. package/dist/postgres/migrate/backfill.d.ts +2 -0
  163. package/dist/postgres/migrate/backfill.d.ts.map +1 -1
  164. package/dist/postgres/migrate/backfill.js +7 -1
  165. package/dist/postgres/migrate/carry.d.ts +45 -1
  166. package/dist/postgres/migrate/carry.d.ts.map +1 -1
  167. package/dist/postgres/migrate/carry.js +72 -8
  168. package/dist/postgres/migrate/observe.d.ts +28 -4
  169. package/dist/postgres/migrate/observe.d.ts.map +1 -1
  170. package/dist/postgres/migrate/observe.js +41 -12
  171. package/dist/postgres/migrate/op.d.ts +26 -1
  172. package/dist/postgres/migrate/op.d.ts.map +1 -1
  173. package/dist/postgres/migrate/op.js +3 -1
  174. package/dist/postgres/migrate/steps.d.ts +35 -2
  175. package/dist/postgres/migrate/steps.d.ts.map +1 -1
  176. package/dist/postgres/migrate/steps.js +158 -12
  177. package/dist/postgres/parser.d.ts +72 -1
  178. package/dist/postgres/parser.d.ts.map +1 -1
  179. package/dist/postgres/parser.js +194 -2
  180. package/dist/postgres/plan/deep.d.ts.map +1 -1
  181. package/dist/postgres/plan/deep.js +29 -1
  182. package/dist/postgres/plan/diff.d.ts.map +1 -1
  183. package/dist/postgres/plan/diff.js +39 -1
  184. package/dist/postgres/plan/normalize.d.ts +17 -2
  185. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  186. package/dist/postgres/plan/normalize.js +109 -1
  187. package/dist/postgres/plan/rules.d.ts +1 -0
  188. package/dist/postgres/plan/rules.d.ts.map +1 -1
  189. package/dist/postgres/plan/rules.js +3 -2
  190. package/dist/postgres/plan/schema.js +1 -1
  191. package/dist/postgres/secret-options.d.ts +10 -0
  192. package/dist/postgres/secret-options.d.ts.map +1 -0
  193. package/dist/postgres/secret-options.js +9 -0
  194. package/dist/postgres/testing/roundtrip-schema.d.ts +3 -2
  195. package/dist/postgres/testing/roundtrip-schema.d.ts.map +1 -1
  196. package/dist/postgres/testing/roundtrip-schema.js +13 -1
  197. package/dist/postgres.d.ts +1 -1
  198. package/dist/postgres.d.ts.map +1 -1
  199. package/dist/postgres.js +1 -1
  200. package/dist/rules/audit-entries.ts +1 -0
  201. package/dist/rules/postgres-templates.ts +2 -2
  202. package/dist/rules/sqlch128.ts +230 -0
  203. package/dist/serializer.d.ts +5 -0
  204. package/dist/serializer.d.ts.map +1 -1
  205. package/dist/serializer.js +13 -2
  206. package/dist/sql-types-command.d.ts +13 -0
  207. package/dist/sql-types-command.d.ts.map +1 -1
  208. package/dist/sql-types-command.js +31 -16
  209. package/dist/sql-types.d.ts +22 -0
  210. package/dist/sql-types.d.ts.map +1 -1
  211. package/dist/sql-types.js +10 -6
  212. package/dist/sqlserver/lexer.d.ts +37 -0
  213. package/dist/sqlserver/lexer.d.ts.map +1 -0
  214. package/dist/sqlserver/lexer.js +254 -0
  215. package/dist/sqlserver/parse.d.ts +185 -0
  216. package/dist/sqlserver/parse.d.ts.map +1 -0
  217. package/dist/sqlserver/parse.js +767 -0
  218. package/dist/sqlserver/translate.d.ts +34 -0
  219. package/dist/sqlserver/translate.d.ts.map +1 -0
  220. package/dist/sqlserver/translate.js +495 -0
  221. package/dist/sqlserver/types.d.ts +44 -0
  222. package/dist/sqlserver/types.d.ts.map +1 -0
  223. package/dist/sqlserver/types.js +133 -0
  224. package/dist/sqlserver/validate.d.ts +31 -0
  225. package/dist/sqlserver/validate.d.ts.map +1 -0
  226. package/dist/sqlserver/validate.js +67 -0
  227. package/dist/validate.d.ts.map +1 -1
  228. package/dist/validate.js +5 -0
  229. package/package.json +2 -2
  230. package/src/change-set.ts +2 -1
  231. package/src/clickhouse/apply/statements.ts +25 -0
  232. package/src/clickhouse/import/live-export.ts +20 -5
  233. package/src/clickhouse/live/named-collections.e2e.test.ts +88 -0
  234. package/src/clickhouse/live/named-collections.ts +61 -0
  235. package/src/clickhouse/live/undeclarable.e2e.test.ts +5 -3
  236. package/src/clickhouse/live/undeclarable.ts +20 -4
  237. package/src/clickhouse/named-collections.test.ts +187 -0
  238. package/src/clickhouse/named-collections.ts +224 -0
  239. package/src/clickhouse/parser.ts +2 -1
  240. package/src/clickhouse/plan/commands.ts +17 -2
  241. package/src/clickhouse/plan/diff.ts +57 -1
  242. package/src/clickhouse/plan/disruption.ts +5 -0
  243. package/src/clickhouse/plan/rebuild-handoff.ts +3 -0
  244. package/src/clickhouse/plan/report.ts +6 -1
  245. package/src/clickhouse/plan/rules.ts +14 -4
  246. package/src/clickhouse/plan/stream-s3queue.e2e.test.ts +148 -0
  247. package/src/clickhouse/plan/stream.test.ts +237 -0
  248. package/src/clickhouse/plan/stream.ts +111 -0
  249. package/src/clickhouse/rebuild/observe.ts +15 -1
  250. package/src/clickhouse/review-catalog.test.ts +181 -0
  251. package/src/clickhouse/review-catalog.ts +489 -0
  252. package/src/codegen/classifier-page.ts +21 -4
  253. package/src/codegen/clickhouse-module.ts +3 -0
  254. package/src/codegen/coverage-page.ts +5 -3
  255. package/src/codegen/docs.ts +1 -1
  256. package/src/codegen/postgres-classifier-page.ts +1 -1
  257. package/src/codegen/registry.ts +5 -0
  258. package/src/composites/catalog.test.ts +6 -1
  259. package/src/composites/catalog.ts +100 -0
  260. package/src/composites/clickhouse.ts +2 -0
  261. package/src/composites/composites.test.ts +1 -1
  262. package/src/composites/otel-tables.test.ts +118 -0
  263. package/src/composites/otel-tables.ts +339 -0
  264. package/src/composites/stream-ingest.test.ts +114 -0
  265. package/src/composites/stream-ingest.ts +82 -0
  266. package/src/core/undeclarable.test.ts +6 -5
  267. package/src/coverage/clickhouse.json +1 -1
  268. package/src/coverage/postgres.json +7 -7
  269. package/src/coverage.test.ts +27 -2
  270. package/src/coverage.ts +18 -7
  271. package/src/drizzle-command.ts +94 -0
  272. package/src/drizzle-kit.e2e.test.ts +89 -0
  273. package/src/drizzle-types.e2e.test.ts +67 -0
  274. package/src/drizzle.test.ts +358 -0
  275. package/src/drizzle.ts +737 -0
  276. package/src/files/clickhouse.ts +8 -1
  277. package/src/files/common.ts +14 -1
  278. package/src/files/postgres.ts +12 -2
  279. package/src/generated/clickhouse.ts +3 -0
  280. package/src/generated/lexicon-sql.json +25 -0
  281. package/src/import-parser.sqlserver.test.ts +92 -0
  282. package/src/import-parser.ts +79 -5
  283. package/src/index.ts +1 -1
  284. package/src/lint/post-synth/audit-entries.ts +1 -0
  285. package/src/lint/post-synth/index.ts +2 -0
  286. package/src/lint/post-synth/sqlch128.test.ts +107 -0
  287. package/src/lint/post-synth/sqlch128.ts +230 -0
  288. package/src/lint/rules/postgres-templates.ts +2 -2
  289. package/src/lsp/postgres-template.ts +11 -0
  290. package/src/mcp/postgres.ts +1 -1
  291. package/src/migrate/from-postgres/cli.test.ts +165 -0
  292. package/src/migrate/from-postgres/detect.ts +7 -0
  293. package/src/migrate/from-postgres/emit.ts +96 -0
  294. package/src/migrate/from-postgres/example.test.ts +205 -0
  295. package/src/migrate/from-postgres/index.ts +122 -0
  296. package/src/migrate/from-postgres/translate.test.ts +242 -0
  297. package/src/migrate/from-postgres/translate.ts +359 -0
  298. package/src/migrate/from-postgres/types.test.ts +116 -0
  299. package/src/migrate/from-postgres/types.ts +236 -0
  300. package/src/migrate/from-postgres/validate.e2e.test.ts +44 -0
  301. package/src/migrate/from-postgres/workspace.e2e.test.ts +114 -0
  302. package/src/migration-replay.e2e.test.ts +155 -0
  303. package/src/migration-replay.test.ts +173 -0
  304. package/src/migration-replay.ts +289 -0
  305. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +2 -2
  306. package/src/op/activities/postgres-migration.ts +4 -0
  307. package/src/op/activity-contracts.ts +2 -0
  308. package/src/plugin.ts +37 -2
  309. package/src/postgres/apply/apply.ts +9 -1
  310. package/src/postgres/apply/statements.ts +74 -1
  311. package/src/postgres/constraint-names.ts +71 -0
  312. package/src/postgres/database-kinds.test.ts +384 -0
  313. package/src/postgres/entities.ts +266 -1
  314. package/src/postgres/entity-types.ts +5 -0
  315. package/src/postgres/import/generator.ts +42 -2
  316. package/src/postgres/import/live-export.ts +1 -0
  317. package/src/postgres/live/catalog-kinds.ts +223 -0
  318. package/src/postgres/live/catalog.ts +6 -1
  319. package/src/postgres/live/database-kinds.e2e.test.ts +164 -0
  320. package/src/postgres/live/describe-resources.ts +11 -2
  321. package/src/postgres/live/undeclarable.e2e.test.ts +13 -6
  322. package/src/postgres/migrate/backfill.ts +13 -1
  323. package/src/postgres/migrate/carry.ts +103 -8
  324. package/src/postgres/migrate/followups.e2e.test.ts +218 -0
  325. package/src/postgres/migrate/followups.test.ts +199 -0
  326. package/src/postgres/migrate/kinds.e2e.test.ts +5 -5
  327. package/src/postgres/migrate/observe.ts +53 -14
  328. package/src/postgres/migrate/op.ts +28 -1
  329. package/src/postgres/migrate/partition.e2e.test.ts +129 -0
  330. package/src/postgres/migrate/partition.test.ts +206 -0
  331. package/src/postgres/migrate/steps.ts +157 -12
  332. package/src/postgres/parser.ts +234 -2
  333. package/src/postgres/plan/deep.ts +28 -1
  334. package/src/postgres/plan/diff.ts +36 -1
  335. package/src/postgres/plan/normalize.ts +122 -1
  336. package/src/postgres/plan/rules.ts +10 -3
  337. package/src/postgres/plan/schema.ts +1 -1
  338. package/src/postgres/secret-options.ts +10 -0
  339. package/src/postgres/testing/roundtrip-schema.ts +13 -1
  340. package/src/postgres.ts +16 -0
  341. package/src/serializer.ts +13 -2
  342. package/src/skills/chant-sql-postgres-migration.md +3 -3
  343. package/src/skills/chant-sql-postgres.md +6 -1
  344. package/src/skills/chant-sql.md +2 -1
  345. package/src/sql-types-command.ts +38 -16
  346. package/src/sql-types.ts +11 -6
  347. package/src/sqlserver/fixture.test.ts +186 -0
  348. package/src/sqlserver/import-cli.test.ts +61 -0
  349. package/src/sqlserver/lexer.ts +260 -0
  350. package/src/sqlserver/parse.ts +865 -0
  351. package/src/sqlserver/translate.test.ts +396 -0
  352. package/src/sqlserver/translate.ts +558 -0
  353. package/src/sqlserver/types.test.ts +102 -0
  354. package/src/sqlserver/types.ts +154 -0
  355. package/src/sqlserver/validate.e2e.test.ts +32 -0
  356. package/src/sqlserver/validate.test.ts +49 -0
  357. package/src/sqlserver/validate.ts +76 -0
  358. package/src/validate.ts +5 -0
  359. package/src/workspace-kinds.test.ts +11 -0
  360. package/workspace-kinds.json +100 -2
@@ -10,6 +10,7 @@
10
10
  * | On the old column | Before the switch | In the switch's transaction |
11
11
  * |---|---|---|
12
12
  * | an index | `CREATE INDEX CONCURRENTLY` on the new column | the old one dropped (a type change) or kept as `<name>__chant_old` (a rename), the new one renamed to the name |
13
+ * | an index on a partitioned table | `CREATE INDEX ... ON ONLY` the table, `CONCURRENTLY` on each partition, `ALTER INDEX ... ATTACH PARTITION` | as an index; the partitions' indexes go with the old one |
13
14
  * | a primary key or unique constraint | its unique index built `CONCURRENTLY` | the old constraint dropped (or a rename's unique one kept as `__chant_old`), `ADD CONSTRAINT ... USING INDEX` |
14
15
  * | a check or foreign key, this table's or another's that references the column | added `NOT VALID`, then `VALIDATE` | the old one dropped, the new one renamed to the name |
15
16
  * | a view (and the views that read it) | nothing | dropped, then created again from its declaration once the columns are swapped, with its grants and owner |
@@ -33,7 +34,8 @@
33
34
  * key or index the build declares on the new column takes the declared name.
34
35
  *
35
36
  * Refused, each named with what to do: exclusion constraints and constraint
36
- * triggers (no `USING INDEX` form), materialized views (made again only by
37
+ * triggers (no `USING INDEX` form), a key or foreign key on a partitioned table,
38
+ * a partitioned index over a foreign-table partition, materialized views (made again only by
37
39
  * a full scan in the switch), rules, triggers, policies and statistics on the
38
40
  * column, a generated column computed from it, an INVALID index on it, a view
39
41
  * the build does not declare, a view something other than a view depends on.
@@ -84,6 +86,12 @@ export interface CarriedObject {
84
86
  indexComment?: string;
85
87
  replicaIdentity?: boolean;
86
88
  clustered?: boolean;
89
+ /**
90
+ * An index on a partitioned table (#3333): `definition` is the `CREATE INDEX ... ON ONLY` that makes the
91
+ * partitioned index without building anything. Each partition's index is built `CONCURRENTLY` on its own and
92
+ * attached (`partitionIndexStatement`, `carryPartitionedIndex` in `./steps.ts`).
93
+ */
94
+ partitioned?: boolean;
87
95
  }
88
96
 
89
97
  /** A view on the old column (or on such a view), made again at the switch from its declaration. */
@@ -187,6 +195,73 @@ export function carriedIndexStatement(def: string, working: string, from: string
187
195
  return `CREATE ${m[1] ?? ""}INDEX CONCURRENTLY ${quoteIdent(working)} ON ${replaceColumn(m[2]!, from, to)}`;
188
196
  }
189
197
 
198
+ /**
199
+ * A partitioned index (`pg_get_indexdef` prints it `ON ONLY`) as the
200
+ * `CREATE INDEX ... ON ONLY` of `working` on the new column: it builds nothing
201
+ * and stays INVALID until every partition's index is attached.
202
+ */
203
+ export function carriedPartitionedIndexStatement(def: string, working: string, from: string, to: string): string {
204
+ const m = /^CREATE (UNIQUE )?INDEX (?:"(?:[^"]|"")+"|\S+) ON (?:ONLY )?(.*)$/s.exec(def);
205
+ if (!m) throw new Error(`unexpected index definition: ${def}`);
206
+ return `CREATE ${m[1] ?? ""}INDEX ${quoteIdent(working)} ON ONLY ${replaceColumn(m[2]!, from, to)}`;
207
+ }
208
+
209
+ /** One table of a partition tree. */
210
+ export interface PartitionNode {
211
+ oid: string;
212
+ schema: string;
213
+ name: string;
214
+ /** Quoted and qualified. */
215
+ ident: string;
216
+ /** The partitioned table (or partition) it is a partition of; absent for the root. */
217
+ parent?: string;
218
+ /** Not partitioned further: the tables that hold rows. */
219
+ leaf: boolean;
220
+ /** `r` (ordinary), `p` (partitioned) or `f` (foreign). */
221
+ kind: string;
222
+ level: number;
223
+ }
224
+
225
+ /** The partition tree under a partitioned table, the table itself first (level 0). */
226
+ export async function partitionTree(client: Pick<PostgresClient, "query">, tableOid: string): Promise<PartitionNode[]> {
227
+ const rows = await client.query<{ oid: string; schema: string; name: string; parent: string | null; leaf: boolean; kind: string; level: number }>(
228
+ `SELECT t.relid::oid::text AS oid, n.nspname AS schema, c.relname AS name, t.parentrelid::oid::text AS parent, t.isleaf AS leaf, c.relkind::text AS kind, t.level
229
+ FROM pg_catalog.pg_partition_tree($1::oid::pg_catalog.regclass) t JOIN pg_catalog.pg_class c ON c.oid = t.relid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
230
+ ORDER BY t.level, n.nspname, c.relname`,
231
+ [tableOid],
232
+ );
233
+ return rows.map((r) => ({
234
+ oid: String(r.oid),
235
+ schema: r.schema,
236
+ name: r.name,
237
+ ident: `${quoteIdent(r.schema)}.${quoteIdent(r.name)}`,
238
+ ...(r.parent !== null ? { parent: String(r.parent) } : {}),
239
+ leaf: r.leaf === true,
240
+ kind: r.kind,
241
+ level: Number(r.level),
242
+ }));
243
+ }
244
+
245
+ /** The name of the index a partitioned index's working copy has on one partition. */
246
+ export const partitionIndexName = (partition: Pick<PartitionNode, "name">, working: string): string => pgName(`${partition.name}_${working}`);
247
+
248
+ /**
249
+ * The statement that makes a partitioned index's working copy on one
250
+ * partition. A partition that is partitioned further gets `ON ONLY` (it is
251
+ * valid once its own partitions are attached); one that holds rows gets the
252
+ * `CONCURRENTLY` build, so writes go on.
253
+ */
254
+ export function partitionIndexStatement(c: Pick<CarriedObject, "definition">, node: Pick<PartitionNode, "ident" | "leaf">, indexName: string): string {
255
+ const m = /^CREATE (UNIQUE )?INDEX (?:"(?:[^"]|"")+"|\S+) ON ONLY (.*)$/s.exec(c.definition);
256
+ if (!m) throw new Error(`unexpected partitioned index definition: ${c.definition}`);
257
+ const using = m[2]!.indexOf(" USING ");
258
+ if (using < 0) throw new Error(`unexpected partitioned index definition: ${c.definition}`);
259
+ const tail = m[2]!.slice(using);
260
+ return node.leaf
261
+ ? `CREATE ${m[1] ?? ""}INDEX CONCURRENTLY ${quoteIdent(indexName)} ON ${node.ident}${tail}`
262
+ : `CREATE ${m[1] ?? ""}INDEX ${quoteIdent(indexName)} ON ONLY ${node.ident}${tail}`;
263
+ }
264
+
190
265
  /**
191
266
  * The name Postgres gives an object it names after its columns
192
267
  * (`users_email_key`, `orders_user_id_fkey`, `users_email_idx`), when `name`
@@ -214,12 +289,26 @@ interface DiscoverInput {
214
289
  defaultSchema: string;
215
290
  /** The table is partitioned: its indexes cannot be built CONCURRENTLY, nor its keys attached USING INDEX. */
216
291
  partitioned?: boolean;
292
+ /** A rename keeps the column's sequence on the old column until the contract (`defaultAtContract`), instead of being refused. */
293
+ keepSequence?: boolean;
217
294
  }
218
295
 
219
296
  interface Row {
220
297
  [key: string]: unknown;
221
298
  }
222
299
 
300
+ /** The sequence that belongs to a column (an identity's, or a `serial`'s), if it has one. */
301
+ export async function columnSequence(client: PostgresClient, tableOid: string, attnum: number): Promise<ColumnSequence | undefined> {
302
+ const [row] = await client.query<{ schema: string; name: string; deptype: string }>(
303
+ `SELECT n.nspname AS schema, c.relname AS name, d.deptype::text AS deptype
304
+ FROM pg_catalog.pg_depend d JOIN pg_catalog.pg_class c ON c.oid = d.objid AND d.classid = 'pg_catalog.pg_class'::pg_catalog.regclass JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
305
+ WHERE d.refclassid = 'pg_catalog.pg_class'::pg_catalog.regclass AND d.refobjid = $1::oid AND d.refobjsubid = $2 AND d.deptype IN ('a', 'i') AND c.relkind = 'S'`,
306
+ [tableOid, attnum],
307
+ );
308
+ if (!row) return undefined;
309
+ return { kind: row.deptype === "i" ? "identity" : "serial", schema: row.schema, name: row.name, ident: `${quoteIdent(row.schema)}.${quoteIdent(row.name)}` };
310
+ }
311
+
223
312
  /** What uses the column the values come from, sorted into what the Op carries over and what it refuses. */
224
313
  export async function discoverDependents(input: DiscoverInput): Promise<Dependents> {
225
314
  const { client, names: n, oid, attnum } = input;
@@ -343,10 +432,6 @@ export async function discoverDependents(input: DiscoverInput): Promise<Dependen
343
432
  const kind = String(r.kind);
344
433
  if (kind === "i" || kind === "I") {
345
434
  if (keyIndexes.has(String(r.oid))) continue;
346
- if (kind === "I") {
347
- refused.push(`index ${r.schema}.${name} is a partitioned index, which cannot be built CONCURRENTLY; drop it, migrate, then declare it again (chant #3333)`);
348
- continue;
349
- }
350
435
  if (r.valid !== true) {
351
436
  refused.push(`index ${r.schema}.${name} is INVALID (a failed CONCURRENTLY build); drop it, or REINDEX it, first`);
352
437
  continue;
@@ -375,6 +460,14 @@ export async function discoverDependents(input: DiscoverInput): Promise<Dependen
375
460
  });
376
461
  target = declaredIndex?.canonical.name ?? (r.has_exprs === true ? undefined : renamedDefault(name, table, oldCols, cols, "idx")) ?? name;
377
462
  }
463
+ if (kind === "I") {
464
+ // A partition the index cannot be built on (a foreign table) stops the carry before anything is made.
465
+ const foreign = (await partitionTree(client, oid)).filter((p) => p.kind === "f");
466
+ if (foreign.length > 0) {
467
+ refused.push(`partitioned index ${r.schema}.${name}: ${foreign.map((p) => `${p.schema}.${p.name}`).join(", ")} is a foreign table, which holds no index to attach; drop the index, migrate, then declare it again`);
468
+ continue;
469
+ }
470
+ }
378
471
  carried.push({
379
472
  kind: "index",
380
473
  name,
@@ -384,7 +477,8 @@ export async function discoverDependents(input: DiscoverInput): Promise<Dependen
384
477
  schema: String(r.schema),
385
478
  working,
386
479
  target,
387
- definition: carriedIndexStatement(String(r.def), working, from, to),
480
+ definition: kind === "I" ? carriedPartitionedIndexStatement(String(r.def), working, from, to) : carriedIndexStatement(String(r.def), working, from, to),
481
+ ...(kind === "I" ? { partitioned: true } : {}),
388
482
  validated: true,
389
483
  ...(r.comment !== null ? { comment: String(r.comment) } : {}),
390
484
  ...(r.replident === true ? { replicaIdentity: true } : {}),
@@ -393,9 +487,10 @@ export async function discoverDependents(input: DiscoverInput): Promise<Dependen
393
487
  } else if (kind === "S") {
394
488
  const dep = deps.find((d) => d.kind === "class" && d.objid === String(r.oid));
395
489
  const seq: ColumnSequence = { kind: dep?.deptype === "i" ? "identity" : "serial", schema: String(r.schema), name, ident: `${quoteIdent(String(r.schema))}.${quoteIdent(name)}` };
396
- if (rename) {
490
+ if (rename && !input.keepSequence) {
397
491
  refused.push(
398
- `sequence ${r.schema}.${name} gives the column its values (${seq.kind === "identity" ? "an identity column" : "a serial column's default"}); during a rename a writer that names the new column would be given the old one's next value by the trigger, which it cannot tell from a value the writer chose; a rename that keeps its sequence needs versioned views (chant #3331)`,
492
+ `sequence ${r.schema}.${name} gives the column its values (${seq.kind === "identity" ? "an identity column" : "a serial column's default"}); during a rename a writer that names the new column would get the old one's next value, and the trigger would take it over the writer's, ` +
493
+ `because it cannot tell a defaulted value from one the writer gave. Set defaultAtContract: true to leave the new column without the default until the contract (see the Op's reference), or drop the default first`,
399
494
  );
400
495
  } else sequence = seq;
401
496
  } else {
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Renames the Op refused in #3322 and makes since (#3331), against the
3
+ * pinned server:
4
+ *
5
+ * - A rename of a column with a default (`defaultAtContract`): writers of
6
+ * either name during the migration, the new column without the default
7
+ * until the contract, and the default there once it ends.
8
+ * - A rename of a `serial` column: the sequence stays on the old column
9
+ * until the contract and is handed to the new one there.
10
+ * - A rename of an identity column: the identity moves to the new column at
11
+ * the contract and carries on where the old one stood.
12
+ * - A rename with a type change (`reverse`): writers of either name, and a
13
+ * `reverse` that does not give the old value back fails the verification.
14
+ *
15
+ * Needs Docker; skips cleanly without it.
16
+ */
17
+
18
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
22
+ import { OpRunFailure } from "@intentius/chant/op";
23
+ import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
24
+ import { ApprovingLedger, runMigrationOp } from "../testing/migration";
25
+ import type { PostgresClient } from "../live/client";
26
+ import { planPgAgainstServer } from "../plan/commands";
27
+ import { postgresApply } from "../../op/activities/postgres-apply";
28
+ import { postgresMigrationPlan, type PostgresMigrationDeps } from "../../op/activities/postgres-migration";
29
+ import type { PostgresMigrationOpConfig } from "./op";
30
+
31
+ const enabled = await dockerAvailable();
32
+ let server: TestPostgres | undefined;
33
+ let admin: PostgresClient | undefined;
34
+ const dir = mkdtempSync(join(tmpdir(), "chant-pg-followups-"));
35
+
36
+ beforeAll(async () => {
37
+ if (!enabled) return;
38
+ server = await startTestPostgres();
39
+ admin = await server.connect();
40
+ }, 600_000);
41
+
42
+ afterAll(async () => {
43
+ await admin?.end();
44
+ await server?.stop();
45
+ rmSync(dir, { recursive: true, force: true });
46
+ });
47
+
48
+ interface Obj {
49
+ export: string;
50
+ type: string;
51
+ ddl: string;
52
+ dependsOn?: string[];
53
+ }
54
+
55
+ function writeBuild(file: string, objects: Obj[]): string {
56
+ writeFileSync(join(dir, file), JSON.stringify({ dialect: "postgres", postgresMajor: 18, applyOrder: objects.map((o) => o.export), objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
57
+ return file;
58
+ }
59
+
60
+ const MARKER = { stack: "e2e", env: "test" };
61
+ const profile = () => ({
62
+ config: { ownership: MARKER, sql: { profiles: { e2e: { url: server!.endpoint().url, password: { env: "PG_E2E_PASSWORD" } } } } },
63
+ env: { PG_E2E_PASSWORD: server!.endpoint().password },
64
+ });
65
+ const deps = (): PostgresMigrationDeps => ({ ...profile(), log: () => undefined });
66
+ const apply = (file: string) => postgresApply({ buildPath: join(dir, file), environment: "e2e" }, undefined, { ...profile(), log: () => undefined });
67
+ const plan = (file: string) => planPgAgainstServer("e2e", join(dir, file), profile());
68
+ const one = async <T>(sql: string): Promise<T> => (await admin!.query<T>(sql))[0]!;
69
+ const schemaObj = (name: string): Obj => ({ export: name, type: "Postgres::Schema", ddl: `CREATE SCHEMA ${name}` });
70
+ const table = (exportName: string, schema: string, ddl: string): Obj => ({ export: exportName, type: "Postgres::Table", dependsOn: [schema], ddl });
71
+ /** A table of a `bigint` key and one more column, last, with the `-- previously:` hint a rename is declared by. */
72
+ const keyed = (exportName: string, schema: string, column: string, previously?: string): Obj =>
73
+ table(exportName, schema, `CREATE TABLE ${schema}.${exportName} (\n id bigint PRIMARY KEY,\n ${column}${previously ? ` -- previously: ${previously}` : ""}\n)`);
74
+ const config = (over: Pick<PostgresMigrationOpConfig, "name" | "table" | "column" | "output"> & Partial<PostgresMigrationOpConfig>): PostgresMigrationOpConfig => ({
75
+ env: "e2e",
76
+ build: false,
77
+ path: dir,
78
+ retain: "0s",
79
+ stack: MARKER.stack,
80
+ ownershipEnv: MARKER.env,
81
+ ...over,
82
+ });
83
+ const refusal = (tableName: string, column: string, output: string, extra: Partial<PostgresMigrationOpConfig> = {}) =>
84
+ postgresMigrationPlan({ table: tableName, column, buildPath: output, environment: "e2e", stack: MARKER.stack, ownershipEnv: MARKER.env, cwd: dir, ...extra } as never, undefined, deps()).then(
85
+ () => "",
86
+ (e: unknown) => String((e as Error).message),
87
+ );
88
+ const defaultOf = async (t: string, c: string) =>
89
+ (await one<{ d: string | null }>(`SELECT pg_catalog.pg_get_expr(d.adbin, d.adrelid) AS d FROM pg_catalog.pg_attribute a LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum WHERE a.attrelid = '${t}'::regclass AND a.attname = '${c}'`)).d;
90
+
91
+ /** Run the Op until it is done, approving each gate; `atGate` runs after the first run, with both columns written and nothing switched. */
92
+ async function runToEnd(c: PostgresMigrationOpConfig, atGate?: () => Promise<void>) {
93
+ const ledger = new ApprovingLedger();
94
+ const runs = [];
95
+ for (let i = 0; i < 4; i++) {
96
+ const r = await runMigrationOp(c, ledger.port, deps);
97
+ runs.push(r);
98
+ if (i === 0) await atGate?.();
99
+ if (r.status !== "gated") break;
100
+ ledger.approveLast();
101
+ }
102
+ return runs;
103
+ }
104
+
105
+ describe.skipIf(!enabled)("a rename of a column with a default", () => {
106
+ const DF = schemaObj("df");
107
+ const users = (c: string, previously?: string) => keyed("users", "df", `${c} text NOT NULL DEFAULT 'none'`, previously);
108
+
109
+ test("refused without defaultAtContract; with it, writers of either name agree and the default lands at the contract", async () => {
110
+ await apply(writeBuild("df-v1.json", [DF, users("email")]));
111
+ await admin!.query("INSERT INTO df.users (id, email) SELECT g, 'u' || g FROM generate_series(1, 500) g");
112
+ writeBuild("df-v2.json", [DF, users("login", "email")]);
113
+ expect(await refusal("df.users", "login", "df-v2.json")).toMatch(/Set defaultAtContract: true/);
114
+
115
+ const runs = await runToEnd(config({ name: "migrate-df-login", table: "df.users", column: "login", output: "df-v2.json", defaultAtContract: true, batchSize: 200 }), async () => {
116
+ // Nothing is switched: the new column has no default, the old one keeps its own.
117
+ expect(await defaultOf("df.users", "login")).toBeNull();
118
+ expect(await defaultOf("df.users", "email")).toBe("'none'::text");
119
+ await admin!.query("INSERT INTO df.users (id) VALUES (1001)"); // neither name
120
+ await admin!.query("INSERT INTO df.users (id, email) VALUES (1002, 'old')"); // the old name
121
+ await admin!.query("INSERT INTO df.users (id, login) VALUES (1003, 'new')"); // the new name
122
+ await admin!.query("UPDATE df.users SET login = 'new2' WHERE id = 1002");
123
+ await admin!.query("UPDATE df.users SET email = 'old2' WHERE id = 1003");
124
+ });
125
+ expect(runs.map((r) => r.status)).toEqual(["gated", "gated", "ok"]);
126
+ expect(await admin!.query("SELECT id::int AS id, login FROM df.users WHERE id > 1000 ORDER BY id")).toEqual([
127
+ { id: 1001, login: "none" },
128
+ { id: 1002, login: "new2" },
129
+ { id: 1003, login: "old2" },
130
+ ]);
131
+ expect(await defaultOf("df.users", "login")).toBe("'none'::text");
132
+ expect(await admin!.query("SELECT attname FROM pg_attribute WHERE attrelid = 'df.users'::regclass AND attnum > 0 AND NOT attisdropped ORDER BY attnum")).toEqual([{ attname: "id" }, { attname: "login" }]);
133
+ expect((await plan("df-v2.json")).changes).toEqual([]);
134
+ }, 300_000);
135
+ });
136
+
137
+ describe.skipIf(!enabled)("a rename of a serial column", () => {
138
+ const SR = schemaObj("sq");
139
+ const items = (c: string, previously?: string) => keyed("items", "sq", `${c} serial`, previously);
140
+
141
+ test("the sequence stays with the old column until the contract, then belongs to the new one and stays its default", async () => {
142
+ await apply(writeBuild("sq-v1.json", [SR, items("n")]));
143
+ await admin!.query("INSERT INTO sq.items (id) SELECT g FROM generate_series(1, 300) g");
144
+ writeBuild("sq-v2.json", [SR, items("num", "n")]);
145
+
146
+ const runs = await runToEnd(config({ name: "migrate-items-num", table: "sq.items", column: "num", output: "sq-v2.json", defaultAtContract: true }), async () => {
147
+ await admin!.query("INSERT INTO sq.items (id) VALUES (1001)"); // takes the old default; the trigger copies it
148
+ await admin!.query("INSERT INTO sq.items (id, num) VALUES (1002, 7000)"); // the new name
149
+ expect(await one("SELECT n = num AS same FROM sq.items WHERE id = 1001")).toEqual({ same: true });
150
+ expect(await one("SELECT n AS n FROM sq.items WHERE id = 1002")).toEqual({ n: 7000 });
151
+ });
152
+ expect(runs.map((r) => r.status)).toEqual(["gated", "gated", "ok"]);
153
+ expect(await one("SELECT pg_catalog.pg_get_serial_sequence('sq.items', 'num') AS seq")).toEqual({ seq: "sq.items_n_seq" });
154
+ const next = await one<{ id: number; num: number }>("INSERT INTO sq.items (id) VALUES (2000) RETURNING id::int AS id, num");
155
+ expect(next.num).toBeGreaterThan(300);
156
+ expect((await plan("sq-v2.json")).changes).toEqual([]);
157
+ }, 300_000);
158
+ });
159
+
160
+ describe.skipIf(!enabled)("a rename of an identity column", () => {
161
+ const ID = schemaObj("ix");
162
+ const things = (c: string, previously?: string) => keyed("things", "ix", `${c} integer GENERATED ALWAYS AS IDENTITY`, previously);
163
+
164
+ test("the identity moves to the new column at the contract and carries on where the old one stood", async () => {
165
+ await apply(writeBuild("ix-v1.json", [ID, things("n")]));
166
+ await admin!.query("INSERT INTO ix.things (id) SELECT g FROM generate_series(1, 120) g");
167
+ writeBuild("ix-v2.json", [ID, things("num", "n")]);
168
+
169
+ const runs = await runToEnd(config({ name: "migrate-things-num", table: "ix.things", column: "num", output: "ix-v2.json", defaultAtContract: true }), async () => {
170
+ await admin!.query("INSERT INTO ix.things (id) VALUES (1001)"); // the old identity gives 121; the trigger copies it
171
+ expect(await one("SELECT n = num AS same FROM ix.things WHERE id = 1001")).toEqual({ same: true });
172
+ // A writer of the new name gives a value; the trigger sets the old column's ALWAYS identity from it.
173
+ await admin!.query("INSERT INTO ix.things (id, num) VALUES (1002, 5000)");
174
+ expect(await one("SELECT n AS n FROM ix.things WHERE id = 1002")).toEqual({ n: 5000 });
175
+ });
176
+ expect(runs.map((r) => r.status)).toEqual(["gated", "gated", "ok"]);
177
+ expect(await one("SELECT attidentity::text AS identity FROM pg_attribute WHERE attrelid = 'ix.things'::regclass AND attname = 'num'")).toEqual({ identity: "a" });
178
+ const next = await one<{ num: number }>("INSERT INTO ix.things (id) VALUES (2000) RETURNING num");
179
+ expect(next.num).toBeGreaterThan(120);
180
+ expect((await plan("ix-v2.json")).changes).toEqual([]);
181
+ }, 300_000);
182
+ });
183
+
184
+ describe.skipIf(!enabled)("a rename with a type change", () => {
185
+ const RT = schemaObj("rt");
186
+ const people = (column: string, previously?: string) => keyed("people", "rt", column, previously);
187
+
188
+ test("refused without reverse; with it, writers of either name agree and a wrong reverse fails the verification", async () => {
189
+ await apply(writeBuild("rt-v1.json", [RT, people("age_text text NOT NULL")]));
190
+ await admin!.query("INSERT INTO rt.people SELECT g, (g % 90)::text FROM generate_series(1, 400) g");
191
+ writeBuild("rt-v2.json", [RT, people("age integer NOT NULL", "age_text")]);
192
+ expect(await refusal("rt.people", "age", "rt-v2.json")).toMatch(/give that expression as reverse/);
193
+
194
+ // A reverse that does not give the old value back: nothing is switched and onFailure drops what the expand added.
195
+ const bad = await runMigrationOp(config({ name: "migrate-people-age", table: "rt.people", column: "age", output: "rt-v2.json", using: "CAST(age_text AS integer)", reverse: "CAST(age AS text) || 'x'" }), new ApprovingLedger().port, deps).then(
196
+ () => undefined,
197
+ (e: unknown) => e,
198
+ );
199
+ expect(bad).toBeInstanceOf(OpRunFailure);
200
+ expect((bad as OpRunFailure).result.records.find((x) => x.fn === "postgresMigrationVerify")?.error).toMatch(/must give the old value back/);
201
+ expect(await one("SELECT count(*)::int AS n FROM pg_attribute WHERE attrelid = 'rt.people'::regclass AND attname = 'age' AND NOT attisdropped")).toEqual({ n: 0 });
202
+
203
+ const runs = await runToEnd(config({ name: "migrate-people-age", table: "rt.people", column: "age", output: "rt-v2.json", using: "CAST(age_text AS integer)", reverse: "CAST(age AS text)" }), async () => {
204
+ await admin!.query("INSERT INTO rt.people (id, age_text) VALUES (1001, '41')"); // the old name
205
+ await admin!.query("INSERT INTO rt.people (id, age) VALUES (1002, 42)"); // the new name
206
+ await admin!.query("UPDATE rt.people SET age = 43 WHERE id = 1001");
207
+ await admin!.query("UPDATE rt.people SET age_text = '44' WHERE id = 1002");
208
+ expect(await admin!.query("SELECT id::int AS id, age, age_text FROM rt.people WHERE id > 1000 ORDER BY id")).toEqual([
209
+ { id: 1001, age: 43, age_text: "43" },
210
+ { id: 1002, age: 44, age_text: "44" },
211
+ ]);
212
+ });
213
+ expect(runs.map((r) => r.status)).toEqual(["gated", "gated", "ok"]);
214
+ expect(await one("SELECT pg_catalog.format_type(atttypid, atttypmod) AS t FROM pg_attribute WHERE attrelid = 'rt.people'::regclass AND attname = 'age'")).toEqual({ t: "integer" });
215
+ expect(await admin!.query("SELECT attname FROM pg_attribute WHERE attrelid = 'rt.people'::regclass AND attnum > 0 AND NOT attisdropped ORDER BY attnum")).toEqual([{ attname: "id" }, { attname: "age" }]);
216
+ expect((await plan("rt-v2.json")).changes).toEqual([]);
217
+ }, 300_000);
218
+ });
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The migration Op's follow-ups to #3322 that need no server (#3331): a
3
+ * rename combined with a type change (`reverse`), and a rename of a column
4
+ * with a default, a serial or an identity (`defaultAtContract`). The dual
5
+ * write's body, the contract's statements and the refusals; the e2e is
6
+ * `followups.e2e.test.ts`.
7
+ */
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { validateActivitySteps, collectActivityContracts, loadActivityContracts, mergeActivityContracts, type ActivityContract, type OpConfig } from "@intentius/chant/op";
11
+ import * as contractsModule from "../../op/activity-contracts";
12
+ import type { PostgresClient } from "../live/client";
13
+ import type { PgChange } from "../plan/diff";
14
+ import { PostgresMigrationOp, type PostgresMigrationOpConfig } from "./op";
15
+ import { migrationNames } from "./names";
16
+ import { dualWriteBody, contractStatements } from "./steps";
17
+ import { migrationPlanSubject, refuseUnsupported, type MigrationObservation, type RelationRow, type ServerColumn } from "./observe";
18
+
19
+ const BASE: PostgresMigrationOpConfig = { name: "migrate-users-login", env: "prod", table: "app.users", column: "login", build: false };
20
+ const propsOf = (config: PostgresMigrationOpConfig) => (PostgresMigrationOp(config).op as unknown as { props: OpConfig }).props;
21
+
22
+ const names = migrationNames("app", "users", "login", "rename", "email");
23
+
24
+ const observation = (over: Partial<MigrationObservation> = {}): MigrationObservation =>
25
+ ({
26
+ state: "switched",
27
+ names,
28
+ declared: {} as MigrationObservation["declared"],
29
+ column: { name: "login", type: "text" },
30
+ oid: "16384",
31
+ batchKey: "id",
32
+ keyColumns: [{ name: "id", type: "bigint" }],
33
+ carried: [],
34
+ carriedStates: new Map(),
35
+ views: [],
36
+ partitioned: false,
37
+ columns: new Map(),
38
+ changes: [],
39
+ expression: '"email"',
40
+ publications: [],
41
+ major: 18,
42
+ old: { attnum: 2, name: "email", type: "text", notNull: true, generated: false, identity: false },
43
+ trigger: { name: names.trigger },
44
+ fn: { name: names.fn },
45
+ ...over,
46
+ }) as MigrationObservation;
47
+
48
+ describe("a rename with a type change", () => {
49
+ const retyped = migrationNames("app", "users", "age", "rename", "age_text");
50
+ const o = { names: retyped, expression: "CAST(age_text AS integer)", reverse: "CAST(age AS text)" };
51
+
52
+ test("a writer of the old name has the expression applied, a writer of the new name the reverse", () => {
53
+ const body = dualWriteBody(o);
54
+ expect(body).toContain("IF NEW.age IS NULL THEN NEW.age := (SELECT CAST(age_text AS integer) FROM (SELECT NEW.*) AS users); ELSE NEW.age_text := (SELECT CAST(age AS text) FROM (SELECT NEW.*) AS users); END IF;");
55
+ expect(body).toContain("ELSIF NEW.age IS DISTINCT FROM OLD.age THEN\n NEW.age_text := (SELECT CAST(age AS text) FROM (SELECT NEW.*) AS users);");
56
+ expect(body).toContain("ELSE\n NEW.age := (SELECT CAST(age_text AS integer) FROM (SELECT NEW.*) AS users);");
57
+ });
58
+
59
+ test("a plain rename's body is unchanged", () => {
60
+ expect(dualWriteBody({ names, expression: '"email"' })).toContain("IF NEW.login IS NULL THEN NEW.login := NEW.email; ELSE NEW.email := NEW.login; END IF;");
61
+ });
62
+
63
+ test("the reverse is part of what the gate binds", () => {
64
+ const subject = (reverse?: string) =>
65
+ migrationPlanSubject(observation({ state: "migrate", source: { attnum: 2, name: "age_text", type: "text", notNull: false, generated: false, identity: false }, ...(reverse ? { reverse } : {}) }));
66
+ expect(subject("CAST(age AS text)")).toMatchObject({ reverse: "CAST(age AS text)" });
67
+ expect(subject()).not.toHaveProperty("reverse");
68
+ });
69
+ });
70
+
71
+ describe("the contract of a rename that kept its default", () => {
72
+ const sequence = { kind: "serial", schema: "app", name: "users_seq_seq", ident: "app.users_seq_seq" } as const;
73
+
74
+ test("a plain default is set on the new column in the contract's transaction, after the old column goes", () => {
75
+ const o = observation({ column: { name: "login", type: "text", default: "'none'" } });
76
+ expect(contractStatements(o)).toEqual([
77
+ "DROP TRIGGER login__chant_sync ON app.users",
78
+ "DROP FUNCTION app.users__login__chant_sync()",
79
+ "ALTER TABLE app.users DROP COLUMN email",
80
+ "ALTER TABLE app.users ALTER COLUMN login SET DEFAULT 'none'",
81
+ ]);
82
+ });
83
+
84
+ test("a serial's sequence is handed to the new column before the old one is dropped, and stays its default", () => {
85
+ const o = observation({
86
+ column: { name: "login", type: "serial" },
87
+ old: { attnum: 2, name: "email", type: "integer", notNull: true, default: "nextval('app.users_seq_seq'::regclass)", generated: false, identity: false },
88
+ sequence,
89
+ });
90
+ expect(contractStatements(o)).toEqual([
91
+ "DROP TRIGGER login__chant_sync ON app.users",
92
+ "DROP FUNCTION app.users__login__chant_sync()",
93
+ "ALTER SEQUENCE app.users_seq_seq OWNED BY app.users.login",
94
+ "ALTER TABLE app.users DROP COLUMN email",
95
+ "ALTER TABLE app.users ALTER COLUMN login SET DEFAULT nextval('app.users_seq_seq'::regclass)",
96
+ ]);
97
+ });
98
+
99
+ test("an identity moves to the new column under its sequence's name and carries on where the old one stood", () => {
100
+ const o = observation({
101
+ column: { name: "login", type: "integer", generated: { kind: "identity", always: true, options: "(CACHE 5)" } },
102
+ old: { attnum: 2, name: "email", type: "integer", notNull: true, generated: false, identity: true },
103
+ sequence: { ...sequence, kind: "identity" },
104
+ });
105
+ expect(contractStatements(o, { lastValue: "41", isCalled: true })).toEqual([
106
+ "DROP TRIGGER login__chant_sync ON app.users",
107
+ "DROP FUNCTION app.users__login__chant_sync()",
108
+ "ALTER TABLE app.users ALTER COLUMN email DROP IDENTITY",
109
+ "ALTER TABLE app.users DROP COLUMN email",
110
+ "ALTER TABLE app.users ALTER COLUMN login ADD GENERATED ALWAYS AS IDENTITY (SEQUENCE NAME app.users_seq_seq CACHE 5)",
111
+ "SELECT pg_catalog.setval('app.users_seq_seq', 41, true)",
112
+ ]);
113
+ expect(() => contractStatements(o)).toThrow(/needs its sequence's state/);
114
+ });
115
+
116
+ test("a rename without a default drops the old column and nothing else, as before", () => {
117
+ expect(contractStatements(observation())).toEqual([
118
+ "DROP TRIGGER login__chant_sync ON app.users",
119
+ "DROP FUNCTION app.users__login__chant_sync()",
120
+ "ALTER TABLE app.users DROP COLUMN email",
121
+ ]);
122
+ });
123
+
124
+ test("a type change's contract is the plain drop: its trigger went at the switch", () => {
125
+ const t = migrationNames("app", "orders", "amount", "type", "amount");
126
+ const o = observation({
127
+ names: t,
128
+ old: { attnum: 3, name: t.oldColumn, type: "text", notNull: false, generated: false, identity: false },
129
+ column: { name: "amount", type: "numeric", default: "0" },
130
+ });
131
+ delete (o as { trigger?: unknown }).trigger;
132
+ delete (o as { fn?: unknown }).fn;
133
+ expect(contractStatements(o)).toEqual(["ALTER TABLE app.orders DROP COLUMN amount__chant_old"]);
134
+ });
135
+ });
136
+
137
+ describe("what the Op still refuses", () => {
138
+ const rel: RelationRow = { oid: "16384", kind: "r", partition: false, parents: null, children: null, partkey: null, partattrs: null };
139
+ const source = (over: Partial<ServerColumn> = {}): ServerColumn => ({ attnum: 2, name: "email", type: "text", notNull: false, generated: false, identity: false, ...over });
140
+ const rename = { rule: "SQLPG205", object: "app.users", field: "columns.login", before: "email", after: "login" } as PgChange;
141
+ const retype = { rule: "SQLPG208", object: "app.users", field: "columns.login.type", before: "text", after: "integer" } as PgChange;
142
+ // Every query answers nothing, except the primary key's.
143
+ const client = { query: async (sql: string) => (sql.includes("contype = 'p'") ? [{ name: "id", type: "bigint" }] : []) } as unknown as PostgresClient;
144
+ const run = (extra: { reverse?: string; defaultAtContract?: boolean }, src: ServerColumn, column: { name: string; type: string; default?: string }, changes: PgChange[], isRename = true) =>
145
+ refuseUnsupported(
146
+ { client, target: { defaultSchema: "app" }, declared: { props: {}, canonical: { schema: "app", name: "users" } }, column: "login", ...extra } as never,
147
+ names,
148
+ rel,
149
+ src,
150
+ column as never,
151
+ changes,
152
+ isRename,
153
+ changes.find((c) => c.rule !== "SQLPG205"),
154
+ );
155
+
156
+ test("a rename with a type change names reverse, and goes on with it", async () => {
157
+ await expect(run({}, source(), { name: "login", type: "integer" }, [rename, retype])).rejects.toThrow(/give that expression as reverse/);
158
+ await expect(run({ reverse: "CAST(login AS text)" }, source(), { name: "login", type: "integer" }, [rename, retype])).resolves.toMatchObject({ batchKey: "id" });
159
+ });
160
+
161
+ test("reverse is refused where nothing needs it, and may not carry the function's quote", async () => {
162
+ await expect(run({ reverse: "x" }, source(), { name: "login", type: "text" }, [rename])).rejects.toThrow(/reverse is for a rename combined with a type change, and this migration is a rename only/);
163
+ await expect(run({ reverse: "x" }, source(), { name: "login", type: "integer" }, [retype], false)).rejects.toThrow(/a type change without a rename/);
164
+ await expect(run({ reverse: "$chant$" }, source(), { name: "login", type: "integer" }, [rename, retype])).rejects.toThrow(/may not contain \$chant\$/);
165
+ });
166
+
167
+ test("a rename with a type change and a default or identity is refused whatever is set", async () => {
168
+ const msg = /also changes its type[\s\S]*Rename first/;
169
+ await expect(run({ reverse: "x", defaultAtContract: true }, source({ default: "'a'::text" }), { name: "login", type: "integer" }, [rename, retype])).rejects.toThrow(msg);
170
+ await expect(run({ reverse: "x", defaultAtContract: true }, source({ identity: true }), { name: "login", type: "integer" }, [rename, retype])).rejects.toThrow(msg);
171
+ });
172
+
173
+ test("a rename of a column with a default needs defaultAtContract, and says what it gives up", async () => {
174
+ const withDefault = source({ default: "'none'::text" });
175
+ const refused = run({}, withDefault, { name: "login", type: "text", default: "'none'" }, [rename]);
176
+ await expect(refused).rejects.toThrow(/Set defaultAtContract: true/);
177
+ await expect(run({}, withDefault, { name: "login", type: "text", default: "'none'" }, [rename])).rejects.toThrow(/explicit NULL gets the default, and UPDATE \.\.\. SET <new> = DEFAULT sets NULL/);
178
+ await expect(run({}, source({ identity: true }), { name: "login", type: "integer" }, [rename])).rejects.toThrow(/Set defaultAtContract: true/);
179
+ await expect(run({ defaultAtContract: true }, withDefault, { name: "login", type: "text", default: "'none'" }, [rename])).resolves.toMatchObject({ batchKey: "id" });
180
+ });
181
+
182
+ test("defaultAtContract is refused on a rename that has no default to wait for", async () => {
183
+ await expect(run({ defaultAtContract: true }, source(), { name: "login", type: "text" }, [rename])).rejects.toThrow(/defaultAtContract is for a rename of a column with a default/);
184
+ });
185
+ });
186
+
187
+ describe("the Op's configuration", () => {
188
+ test("reverse and defaultAtContract reach every step, and the steps still pass the activity contracts", async () => {
189
+ const config: PostgresMigrationOpConfig = { ...BASE, using: "CAST(age_text AS integer)", reverse: "CAST(age AS text)", defaultAtContract: true };
190
+ const props = propsOf(config);
191
+ const steps = props.phases.flatMap((p) => p.steps).filter((s) => s.kind === "activity" && (s as { fn: string }).fn.startsWith("postgresMigration")) as Array<{ args: Record<string, unknown> }>;
192
+ expect(steps).toHaveLength(9);
193
+ for (const s of steps) expect(s.args).toMatchObject({ reverse: "CAST(age AS text)", defaultAtContract: true });
194
+ const contracts = new Map<string, ActivityContract>();
195
+ collectActivityContracts(contractsModule as unknown as Record<string, unknown>, contracts);
196
+ expect(validateActivitySteps(props, mergeActivityContracts(await loadActivityContracts([]), contracts))).toEqual([]);
197
+ for (const s of propsOf(BASE).phases.flatMap((p) => p.steps)) if (s.kind === "activity") expect((s as { args?: Record<string, unknown> }).args ?? {}).not.toHaveProperty("defaultAtContract");
198
+ });
199
+ });
@@ -10,7 +10,7 @@
10
10
  * - A column of a partitioned table: every partition follows the
11
11
  * partitioned table.
12
12
  * - Refused, each naming why: a partition, an inheritance tree, a column in
13
- * the partition key, an index on a partitioned table, a generated column,
13
+ * the partition key, a key on a partitioned table, a generated column,
14
14
  * a rename of an identity column.
15
15
  *
16
16
  * Needs Docker; skips cleanly without it.
@@ -177,7 +177,7 @@ describe.skipIf(!enabled)("what the Op refuses among the rest", () => {
177
177
  (e: unknown) => String((e as Error).message),
178
178
  );
179
179
 
180
- test("a partition, an inheritance tree, the partition key, an index on a partitioned table, a generated column, a renamed identity", async () => {
180
+ test("a partition, an inheritance tree, the partition key, a key on a partitioned table, a generated column, a renamed identity", async () => {
181
181
  const parted = (v: string, key = "at") => table("parted", "rf", `CREATE TABLE rf.parted (id bigint, at date, v ${v}, PRIMARY KEY (id, at)) PARTITION BY RANGE (${key})`);
182
182
  const p1 = table("parted1", "rf", "CREATE TABLE rf.parted1 PARTITION OF rf.parted FOR VALUES FROM ('2025-01-01') TO ('2026-01-01')", ["parted"]);
183
183
  const parent = (v: string) => table("parent", "rf", `CREATE TABLE rf.parent (id bigint PRIMARY KEY, v ${v})`);
@@ -185,16 +185,16 @@ describe.skipIf(!enabled)("what the Op refuses among the rest", () => {
185
185
  const ident = (c: string, hint = "") => table("ident", "rf", `CREATE TABLE rf.ident (\n ${c} integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,${hint}\n v text\n)`);
186
186
  await apply(writeBuild("rf-v1.json", [RF, parted("integer"), p1, parent("integer"), gen("integer"), ident("id")]));
187
187
  await admin!.query("CREATE TABLE rf.child (extra text) INHERITS (rf.parent)");
188
- await admin!.query("CREATE INDEX parted_v_idx ON rf.parted (v)");
188
+ await admin!.query("ALTER TABLE rf.parted ADD CONSTRAINT parted_v_key UNIQUE (id, at, v)");
189
189
  writeBuild("rf-v2.json", [RF, parted("bigint"), p1, parent("bigint"), gen("bigint"), ident("key", " -- previously: id")]);
190
190
 
191
191
  expect(await refusal("rf.parted1", "v", "rf-v2.json")).toMatch(/^rf\.parted1 is a partition of rf\.parted\. A partition's columns are its partitioned table's: run the Op on rf\.parted/);
192
192
  expect(await refusal("rf.parent", "v", "rf-v2.json")).toMatch(/^rf\.parent has inheritance children \(rf\.child\)\. The Op does not migrate inheritance trees: a write to a child table does not fire the parent's dual-write trigger/);
193
- expect(await refusal("rf.parted", "v", "rf-v2.json")).toMatch(/index rf\.parted_v_idx is a partitioned index, which cannot be built CONCURRENTLY/);
193
+ expect(await refusal("rf.parted", "v", "rf-v2.json")).toMatch(/unique constraint parted_v_key on rf\.parted: on a partitioned table a key cannot be attached from an index built CONCURRENTLY/);
194
194
  expect(await refusal("rf.gen", "g", "rf-v2.json")).toMatch(/^rf\.gen\.g is a generated column\. There is no expand and contract for one/);
195
195
  expect(await refusal("rf.ident", "key", "rf-v2.json")).toMatch(/sequence rf\.ident_id_seq gives the column its values \(an identity column\); during a rename/);
196
196
 
197
- await admin!.query("DROP INDEX rf.parted_v_idx");
197
+ await admin!.query("ALTER TABLE rf.parted DROP CONSTRAINT parted_v_key");
198
198
  writeBuild("rf-v3.json", [RF, parted("integer", "v"), p1]);
199
199
  await admin!.query("DROP TABLE rf.parted");
200
200
  await apply(writeBuild("rf-v3.json", [RF, table("parted", "rf", "CREATE TABLE rf.parted (id bigint, at date, v integer, PRIMARY KEY (id, v)) PARTITION BY RANGE (v)")]));