@intentius/chant-lexicon-sql 0.116.0 → 0.118.0

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 (259) hide show
  1. package/dist/clickhouse/access.d.ts +132 -0
  2. package/dist/clickhouse/access.d.ts.map +1 -0
  3. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  4. package/dist/clickhouse/apply/statements.d.ts +2 -0
  5. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  6. package/dist/clickhouse/entities.d.ts +171 -2
  7. package/dist/clickhouse/entities.d.ts.map +1 -1
  8. package/dist/clickhouse/http.d.ts +3 -0
  9. package/dist/clickhouse/http.d.ts.map +1 -1
  10. package/dist/clickhouse/import/generator.d.ts +28 -0
  11. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  12. package/dist/clickhouse/import/ir.d.ts.map +1 -1
  13. package/dist/clickhouse/import/parser.d.ts +3 -3
  14. package/dist/clickhouse/import/parser.d.ts.map +1 -1
  15. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  16. package/dist/clickhouse/live/catalog.d.ts +41 -6
  17. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  18. package/dist/clickhouse/live/describe-resources.d.ts +3 -2
  19. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  20. package/dist/clickhouse/parser.d.ts +54 -3
  21. package/dist/clickhouse/parser.d.ts.map +1 -1
  22. package/dist/clickhouse/plan/commands.d.ts +3 -1
  23. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  24. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  25. package/dist/clickhouse/plan/diff.d.ts +12 -0
  26. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  27. package/dist/clickhouse/plan/normalize.d.ts +61 -1
  28. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  29. package/dist/clickhouse/plan/report.d.ts.map +1 -1
  30. package/dist/clickhouse/plan/rules.d.ts +7 -0
  31. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  32. package/dist/clickhouse/plan/schema.d.ts +8 -4
  33. package/dist/clickhouse/plan/schema.d.ts.map +1 -1
  34. package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
  35. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  36. package/dist/clickhouse/rebuild/observe.d.ts +7 -3
  37. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  38. package/dist/clickhouse/rebuild/op.d.ts +8 -0
  39. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  40. package/dist/clickhouse/rebuild/steps.d.ts +48 -0
  41. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  42. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  43. package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
  44. package/dist/clickhouse/topology.d.ts.map +1 -1
  45. package/dist/clickhouse.d.ts +1 -1
  46. package/dist/clickhouse.d.ts.map +1 -1
  47. package/dist/codegen/classifier-page.d.ts.map +1 -1
  48. package/dist/codegen/registry.d.ts.map +1 -1
  49. package/dist/config.d.ts +70 -7
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/core/references.d.ts +4 -2
  52. package/dist/core/references.d.ts.map +1 -1
  53. package/dist/core/statements.d.ts +11 -0
  54. package/dist/core/statements.d.ts.map +1 -1
  55. package/dist/files/build-root.d.ts +26 -0
  56. package/dist/files/build-root.d.ts.map +1 -0
  57. package/dist/files/clickhouse.d.ts +19 -0
  58. package/dist/files/clickhouse.d.ts.map +1 -0
  59. package/dist/files/common.d.ts +25 -0
  60. package/dist/files/common.d.ts.map +1 -0
  61. package/dist/files/postgres.d.ts +36 -0
  62. package/dist/files/postgres.d.ts.map +1 -0
  63. package/dist/import-parser.d.ts +25 -0
  64. package/dist/import-parser.d.ts.map +1 -0
  65. package/dist/index.d.ts +2 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/integrity.json +8 -8
  68. package/dist/lint/rules/sqlch001.d.ts +1 -1
  69. package/dist/lint/rules/sqlch001.d.ts.map +1 -1
  70. package/dist/lint/rules/sqlch002.d.ts.map +1 -1
  71. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  72. package/dist/lint/rules/templates.d.ts +1 -1
  73. package/dist/lint/rules/templates.d.ts.map +1 -1
  74. package/dist/lsp/postgres-template.d.ts.map +1 -1
  75. package/dist/manifest.json +1 -1
  76. package/dist/meta.json +50 -0
  77. package/dist/okf/index.md +11 -1
  78. package/dist/okf/rules/SQLCH001.md +2 -2
  79. package/dist/okf/types/Dictionary.md +9 -0
  80. package/dist/okf/types/Function.md +9 -0
  81. package/dist/okf/types/Grant.md +9 -0
  82. package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
  83. package/dist/okf/types/PostgresGrant.md +9 -0
  84. package/dist/okf/types/PostgresPolicy.md +9 -0
  85. package/dist/okf/types/PostgresRole.md +9 -0
  86. package/dist/okf/types/Role.md +9 -0
  87. package/dist/okf/types/RowPolicy.md +9 -0
  88. package/dist/okf/types/User.md +9 -0
  89. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  90. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  91. package/dist/op/activity-contracts.d.ts +19 -0
  92. package/dist/op/activity-contracts.d.ts.map +1 -1
  93. package/dist/plugin.d.ts.map +1 -1
  94. package/dist/postgres/access/acl.d.ts +119 -0
  95. package/dist/postgres/access/acl.d.ts.map +1 -0
  96. package/dist/postgres/access/import.d.ts +13 -0
  97. package/dist/postgres/access/import.d.ts.map +1 -0
  98. package/dist/postgres/access/live.d.ts +24 -0
  99. package/dist/postgres/access/live.d.ts.map +1 -0
  100. package/dist/postgres/apply/apply.d.ts.map +1 -1
  101. package/dist/postgres/apply/statements.d.ts +71 -0
  102. package/dist/postgres/apply/statements.d.ts.map +1 -1
  103. package/dist/postgres/entities.d.ts +106 -4
  104. package/dist/postgres/entities.d.ts.map +1 -1
  105. package/dist/postgres/entity-types.d.ts +4 -0
  106. package/dist/postgres/entity-types.d.ts.map +1 -1
  107. package/dist/postgres/import/generator.d.ts +37 -1
  108. package/dist/postgres/import/generator.d.ts.map +1 -1
  109. package/dist/postgres/import/live-export.d.ts +6 -0
  110. package/dist/postgres/import/live-export.d.ts.map +1 -1
  111. package/dist/postgres/live/bind.d.ts +2 -0
  112. package/dist/postgres/live/bind.d.ts.map +1 -1
  113. package/dist/postgres/live/catalog.d.ts +11 -2
  114. package/dist/postgres/live/catalog.d.ts.map +1 -1
  115. package/dist/postgres/live/client.d.ts +3 -0
  116. package/dist/postgres/live/client.d.ts.map +1 -1
  117. package/dist/postgres/live/describe-resources.d.ts +9 -0
  118. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  119. package/dist/postgres/migrate/op.d.ts +29 -0
  120. package/dist/postgres/migrate/op.d.ts.map +1 -1
  121. package/dist/postgres/parser.d.ts +76 -3
  122. package/dist/postgres/parser.d.ts.map +1 -1
  123. package/dist/postgres/plan/commands.d.ts +12 -0
  124. package/dist/postgres/plan/commands.d.ts.map +1 -1
  125. package/dist/postgres/plan/deep.d.ts.map +1 -1
  126. package/dist/postgres/plan/diff.d.ts +10 -1
  127. package/dist/postgres/plan/diff.d.ts.map +1 -1
  128. package/dist/postgres/plan/normalize.d.ts +8 -1
  129. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  130. package/dist/postgres/plan/rules.d.ts +8 -0
  131. package/dist/postgres/plan/rules.d.ts.map +1 -1
  132. package/dist/postgres/plan/schema.d.ts +2 -1
  133. package/dist/postgres/plan/schema.d.ts.map +1 -1
  134. package/dist/postgres/plan/server-normalize.d.ts +4 -1
  135. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  136. package/dist/postgres.d.ts +1 -1
  137. package/dist/postgres.d.ts.map +1 -1
  138. package/dist/rules/postgres-templates.ts +2 -2
  139. package/dist/rules/sqlch001.ts +26 -4
  140. package/dist/rules/sqlch002.ts +3 -2
  141. package/dist/rules/sqlpg001.ts +7 -4
  142. package/dist/rules/templates.ts +2 -2
  143. package/dist/serializer.d.ts +2 -1
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/sql-files.d.ts +79 -0
  146. package/dist/sql-files.d.ts.map +1 -0
  147. package/dist/token-source.d.ts +88 -0
  148. package/dist/token-source.d.ts.map +1 -0
  149. package/dist/validate.d.ts.map +1 -1
  150. package/package.json +2 -2
  151. package/src/clickhouse/access.test.ts +203 -0
  152. package/src/clickhouse/access.ts +595 -0
  153. package/src/clickhouse/apply/apply.ts +29 -20
  154. package/src/clickhouse/apply/statements.ts +105 -8
  155. package/src/clickhouse/dictionary.test.ts +177 -0
  156. package/src/clickhouse/entities.ts +356 -6
  157. package/src/clickhouse/function.test.ts +91 -0
  158. package/src/clickhouse/http.ts +11 -2
  159. package/src/clickhouse/import/generator.ts +81 -46
  160. package/src/clickhouse/import/import.test.ts +9 -3
  161. package/src/clickhouse/import/ir.ts +6 -2
  162. package/src/clickhouse/import/parser.ts +10 -18
  163. package/src/clickhouse/live/access.e2e.test.ts +218 -0
  164. package/src/clickhouse/live/bind.ts +17 -6
  165. package/src/clickhouse/live/catalog.ts +106 -10
  166. package/src/clickhouse/live/describe-resources.ts +23 -10
  167. package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
  168. package/src/clickhouse/live/functions.e2e.test.ts +155 -0
  169. package/src/clickhouse/live/live.test.ts +2 -2
  170. package/src/clickhouse/parser.ts +136 -4
  171. package/src/clickhouse/plan/commands.ts +18 -7
  172. package/src/clickhouse/plan/deep.ts +71 -11
  173. package/src/clickhouse/plan/diff.ts +65 -2
  174. package/src/clickhouse/plan/normalize.ts +200 -6
  175. package/src/clickhouse/plan/report.ts +14 -3
  176. package/src/clickhouse/plan/rules.ts +44 -1
  177. package/src/clickhouse/plan/schema.ts +40 -17
  178. package/src/clickhouse/plan/server-format.ts +1 -1
  179. package/src/clickhouse/rebuild/backfill.ts +5 -25
  180. package/src/clickhouse/rebuild/observe.ts +7 -3
  181. package/src/clickhouse/rebuild/op.ts +9 -0
  182. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
  183. package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
  184. package/src/clickhouse/rebuild/steps.ts +136 -1
  185. package/src/clickhouse/testing/fake-server.ts +2 -0
  186. package/src/clickhouse/testing/writable-server.ts +2 -0
  187. package/src/clickhouse/topology.ts +17 -2
  188. package/src/clickhouse.ts +19 -0
  189. package/src/codegen/classifier-page.ts +2 -0
  190. package/src/codegen/registry.ts +10 -0
  191. package/src/config.ts +22 -3
  192. package/src/core/references.ts +5 -3
  193. package/src/core/statements.ts +11 -1
  194. package/src/files/build-root.ts +62 -0
  195. package/src/files/clickhouse.ts +52 -0
  196. package/src/files/common.ts +54 -0
  197. package/src/files/postgres.ts +371 -0
  198. package/src/generated/lexicon-sql.json +50 -0
  199. package/src/import-parser.ts +61 -0
  200. package/src/index.ts +14 -1
  201. package/src/lint/rules/postgres-templates.ts +2 -2
  202. package/src/lint/rules/sqlch001.ts +26 -4
  203. package/src/lint/rules/sqlch002.ts +3 -2
  204. package/src/lint/rules/sqlpg001.ts +7 -4
  205. package/src/lint/rules/templates.ts +2 -2
  206. package/src/lsp/postgres-template.ts +2 -0
  207. package/src/lsp/template.ts +3 -3
  208. package/src/mcp/index.ts +6 -6
  209. package/src/mcp/postgres.ts +1 -1
  210. package/src/migration-statements.test.ts +25 -0
  211. package/src/migration-statements.ts +8 -0
  212. package/src/op/activities/clickhouse-rebuild.ts +1 -0
  213. package/src/op/activities/postgres-migration.ts +27 -2
  214. package/src/op/activity-contracts.ts +2 -0
  215. package/src/plugin.ts +29 -5
  216. package/src/postgres/access/access.e2e.test.ts +191 -0
  217. package/src/postgres/access/access.test.ts +147 -0
  218. package/src/postgres/access/acl.ts +400 -0
  219. package/src/postgres/access/import.ts +64 -0
  220. package/src/postgres/access/live.ts +123 -0
  221. package/src/postgres/apply/apply.ts +117 -4
  222. package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
  223. package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
  224. package/src/postgres/apply/statements.test.ts +138 -6
  225. package/src/postgres/apply/statements.ts +210 -18
  226. package/src/postgres/entities.ts +287 -11
  227. package/src/postgres/entity-types.ts +4 -0
  228. package/src/postgres/import/generator.ts +129 -46
  229. package/src/postgres/import/live-export.ts +12 -2
  230. package/src/postgres/live/bind.ts +20 -6
  231. package/src/postgres/live/catalog.ts +79 -3
  232. package/src/postgres/live/client.ts +10 -2
  233. package/src/postgres/live/describe-resources.ts +27 -3
  234. package/src/postgres/migrate/migrate.test.ts +24 -1
  235. package/src/postgres/migrate/op.ts +80 -35
  236. package/src/postgres/migrate/outer.e2e.test.ts +163 -0
  237. package/src/postgres/parser.ts +247 -8
  238. package/src/postgres/plan/commands.ts +60 -7
  239. package/src/postgres/plan/deep.ts +24 -3
  240. package/src/postgres/plan/diff.ts +71 -6
  241. package/src/postgres/plan/normalize.ts +115 -2
  242. package/src/postgres/plan/plan.test.ts +2 -2
  243. package/src/postgres/plan/rules.ts +49 -5
  244. package/src/postgres/plan/schema.ts +4 -2
  245. package/src/postgres/plan/server-normalize.ts +29 -1
  246. package/src/postgres.ts +11 -0
  247. package/src/serializer.ts +14 -9
  248. package/src/skill-defs.ts +1 -1
  249. package/src/skills/chant-sql-postgres-migration.md +3 -1
  250. package/src/skills/chant-sql-postgres-plan.md +3 -3
  251. package/src/skills/chant-sql-postgres.md +9 -2
  252. package/src/skills/chant-sql-rebuild.md +3 -2
  253. package/src/skills/chant-sql.md +7 -1
  254. package/src/sql-files.test.ts +188 -0
  255. package/src/sql-files.ts +218 -0
  256. package/src/token-source.e2e.test.ts +142 -0
  257. package/src/token-source.test.ts +119 -0
  258. package/src/token-source.ts +189 -0
  259. package/src/validate.ts +10 -0
