@intentius/chant-lexicon-sql 0.124.30 → 0.124.32

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 (322) hide show
  1. package/dist/change-set.js +250 -0
  2. package/dist/clickhouse/access.js +722 -0
  3. package/dist/clickhouse/apply/apply.js +243 -0
  4. package/dist/clickhouse/apply/mutations.js +66 -0
  5. package/dist/clickhouse/apply/statements.js +545 -0
  6. package/dist/clickhouse/catalog-types.js +7 -0
  7. package/dist/clickhouse/container.js +152 -0
  8. package/dist/clickhouse/engine-syntax.js +159 -0
  9. package/dist/clickhouse/entities.js +593 -0
  10. package/dist/clickhouse/expressions.js +107 -0
  11. package/dist/clickhouse/http.js +55 -0
  12. package/dist/clickhouse/import/generator.js +192 -0
  13. package/dist/clickhouse/import/inlined.js +207 -0
  14. package/dist/clickhouse/import/ir.js +160 -0
  15. package/dist/clickhouse/import/live-export.js +131 -0
  16. package/dist/clickhouse/import/parser.js +17 -0
  17. package/dist/clickhouse/live/bind.js +116 -0
  18. package/dist/clickhouse/live/catalog.js +223 -0
  19. package/dist/clickhouse/live/describe-resources.js +91 -0
  20. package/dist/clickhouse/live/undeclarable.js +59 -0
  21. package/dist/clickhouse/overlays/codecs.js +57 -0
  22. package/dist/clickhouse/overlays/engines.js +101 -0
  23. package/dist/clickhouse/overlays/kinds.js +1 -0
  24. package/dist/clickhouse/overlays/projections.js +26 -0
  25. package/dist/clickhouse/overlays/skip-indexes.js +40 -0
  26. package/dist/clickhouse/overlays/ttl.js +34 -0
  27. package/dist/clickhouse/overlays/types.js +59 -0
  28. package/dist/clickhouse/ownership.js +83 -0
  29. package/dist/clickhouse/parser.js +674 -0
  30. package/dist/clickhouse/plan/commands.js +159 -0
  31. package/dist/clickhouse/plan/deep.js +345 -0
  32. package/dist/clickhouse/plan/diff.js +312 -0
  33. package/dist/clickhouse/plan/disruption.js +73 -0
  34. package/dist/clickhouse/plan/normalize.js +545 -0
  35. package/dist/clickhouse/plan/rebuild-handoff.js +57 -0
  36. package/dist/clickhouse/plan/report.js +26 -0
  37. package/dist/clickhouse/plan/rules.js +72 -0
  38. package/dist/clickhouse/plan/schema.js +67 -0
  39. package/dist/clickhouse/plan/server-format.js +35 -0
  40. package/dist/clickhouse/rebuild/backfill.js +265 -0
  41. package/dist/clickhouse/rebuild/engines.js +146 -0
  42. package/dist/clickhouse/rebuild/observe.js +260 -0
  43. package/dist/clickhouse/rebuild/op.js +152 -0
  44. package/dist/clickhouse/rebuild/partitions.js +65 -0
  45. package/dist/clickhouse/rebuild/receipts.js +120 -0
  46. package/dist/clickhouse/rebuild/replicas.js +121 -0
  47. package/dist/clickhouse/rebuild/shards.js +76 -0
  48. package/dist/clickhouse/rebuild/steps.js +383 -0
  49. package/dist/clickhouse/rebuild/verify.js +279 -0
  50. package/dist/clickhouse/row-types.js +151 -0
  51. package/dist/clickhouse/testing/fake-server.js +47 -0
  52. package/dist/clickhouse/testing/roundtrip-schema.js +25 -0
  53. package/dist/clickhouse/testing/writable-server.js +123 -0
  54. package/dist/clickhouse/tokens.js +23 -0
  55. package/dist/clickhouse/topology.js +463 -0
  56. package/dist/clickhouse/type-syntax.js +73 -0
  57. package/dist/clickhouse.js +25 -0
  58. package/dist/codegen/classifier-page.js +93 -0
  59. package/dist/codegen/clickhouse-module.js +249 -0
  60. package/dist/codegen/coverage-page.js +70 -0
  61. package/dist/codegen/docs-cli.js +3 -0
  62. package/dist/codegen/docs.js +222 -0
  63. package/dist/codegen/generate-cli.js +13 -0
  64. package/dist/codegen/generate.js +97 -0
  65. package/dist/codegen/package.js +28 -0
  66. package/dist/codegen/postgres-classifier-page.js +95 -0
  67. package/dist/codegen/postgres-module.js +418 -0
  68. package/dist/codegen/registry.js +52 -0
  69. package/dist/composites/audit-log-table.js +35 -0
  70. package/dist/composites/catalog.js +524 -0
  71. package/dist/composites/cdc-mirror.js +32 -0
  72. package/dist/composites/clickhouse.js +6 -0
  73. package/dist/composites/events-table.js +26 -0
  74. package/dist/composites/index.js +8 -0
  75. package/dist/composites/join-table.js +25 -0
  76. package/dist/composites/postgres.js +6 -0
  77. package/dist/composites/refreshed-view.js +24 -0
  78. package/dist/composites/replacing-table.js +28 -0
  79. package/dist/composites/rollup-view.js +29 -0
  80. package/dist/composites/sharded-table.js +27 -0
  81. package/dist/composites/soft-delete-table.js +34 -0
  82. package/dist/composites/tenant-table.js +26 -0
  83. package/dist/composites/typed.js +8 -0
  84. package/dist/config.js +206 -0
  85. package/dist/core/apply.js +93 -0
  86. package/dist/core/classifier.js +115 -0
  87. package/dist/core/container.js +69 -0
  88. package/dist/core/cursor.js +261 -0
  89. package/dist/core/diff.js +48 -0
  90. package/dist/core/entity.js +58 -0
  91. package/dist/core/find-templates.js +118 -0
  92. package/dist/core/foreign-tables.js +27 -0
  93. package/dist/core/handoff.js +32 -0
  94. package/dist/core/index.js +64 -0
  95. package/dist/core/interpolation.js +191 -0
  96. package/dist/core/normalize.js +43 -0
  97. package/dist/core/ownership.js +119 -0
  98. package/dist/core/receipts.js +22 -0
  99. package/dist/core/references.js +61 -0
  100. package/dist/core/statements.js +42 -0
  101. package/dist/core/template.js +79 -0
  102. package/dist/core/testing/http-stub.js +21 -0
  103. package/dist/core/tokens.js +202 -0
  104. package/dist/core/typed.js +40 -0
  105. package/dist/core/undeclarable.js +46 -0
  106. package/dist/coverage.js +161 -0
  107. package/dist/detect.js +32 -0
  108. package/dist/dialects.js +18 -0
  109. package/dist/files/build-root.js +72 -0
  110. package/dist/files/clickhouse.js +138 -0
  111. package/dist/files/common.js +58 -0
  112. package/dist/files/postgres.js +389 -0
  113. package/dist/generated/clickhouse-types.js +5 -0
  114. package/dist/generated/clickhouse.js +4361 -0
  115. package/dist/generated/postgres-types.js +227 -0
  116. package/dist/generated/postgres.js +3667 -0
  117. package/dist/import-generator.js +14 -0
  118. package/dist/import-parser.js +71 -0
  119. package/dist/index.js +24 -0
  120. package/dist/init-templates.js +290 -0
  121. package/dist/integrity.json +2 -2
  122. package/dist/lint/audit-catalog.js +31 -0
  123. package/dist/lint/audit-lineage.js +22 -0
  124. package/dist/lint/post-synth/audit-entries.js +34 -0
  125. package/dist/lint/post-synth/clickhouse-helpers.js +79 -0
  126. package/dist/lint/post-synth/clickhouse-type-check.js +162 -0
  127. package/dist/lint/post-synth/index.js +112 -0
  128. package/dist/lint/post-synth/postgres-audit-entries.js +35 -0
  129. package/dist/lint/post-synth/postgres-helpers.js +65 -0
  130. package/dist/lint/post-synth/postgres-names.js +507 -0
  131. package/dist/lint/post-synth/sql-helpers.js +23 -0
  132. package/dist/lint/post-synth/sql101.js +59 -0
  133. package/dist/lint/post-synth/sql102.js +34 -0
  134. package/dist/lint/post-synth/sqlch101.js +34 -0
  135. package/dist/lint/post-synth/sqlch102.js +23 -0
  136. package/dist/lint/post-synth/sqlch103.js +45 -0
  137. package/dist/lint/post-synth/sqlch104.js +41 -0
  138. package/dist/lint/post-synth/sqlch105.js +36 -0
  139. package/dist/lint/post-synth/sqlch106.js +27 -0
  140. package/dist/lint/post-synth/sqlch107.js +39 -0
  141. package/dist/lint/post-synth/sqlch108.js +28 -0
  142. package/dist/lint/post-synth/sqlch109.js +21 -0
  143. package/dist/lint/post-synth/sqlch110.js +31 -0
  144. package/dist/lint/post-synth/sqlch111.js +41 -0
  145. package/dist/lint/post-synth/sqlch112.js +28 -0
  146. package/dist/lint/post-synth/sqlch113.js +19 -0
  147. package/dist/lint/post-synth/sqlch114.js +26 -0
  148. package/dist/lint/post-synth/sqlch115.js +30 -0
  149. package/dist/lint/post-synth/sqlch116.js +22 -0
  150. package/dist/lint/post-synth/sqlch117.js +26 -0
  151. package/dist/lint/post-synth/sqlch118.js +27 -0
  152. package/dist/lint/post-synth/sqlch119.js +32 -0
  153. package/dist/lint/post-synth/sqlch120.js +20 -0
  154. package/dist/lint/post-synth/sqlch121.js +11 -0
  155. package/dist/lint/post-synth/sqlch122.js +11 -0
  156. package/dist/lint/post-synth/sqlch123.js +10 -0
  157. package/dist/lint/post-synth/sqlch124.js +21 -0
  158. package/dist/lint/post-synth/sqlch125.js +55 -0
  159. package/dist/lint/post-synth/sqlch126.js +62 -0
  160. package/dist/lint/post-synth/sqlch127.js +132 -0
  161. package/dist/lint/post-synth/sqlpg101.js +25 -0
  162. package/dist/lint/post-synth/sqlpg102.js +35 -0
  163. package/dist/lint/post-synth/sqlpg103.js +23 -0
  164. package/dist/lint/post-synth/sqlpg104.js +23 -0
  165. package/dist/lint/post-synth/sqlpg105.js +22 -0
  166. package/dist/lint/post-synth/sqlpg106.js +22 -0
  167. package/dist/lint/post-synth/sqlpg107.js +22 -0
  168. package/dist/lint/post-synth/sqlpg108.js +28 -0
  169. package/dist/lint/post-synth/sqlpg109.js +39 -0
  170. package/dist/lint/post-synth/sqlpg110.js +38 -0
  171. package/dist/lint/post-synth/sqlpg111.js +23 -0
  172. package/dist/lint/post-synth/sqlpg112.js +25 -0
  173. package/dist/lint/post-synth/sqlpg113.js +29 -0
  174. package/dist/lint/post-synth/sqlpg114.js +39 -0
  175. package/dist/lint/post-synth/sqlpg115.js +48 -0
  176. package/dist/lint/post-synth/sqlpg116.js +26 -0
  177. package/dist/lint/post-synth/sqlpg117.js +24 -0
  178. package/dist/lint/post-synth/sqlpg118.js +22 -0
  179. package/dist/lint/post-synth/sqlpg119.js +41 -0
  180. package/dist/lint/post-synth/sqlpg120.js +83 -0
  181. package/dist/lint/post-synth/sqlpg121.js +33 -0
  182. package/dist/lint/post-synth/sqlpg122.js +27 -0
  183. package/dist/lint/post-synth/sqlpg123.js +69 -0
  184. package/dist/lint/post-synth/sqlpg124.js +50 -0
  185. package/dist/lint/post-synth/sqlpg125.js +41 -0
  186. package/dist/lint/post-synth/sqlpg126.js +38 -0
  187. package/dist/lint/rules/index.js +12 -0
  188. package/dist/lint/rules/postgres-templates.js +27 -0
  189. package/dist/lint/rules/sqlch001.js +79 -0
  190. package/dist/lint/rules/sqlch002.js +124 -0
  191. package/dist/lint/rules/sqlch003.js +69 -0
  192. package/dist/lint/rules/sqlch004.js +83 -0
  193. package/dist/lint/rules/sqlpg001.js +68 -0
  194. package/dist/lint/rules/sqlpg002.js +69 -0
  195. package/dist/lint/rules/sqlpg003.js +58 -0
  196. package/dist/lint/rules/sqlpg004.js +77 -0
  197. package/dist/lint/rules/sqlpg005.js +86 -0
  198. package/dist/lint/rules/templates.js +25 -0
  199. package/dist/lint/rules/typed-names.js +132 -0
  200. package/dist/live-dialect.js +32 -0
  201. package/dist/lsp/catalog.js +44 -0
  202. package/dist/lsp/completions.js +136 -0
  203. package/dist/lsp/hover.js +115 -0
  204. package/dist/lsp/postgres-catalog.js +176 -0
  205. package/dist/lsp/postgres-template.js +171 -0
  206. package/dist/lsp/postgres.js +266 -0
  207. package/dist/lsp/refs.js +60 -0
  208. package/dist/lsp/registry.d.ts.map +1 -1
  209. package/dist/lsp/registry.js +19 -0
  210. package/dist/lsp/template.js +143 -0
  211. package/dist/manifest.json +1 -1
  212. package/dist/mcp/index.d.ts.map +1 -1
  213. package/dist/mcp/index.js +293 -0
  214. package/dist/mcp/postgres.js +117 -0
  215. package/dist/migration-statements.js +243 -0
  216. package/dist/op/activities/clickhouse-apply.js +83 -0
  217. package/dist/op/activities/clickhouse-emulator.js +53 -0
  218. package/dist/op/activities/clickhouse-rebuild.js +119 -0
  219. package/dist/op/activities/index.js +26 -0
  220. package/dist/op/activities/postgres-apply.js +126 -0
  221. package/dist/op/activities/postgres-emulator.js +70 -0
  222. package/dist/op/activities/postgres-migration.js +156 -0
  223. package/dist/op/activities/sql-exec.js +57 -0
  224. package/dist/op/activity-contracts.js +103 -0
  225. package/dist/package-cli.js +14 -0
  226. package/dist/plugin.js +259 -0
  227. package/dist/postgres/access/acl.js +344 -0
  228. package/dist/postgres/access/changes.js +30 -0
  229. package/dist/postgres/access/import.js +61 -0
  230. package/dist/postgres/access/live.js +96 -0
  231. package/dist/postgres/apply/apply.js +557 -0
  232. package/dist/postgres/apply/statements.js +882 -0
  233. package/dist/postgres/catalog-types.js +18 -0
  234. package/dist/postgres/container.js +58 -0
  235. package/dist/postgres/entities.js +1390 -0
  236. package/dist/postgres/entity-types.js +28 -0
  237. package/dist/postgres/import/generator.js +283 -0
  238. package/dist/postgres/import/ir.js +53 -0
  239. package/dist/postgres/import/live-export.js +83 -0
  240. package/dist/postgres/keywords.js +32 -0
  241. package/dist/postgres/live/bind.js +130 -0
  242. package/dist/postgres/live/catalog-kinds.js +301 -0
  243. package/dist/postgres/live/catalog.js +644 -0
  244. package/dist/postgres/live/client.js +151 -0
  245. package/dist/postgres/live/describe-resources.js +135 -0
  246. package/dist/postgres/live/undeclarable.js +111 -0
  247. package/dist/postgres/migrate/backfill.js +170 -0
  248. package/dist/postgres/migrate/batches.js +122 -0
  249. package/dist/postgres/migrate/carry.js +518 -0
  250. package/dist/postgres/migrate/handoff.js +59 -0
  251. package/dist/postgres/migrate/names.js +61 -0
  252. package/dist/postgres/migrate/observe.js +360 -0
  253. package/dist/postgres/migrate/op.js +178 -0
  254. package/dist/postgres/migrate/receipts.js +117 -0
  255. package/dist/postgres/migrate/replication.js +106 -0
  256. package/dist/postgres/migrate/steps.js +514 -0
  257. package/dist/postgres/overlays/columns.js +31 -0
  258. package/dist/postgres/overlays/constraints.js +52 -0
  259. package/dist/postgres/overlays/extension-objects.js +88 -0
  260. package/dist/postgres/overlays/indexes.js +25 -0
  261. package/dist/postgres/overlays/kinds.js +2 -0
  262. package/dist/postgres/overlays/partitioning.js +23 -0
  263. package/dist/postgres/overlays/relations.js +25 -0
  264. package/dist/postgres/overlays/storage.js +65 -0
  265. package/dist/postgres/overlays/type-ddl.js +36 -0
  266. package/dist/postgres/overlays/types.js +74 -0
  267. package/dist/postgres/parser.js +1824 -0
  268. package/dist/postgres/plan/commands.js +137 -0
  269. package/dist/postgres/plan/deep.js +324 -0
  270. package/dist/postgres/plan/diff.js +499 -0
  271. package/dist/postgres/plan/disruption.js +64 -0
  272. package/dist/postgres/plan/normalize.js +889 -0
  273. package/dist/postgres/plan/report.js +21 -0
  274. package/dist/postgres/plan/rules.js +115 -0
  275. package/dist/postgres/plan/schema.js +105 -0
  276. package/dist/postgres/plan/server-normalize.js +166 -0
  277. package/dist/postgres/providers/aurora.js +44 -0
  278. package/dist/postgres/providers/azure.js +53 -0
  279. package/dist/postgres/providers/cloud-sql.js +41 -0
  280. package/dist/postgres/providers/index.js +73 -0
  281. package/dist/postgres/providers/neon.js +44 -0
  282. package/dist/postgres/providers/rds.js +47 -0
  283. package/dist/postgres/providers/supabase.js +45 -0
  284. package/dist/postgres/providers/types.js +21 -0
  285. package/dist/postgres/row-types.js +107 -0
  286. package/dist/postgres/testing/fake-client.js +34 -0
  287. package/dist/postgres/testing/migration.js +48 -0
  288. package/dist/postgres/testing/roundtrip-schema.js +64 -0
  289. package/dist/postgres/testing/server.js +47 -0
  290. package/dist/postgres/testing/writable-server.js +160 -0
  291. package/dist/postgres/tokens.js +41 -0
  292. package/dist/postgres.js +15 -0
  293. package/dist/read/clickhouse.js +82 -0
  294. package/dist/read/objects.js +44 -0
  295. package/dist/read/postgres.js +104 -0
  296. package/dist/read.js +52 -0
  297. package/dist/receipts.js +207 -0
  298. package/dist/serializer.js +172 -0
  299. package/dist/skill-defs.js +93 -0
  300. package/dist/spec/catalog.js +191 -0
  301. package/dist/spec/fetch.d.ts.map +1 -1
  302. package/dist/spec/fetch.js +90 -0
  303. package/dist/spec/pin.js +42 -0
  304. package/dist/spec/postgres-catalog.js +171 -0
  305. package/dist/spec/postgres-fetch.js +91 -0
  306. package/dist/spec/postgres-pin.js +82 -0
  307. package/dist/sql-files.js +206 -0
  308. package/dist/sql-types-command.js +97 -0
  309. package/dist/sql-types.js +323 -0
  310. package/dist/token-source.js +156 -0
  311. package/dist/validate-cli.js +5 -0
  312. package/dist/validate.js +151 -0
  313. package/dist/workspace-outputs.js +191 -0
  314. package/package.json +7 -7
  315. package/src/codegen/package.ts +2 -2
  316. package/src/lint/rules/typescript-7.test.ts +2 -1
  317. package/src/lsp/catalog.ts +2 -2
  318. package/src/lsp/postgres-catalog.ts +2 -2
  319. package/src/lsp/registry.ts +2 -1
  320. package/src/mcp/index.ts +4 -2
  321. package/src/spec/fetch.ts +2 -2
  322. package/src/spec/postgres-fetch.ts +2 -2
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `PostgresMigrationOp` (#3281): the column change Postgres cannot make in
3
+ * place without breaking readers (a rename, SQLPG205; a type change across
4
+ * kinds, SQLPG208), run as expand and contract in a gated composite Op. A
5
+ * type change within a kind that rewrites the table under ACCESS EXCLUSIVE
6
+ * (SQLPG207) can run as the same Op, which fills the new column in batches
7
+ * instead.
8
+ *
9
+ * export const { op } = PostgresMigrationOp({
10
+ * name: "migrate-app-users-login",
11
+ * env: "prod",
12
+ * table: "app.users",
13
+ * column: "login",
14
+ * });
15
+ *
16
+ * | Phase | Step |
17
+ * |---|---|
18
+ * | Build | `chant build` (`build: false` skips it) |
19
+ * | Plan | classify the column's change against the server; refuse what the Op does not make |
20
+ * | Expand | add the new column, nullable, no default (a catalog change) |
21
+ * | Dual write | a trigger that keeps the new column written: computed from the old one for a type change, both ways for a rename |
22
+ * | Backfill | `UPDATE` per key range, each batch with its receipt in its own transaction, under `lock_timeout`, pausing while a replica is behind |
23
+ * | Carry over | each index on the old column built again on the new one `CONCURRENTLY`, each check and foreign key (another table's too) added `NOT VALID` and validated (`./carry.ts`) |
24
+ * | Verify | rows, mismatches and checksums of the new column against the old |
25
+ * | Approve | a gate bound to the plan and the verified new column |
26
+ * | Switch | NOT NULL proven by a validated check; then one short transaction: a type change swaps the columns by name, a rename finishes the new column; the carried indexes and constraints take their names (keys `USING INDEX`), and the views that read the column are made again from their declarations |
27
+ * | Retain | the old column, until `retain` has passed |
28
+ * | Approve contract | a gate bound to that old column |
29
+ * | Contract | drop the old column (and a rename's trigger) once its date has passed |
30
+ * | onFailure | drop what the expand added: the trigger, its function, the check, the new column, the receipts |
31
+ *
32
+ * Run it until it is done: each run goes as far as the next gate. Every step
33
+ * re-reads the server, so a run after a gate, a crash or an approval picks up
34
+ * where the last stopped. A run killed during the backfill (Ctrl-C, a lost
35
+ * machine) is not a failure, runs no onFailure, and the next run resumes the
36
+ * backfill from its receipts; a step that fails runs onFailure, and the next
37
+ * run starts again from a new column.
38
+ *
39
+ * `gates: "outer"` (#3687) leaves out both approval gates and the Contract
40
+ * phase, for a caller that runs the Op under an approval of its own: the run
41
+ * goes from Plan to Retain, verification included. `onFailure: "keep"`
42
+ * leaves out the onFailure, so a failed run's next run resumes the backfill
43
+ * from its receipts instead of starting again.
44
+ */
45
+ import { Op, activity, gate, phase, stepOutput } from "@intentius/chant/op";
46
+ import { parseDuration } from "@intentius/chant/op";
47
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$|^"(?:[^"]|"")+"$/;
48
+ export function PostgresMigrationOp(config) {
49
+ const parts = config.table.split(".");
50
+ if (parts.length > 2 || !parts.every((p) => /^[A-Za-z_][A-Za-z0-9_$]*$/.test(p))) {
51
+ throw new Error(`PostgresMigrationOp "${config.name}": table must be "schema.name" or an export name, got ${JSON.stringify(config.table)}`);
52
+ }
53
+ if (!config.column || !IDENT.test(config.column))
54
+ throw new Error(`PostgresMigrationOp "${config.name}": column must be the declared column's name, got ${JSON.stringify(config.column)}`);
55
+ if (config.batchSize !== undefined && (!Number.isInteger(config.batchSize) || config.batchSize < 1)) {
56
+ throw new Error(`PostgresMigrationOp "${config.name}": batchSize must be a positive integer, got ${config.batchSize}`);
57
+ }
58
+ if (config.replicationLag)
59
+ for (const d of [config.replicationLag.max, config.replicationLag.wait])
60
+ if (d !== undefined)
61
+ parseDuration(d);
62
+ if (config.retain !== undefined)
63
+ parseDuration(config.retain);
64
+ if (config.gates !== undefined && config.gates !== "own" && config.gates !== "outer") {
65
+ throw new Error(`PostgresMigrationOp "${config.name}": gates must be "own" or "outer"`);
66
+ }
67
+ if (config.onFailure !== undefined && config.onFailure !== "drop" && config.onFailure !== "keep") {
68
+ throw new Error(`PostgresMigrationOp "${config.name}": onFailure must be "drop" or "keep"`);
69
+ }
70
+ const outer = config.gates === "outer";
71
+ const keep = config.onFailure === "keep";
72
+ const args = {
73
+ table: config.table,
74
+ column: config.column,
75
+ buildPath: config.output ?? "dist/schema.json",
76
+ environment: config.env,
77
+ ...(config.using !== undefined ? { using: config.using } : {}),
78
+ ...(config.retain ? { retain: config.retain } : {}),
79
+ ...(config.batchSize !== undefined ? { batchSize: config.batchSize } : {}),
80
+ ...(config.replicationLag !== undefined ? { replicationLag: config.replicationLag } : {}),
81
+ ...(config.lockTimeoutMs !== undefined ? { lockTimeoutMs: config.lockTimeoutMs } : {}),
82
+ ...(config.statementTimeoutMs !== undefined ? { statementTimeoutMs: config.statementTimeoutMs } : {}),
83
+ ...(config.stack ? { stack: config.stack } : {}),
84
+ ...(config.ownershipEnv ? { ownershipEnv: config.ownershipEnv } : {}),
85
+ ...(config.path && config.path !== "." ? { cwd: config.path } : {}),
86
+ ...(keep ? { keepOnFailure: true } : {}),
87
+ };
88
+ const a = args;
89
+ const switchGate = config.gate?.gate ?? `approve-${config.name}`;
90
+ const contractGate = config.contractGate?.gate ?? `approve-${config.name}-contract`;
91
+ const policy = config.gate?.approval?.policy;
92
+ const what = `${config.table}.${config.column}`;
93
+ const step = (fn, extra = {}) => ({ ...activity(fn, a), ...extra });
94
+ // The switch waits for this gate, unless the caller holds the approval (`gates: "outer"`).
95
+ const approve = phase("Approve", [
96
+ gate(switchGate, {
97
+ ...(config.gate?.timeout ? { timeout: config.gate.timeout } : {}),
98
+ plan: stepOutput("verify", "planDigest"),
99
+ description: config.gate?.description ??
100
+ `Approve switching ${what} on ${config.env}: the run record's Verification line has what this approval binds; for a rename, readers move to the new name once it is switched`,
101
+ ...(config.gate?.approval
102
+ ? {
103
+ approval: {
104
+ ...config.gate.approval,
105
+ ...(policy
106
+ ? {
107
+ context: {
108
+ verifiedRows: stepOutput("verify", "rows"),
109
+ mismatched: stepOutput("verify", "mismatched"),
110
+ ...config.gate.approval.context,
111
+ },
112
+ }
113
+ : {}),
114
+ },
115
+ }
116
+ : {}),
117
+ }),
118
+ ]);
119
+ const phases = [
120
+ ...(config.build === false ? [] : [phase("Build", [activity("chantBuild", { path: config.path ?? "." })])]),
121
+ phase("Plan", [step("postgresMigrationPlan", { id: "plan", outcomeAttribute: [{ name: "MigrationState", from: "state" }, { name: "Change", from: "change" }] })]),
122
+ phase("Expand", [step("postgresMigrationExpand")]),
123
+ phase("Dual write", [step("postgresMigrationDualWrite")]),
124
+ phase("Backfill", [
125
+ step("postgresMigrationBackfill", {
126
+ profile: "fastIdempotent",
127
+ timeout: config.backfillTimeout ?? "6h",
128
+ outcomeAttribute: [
129
+ { name: "Filled", from: "filled" },
130
+ { name: "Skipped", from: "skipped" },
131
+ { name: "BackfilledRows", from: "rows" },
132
+ ],
133
+ }),
134
+ ]),
135
+ phase("Carry over", [
136
+ step("postgresMigrationCarry", {
137
+ timeout: "6h",
138
+ outcomeAttribute: [
139
+ { name: "Carried", from: "carried" },
140
+ { name: "CarriedBuilt", from: "built" },
141
+ ],
142
+ }),
143
+ ]),
144
+ phase("Verify", [
145
+ step("postgresMigrationVerify", {
146
+ id: "verify",
147
+ timeout: "6h",
148
+ outcomeAttribute: [
149
+ { name: "Verification", from: "summary" },
150
+ { name: "VerifiedRows", from: "rows" },
151
+ ],
152
+ }),
153
+ ]),
154
+ ...(outer ? [] : [approve]),
155
+ phase("Switch", [step("postgresMigrationSwitch", { timeout: "6h", outcomeAttribute: [{ name: "OldColumn", from: "oldColumn" }] })]),
156
+ phase("Retain", [step("postgresMigrationRetain", { id: "retain", outcomeAttribute: { name: "RetainUntil", from: "retainUntil" } })]),
157
+ ...(outer
158
+ ? []
159
+ : [
160
+ phase("Approve contract", [
161
+ gate(contractGate, {
162
+ ...(config.contractGate?.timeout ? { timeout: config.contractGate.timeout } : {}),
163
+ plan: stepOutput("retain", "contractDigest"),
164
+ description: config.contractGate?.description ?? `Approve dropping the old column of ${what} once its retention has passed and no reader uses it`,
165
+ }),
166
+ ]),
167
+ phase("Contract", [step("postgresMigrationContract", { outcomeAttribute: { name: "Dropped", from: "dropped" } })]),
168
+ ]),
169
+ ];
170
+ const op = Op({
171
+ name: config.name,
172
+ overview: `Migrate ${what} on ${config.env} as expand and contract: new column, dual write, backfill, verify, ${outer ? "switch under the caller's approval" : "gated switch, gated contract"}`,
173
+ labels: { Migration: "true", Env: config.env, Table: config.table, Column: config.column },
174
+ phases,
175
+ ...(keep ? {} : { onFailure: [phase("Compensate", [step("postgresMigrationCompensate")])] }),
176
+ });
177
+ return { op };
178
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Effect receipts kept in Postgres (#3281): where the expand-and-contract
3
+ * migration's backfill records which batches it has filled.
4
+ *
5
+ * A receipt is the proof that one effect ran, kept where the effect happened
6
+ * (concepts/effect-receipts.mdx). A batch's effect is "these rows of the
7
+ * table have the new column filled", so the receipt is a row in the same
8
+ * database, in the table's own schema:
9
+ *
10
+ * SELECT * FROM app.__chant_receipts
11
+ *
12
+ * In the table's schema rather than a `chant_receipts` schema, as #3265 put
13
+ * the ClickHouse receipts of a Replicated database beside the tables they
14
+ * witness: whoever can read the table can read its receipts, and a
15
+ * provider that reserves schemas or limits `CREATE SCHEMA` (#3282) does not
16
+ * stop the migration. The table's comment carries chant's trailer with the
17
+ * `receipts` key, so the catalog read leaves it out of plans, import and
18
+ * prune.
19
+ *
20
+ * Postgres adds what ClickHouse could not: the receipt is written in the
21
+ * batch's own transaction, after its `UPDATE`, and commits with it. The
22
+ * receipt is still written last and on success only, and a run killed
23
+ * anywhere leaves either the batch and its receipt or neither, so a resumed
24
+ * backfill never updates a batch twice. Each expectation is bound to the
25
+ * table's oid and the new column's attribute number, which a column dropped
26
+ * by onFailure and added again does not keep, so an old receipt reads as
27
+ * stale; onFailure and the contract also delete the migration's receipts.
28
+ *
29
+ * The store implements the shared core's `SqlReceiptStore`
30
+ * (`../../core/receipts.ts`) over the migration's own connection, which the
31
+ * backfill reaches directly. A sql project's `effect()` receipts use the same
32
+ * store over `chant_receipts.receipts` (`../../receipts.ts`).
33
+ */
34
+ import { OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
35
+ import { COMMENT_OWNERSHIP_KEYS, hasChantTrailerKey, RECEIPTS_TRAILER_KEY } from "../../core/ownership.js";
36
+ import { receiptAddress } from "../../core/receipts.js";
37
+ import { quoteIdent } from "../keywords.js";
38
+ /** The receipts table, in the migrated table's schema. */
39
+ export const POSTGRES_RECEIPTS_TABLE = "__chant_receipts";
40
+ const COMMENT = `chant effect receipts [chant ${COMMENT_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE} ${RECEIPTS_TRAILER_KEY}=effects]`;
41
+ export { receiptAddress } from "../../core/receipts.js";
42
+ /** A receipts table chant did not make: a user's table under the same name. */
43
+ export class ReceiptsTableConflict extends Error {
44
+ constructor(schema, table = POSTGRES_RECEIPTS_TABLE) {
45
+ super(`${schema}.${table} exists and is not chant's receipts table (its comment carries no receipts marker). ` +
46
+ `chant keeps its receipts under that name, so it stops rather than write into it; rename the table.`);
47
+ this.name = "ReceiptsTableConflict";
48
+ }
49
+ }
50
+ /**
51
+ * A receipt store in `<schema>.__chant_receipts`, over `client`. `write` runs
52
+ * on the client as it is, so a write inside the caller's transaction commits
53
+ * or rolls back with it. `identity` is the project's ownership stack and env,
54
+ * which every address starts with. `table` names the table in `schema`,
55
+ * `__chant_receipts` by default (`../../receipts.ts` keeps `effect()`
56
+ * receipts in `chant_receipts.receipts`).
57
+ */
58
+ export function postgresReceiptStore(client, schema, identity, opts = {}) {
59
+ const name = opts.table ?? POSTGRES_RECEIPTS_TABLE;
60
+ const table = `${quoteIdent(schema)}.${quoteIdent(name)}`;
61
+ let ensured = false;
62
+ const comment = async () => {
63
+ const [row] = await client.query("SELECT pg_catalog.to_regclass($1) IS NOT NULL AS present, pg_catalog.obj_description(pg_catalog.to_regclass($1), 'pg_class') AS comment", [table]);
64
+ return row?.present ? (row.comment ?? null) : undefined;
65
+ };
66
+ /** Whether there is a table to read; one that is not chant's is refused. */
67
+ const readable = async () => {
68
+ if (ensured)
69
+ return true;
70
+ const c = await comment();
71
+ if (c === undefined)
72
+ return false;
73
+ if (!hasChantTrailerKey(c ?? undefined, [RECEIPTS_TRAILER_KEY]))
74
+ throw new ReceiptsTableConflict(schema, name);
75
+ return true;
76
+ };
77
+ const address = (receipt) => receiptAddress(identity, receipt.effect);
78
+ return {
79
+ table,
80
+ async ensure() {
81
+ if (ensured)
82
+ return;
83
+ if (!(await readable())) {
84
+ await client.query(`CREATE TABLE IF NOT EXISTS ${table} (address text PRIMARY KEY, effect text NOT NULL, expectation text NOT NULL, run_id text NOT NULL DEFAULT '', written_at timestamptz NOT NULL DEFAULT pg_catalog.now())`);
85
+ await client.query(`COMMENT ON TABLE ${table} IS '${COMMENT.replace(/'/g, "''")}'`);
86
+ }
87
+ ensured = true;
88
+ },
89
+ async read(receipt) {
90
+ if (!(await readable()))
91
+ return undefined;
92
+ const [row] = await client.query(`SELECT expectation FROM ${table} WHERE address = $1`, [address(receipt)]);
93
+ return row?.expectation;
94
+ },
95
+ async readAll(prefix) {
96
+ if (!(await readable()))
97
+ return new Map();
98
+ const rows = await client.query(`SELECT address, expectation FROM ${table} WHERE starts_with(address, $1)`, [prefix]);
99
+ return new Map(rows.map((r) => [r.address, r.expectation]));
100
+ },
101
+ async write(receipt, expectation) {
102
+ await client.query(`INSERT INTO ${table} (address, effect, expectation, run_id) VALUES ($1, $2, $3, $4) ` +
103
+ "ON CONFLICT (address) DO UPDATE SET effect = EXCLUDED.effect, expectation = EXCLUDED.expectation, run_id = EXCLUDED.run_id, written_at = pg_catalog.now()", [address(receipt), receipt.effect, expectation, opts.runId ?? ""]);
104
+ },
105
+ async forget(prefix) {
106
+ if (!(await readable()))
107
+ return 0;
108
+ const deleted = await client.query(`DELETE FROM ${table} WHERE starts_with(address, $1) RETURNING address`, [prefix]);
109
+ const [left] = await client.query(`SELECT count(*)::int AS n FROM ${table}`);
110
+ if (left?.n === 0) {
111
+ await client.query(`DROP TABLE IF EXISTS ${table}`);
112
+ ensured = false;
113
+ }
114
+ return deleted.length;
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Replication around an expand-and-contract migration (#3281): the backfill
3
+ * pauses while a replica is behind, and a table that logical replication
4
+ * publishes is refused.
5
+ *
6
+ * ## Lag
7
+ *
8
+ * A backfill writes every row of the table once more, and each batch's WAL
9
+ * goes to every standby and every logical subscriber. Before each batch the
10
+ * backfill reads `pg_stat_replication` on the primary
11
+ * (https://www.postgresql.org/docs/18/monitoring-stats.html#MONITORING-PG-STAT-REPLICATION-VIEW),
12
+ * one row per WAL sender, and waits while any `replay_lag` is above the
13
+ * bound, up to a deadline, then stops naming the replica. `replay_lag` is
14
+ * NULL once a standby has caught up and nothing new is being sent, which is
15
+ * no lag. No rows means no replicas, and the check passes at once: a single
16
+ * server, or a managed one whose replicas are not WAL senders (Aurora's
17
+ * share storage), skips cleanly.
18
+ *
19
+ * A role that is neither superuser nor a member of `pg_monitor` sees the
20
+ * rows with every column but the process's own NULL, so the lag cannot be
21
+ * read; that stops the backfill with the grant to make, rather than go on
22
+ * blind. `replicationLag: false` on the Op turns the check off.
23
+ *
24
+ * ## Publications
25
+ *
26
+ * A logical replication subscriber applies each published row change by
27
+ * column name, and its own table must have every column the publisher
28
+ * sends ("Logical Replication", Restrictions and Column Lists,
29
+ * https://www.postgresql.org/docs/18/logical-replication-col-lists.html).
30
+ * The expand adds a column, so a publication that sends the whole table
31
+ * would fail the subscriber's apply at the first backfilled row, and a
32
+ * rename or a type change would need the same change on the subscriber at
33
+ * the same moment as the switch. The Plan phase therefore refuses a table a
34
+ * publication sends the migrated column of, naming each publication. A
35
+ * publication with a column list (15 and later) that leaves the column out
36
+ * sends neither it nor the new one, and does not stop the migration.
37
+ */
38
+ export const DEFAULT_REPLICATION_LAG = { maxLagMs: 10000, waitMs: 30 * 60000 };
39
+ /** A replica that stayed behind past the wait, or whose lag this role cannot read. */
40
+ export class ReplicationLagError extends Error {
41
+ constructor(message) {
42
+ super(message);
43
+ this.name = "ReplicationLagError";
44
+ }
45
+ }
46
+ /** The WAL senders on this server and how far behind each replica's replay is. */
47
+ export async function replicaLags(client) {
48
+ const rows = await client.query(`SELECT COALESCE(NULLIF(application_name, ''), client_addr::text, pid::text) AS name, state,
49
+ (EXTRACT(EPOCH FROM replay_lag) * 1000)::bigint AS lag_ms
50
+ FROM pg_catalog.pg_stat_replication`);
51
+ return rows.map((r) => ({ name: r.name, ...(r.state !== null ? { lagMs: r.lag_ms === null ? 0 : Number(r.lag_ms) } : {}) }));
52
+ }
53
+ /**
54
+ * Wait until every replica is within the bound. Returns how long it waited,
55
+ * in ms (0 when no replica was behind, or there are none).
56
+ */
57
+ export async function waitForReplicas(client, bound, opts = {}) {
58
+ const now = opts.now ?? Date.now;
59
+ const sleep = opts.sleep ?? sleepFor;
60
+ const started = now();
61
+ for (;;) {
62
+ opts.signal?.throwIfAborted();
63
+ const lags = await replicaLags(client);
64
+ const unreadable = lags.filter((l) => l.lagMs === undefined);
65
+ if (unreadable.length > 0) {
66
+ throw new ReplicationLagError(`pg_stat_replication lists ${unreadable.length} replica(s) (${unreadable.map((l) => l.name).join(", ")}) whose lag this role cannot read. ` +
67
+ `Grant it pg_monitor (GRANT pg_monitor TO <role>) so the backfill can pause while a replica is behind, or set replicationLag: false on the Op to run without the check.`);
68
+ }
69
+ const behind = lags.filter((l) => l.lagMs > bound.maxLagMs);
70
+ if (behind.length === 0)
71
+ return now() - started;
72
+ const waited = now() - started;
73
+ const shown = behind.map((l) => `${l.name} ${Math.round(l.lagMs / 1000)}s`).join(", ");
74
+ if (waited >= bound.waitMs) {
75
+ throw new ReplicationLagError(`waited ${Math.round(waited / 1000)}s for replicas to come within ${Math.round(bound.maxLagMs / 1000)}s of the primary and they did not: ${shown} behind. ` +
76
+ `The backfill stops here; its receipts keep the batches it filled, and the next run goes on from there.`);
77
+ }
78
+ opts.log?.(`-- replicas behind (${shown}); pausing the backfill`);
79
+ await sleep(Math.min(1000, bound.waitMs - waited), opts.signal);
80
+ }
81
+ }
82
+ /**
83
+ * The publications that send `column` of `schema.table`. On 15 and later a
84
+ * publication with a column list that leaves the column out is not one;
85
+ * before 15 there are no column lists, and any publication of the table is.
86
+ */
87
+ export async function publicationsOf(client, schema, table, column, major) {
88
+ if (major >= 15) {
89
+ const rows = await client.query(`SELECT pt.pubname, pt.attnames,
90
+ EXISTS (SELECT 1 FROM pg_catalog.pg_publication p JOIN pg_catalog.pg_publication_rel r ON r.prpubid = p.oid
91
+ WHERE p.pubname = pt.pubname AND r.prrelid = pg_catalog.to_regclass(pg_catalog.quote_ident($1) || '.' || pg_catalog.quote_ident($2)) AND r.prattrs IS NOT NULL) AS listed
92
+ FROM pg_catalog.pg_publication_tables pt WHERE pt.schemaname = $1 AND pt.tablename = $2 ORDER BY pt.pubname`, [schema, table]);
93
+ return rows.filter((r) => !r.listed || (r.attnames ?? []).includes(column)).map((r) => ({ name: r.pubname, allColumns: !r.listed }));
94
+ }
95
+ const rows = await client.query("SELECT pubname FROM pg_catalog.pg_publication_tables WHERE schemaname = $1 AND tablename = $2 ORDER BY pubname", [schema, table]);
96
+ return rows.map((r) => ({ name: r.pubname, allColumns: true }));
97
+ }
98
+ function sleepFor(ms, signal) {
99
+ return new Promise((resolve, reject) => {
100
+ const t = setTimeout(resolve, ms);
101
+ signal?.addEventListener("abort", () => {
102
+ clearTimeout(t);
103
+ reject(signal.reason);
104
+ }, { once: true });
105
+ });
106
+ }