@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,9 @@
10
10
  * parts until `MATERIALIZE INDEX`).
11
11
  * - `rewrite`: the change is a mutation that rewrites existing parts in the
12
12
  * background (`system.mutations`), with no rollback.
13
+ * - `replace`: ClickHouse cannot alter the object, but it holds no rows to
14
+ * keep, so the applier drops and creates it, around the views that read it
15
+ * (a stream-engine table, #4396). Nothing is copied.
13
16
  * - `rebuild`: ClickHouse cannot make the change to the table it has; the data
14
17
  * has to be copied into a new table. A plan refuses to make one in place.
15
18
  *
@@ -18,16 +21,16 @@
18
21
 
19
22
  import { classifierRule, type ChangeClasses, type ClassifierRule as SqlClassifierRule } from "../../core/classifier";
20
23
 
21
- export type ChangeClass = "metadata" | "rewrite" | "rebuild" | "create" | "drop";
24
+ export type ChangeClass = "metadata" | "rewrite" | "replace" | "rebuild" | "create" | "drop";
22
25
 
23
26
  /** A ClickHouse rule: the shared core's rule shape over these classes, citing a clickhouse.com page. */
24
27
  export type ClassifierRule = SqlClassifierRule<ChangeClass>;
25
28
 
26
29
  /** The classes as data: report order and labels, and what each costs for `classifyDisruption()`. */
27
30
  export const CHANGE_CLASSES: ChangeClasses<ChangeClass> = {
28
- order: ["create", "metadata", "rewrite", "rebuild", "drop"],
29
- label: { create: "create", drop: "drop", metadata: "metadata only", rewrite: "background rewrite", rebuild: "REBUILD" },
30
- disruption: { metadata: "in-place", rewrite: "rolling", rebuild: "replace", create: "in-place", drop: "destroy" },
31
+ order: ["create", "metadata", "rewrite", "replace", "rebuild", "drop"],
32
+ label: { create: "create", drop: "drop", metadata: "metadata only", rewrite: "background rewrite", replace: "replace table", rebuild: "REBUILD" },
33
+ disruption: { metadata: "in-place", rewrite: "rolling", replace: "replace", rebuild: "replace", create: "in-place", drop: "destroy" },
31
34
  };
32
35
 
33
36
  const DOCS = "https://clickhouse.com/docs/sql-reference/statements";
@@ -192,6 +195,13 @@ export const CLASSIFIER_RULES = {
192
195
  "A table cannot become a view or a view a table; the old object is dropped and the new one created.",
193
196
  `${DOCS}/create`,
194
197
  ),
198
+ SQLCH225: rule(
199
+ "SQLCH225",
200
+ "replace",
201
+ "Change a stream table",
202
+ "A Kafka, S3Queue, AzureQueue, RabbitMQ, NATS or FileLog table stores no rows and has no ALTER for its engine arguments, settings or columns; the views that read it are detached, the table is dropped and created, and the views are attached again. Its position (a Kafka group's offsets, an S3Queue's record of processed files) is kept by the broker or Keeper, not by the table.",
203
+ `${DOCS}/create/table`,
204
+ ),
195
205
  SQLCH230: rule(
196
206
  "SQLCH230",
197
207
  "metadata",
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Replacing an S3Queue table against the pinned server (#4396): a change to
3
+ * the table's settings is planned as SQLCH225, the apply detaches the view,
4
+ * drops and creates the table and attaches the view, and the view keeps
5
+ * feeding the target with no file processed twice. A different keeper_path
6
+ * plans with the reprocessing warning.
7
+ *
8
+ * Needs Docker; skips cleanly without it. A one-server cluster (its embedded
9
+ * Keeper is what S3Queue records processed files in) and a MinIO container on
10
+ * its network as the object store, both removed afterwards even on failure.
11
+ * The credentials are MinIO's published test values, held in a named
12
+ * collection on the server (#4395) and never in the declaration.
13
+ */
14
+
15
+ import { execFile } from "node:child_process";
16
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { promisify } from "node:util";
20
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
21
+ import { expandComposite } from "@intentius/chant/composite";
22
+ import type { Declarable } from "@intentius/chant/declarable";
23
+ import type { SerializerResult } from "@intentius/chant/serializer";
24
+ import { dockerAvailable, startScratchCluster, type ScratchCluster } from "../container";
25
+ import { clickhouseQuery } from "../http";
26
+ import { clickhouseImage } from "../../spec/pin";
27
+ import { sqlSerializer } from "../../serializer";
28
+ import { StreamIngest } from "../../composites";
29
+ import { bindClickHouse } from "../live/bind";
30
+ import { applyClickHouse, declaredObjects } from "../apply/apply";
31
+ import { planAgainstServer } from "./commands";
32
+ import { renderDiff } from "./report";
33
+
34
+ const exec = promisify(execFile);
35
+ const enabled = await dockerAvailable();
36
+ let cluster: ScratchCluster | undefined;
37
+ let minio: string | undefined;
38
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-s3queue-"));
39
+ const buildFile = join(dir, "files.json");
40
+
41
+ const MINIO_IMAGE = "minio/minio:RELEASE.2025-04-22T22-12-26Z";
42
+ const q = <T = unknown>(sql: string) => clickhouseQuery<T>(cluster!.replicas[0]!, sql);
43
+ const env = () => ({ config: {}, env: { CLICKHOUSE_URL: cluster!.replicas[0]!.url } });
44
+
45
+ beforeAll(async () => {
46
+ if (!enabled) return;
47
+ cluster = await startScratchCluster(clickhouseImage(), { replicas: 1, namePrefix: "chant-sql-s3queue" });
48
+ minio = `${cluster.name}-minio`;
49
+ await exec("docker", [
50
+ "run", "-d", "--name", minio, "--network", `${cluster.name}-net`,
51
+ "-e", "MINIO_ROOT_USER=minioadmin", "-e", "MINIO_ROOT_PASSWORD=minioadmin",
52
+ MINIO_IMAGE, "server", "/data",
53
+ ], { timeout: 600_000 });
54
+ await exec("docker", ["exec", minio, "mkdir", "-p", "/data/lake"]);
55
+ await q(`CREATE NAMED COLLECTION lake_creds AS url = 'http://${minio}:9000/lake/in/*.csv', access_key_id = 'minioadmin', secret_access_key = 'minioadmin'`);
56
+ await q("CREATE DATABASE shop");
57
+ }, 900_000);
58
+
59
+ afterAll(async () => {
60
+ if (minio) await exec("docker", ["rm", "-f", minio]).catch(() => undefined);
61
+ await cluster?.stop();
62
+ rmSync(dir, { recursive: true, force: true });
63
+ });
64
+
65
+ /** The ingest chain with the S3Queue table's settings as given. */
66
+ function buildOf(settings: string): string {
67
+ const entities = new Map<string, Declarable>();
68
+ for (const [k, v] of expandComposite(
69
+ "files",
70
+ StreamIngest({
71
+ name: "shop.files",
72
+ columns: "id UInt64, v String",
73
+ engine: "S3Queue(lake_creds, format = 'CSV')",
74
+ settings,
75
+ orderBy: "id",
76
+ }),
77
+ )) {
78
+ entities.set(k, v);
79
+ }
80
+ return (sqlSerializer.serialize(entities) as SerializerResult).primary;
81
+ }
82
+
83
+ const SETTINGS = (maxMs: number, path = "/chant/e2e/files") =>
84
+ `mode = 'unordered', keeper_path = '${path}', s3queue_polling_min_timeout_ms = 100, s3queue_polling_max_timeout_ms = ${maxMs}`;
85
+
86
+ async function putFile(name: string, rows: string): Promise<void> {
87
+ await q(`INSERT INTO FUNCTION s3('http://${minio}:9000/lake/in/${name}', 'minioadmin', 'minioadmin', 'CSV', 'id UInt64, v String') VALUES ${rows}`);
88
+ }
89
+
90
+ async function waitForRows(n: number): Promise<void> {
91
+ const deadline = Date.now() + 90_000;
92
+ for (;;) {
93
+ const [row] = await q<{ n: string }>("SELECT count() AS n FROM shop.files");
94
+ if (Number(row?.n) >= n) return;
95
+ if (Date.now() > deadline) throw new Error(`shop.files held ${row?.n} rows, wanted ${n}`);
96
+ await new Promise((r) => setTimeout(r, 500));
97
+ }
98
+ }
99
+
100
+ async function apply(json: string) {
101
+ const target = await bindClickHouse({ environment: "e2e", ...env() });
102
+ return applyClickHouse(target, declaredObjects(json, "default", target.topology));
103
+ }
104
+
105
+ describe.skipIf(!enabled)("replacing an S3Queue table", () => {
106
+ test("the view keeps feeding the target, and no file is processed twice", async () => {
107
+ await apply(buildOf(SETTINGS(500)));
108
+ await putFile("f1.csv", "(1, 'a'), (2, 'b')");
109
+ await putFile("f2.csv", "(3, 'c')");
110
+ await waitForRows(3);
111
+ const [before] = await q<{ uuid: string }>("SELECT toString(uuid) AS uuid FROM system.tables WHERE database = 'shop' AND name = 'files_stream'");
112
+
113
+ // The settings change: planned as a stream-table replacement, not a rebuild, with nothing refused.
114
+ const next = buildOf(SETTINGS(1000));
115
+ writeFileSync(buildFile, next);
116
+ const diff = await planAgainstServer("e2e", buildFile, env());
117
+ expect(diff.changes.map((c) => [c.rule, c.field])).toEqual([["SQLCH225", "settings.s3queue_polling_max_timeout_ms"]]);
118
+ expect(diff.rebuilds).toEqual([]);
119
+ expect(diff.changes[0]!.note).toBeUndefined();
120
+ expect(diff.hints.join("\n")).toMatch(/shop\.files_stream is replaced: shop\.files_mv reads it/);
121
+
122
+ const outcome = await apply(next);
123
+ expect(outcome.failed).toEqual([]);
124
+ const replaced = outcome.applied.find((a) => a.name === "shop.files_stream");
125
+ expect(replaced?.statements.map((s) => s.split(" ").slice(0, 2).join(" "))).toEqual(["DETACH TABLE", "DROP TABLE", "CREATE TABLE", "ATTACH TABLE"]);
126
+
127
+ const [after] = await q<{ uuid: string }>("SELECT toString(uuid) AS uuid FROM system.tables WHERE database = 'shop' AND name = 'files_stream'");
128
+ expect(after!.uuid).not.toBe(before!.uuid);
129
+ const [view] = await q<{ n: string }>("SELECT count() AS n FROM system.tables WHERE database = 'shop' AND name = 'files_mv'");
130
+ expect(Number(view!.n)).toBe(1);
131
+
132
+ // A file that lands after the replacement is read by the new table through the same view.
133
+ await putFile("f3.csv", "(4, 'd'), (5, 'e')");
134
+ await waitForRows(5);
135
+ // Give the new table the chance to read f1 and f2 again, which it must not.
136
+ await new Promise((r) => setTimeout(r, 5000));
137
+ const [rows] = await q<{ n: string; distinct: string }>("SELECT count() AS n, uniqExact(id) AS distinct FROM shop.files");
138
+ expect([Number(rows!.n), Number(rows!.distinct)]).toEqual([5, 5]);
139
+ expect((await planAgainstServer("e2e", buildFile, env())).changes).toEqual([]);
140
+ }, 300_000);
141
+
142
+ test("a different keeper_path plans with the reprocessing warning", async () => {
143
+ writeFileSync(buildFile, buildOf(SETTINGS(1000, "/chant/e2e/files-v2")));
144
+ const diff = await planAgainstServer("e2e", buildFile, env());
145
+ expect(diff.changes.map((c) => [c.rule, c.field])).toEqual([["SQLCH225", "settings.keeper_path"]]);
146
+ expect(renderDiff(diff)).toMatch(/every file already seen is processed again/);
147
+ }, 120_000);
148
+ });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Stream engines (#4396): a change to a Kafka or S3Queue table is SQLCH225
3
+ * and not SQLCH223, the plan holds nothing back for it, the statements go
4
+ * detach, drop, create, attach, and a replacement that loses position says so.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { canonicalObject } from "./normalize";
9
+ import { diffSchemas, type SchemaObject } from "./diff";
10
+ import { renderDiff } from "./report";
11
+ import { classifyDisruption } from "./disruption";
12
+ import { CLASSIFIER_RULES } from "./rules";
13
+ import { rebuildOpSuggestions } from "./rebuild-handoff";
14
+ import { isStreamEngine, readsTable, STREAM_ENGINES, streamEngineArguments } from "./stream";
15
+ import { planStatements, type DeclaredObject } from "../apply/statements";
16
+ import { CLICKHOUSE_ENTITY_TYPES } from "../entities";
17
+ import { observeRebuild, RebuildRefusal } from "../rebuild/observe";
18
+ import type { ClickHouseTarget } from "../live/bind";
19
+
20
+ const KAFKA = (topics: string, group = "ingest", extra = "") =>
21
+ `CREATE TABLE shop.events_stream (user_id UInt64, kind String) ENGINE = Kafka(kafka_creds) SETTINGS kafka_topic_list = '${topics}', kafka_group_name = '${group}', kafka_format = 'JSONEachRow'${extra}`;
22
+ const S3QUEUE = (path: string, mode = "unordered") =>
23
+ `CREATE TABLE shop.files_stream (user_id UInt64) ENGINE = S3Queue(lake_creds) SETTINGS mode = '${mode}', keeper_path = '${path}'`;
24
+ const MV = "CREATE MATERIALIZED VIEW shop.events_mv TO shop.events AS SELECT user_id, kind FROM shop.events_stream";
25
+ const MV_OTHER = "CREATE MATERIALIZED VIEW shop.audit_mv TO shop.audit AS SELECT user_id FROM events_stream";
26
+ const MV_UNRELATED = "CREATE MATERIALIZED VIEW shop.other_mv TO shop.other AS SELECT user_id FROM shop.users";
27
+
28
+ const obj = (key: string, ddl: string): SchemaObject => ({ key, canonical: canonicalObject(ddl) });
29
+ const one = (key: string, before: string, after: string) => diffSchemas([obj(key, before)], [obj(key, after)]);
30
+
31
+ describe("stream engines", () => {
32
+ test("the six engines are the ones the pinned catalog has", () => {
33
+ expect([...STREAM_ENGINES].sort()).toEqual(["AzureQueue", "FileLog", "Kafka", "NATS", "RabbitMQ", "S3Queue"]);
34
+ expect(isStreamEngine("MergeTree")).toBe(false);
35
+ expect(isStreamEngine(undefined)).toBe(false);
36
+ });
37
+
38
+ test("the rule is its own class, with a rule id in the SQLCH2xx range", () => {
39
+ expect(CLASSIFIER_RULES.SQLCH225.class).toBe("replace");
40
+ expect(CLASSIFIER_RULES.SQLCH223.class).toBe("rebuild");
41
+ });
42
+ });
43
+
44
+ describe("classification", () => {
45
+ test("a different topic list is SQLCH225, not SQLCH223, and the plan refuses nothing", () => {
46
+ const diff = one("events_stream", KAFKA("events"), KAFKA("events,clicks"));
47
+ expect(diff.changes.map((c) => [c.rule, c.class, c.field])).toEqual([["SQLCH225", "replace", "settings.kafka_topic_list"]]);
48
+ expect(diff.rebuilds).toEqual([]);
49
+ expect(rebuildOpSuggestions(diff, new Map([["events_stream", canonicalObject(KAFKA("events,clicks"))]]), "prod")).toEqual([]);
50
+ });
51
+
52
+ test("a topic list given as an engine argument is the engine changing", () => {
53
+ const positional = (topic: string) => `CREATE TABLE shop.q (a String) ENGINE = Kafka('broker:9092', '${topic}', 'g', 'JSONEachRow')`;
54
+ const diff = one("q", positional("a"), positional("b"));
55
+ expect(diff.changes.map((c) => [c.rule, c.field])).toEqual([["SQLCH225", "engine"]]);
56
+ });
57
+
58
+ test("a format, a column and a setting added are all the stream rule", () => {
59
+ const diff = one(
60
+ "events_stream",
61
+ KAFKA("events"),
62
+ KAFKA("events", "ingest", ", kafka_num_consumers = 2").replace("kafka_format = 'JSONEachRow'", "kafka_format = 'CSV'").replace("kind String", "kind String, ts DateTime"),
63
+ );
64
+ expect(new Set(diff.changes.map((c) => c.rule))).toEqual(new Set(["SQLCH225"]));
65
+ expect(diff.changes.map((c) => c.field).sort()).toEqual(["columns.ts", "settings.kafka_format", "settings.kafka_num_consumers"]);
66
+ });
67
+
68
+ test("S3Queue, RabbitMQ and NATS tables are stream tables too", () => {
69
+ expect(one("f", S3QUEUE("/q/a"), S3QUEUE("/q/a", "ordered")).changes.map((c) => c.rule)).toEqual(["SQLCH225"]);
70
+ const nats = (subject: string) => `CREATE TABLE shop.n (a String) ENGINE = NATS(nats_creds) SETTINGS nats_subjects = '${subject}', nats_format = 'JSONEachRow'`;
71
+ expect(one("n", nats("a"), nats("b")).changes.map((c) => c.rule)).toEqual(["SQLCH225"]);
72
+ const rabbit = (queue: string) => `CREATE TABLE shop.r (a String) ENGINE = RabbitMQ(rabbit_creds) SETTINGS rabbitmq_queue_base = '${queue}', rabbitmq_format = 'JSONEachRow'`;
73
+ expect(one("r", rabbit("a"), rabbit("b")).changes.map((c) => c.rule)).toEqual(["SQLCH225"]);
74
+ });
75
+
76
+ test("a comment alone stays metadata", () => {
77
+ const diff = one("events_stream", KAFKA("events"), `${KAFKA("events")} COMMENT 'ingest'`);
78
+ expect(diff.changes.map((c) => c.rule)).toEqual(["SQLCH203"]);
79
+ });
80
+
81
+ test("a MergeTree engine change is still SQLCH223", () => {
82
+ const mt = (v: string) => `CREATE TABLE shop.t (a UInt64, v UInt64) ENGINE = ReplacingMergeTree(${v}) ORDER BY a`;
83
+ expect(one("t", mt("v"), mt("a")).changes.map((c) => c.rule)).toEqual(["SQLCH223"]);
84
+ });
85
+
86
+ test("a table that becomes a stream table is a rebuild with a note, and gets no Op suggestion", () => {
87
+ const mt = "CREATE TABLE shop.events_stream (user_id UInt64, kind String) ENGINE = MergeTree ORDER BY user_id";
88
+ const diff = one("events_stream", mt, KAFKA("events"));
89
+ expect(diff.changes[0]).toMatchObject({ rule: "SQLCH223", class: "rebuild" });
90
+ expect(diff.changes[0]!.note).toMatch(/stream engine/);
91
+ expect(rebuildOpSuggestions(diff, new Map([["events_stream", canonicalObject(KAFKA("events"))]]), "prod")).toEqual([]);
92
+ });
93
+
94
+ test("the report counts the class and names the views that are detached around the replacement", () => {
95
+ const diff = diffSchemas(
96
+ [obj("events_stream", KAFKA("events")), obj("events_mv", MV), obj("audit_mv", MV_OTHER), obj("other_mv", MV_UNRELATED)],
97
+ [obj("events_stream", KAFKA("clicks")), obj("events_mv", MV), obj("audit_mv", MV_OTHER), obj("other_mv", MV_UNRELATED)],
98
+ );
99
+ const text = renderDiff(diff);
100
+ expect(text).toMatch(/\[replace table\] settings\.kafka_topic_list/);
101
+ expect(text).toMatch(/SQLCH225 Change a stream table/);
102
+ expect(text).toMatch(/1 replace table/);
103
+ expect(text).toMatch(/hint: shop\.events_stream is replaced: shop\.events_mv, shop\.audit_mv read it and are detached first and attached after/);
104
+ expect(text).not.toMatch(/Refused/);
105
+ });
106
+ });
107
+
108
+ describe("position", () => {
109
+ test("a different kafka_group_name warns, naming both groups", () => {
110
+ const [c] = one("events_stream", KAFKA("events", "ingest"), KAFKA("events", "ingest-v2")).changes;
111
+ expect(c!.rule).toBe("SQLCH225");
112
+ expect(c!.note).toMatch(/loses its position: kafka_group_name changes from ingest to ingest-v2/);
113
+ expect(c!.note).toMatch(/committed offsets, or from the start of the topic/);
114
+ });
115
+
116
+ test("a group given as an engine argument is read too", () => {
117
+ const positional = (g: string) => `CREATE TABLE shop.q (a String) ENGINE = Kafka('broker:9092', 't', '${g}', 'JSONEachRow')`;
118
+ expect(one("q", positional("g1"), positional("g2")).changes[0]!.note).toMatch(/from g1 to g2/);
119
+ });
120
+
121
+ test("a topic list change keeps the group, so there is no warning", () => {
122
+ expect(one("events_stream", KAFKA("events"), KAFKA("clicks")).changes[0]!.note).toBeUndefined();
123
+ });
124
+
125
+ test("a different S3Queue keeper_path warns that every file is processed again", () => {
126
+ const [c] = one("files_stream", S3QUEUE("/q/a"), S3QUEUE("/q/b")).changes;
127
+ expect(c!.rule).toBe("SQLCH225");
128
+ expect(c!.note).toMatch(/keeper_path changes from \/q\/a to \/q\/b: .*every file already seen is processed again/);
129
+ });
130
+
131
+ test("the same keeper_path with another mode does not warn", () => {
132
+ expect(one("files_stream", S3QUEUE("/q/a"), S3QUEUE("/q/a", "ordered")).changes[0]!.note).toBeUndefined();
133
+ });
134
+ });
135
+
136
+ describe("reading a table", () => {
137
+ const table = canonicalObject(KAFKA("events"));
138
+ test("a view reads it by a bare name in its own database, or by a qualified one", () => {
139
+ expect(readsTable(canonicalObject(MV), table)).toBe(true);
140
+ expect(readsTable(canonicalObject(MV_OTHER), table)).toBe(true);
141
+ expect(readsTable(canonicalObject("CREATE MATERIALIZED VIEW audit.mv TO audit.t AS SELECT a FROM shop.events_stream"), table)).toBe(true);
142
+ });
143
+ test("a view of another table, another database's table of the same name, or a plain view is not a reader", () => {
144
+ expect(readsTable(canonicalObject(MV_UNRELATED), table)).toBe(false);
145
+ expect(readsTable(canonicalObject("CREATE MATERIALIZED VIEW audit.mv TO audit.t AS SELECT a FROM events_stream"), table)).toBe(false);
146
+ expect(readsTable(canonicalObject("CREATE VIEW shop.v AS SELECT a FROM shop.events_stream"), table)).toBe(false);
147
+ expect(readsTable(canonicalObject("CREATE MATERIALIZED VIEW shop.mv TO shop.t AS SELECT a FROM shop.events_stream_old"), table)).toBe(false);
148
+ });
149
+ test("engine arguments split outside quotes and parentheses", () => {
150
+ expect(streamEngineArguments("Kafka('a, b:9092', 't', 'g', 'JSONEachRow')")).toEqual(["a, b:9092", "t", "g", "JSONEachRow"]);
151
+ expect(streamEngineArguments("Kafka")).toEqual([]);
152
+ });
153
+ });
154
+
155
+ const declared = (key: string, type: DeclaredObject["type"], ddl: string): DeclaredObject => ({ exportName: key, type, key, ddl, canonical: canonicalObject(ddl), dependsOn: [] });
156
+
157
+ describe("the statements", () => {
158
+ const before = [KAFKA("events"), MV, MV_OTHER, MV_UNRELATED].map((d) => canonicalObject(d));
159
+ const names = ["shop.events_stream", "shop.events_mv", "shop.audit_mv", "shop.other_mv"];
160
+ const current = new Map(names.map((n, i) => [n, before[i]!]));
161
+ const after = [
162
+ declared("shop.events_stream", CLICKHOUSE_ENTITY_TYPES.table, KAFKA("clicks")),
163
+ declared("shop.events_mv", CLICKHOUSE_ENTITY_TYPES.materializedView, MV),
164
+ declared("shop.audit_mv", CLICKHOUSE_ENTITY_TYPES.materializedView, MV_OTHER),
165
+ declared("shop.other_mv", CLICKHOUSE_ENTITY_TYPES.materializedView, MV_UNRELATED),
166
+ ];
167
+ const changes = diffSchemas(
168
+ names.map((n, i) => ({ key: n, canonical: before[i]! })),
169
+ after.map((o) => ({ key: o.key, canonical: o.canonical })),
170
+ ).changes;
171
+ const stepsOf = (plan: ReturnType<typeof planStatements>, i = 0) => (plan.objects[i] as { steps: { sql: string; rule: string; class: string }[] }).steps;
172
+
173
+ test("detach the views that read the table, replace it, attach them again", () => {
174
+ const plan = planStatements({ declared: after, changes, current });
175
+ expect(plan.objects[0]!.verdict).toBe("alter");
176
+ expect(stepsOf(plan).map((s) => s.sql.replace(/\s+/g, " "))).toEqual([
177
+ "DETACH TABLE `shop`.`events_mv`",
178
+ "DETACH TABLE `shop`.`audit_mv`",
179
+ "DROP TABLE `shop`.`events_stream` SYNC",
180
+ expect.stringMatching(/^CREATE TABLE shop\.events_stream \(user_id UInt64, kind String\) ENGINE = Kafka\(kafka_creds\) SETTINGS kafka_topic_list = 'clicks', .* COMMENT '.*managed-by=chant/),
181
+ "ATTACH TABLE `shop`.`events_mv`",
182
+ "ATTACH TABLE `shop`.`audit_mv`",
183
+ ]);
184
+ expect(stepsOf(plan).every((s) => s.rule === "SQLCH225" && s.class === "replace")).toBe(true);
185
+ });
186
+
187
+ test("the views themselves have nothing to do, and the unrelated one is not touched", () => {
188
+ const plan = planStatements({ declared: after, changes, current });
189
+ expect(plan.objects.slice(1).map((o) => [o.verdict, (o as { steps: unknown[] }).steps.length])).toEqual([
190
+ ["alter", 0],
191
+ ["alter", 0],
192
+ ["alter", 0],
193
+ ]);
194
+ });
195
+
196
+ test("a view the build drops in the same plan is not detached", () => {
197
+ const gone = [...changes, { object: "shop.audit_mv", field: "object", rule: "SQLCH250" as const, class: "drop" as const }];
198
+ const plan = planStatements({ declared: after.slice(0, 2), changes: gone, current });
199
+ expect(stepsOf(plan).filter((s) => s.sql.startsWith("DETACH")).map((s) => s.sql)).toEqual(["DETACH TABLE `shop`.`events_mv`"]);
200
+ });
201
+
202
+ test("on a cluster each statement runs ON CLUSTER", () => {
203
+ const plan = planStatements({ declared: after, changes, current, topology: { kind: "cluster", cluster: "main" } as never });
204
+ expect(stepsOf(plan).every((s) => /ON CLUSTER/.test(s.sql))).toBe(true);
205
+ });
206
+ });
207
+
208
+ describe("classifyDisruption", () => {
209
+ test("a stream engine's setting replaces; a MergeTree setting stays in place", () => {
210
+ const out = classifyDisruption({
211
+ environment: "prod",
212
+ changes: [
213
+ { name: "events_stream", type: "ClickHouse::Table", deltas: [{ path: "settings.kafka_topic_list" } as never] },
214
+ { name: "events", type: "ClickHouse::Table", deltas: [{ path: "settings.merge_with_ttl_timeout" } as never] },
215
+ ],
216
+ });
217
+ expect(out.events_stream).toMatchObject({ disruption: "replace" });
218
+ expect(out.events_stream!.detail).toMatch(/SQLCH225/);
219
+ expect(out.events).toMatchObject({ disruption: "in-place" });
220
+ });
221
+ });
222
+
223
+ describe("ClickHouseRebuildOp's observation", () => {
224
+ const target = { endpoint: { url: "http://unreachable.invalid" }, defaultDatabase: "shop" } as unknown as ClickHouseTarget;
225
+
226
+ test.each([
227
+ ["Kafka", KAFKA("events")],
228
+ ["S3Queue", S3QUEUE("/q/a")],
229
+ ])("refuses a %s table by name, before it asks the server anything", async (engine, ddl) => {
230
+ const o = declared("shop.t", CLICKHOUSE_ENTITY_TYPES.table, ddl);
231
+ const err = await observeRebuild(target, o, undefined).catch((e: unknown) => e);
232
+ expect(err).toBeInstanceOf(RebuildRefusal);
233
+ expect((err as Error).message).toContain(`the ${engine} engine`);
234
+ expect((err as Error).message).toMatch(/SQLCH225/);
235
+ expect((err as Error).message).toMatch(/stores no rows/);
236
+ });
237
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Stream engines (#4396): Kafka, S3Queue, AzureQueue, RabbitMQ, NATS and
3
+ * FileLog. A table on one of them stores no rows. It is a consumer: the
4
+ * materialized views that read it pull messages (or files) through it into a
5
+ * target, and the position it has reached lives outside the table, in the
6
+ * broker's committed offsets or in Keeper.
7
+ *
8
+ * What follows from that, and what this file holds:
9
+ *
10
+ * - There is no ALTER for the engine's arguments, and nothing to copy to a
11
+ * new table, so a change is neither `metadata` nor a `rebuild`: the table is
12
+ * replaced, with the views that read it detached around the replacement
13
+ * (SQLCH225, `./rules.ts`).
14
+ * - A replacement can lose the table's position. The two changes that do
15
+ * are found here and reported on the change as a note.
16
+ */
17
+
18
+ import type { CanonicalObject } from "./normalize";
19
+
20
+ /** The stream engines, as `system.tables.engine` names them. */
21
+ export const STREAM_ENGINES: ReadonlySet<string> = new Set(["Kafka", "S3Queue", "AzureQueue", "RabbitMQ", "NATS", "FileLog"]);
22
+
23
+ /** Whether an engine name is a stream engine. */
24
+ export const isStreamEngine = (engine: string | undefined): boolean => engine !== undefined && STREAM_ENGINES.has(engine);
25
+
26
+ /** Whether a canonical table is on a stream engine. */
27
+ export const isStreamTable = (o: CanonicalObject): boolean => o.kind === "table" && isStreamEngine(o.engineName);
28
+
29
+ /** The arguments inside an engine's parentheses, split at top-level commas, string quotes removed. Empty when it has none. */
30
+ export function streamEngineArguments(engine: string | undefined): string[] {
31
+ const open = engine?.indexOf("(") ?? -1;
32
+ if (engine === undefined || open < 0 || !engine.endsWith(")")) return [];
33
+ const inner = engine.slice(open + 1, -1);
34
+ const out: string[] = [];
35
+ let depth = 0;
36
+ let quote: string | undefined;
37
+ let cur = "";
38
+ for (let i = 0; i < inner.length; i++) {
39
+ const ch = inner[i]!;
40
+ if (quote) {
41
+ cur += ch;
42
+ if (ch === "\\") cur += inner[++i] ?? "";
43
+ else if (ch === quote) quote = undefined;
44
+ continue;
45
+ }
46
+ if (ch === "'" || ch === '"' || ch === "`") quote = ch;
47
+ if (ch === "(" || ch === "[") depth++;
48
+ if (ch === ")" || ch === "]") depth--;
49
+ if (ch === "," && depth === 0) {
50
+ out.push(cur.trim());
51
+ cur = "";
52
+ } else cur += ch;
53
+ }
54
+ if (cur.trim()) out.push(cur.trim());
55
+ return out.map((a) => (/^'.*'$/.test(a) ? a.slice(1, -1) : a));
56
+ }
57
+
58
+ /** Kafka's positional arguments, in the order the engine takes them. */
59
+ const KAFKA_POSITIONAL = ["kafka_broker_list", "kafka_topic_list", "kafka_group_name", "kafka_format"];
60
+
61
+ /** A Kafka setting, from SETTINGS or from the engine's positional arguments. */
62
+ function kafkaSetting(o: CanonicalObject, name: string): string | undefined {
63
+ const at = KAFKA_POSITIONAL.indexOf(name);
64
+ return o.settings[name] ?? (at >= 0 ? streamEngineArguments(o.engine)[at] : undefined);
65
+ }
66
+
67
+ /**
68
+ * What a replacement would lose, in a sentence each (#4396):
69
+ *
70
+ * - a different `kafka_group_name` is a different consumer group: it starts
71
+ * from the group's committed offsets, or from the topic's start when the
72
+ * group has none (`auto.offset.reset`);
73
+ * - a different S3Queue `keeper_path` is a different place for the table's
74
+ * record of processed files, so every file already seen is processed again.
75
+ *
76
+ * Neither is a refusal: a new group or path is sometimes the point.
77
+ */
78
+ export function positionWarnings(before: CanonicalObject, after: CanonicalObject): string[] {
79
+ const out: string[] = [];
80
+ if (before.engineName === "Kafka" && after.engineName === "Kafka") {
81
+ const b = kafkaSetting(before, "kafka_group_name");
82
+ const a = kafkaSetting(after, "kafka_group_name");
83
+ if (b !== a) {
84
+ out.push(
85
+ `kafka_group_name changes from ${b ?? "none"} to ${a ?? "none"}: the new group starts from its own committed offsets, or from the start of the topic when it has none, ` +
86
+ "so messages already consumed may be read again or skipped",
87
+ );
88
+ }
89
+ }
90
+ if (before.engineName === "S3Queue" && after.engineName === "S3Queue") {
91
+ const b = before.settings.keeper_path;
92
+ const a = after.settings.keeper_path;
93
+ if (b !== a) {
94
+ out.push(
95
+ `keeper_path changes from ${b ?? "none"} to ${a ?? "none"}: the new path holds no record of processed files, so every file already seen is processed again`,
96
+ );
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** Whether a materialized view reads from the table: its SELECT names the table, qualified or, in the table's own database, bare. */
103
+ export function readsTable(view: CanonicalObject, table: CanonicalObject): boolean {
104
+ if (view.kind !== "materializedView" || view.select === undefined) return false;
105
+ const escaped = table.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
106
+ const sameDatabase = view.database === table.database;
107
+ const database = table.database?.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+ // The canonical SELECT drops the view's own database from a name, so a bare name is a table in the view's database.
109
+ const names = [...(sameDatabase ? [escaped] : []), ...(database ? [`${database}\\s*\\.\\s*${escaped}`] : [])];
110
+ return names.some((n) => new RegExp(`(?:^|[^\\w.])${n}(?![\\w.])`).test(view.select!));
111
+ }
@@ -27,7 +27,9 @@
27
27
  *
28
28
  * Anything else is refused with a {@link RebuildRefusal}: a database that is
29
29
  * neither Atomic nor Replicated, a table that is not there, a change that is
30
- * not a rebuild, a working object chant did not make.
30
+ * not a rebuild, a working object chant did not make, a table on a stream
31
+ * engine (Kafka, S3Queue, AzureQueue, RabbitMQ, NATS, FileLog), which holds no
32
+ * rows to copy and is replaced by the applier instead (#4396).
31
33
  *
32
34
  * A Replicated database (#3249) is Atomic on every replica, with its DDL run
33
35
  * on each through Keeper, so the same EXCHANGE swaps the table everywhere.
@@ -47,6 +49,7 @@ import { dropFormattingOnly } from "../plan/server-format";
47
49
  import { CLASSIFIER_RULES } from "../plan/rules";
48
50
  import { CLICKHOUSE_ENTITY_TYPES } from "../entities";
49
51
  import { carriesMarker, readTrailerPairs, REBUILD_TRAILER_KEY, stripMarkerFromStatement } from "../ownership";
52
+ import { isStreamEngine } from "../plan/stream";
50
53
  import { ident, isRebuild, qualifiedIdent, sqlString, type DeclaredObject } from "../apply/statements";
51
54
 
52
55
  /** How the new table keeps up with writes to the old one while it is filled. */
@@ -250,10 +253,20 @@ function ownWorkingObject(o: ServerObject | undefined, names: RebuildNames, role
250
253
  return o;
251
254
  }
252
255
 
256
+ /** The refusal for a stream-engine table, naming the engine and where its change goes instead (#4396). */
257
+ function streamRefusal(key: string, engine: string, side: "declared" | "live"): RebuildRefusal {
258
+ return new RebuildRefusal(
259
+ `${key} is ${side === "declared" ? "declared on" : "on the server as"} the ${engine} engine, a stream engine: it stores no rows, so there is nothing to backfill, and a direct SELECT from it either fails ` +
260
+ `or consumes messages. A change to it is SQLCH225 (replace a stream table), which the applier makes in place: it detaches the materialized views that read the table, drops and creates it, and attaches the views again ` +
261
+ `(ApplyOp with target "clickhouse").`,
262
+ );
263
+ }
264
+
253
265
  /** Observe the rebuild of the declared table on the server. */
254
266
  export async function observeRebuild(target: ClickHouseTarget, declared: DeclaredObject, marker: OwnershipMarker | undefined): Promise<RebuildObservation> {
255
267
  const database = declared.canonical.database ?? target.defaultDatabase;
256
268
  const names = rebuildNames(database, declared.canonical.name);
269
+ if (isStreamEngine(declared.canonical.engineName)) throw streamRefusal(names.key, declared.canonical.engineName!, "declared");
257
270
 
258
271
  const [db] = await clickhouseQuery<{ engine: string }>(target.endpoint, `SELECT engine FROM system.databases WHERE name = ${sqlString(database)}`);
259
272
  if (!db) throw new RebuildRefusal(`database ${database} is not on the server, so there is no ${names.key} to rebuild; the applier creates it`);
@@ -272,6 +285,7 @@ export async function observeRebuild(target: ClickHouseTarget, declared: Declare
272
285
  // Between EXCHANGE and the end of the swap, the table's own name holds the
273
286
  // new table, still carrying role=new, and the new table's name holds the
274
287
  // old table until it is renamed; the swap step finishes that.
288
+ if (isStreamEngine(liveRow.engine)) throw streamRefusal(names.key, liveRow.engine, "live");
275
289
  const exchanged = liveRow.pairs?.get(REBUILD_TRAILER_KEY) === names.key && liveRow.pairs.get("role") === "new";
276
290
  const newTable = exchanged ? undefined : ownWorkingObject(objects.get(names.newName), names, "new", marker);
277
291
  const exchangedOld = exchanged ? objects.get(names.newName) : undefined;