@@ -32,13 +32,14 @@
32
32
  */
33
33
 
34
34
  import { isTrivia, tokenizeText, type Token } from "../tokens";
35
- import { parseCreate, unquote, type ColumnNode, type CreateNode, type Span } from "../parser";
35
+ import { parseCreate, unquote, type ColumnNode, type CreateNode, type DictionaryAttributeNode, type DictionaryNode, type Span } from "../parser";
36
36
  import { MERGE_TREE_SETTINGS, TYPE_FAMILIES } from "../../generated/clickhouse";
37
37
  import { previouslyIn, splitTopLevel as splitTop } from "../../core/normalize";
38
+ import { accessCanonical, grantsCanonical, isAccessStatement } from "../access";
38
39
 
39
40
  export { previouslyIn } from "../../core/normalize";
40
41
 
41
- export type ObjectKind = "database" | "table" | "view" | "materializedView";
42
+ export type ObjectKind = "database" | "table" | "view" | "materializedView" | "dictionary" | "function" | "user" | "role" | "rowPolicy" | "grants";
42
43
 
43
44
  export interface CanonicalColumn {
44
45
  name: string;
@@ -50,6 +51,10 @@ export interface CanonicalColumn {
50
51
  codec?: string;
51
52
  ttl?: string;
52
53
  comment?: string;
54
+ /** A dictionary attribute's `EXPRESSION`. */
55
+ expression?: string;
56
+ /** A dictionary attribute's flags, sorted: `HIERARCHICAL`, `INJECTIVE`. */
57
+ flags?: string;
53
58
  /** `-- previously: <name>` on the column's line: the name it had before. */
54
59
  previously?: string;
55
60
  /** The column's name and type as written, for a message. Not compared. */
@@ -77,6 +82,20 @@ export interface CanonicalObject {
77
82
  select?: string;
78
83
  to?: string;
79
84
  refresh?: string;
85
+ /** A dictionary's `SOURCE`, `LAYOUT`, `LIFETIME` and `RANGE`, each what is inside the parentheses. */
86
+ dataSource?: string;
87
+ layout?: string;
88
+ lifetime?: string;
89
+ range?: string;
90
+ /** A function's lambda, `( x , y ) -> expr`. */
91
+ lambda?: string;
92
+ /** A row policy's table. */
93
+ table?: string;
94
+ /**
95
+ * A user's, role's or row policy's fields (`../access.ts`), each absent at
96
+ * the server's default; a grantee's grants, by atom.
97
+ */
98
+ access?: Record<string, string>;
80
99
  /** `-- previously: <name>` before the statement: the object's previous name. */
81
100
  previously?: string;
82
101
  }
@@ -231,7 +250,7 @@ function lineComments(tokens: Token[], from: number): string[] {
231
250
  }
232
251
 
233
252
  const kindOf = (node: CreateNode): ObjectKind =>
234
- node.statement === "database" ? "database" : node.statement === "table" ? "table" : node.materialized ? "materializedView" : "view";
253
+ node.statement === "view" ? (node.materialized ? "materializedView" : "view") : node.statement;
235
254
 
236
255
  const stringValue = (s: string | undefined) =>
237
256
  s === undefined ? undefined : /^'.*'$/s.test(s) ? s.slice(1, -1).replace(/''/g, "'").replace(/\\(.)/g, "$1") : s;
@@ -241,6 +260,22 @@ const stringValue = (s: string | undefined) =>
241
260
  * database an unqualified name is created in.
242
261
  */
243
262
  export function canonicalObject(ddl: string, defaultDatabase = "default"): CanonicalObject {
263
+ if (isAccessStatement(ddl)) {
264
+ if (/^\s*(?:--[^\n]*\n\s*)*(GRANT|REVOKE)\b/i.test(ddl)) throw new Error("a grant is compared as its grantee's grants: grantsObject(ddl, grantee)");
265
+ const a = accessCanonical(ddl, defaultDatabase);
266
+ return {
267
+ kind: a.kind,
268
+ ...(a.database !== undefined ? { database: a.database } : {}),
269
+ name: a.name,
270
+ ...(a.table !== undefined ? { table: a.table } : {}),
271
+ access: a.fields,
272
+ columns: [],
273
+ settings: {},
274
+ indexes: {},
275
+ projections: {},
276
+ constraints: {},
277
+ };
278
+ }
244
279
  const tokens = tokenizeText(ddl, 0);
245
280
  const node = parseCreate(tokens);
246
281
  const nameParts = tokens
@@ -249,7 +284,7 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
249
284
  .map((t) => unquote(t.text));
250
285
  const kind = kindOf(node);
251
286
  const name = nameParts[nameParts.length - 1]!;
252
- const database = kind === "database" ? undefined : nameParts.length >= 2 ? nameParts[nameParts.length - 2] : defaultDatabase;
287
+ const database = kind === "database" || kind === "function" ? undefined : nameParts.length >= 2 ? nameParts[nameParts.length - 2] : defaultDatabase;
253
288
  const expr = (span: Span | undefined) => {
254
289
  const t = spanText(tokens, span);
255
290
  return t === undefined ? undefined : canonicalExpression(t, database);
@@ -269,6 +304,16 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
269
304
  const previously = previouslyIn(leading);
270
305
  if (previously) obj.previously = previously;
271
306
 
307
+ const comment0 = stringValue(spanText(tokens, node.comment));
308
+ if (node.statement === "dictionary") {
309
+ if (comment0 !== undefined && comment0 !== "") obj.comment = comment0;
310
+ return dictionaryObject(obj, tokens, node, database);
311
+ }
312
+ if (node.statement === "function") {
313
+ obj.lambda = canonicalExpression(spanText(tokens, node.lambda) ?? "");
314
+ return obj;
315
+ }
316
+
272
317
  const engine = node.engine;
273
318
  if (engine) {
274
319
  let args = engine.args?.map((a) => canonicalExpression(spanText(tokens, a) ?? "", database)) ?? [];
@@ -291,8 +336,7 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
291
336
  obj.engineName = name;
292
337
  obj.engine = args.length ? `${name}(${args.join(", ")})` : name;
293
338
  }
294
- const comment = stringValue(spanText(tokens, node.comment));
295
- if (comment !== undefined && comment !== "") obj.comment = comment;
339
+ if (comment0 !== undefined && comment0 !== "") obj.comment = comment0;
296
340
 
297
341
  if (node.statement === "database") {
298
342
  for (const s of node.settings ?? []) obj.settings[s.key] = canonicalExpression(spanText(tokens, s.value) ?? "").replace(/^'(.*)'$/, "$1");
@@ -333,6 +377,111 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
333
377
  return obj;
334
378
  }
335
379
 
380
+ // ── dictionaries ──────────────────────────────────────────────────────
381
+
382
+ /**
383
+ * A dictionary's clause (`SOURCE`, `LAYOUT`) as the server prints it: each
384
+ * function name and each key upper case (`clickhouse(table 'r')` is
385
+ * `CLICKHOUSE(TABLE 'r')`), each value as written, and a `PASSWORD` value
386
+ * as `'[HIDDEN]'`, since `SHOW CREATE` never prints it.
387
+ */
388
+ export function canonicalDictionaryClause(text: string): string {
389
+ let sig: Token[];
390
+ try {
391
+ sig = tokenizeText(text, 0).filter((t) => !isTrivia(t));
392
+ } catch {
393
+ return text.trim();
394
+ }
395
+ const out: string[] = [];
396
+ let key = true;
397
+ let hide = false;
398
+ for (let i = 0; i < sig.length; i++) {
399
+ const t = sig[i]!;
400
+ const next = sig[i + 1];
401
+ if (t.kind === "punct" && (t.text === "(" || t.text === "," || t.text === ")")) {
402
+ out.push(t.text);
403
+ key = true;
404
+ continue;
405
+ }
406
+ if (key && t.kind === "ident") {
407
+ const word = t.text.toUpperCase();
408
+ out.push(word);
409
+ // A function name opens its own list of keys; a key is followed by its value.
410
+ key = next?.kind === "punct" && next.text === "(";
411
+ hide = word === "PASSWORD";
412
+ continue;
413
+ }
414
+ out.push(hide ? "'[HIDDEN]'" : t.kind === "qident" ? unquote(t.text) : t.text);
415
+ hide = false;
416
+ key = !(next?.kind === "punct" && next.text === "(");
417
+ }
418
+ return out.join(" ");
419
+ }
420
+
421
+ /** `300` is `MIN 0 MAX 300`; `MAX b MIN a` is `MIN a MAX b`. */
422
+ export function canonicalLifetime(text: string): string {
423
+ const words = canonicalExpression(text).split(" ");
424
+ if (words.length === 1) return `MIN 0 MAX ${words[0]}`;
425
+ const at = (w: string) => words.findIndex((x) => x.toUpperCase() === w);
426
+ const min = at("MIN");
427
+ const max = at("MAX");
428
+ if (min < 0 || max < 0) return words.join(" ");
429
+ return `MIN ${words[min + 1] ?? ""} MAX ${words[max + 1] ?? ""}`;
430
+ }
431
+
432
+ /** A key list with any parentheses around the whole of it left out: `(a, b)` is `a , b`. */
433
+ function keyList(text: string, database?: string): string {
434
+ const e = canonicalExpression(text, database);
435
+ if (!e.startsWith("( ") || !e.endsWith(" )")) return e;
436
+ let depth = 0;
437
+ const words = e.split(" ");
438
+ for (let i = 0; i < words.length; i++) {
439
+ if (words[i] === "(") depth++;
440
+ else if (words[i] === ")") depth--;
441
+ if (depth === 0 && i < words.length - 1) return e;
442
+ }
443
+ return words.slice(1, -1).join(" ");
444
+ }
445
+
446
+ function dictionaryAttribute(tokens: Token[], a: DictionaryAttributeNode, position: number, database?: string): CanonicalColumn {
447
+ const text = (span: Span | undefined) => spanText(tokens, span);
448
+ const out: CanonicalColumn = { name: a.name, position, type: canonicalType(text(a.type)!), text: [a.name, text(a.type)].join(" ") };
449
+ const d = text(a.default);
450
+ if (d !== undefined) {
451
+ out.defaultKind = "DEFAULT";
452
+ out.defaultExpr = canonicalExpression(d, database);
453
+ }
454
+ const e = text(a.expression);
455
+ if (e !== undefined) out.expression = canonicalExpression(e, database);
456
+ if (a.flags.length > 0) out.flags = [...a.flags].sort().join(" ");
457
+ const prev = previouslyIn(lineComments(tokens, a.nameSpan.from));
458
+ if (prev) out.previously = prev;
459
+ return out;
460
+ }
461
+
462
+ function dictionaryObject(obj: CanonicalObject, tokens: Token[], node: DictionaryNode, database?: string): CanonicalObject {
463
+ const text = (span: Span | undefined) => spanText(tokens, span);
464
+ node.attributes.forEach((a, i) => obj.columns.push(dictionaryAttribute(tokens, a, i, database)));
465
+ const pk = text(node.primaryKey);
466
+ if (pk !== undefined) obj.primaryKey = keyList(pk, database);
467
+ const source = text(node.source);
468
+ if (source !== undefined) obj.dataSource = canonicalDictionaryClause(source);
469
+ const layout = text(node.layout);
470
+ if (layout !== undefined) obj.layout = canonicalDictionaryClause(layout);
471
+ const lifetime = text(node.lifetime);
472
+ if (lifetime !== undefined) obj.lifetime = canonicalLifetime(lifetime);
473
+ const range = text(node.range);
474
+ if (range !== undefined) obj.range = canonicalExpression(range, database).replace(/\b(min|max)\b/gi, (w) => w.toUpperCase());
475
+ const settings = text(node.settings);
476
+ if (settings !== undefined) {
477
+ for (const item of splitTop(canonicalExpression(settings))) {
478
+ const m = /^(\S+) = (.*)$/.exec(item);
479
+ if (m) obj.settings[m[1]!] = m[2]!.replace(/^'(.*)'$/, "$1");
480
+ }
481
+ }
482
+ return obj;
483
+ }
484
+
336
485
  function qualified(tokens: Token[], span: Span, defaultDatabase: string): string {
337
486
  const parts = tokens
338
487
  .slice(span.from, span.to)
@@ -369,3 +518,48 @@ function quotedIdentifier(arg: string): string {
369
518
  const bare = /^[A-Za-z_][A-Za-z0-9_]*$/.test(arg) ? arg : /^`((?:[^`]|``)+)`$/.exec(arg)?.[1]?.replace(/``/g, "`");
370
519
  return bare === undefined ? arg : `'${bare.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
371
520
  }
521
+
522
+ /**
523
+ * The key an object is compared under against a server: `database.name`,
524
+ * a database's name, and `function <name>` for a function, which belongs to
525
+ * no database and must not meet a database of the same name.
526
+ */
527
+ export function objectKey(o: { kind?: ObjectKind; type?: string; database?: string; name: string; table?: string }): string {
528
+ const kind = o.kind ?? KIND_OF_TYPE[o.type ?? ""];
529
+ if (kind === "function") return `function ${o.name}`;
530
+ if (kind === "user") return `user ${o.name}`;
531
+ if (kind === "role") return `role ${o.name}`;
532
+ if (kind === "grants") return `grants ${o.name}`;
533
+ if (kind === "rowPolicy") return `row policy ${o.name} ON ${o.database}.${o.table}`;
534
+ return o.database !== undefined ? `${o.database}.${o.name}` : o.name;
535
+ }
536
+
537
+ const KIND_OF_TYPE: Record<string, ObjectKind> = {
538
+ "ClickHouse::Function": "function",
539
+ "ClickHouse::User": "user",
540
+ "ClickHouse::Role": "role",
541
+ "ClickHouse::RowPolicy": "rowPolicy",
542
+ "ClickHouse::Grant": "grants",
543
+ };
544
+
545
+ /** A grantee's grants, from grant statements (one or more, on their own lines), as a plan compares them. */
546
+ export function grantsObject(ddl: string, grantee: string, defaultDatabase = "default"): CanonicalObject {
547
+ return { kind: "grants", name: grantee, access: grantsCanonical(ddl, grantee, defaultDatabase), columns: [], settings: {}, indexes: {}, projections: {}, constraints: {} };
548
+ }
549
+
550
+ /**
551
+ * What an object is scoped by when a plan reads a server: its database, a
552
+ * database's own name, and for a function its key. A plan reads the
553
+ * databases the declarations use and the functions they declare, nothing
554
+ * else.
555
+ */
556
+ export function scopeOf(o: { kind?: ObjectKind; type?: string; database?: string; name: string; table?: string }): string {
557
+ const kind = o.kind ?? KIND_OF_TYPE[o.type ?? ""];
558
+ if (kind === "function" || kind === "user" || kind === "role" || kind === "grants") return objectKey(o);
559
+ return o.database ?? o.name;
560
+ }
561
+
562
+ /** A live object's canonical form: a grantee's grants from its `SHOW GRANTS` lines, anything else from its `SHOW CREATE`. */
563
+ export function liveCanonical(o: { type: string; name: string; statement: string }, defaultDatabase = "default"): CanonicalObject {
564
+ return o.type === "ClickHouse::Grant" ? grantsObject(o.statement, o.name, defaultDatabase) : canonicalObject(o.statement, defaultDatabase);
565
+ }
@@ -6,14 +6,25 @@ import type { SchemaDiff } from "./diff";
6
6
  import { renderRebuildOps } from "./rebuild-handoff";
7
7
 
8
8
  export function renderDiff(diff: SchemaDiff, opts: { title?: string } = {}): string {
9
- const trailer =
10
- diff.rebuilds.length > 0
9
+ const unreadable = diff.unreadable ?? [];
10
+ const refusedRead =
11
+ unreadable.length > 0
12
+ ? [
13
+ "",
14
+ `Refused: ${unreadable.length} object(s) in the declared databases could not be read, so this plan cannot say what changes for them:`,
15
+ ...unreadable.map((u) => ` ${u.object} (${u.type}): ${u.reason}`),
16
+ ]
17
+ : [];
18
+ const trailer = [
19
+ ...refusedRead,
20
+ ...(diff.rebuilds.length > 0
11
21
  ? [
12
22
  "",
13
23
  `Refused: ${diff.rebuilds.length} change(s) need a rebuild, which ClickHouse cannot make to the existing table. ` +
14
24
  "A rebuild runs as its own migration (create the new table, backfill, verify, swap), not in place.",
15
25
  ...renderRebuildOps(diff.rebuildOps ?? []),
16
26
  ]
17
- : [];
27
+ : []),
28
+ ];
18
29
  return renderChangeSet(diff, { ...(opts.title ? { title: opts.title } : {}), rules: CLASSIFIER_RULES, classes: CHANGE_CLASSES, trailer });
19
30
  }
@@ -35,7 +35,7 @@ const DOCS = "https://clickhouse.com/docs/sql-reference/statements";
35
35
  const rule = (id: string, cls: ChangeClass, title: string, restriction: string, cite: string): ClassifierRule => classifierRule(id, cls, title, restriction, cite);
36
36
 
37
37
  export const CLASSIFIER_RULES = {
38
- SQLCH200: rule("SQLCH200", "create", "Create an object", "A new database, table or view is created; nothing existing changes.", `${DOCS}/create`),
38
+ SQLCH200: rule("SQLCH200", "create", "Create an object", "A new database, table, view, dictionary, function, user, role or row policy is created, or a grantee gets its first grants; nothing existing changes.", `${DOCS}/create`),
39
39
  SQLCH201: rule(
40
40
  "SQLCH201",
41
41
  "metadata",
@@ -235,6 +235,49 @@ export const CLASSIFIER_RULES = {
235
235
  "ALTER TABLE ... MODIFY REFRESH changes the schedule of a refreshable materialized view.",
236
236
  `${DOCS}/alter/view`,
237
237
  ),
238
+ SQLCH245: rule(
239
+ "SQLCH245",
240
+ "metadata",
241
+ "Change a dictionary",
242
+ "A dictionary stores no data of its own: CREATE OR REPLACE DICTIONARY replaces its attributes, key, source, layout, lifetime or range, and it loads again from its source.",
243
+ `${DOCS}/create/dictionary`,
244
+ ),
245
+ SQLCH260: rule(
246
+ "SQLCH260",
247
+ "metadata",
248
+ "Change a function",
249
+ "A SQL user-defined function stores no data: CREATE OR REPLACE FUNCTION replaces its parameters and expression, and queries that call it use the new one.",
250
+ `${DOCS}/create/function`,
251
+ ),
252
+ SQLCH270: rule(
253
+ "SQLCH270",
254
+ "metadata",
255
+ "Change a role",
256
+ "ALTER ROLE ... SETTINGS replaces a role's settings; its grants are untouched.",
257
+ `${DOCS}/alter/role`,
258
+ ),
259
+ SQLCH271: rule(
260
+ "SQLCH271",
261
+ "metadata",
262
+ "Change a user",
263
+ "ALTER USER sets one clause (HOST, DEFAULT ROLE, DEFAULT DATABASE, GRANTEES, SETTINGS, VALID UNTIL, IDENTIFIED) and leaves the rest, the password and the grants untouched. Sessions already open keep the old settings.",
264
+ `${DOCS}/alter/user`,
265
+ ),
266
+ SQLCH272: rule(
267
+ "SQLCH272",
268
+ "metadata",
269
+ "Change a row policy",
270
+ "CREATE ROW POLICY OR REPLACE replaces the policy's condition, kind and roles; queries started after it see the new one.",
271
+ `${DOCS}/create/row-policy`,
272
+ ),
273
+ SQLCH273: rule("SQLCH273", "metadata", "Grant a privilege or a role", "GRANT adds a privilege or a role to a user or role; it takes effect for new queries.", `${DOCS}/grant`),
274
+ SQLCH274: rule(
275
+ "SQLCH274",
276
+ "metadata",
277
+ "Revoke a privilege or a role",
278
+ "REVOKE takes a privilege or a role away, including one granted by hand to a grantee the build declares grants for; queries that relied on it fail from then on.",
279
+ `${DOCS}/revoke`,
280
+ ),
238
281
  SQLCH250: rule("SQLCH250", "drop", "Drop an object", "DROP removes the object and, for a table, its data; it is not undone.", `${DOCS}/drop`),
239
282
  } as const satisfies Record<string, ClassifierRule>;
240
283
 
@@ -4,15 +4,16 @@
4
4
  */
5
5
 
6
6
  import { readFileSync } from "node:fs";
7
- import { canonicalObject } from "./normalize";
8
- import type { SchemaObject } from "./diff";
7
+ import { canonicalObject, grantsObject, liveCanonical, objectKey, scopeOf } from "./normalize";
8
+ import { grantsByGrantee } from "../access";
9
+ import type { SchemaObject, UnreadableEntry } from "./diff";
9
10
  import type { ClickHouseTarget } from "../live/bind";
10
- import { readLiveSchema } from "../live/catalog";
11
+ import { readLiveAccess, readLiveSchema, type DeclaredAccess } from "../live/catalog";
11
12
  import { renderFor, type Topology } from "../topology";
12
13
 
13
14
  interface OutputDoc {
14
15
  dialect?: string;
15
- objects?: Array<{ export: string; ddl: string }>;
16
+ objects?: Array<{ export: string; ddl: string; type?: string }>;
16
17
  }
17
18
 
18
19
  /** The objects a `chant build` output holds, keyed by export name; with a `topology`, each declaration as rendered for it (`../topology.ts`). */
@@ -21,7 +22,15 @@ export function schemaFromBuildOutput(json: string, defaultDatabase = "default",
21
22
  if (doc.dialect !== "clickhouse" || !Array.isArray(doc.objects)) {
22
23
  throw new Error("not a sql lexicon build output: expected { dialect: \"clickhouse\", objects: [...] }");
23
24
  }
24
- return doc.objects.map((o) => ({ key: o.export, canonical: canonicalObject(renderFor(o.ddl, topology), defaultDatabase) }));
25
+ const out: SchemaObject[] = doc.objects
26
+ .filter((o) => o.type !== "ClickHouse::Grant")
27
+ .map((o) => ({ key: o.export, canonical: canonicalObject(renderFor(o.ddl, topology), defaultDatabase) }));
28
+ // Grants are compared per grantee (`../access.ts`).
29
+ for (const g of grantsByGrantee(doc.objects.map((o) => ({ ...o, type: o.type ?? "" })))) {
30
+ const canonical = grantsObject(g.ddl, g.grantee, defaultDatabase);
31
+ out.push({ key: objectKey(canonical), canonical });
32
+ }
33
+ return out;
25
34
  }
26
35
 
27
36
  export function schemaFromBuildFile(path: string, defaultDatabase = "default", topology?: Topology): SchemaObject[] {
@@ -34,7 +43,7 @@ export function schemaFromBuildFile(path: string, defaultDatabase = "default", t
34
43
  */
35
44
  export function keyedByQualifiedName(objects: readonly SchemaObject[]): Array<SchemaObject & { exportName: string }> {
36
45
  return objects.map((o) => ({
37
- key: o.canonical.database ? `${o.canonical.database}.${o.canonical.name}` : o.canonical.name,
46
+ key: objectKey(o.canonical),
38
47
  canonical: o.canonical,
39
48
  exportName: o.key,
40
49
  }));
@@ -42,16 +51,30 @@ export function keyedByQualifiedName(objects: readonly SchemaObject[]): Array<Sc
42
51
 
43
52
  /**
44
53
  * What the server holds, keyed by `database.name`, limited to the databases
45
- * the declarations use (and the target's scope). The `default` database is
46
- * left out, as import leaves it out.
54
+ * the declarations use (and the target's scope) and the functions they
55
+ * declare (`scopeOf`). The `default` database is
56
+ * left out, as import leaves it out. An object whose definition chant cannot
57
+ * read is not left out silently (#3653): it goes in `unreadable`, when
58
+ * given, and otherwise fails the read.
47
59
  */
48
- export async function schemaFromServer(target: ClickHouseTarget, databases?: ReadonlySet<string>): Promise<SchemaObject[]> {
49
- const live = await readLiveSchema(target);
50
- return live
51
- .filter((o) => !(o.type === "ClickHouse::Database" && o.name === "default"))
52
- .filter((o) => !databases || databases.has(o.database ?? o.name))
53
- .map((o) => ({
54
- key: o.database ? `${o.database}.${o.name}` : o.name,
55
- canonical: canonicalObject(o.statement, target.defaultDatabase),
56
- }));
60
+ export async function schemaFromServer(
61
+ target: ClickHouseTarget,
62
+ databases?: ReadonlySet<string>,
63
+ unreadable?: UnreadableEntry[],
64
+ access: readonly DeclaredAccess[] = [],
65
+ ): Promise<SchemaObject[]> {
66
+ const live = [...(await readLiveSchema(target)), ...(await readLiveAccess(target, access))];
67
+ const out: SchemaObject[] = [];
68
+ for (const o of live) {
69
+ if (o.type === "ClickHouse::Database" && o.name === "default") continue;
70
+ if (databases && !databases.has(scopeOf(o))) continue;
71
+ const key = objectKey(o);
72
+ try {
73
+ out.push({ key, canonical: liveCanonical(o, target.defaultDatabase) });
74
+ } catch (err) {
75
+ if (!unreadable) throw err;
76
+ unreadable.push({ object: key, type: o.type, reason: `its definition does not parse: ${(err as Error).message}` });
77
+ }
78
+ }
79
+ return out;
57
80
  }
@@ -9,7 +9,7 @@
9
9
  import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
10
10
  import type { Change } from "./diff";
11
11
 
12
- const EXPRESSION_FIELDS = /^(orderBy|primaryKey|partitionBy|sampleBy|select|columns\.[^.]+\.(default|ttl)|indexes\..+|projections\..+)$/;
12
+ const EXPRESSION_FIELDS = /^(orderBy|primaryKey|partitionBy|sampleBy|select|lambda|using|columns\.[^.]+\.(default|ttl)|indexes\..+|projections\..+)$/;
13
13
 
14
14
  const quote = (s: string) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
15
15
 
@@ -71,7 +71,7 @@ import { RebuildRefusal, type RebuildObservation } from "./observe";
71
71
  import { DDL_SETTINGS } from "./replicas";
72
72
  import { shardPartitions, sourcePartitionExpression, sourcePartitions } from "./partitions";
73
73
  import { rowHash } from "./verify";
74
- import { cutoverOf, observe, serverNow, syncReplica, utcLiteral, waitOn, type RebuildRun } from "./steps";
74
+ import { cutoverOf, observe, serverCutoverProbe, syncReplica, utcLiteral, waitForCutover, waitOn, type CutoverProbe, type RebuildRun } from "./steps";
75
75
  import { intoShard, onShard, onShardServers, shardTable, SHARD_DELETE_SETTINGS, SHARD_INSERT_SETTINGS, type Shard } from "./shards";
76
76
  import { renderFor } from "../topology";
77
77
 
@@ -118,6 +118,8 @@ export interface BackfillDeps {
118
118
  receipts?: ClickHouseReceiptStore;
119
119
  /** Called after each partition is copied and its receipt written, with its shard on a cluster of shards; a test interrupts the backfill here. */
120
120
  afterPartition?: (partition: string, shard?: number) => void | Promise<void>;
121
+ /** What the wait for the cut-over reads. Default: the server's clock, queries and insert buffers. */
122
+ cutoverProbe?: CutoverProbe;
121
123
  }
122
124
 
123
125
  export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promise<BackfillResult> {
@@ -128,16 +130,8 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
128
130
  const n = o.names;
129
131
  const cutover = cutoverOf(run, o);
130
132
 
131
- if (cutover !== undefined) {
132
- // Rows before the cut-over are the backfill's; wait until the server's
133
- // clock has passed it, so none of them is still to arrive.
134
- for (;;) {
135
- const now = await serverNow(run.target);
136
- if (now > cutover) break;
137
- run.log(`-- waiting ${Math.ceil((cutover - now) / 1000)}s for the cut-over at ${new Date(cutover).toISOString()}`);
138
- await sleep(Math.min(cutover - now + 50, 10_000), run.signal);
139
- }
140
- }
133
+ // Rows before the cut-over are the backfill's; wait until none of them is still to arrive.
134
+ if (cutover !== undefined) await waitForCutover(run, cutover, deps.cutoverProbe ?? serverCutoverProbe(run, o), run.cutoverTimeoutMs !== undefined ? { timeoutMs: run.cutoverTimeoutMs } : {});
141
135
  await waitOn(run, n.database, n.name);
142
136
  await syncReplica(run, o, n.database, n.name);
143
137
 
@@ -335,17 +329,3 @@ async function copyAfterCutover(run: RebuildRun, o: RebuildObservation, later: L
335
329
  }
336
330
  return repaired;
337
331
  }
338
-
339
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
340
- return new Promise((resolve, reject) => {
341
- const t = setTimeout(resolve, ms);
342
- signal?.addEventListener(
343
- "abort",
344
- () => {
345
- clearTimeout(t);
346
- reject(signal.reason);
347
- },
348
- { once: true },
349
- );
350
- });
351
- }
@@ -57,11 +57,15 @@ export type DualWrite =
57
57
  * `cutoverColumn` is at or after the cut-over into the new table; the
58
58
  * backfill copies the rows before it, and the rows at or after it that
59
59
  * the table held before the view was made. The cut-over is the server's
60
- * time when the view is made plus `cutoverDelay` (default `1m`), rounded
61
- * up to a whole second, and the backfill starts once it has passed. The column must be a time
60
+ * time when the view is made plus `cutoverDelay` (default `5s`), rounded
61
+ * up to a whole second. The backfill starts once the server's clock has
62
+ * passed it and every write into the old table begun before it has
63
+ * finished (`cutoverTimeout` bounds that wait). The column must be a time
62
64
  * (`Date`, `DateTime`, `DateTime64`) that rows arrive in order of, give
63
65
  * or take the delay; a row that arrives later than that is caught by
64
- * the verification, which then fails the run.
66
+ * the verification, which then fails the run. Writers that batch rows
67
+ * for longer than five seconds before inserting them need a delay
68
+ * longer than their batches.
65
69
  */
66
70
  mode: "materialized-view";
67
71
  cutoverColumn: string;
@@ -105,6 +105,13 @@ export interface ClickHouseRebuildOpConfig {
105
105
  * of the step waits this long. Default: `2m`.
106
106
  */
107
107
  replicaTimeout?: string;
108
+ /**
109
+ * Materialized-view mode: how long the backfill waits, once the cut-over
110
+ * has passed, for INSERTs into the old table begun before it to finish and
111
+ * for asynchronous inserts queued before it to be flushed, before it stops
112
+ * naming them. Default: `10m`.
113
+ */
114
+ cutoverTimeout?: string;
108
115
  /** Ownership stack. Default: `ownership.stack` in `chant.config.ts`. */
109
116
  stack?: string;
110
117
  /** Ownership env. Default: `ownership.env` in `chant.config.ts`. */
@@ -124,6 +131,7 @@ export interface ClickHouseRebuildArgs {
124
131
  retain?: string;
125
132
  mutationTimeout?: string;
126
133
  replicaTimeout?: string;
134
+ cutoverTimeout?: string;
127
135
  stack?: string;
128
136
  ownershipEnv?: string;
129
137
  cwd?: string;
@@ -158,6 +166,7 @@ export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHou
158
166
  ...(config.retain ? { retain: config.retain } : {}),
159
167
  ...(config.mutationTimeout ? { mutationTimeout: config.mutationTimeout } : {}),
160
168
  ...(config.replicaTimeout ? { replicaTimeout: config.replicaTimeout } : {}),
169
+ ...(config.cutoverTimeout ? { cutoverTimeout: config.cutoverTimeout } : {}),
161
170
  ...(config.stack ? { stack: config.stack } : {}),
162
171
  ...(config.ownershipEnv ? { ownershipEnv: config.ownershipEnv } : {}),
163
172
  ...(config.path && config.path !== "." ? { cwd: config.path } : {}),
@@ -14,6 +14,9 @@
14
14
  * backfill that fails part way leaves the new table and its receipts, and
15
15
  * the next run skips the partitions already copied, verifies and swaps
16
16
  * with no gate, and keeps the old table.
17
+ * - A small table (#3700): the backfill waits only the default cut-over
18
+ * delay, and the wait sees an INSERT still running and an asynchronous
19
+ * insert still buffered.
17
20
  * - An interrupted backfill: stopped after three partitions, then killed
18
21
  * between a partition's INSERT and its receipt, then resumed: the receipts
19
22
  * skip what was copied, the half-copied partition is cleared first, and the
@@ -38,6 +41,7 @@ import * as rebuildActivities from "../../op/activities/clickhouse-rebuild";
38
41
  import type { ClickHouseRebuildDeps } from "../../op/activities/clickhouse-rebuild";
39
42
  import { ClickHouseRebuildOp, type ClickHouseRebuildOpConfig } from "./op";
40
43
  import { clickhouseReceiptStore, RECEIPTS_DATABASE, RECEIPTS_TABLE, type ClickHouseReceiptStore } from "./receipts";
44
+ import { serverCutoverProbe, type RebuildRun } from "./steps";
41
45
 
42
46
  const enabled = await dockerAvailable();
43
47
  let server: ScratchServer | undefined;
@@ -560,3 +564,61 @@ ORDER BY ${orderBy}`;
560
564
  await q("DROP TABLE shop.sessions__chant_new SYNC");
561
565
  }, 300_000);
562
566
  });
567
+
568
+ describe.skipIf(!enabled)("a small table waits seconds for its cut-over (#3700)", () => {
569
+ const tinyDdl = (orderBy: string) => `CREATE TABLE shop.tiny (ts DateTime, id UInt64) ENGINE = MergeTree PARTITION BY toYYYYMM(ts) ORDER BY ${orderBy}`;
570
+ const V1: Obj = { export: "tiny", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: tinyDdl("(ts, id)") };
571
+ const V2: Obj = { ...V1, ddl: tinyDdl("(id, ts)") };
572
+
573
+ test("thirty rows reach the swap gate with the default delay, a few seconds after the view is made", async () => {
574
+ await clickhouseApply({ buildPath: join(dir, writeBuild("tiny-v1.json", [DB, V1])), environment: "e2e" }, undefined, deps());
575
+ await q("INSERT INTO shop.tiny SELECT now() - toIntervalMinute(number), number FROM numbers(30)");
576
+ writeBuild("tiny-v2.json", [DB, V2]);
577
+ const lines: string[] = [];
578
+ const began = Date.now();
579
+ const r = await runOp(
580
+ {
581
+ name: "rebuild-tiny",
582
+ env: "e2e",
583
+ table: "shop.tiny",
584
+ dualWrite: { mode: "materialized-view", cutoverColumn: "ts" },
585
+ build: false,
586
+ path: dir,
587
+ output: "tiny-v2.json",
588
+ stack: MARKER.stack,
589
+ ownershipEnv: MARKER.env,
590
+ },
591
+ new Ledger().port,
592
+ { log: (l: string) => void lines.push(l) },
593
+ );
594
+ expect(r.status).toBe("gated");
595
+ expect(outcome(r, "VerifiedRows")).toBe(30);
596
+ // The delay is five seconds, rounded up to the next whole second.
597
+ const waited = lines.filter((l) => l.startsWith("-- waiting") && l.includes("for the cut-over"));
598
+ expect(waited.length).toBeGreaterThan(0);
599
+ expect(Number(/waiting (\d+)s/.exec(waited[0]!)![1])).toBeLessThanOrEqual(6);
600
+ expect(Date.now() - began).toBeLessThan(30_000);
601
+ }, 120_000);
602
+
603
+ test("the wait names an INSERT begun before the cut-over while it runs, and an asynchronous insert while it is buffered", async () => {
604
+ await q("CREATE TABLE shop.writes (ts DateTime, id UInt64) ENGINE = MergeTree ORDER BY ts");
605
+ const run = { target: { endpoint: server!.endpoint } } as unknown as RebuildRun;
606
+ const probe = serverCutoverProbe(run, { names: { database: "shop", name: "writes" }, replicated: false } as never);
607
+ const later = (await probe.now()) + 60_000;
608
+ expect(await probe.pendingWrites(later)).toEqual([]);
609
+
610
+ const slow = clickhouseQuery(server!.endpoint, "INSERT INTO shop.writes SELECT now(), number + sleepEachRow(1) FROM numbers(3) SETTINGS max_block_size = 1", { queryId: "e2e-3700-slow" });
611
+ await new Promise((r) => setTimeout(r, 500));
612
+ expect(await probe.pendingWrites(later)).toEqual(["e2e-3700-slow"]);
613
+ // Begun after the cut-over: not the backfill's to wait for.
614
+ expect(await probe.pendingWrites((await probe.now()) - 60_000)).toEqual([]);
615
+ await slow;
616
+ expect(await probe.pendingWrites(later)).toEqual([]);
617
+
618
+ await clickhouseQuery(server!.endpoint, "INSERT INTO shop.writes VALUES (now(), 7)", {
619
+ queryId: "e2e-3700-async",
620
+ settings: { async_insert: "1", wait_for_async_insert: "0", async_insert_busy_timeout_min_ms: "4000", async_insert_busy_timeout_max_ms: "4000", async_insert_use_adaptive_busy_timeout: "0" },
621
+ });
622
+ expect(await probe.pendingWrites(later)).toEqual(["e2e-3700-async"]);
623
+ }, 60_000);
624
+ });