@intentius/chant-lexicon-sql 0.118.0 → 0.120.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 (116) hide show
  1. package/dist/clickhouse/apply/apply.d.ts +1 -1
  2. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  3. package/dist/clickhouse/apply/statements.d.ts +8 -0
  4. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  5. package/dist/clickhouse/import/generator.d.ts +1 -1
  6. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  7. package/dist/clickhouse/import/live-export.d.ts +19 -0
  8. package/dist/clickhouse/import/live-export.d.ts.map +1 -1
  9. package/dist/clickhouse/live/bind.d.ts +8 -0
  10. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  11. package/dist/clickhouse/live/catalog.d.ts +15 -0
  12. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  13. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  14. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  15. package/dist/clickhouse/plan/deep.d.ts +19 -2
  16. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  17. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  18. package/dist/clickhouse/plan/normalize.d.ts +3 -0
  19. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  20. package/dist/clickhouse/plan/rebuild-handoff.d.ts +2 -0
  21. package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
  22. package/dist/clickhouse/rebuild/engines.d.ts +44 -0
  23. package/dist/clickhouse/rebuild/engines.d.ts.map +1 -0
  24. package/dist/clickhouse/rebuild/verify.d.ts +25 -1
  25. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  26. package/dist/clickhouse/testing/fake-server.d.ts +5 -0
  27. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  28. package/dist/config.d.ts +32 -8
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/core/foreign-tables.d.ts +13 -0
  31. package/dist/core/foreign-tables.d.ts.map +1 -0
  32. package/dist/files/build-root.d.ts +9 -1
  33. package/dist/files/build-root.d.ts.map +1 -1
  34. package/dist/files/clickhouse.d.ts +7 -3
  35. package/dist/files/clickhouse.d.ts.map +1 -1
  36. package/dist/files/postgres.d.ts +3 -0
  37. package/dist/files/postgres.d.ts.map +1 -1
  38. package/dist/import-parser.d.ts.map +1 -1
  39. package/dist/integrity.json +2 -2
  40. package/dist/manifest.json +1 -1
  41. package/dist/op/activities/clickhouse-emulator.d.ts +7 -1
  42. package/dist/op/activities/clickhouse-emulator.d.ts.map +1 -1
  43. package/dist/op/activities/postgres-emulator.d.ts +8 -1
  44. package/dist/op/activities/postgres-emulator.d.ts.map +1 -1
  45. package/dist/postgres/access/changes.d.ts +23 -0
  46. package/dist/postgres/access/changes.d.ts.map +1 -0
  47. package/dist/postgres/apply/apply.d.ts.map +1 -1
  48. package/dist/postgres/entities.d.ts.map +1 -1
  49. package/dist/postgres/live/bind.d.ts +8 -1
  50. package/dist/postgres/live/bind.d.ts.map +1 -1
  51. package/dist/postgres/live/catalog.d.ts +3 -3
  52. package/dist/postgres/live/catalog.d.ts.map +1 -1
  53. package/dist/postgres/live/client.d.ts +31 -0
  54. package/dist/postgres/live/client.d.ts.map +1 -1
  55. package/dist/postgres/plan/commands.d.ts.map +1 -1
  56. package/dist/postgres/plan/deep.d.ts.map +1 -1
  57. package/dist/sql-files.d.ts +2 -1
  58. package/dist/sql-files.d.ts.map +1 -1
  59. package/package.json +2 -2
  60. package/src/clickhouse/apply/apply.ts +8 -2
  61. package/src/clickhouse/apply/statements.ts +32 -5
  62. package/src/clickhouse/import/generator.ts +8 -3
  63. package/src/clickhouse/import/import.test.ts +64 -0
  64. package/src/clickhouse/import/live-export.ts +70 -2
  65. package/src/clickhouse/live/access.e2e.test.ts +71 -18
  66. package/src/clickhouse/live/bind.ts +10 -0
  67. package/src/clickhouse/live/catalog.ts +25 -2
  68. package/src/clickhouse/live/describe-resources.ts +5 -2
  69. package/src/clickhouse/live/live.test.ts +8 -0
  70. package/src/clickhouse/plan/commands.ts +6 -3
  71. package/src/clickhouse/plan/deep-grants.test.ts +67 -0
  72. package/src/clickhouse/plan/deep.ts +70 -6
  73. package/src/clickhouse/plan/diff.ts +4 -1
  74. package/src/clickhouse/plan/normalize.ts +5 -2
  75. package/src/clickhouse/plan/plan.test.ts +9 -1
  76. package/src/clickhouse/plan/rebuild-handoff.ts +11 -1
  77. package/src/clickhouse/rebuild/engines.ts +163 -0
  78. package/src/clickhouse/rebuild/rebuild-collapsing.e2e.test.ts +120 -0
  79. package/src/clickhouse/rebuild/rebuild-into-collapsing.e2e.test.ts +198 -0
  80. package/src/clickhouse/rebuild/rebuild.test.ts +78 -0
  81. package/src/clickhouse/rebuild/verify.ts +153 -24
  82. package/src/clickhouse/testing/fake-server.ts +3 -2
  83. package/src/codegen/classifier-page.ts +1 -1
  84. package/src/config.ts +16 -4
  85. package/src/core/foreign-tables.ts +28 -0
  86. package/src/files/build-root.ts +15 -3
  87. package/src/files/clickhouse.ts +71 -5
  88. package/src/files/postgres.ts +2 -1
  89. package/src/import-parser.ts +2 -0
  90. package/src/migration-statements.test.ts +33 -0
  91. package/src/migration-statements.ts +2 -0
  92. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +1 -1
  93. package/src/op/activities/clickhouse-emulator.ts +22 -1
  94. package/src/op/activities/postgres-apply-lock-timeout.e2e.test.ts +87 -0
  95. package/src/op/activities/postgres-apply.test.ts +32 -0
  96. package/src/op/activities/postgres-emulator.test.ts +20 -0
  97. package/src/op/activities/postgres-emulator.ts +41 -1
  98. package/src/op/apply-gate-access-clickhouse.e2e.test.ts +195 -0
  99. package/src/op/apply-gate-access.e2e.test.ts +207 -0
  100. package/src/postgres/access/access.test.ts +26 -1
  101. package/src/postgres/access/changes.ts +39 -0
  102. package/src/postgres/apply/apply.ts +10 -7
  103. package/src/postgres/entities.ts +20 -1
  104. package/src/postgres/live/bind.ts +16 -4
  105. package/src/postgres/live/catalog.ts +3 -15
  106. package/src/postgres/live/client.ts +85 -0
  107. package/src/postgres/live/live.test.ts +54 -0
  108. package/src/postgres/live/lock-timeout.e2e.test.ts +70 -0
  109. package/src/postgres/plan/commands.ts +2 -14
  110. package/src/postgres/plan/deep.ts +44 -2
  111. package/src/serializer.test.ts +4 -0
  112. package/src/skills/chant-sql-postgres-plan.md +1 -1
  113. package/src/skills/chant-sql-rebuild.md +2 -0
  114. package/src/skills/chant-sql.md +1 -1
  115. package/src/sql-files.test.ts +110 -1
  116. package/src/sql-files.ts +2 -1
@@ -17,17 +17,29 @@
17
17
  * (#3664). When a server's copy of a table differs from the profile
18
18
  * server's, that copy is what is reported, with the server in `observedOn`,
19
19
  * which the drift report prints as `seen on`.
20
+ *
21
+ * Where the profile manages access, the grants an apply would give or take
22
+ * from each declared grantee are reported as `pending` (#3733), the same
23
+ * statements `chant sql plan` plans (SQLCH273, SQLCH274), so a privilege
24
+ * granted or revoked by hand after an approval moves the plan digest.
20
25
  */
21
26
 
22
- import { deepObservation, type DeepObservationResult, type DeepResourceObservation, type DeepNormalizationHooks } from "@intentius/chant/deep-observation";
27
+ import {
28
+ deepObservation,
29
+ type DeepObservationResult,
30
+ type DeepPendingChange,
31
+ type DeepResourceObservation,
32
+ type DeepNormalizationHooks,
33
+ } from "@intentius/chant/deep-observation";
23
34
  import type { UnobservedEntity } from "@intentius/chant/lexicon";
24
35
  import { bindClickHouse, classifyClickHouseFailure, type BindOptions, type ClickHouseTarget } from "../live/bind";
25
- import { accessOf, readLiveAccess, readLiveSchema, SYSTEM_DATABASES, type LiveObject } from "../live/catalog";
36
+ import { ACCESS_ENTITY_TYPES, ACCESS_UNMANAGED_DETAIL, accessOf, readLiveAccess, readLiveSchema, SYSTEM_DATABASES, type LiveObject } from "../live/catalog";
26
37
  import { clickhouseQuery } from "../http";
27
38
  import { sqlString } from "../apply/statements";
28
39
  import { stripMarkerFromStatement } from "../ownership";
29
40
  import { database, dictionary, func, policy, role, table, user, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
30
- import { canonicalObject, objectKey, type CanonicalColumn, type CanonicalObject } from "./normalize";
41
+ import { canonicalObject, grantsObject, objectKey, type CanonicalColumn, type CanonicalObject } from "./normalize";
42
+ import { grantStatement, grantsByGrantee, revokeStatement, type GrantAtom } from "../access";
31
43
  import { renderFor } from "../topology";
32
44
 
33
45
  type Props = Record<string, unknown>;
@@ -207,7 +219,7 @@ export async function observeResourcesDeep(
207
219
  let live: LiveObject[];
208
220
  try {
209
221
  target = await bindClickHouse(options);
210
- live = [...(await readLiveSchema(target)), ...(await readLiveAccess(target, accessOf(options.entities, target.defaultDatabase)))];
222
+ live = [...(await readLiveSchema(target)), ...(target.access === true ? await readLiveAccess(target, accessOf(options.entities, target.defaultDatabase)) : [])];
211
223
  } catch (err) {
212
224
  const why = classifyClickHouseFailure(err);
213
225
  for (const name of options.entityNames) {
@@ -227,17 +239,34 @@ export async function observeResourcesDeep(
227
239
  }
228
240
  return deepObservation({}, unobserved);
229
241
  }
242
+ const pending: DeepPendingChange[] = [];
243
+ if (target.access === true) {
244
+ try {
245
+ pending.push(...(await pendingGrants(target, options.entities)));
246
+ } catch (err) {
247
+ const why = classifyClickHouseFailure(err);
248
+ for (const name of options.entityNames) {
249
+ if (options.entities.get(name)?.entityType !== CLICKHOUSE_ENTITY_TYPES.grant) continue;
250
+ unobserved[name] = { type: CLICKHOUSE_ENTITY_TYPES.grant, reason: "read-failed", detail: `the server's grants could not be compared: ${why.detail}` };
251
+ }
252
+ }
253
+ }
230
254
  for (const name of options.entityNames) {
231
255
  const entity = options.entities.get(name);
232
256
  if (!entity || !entity.entityType.startsWith("ClickHouse::")) {
233
257
  unobserved[name] = { type: entity?.entityType ?? "", reason: "unsupported-kind" };
234
258
  continue;
235
259
  }
260
+ if (target.access !== true && ACCESS_ENTITY_TYPES.has(entity.entityType)) {
261
+ unobserved[name] = { type: entity.entityType, reason: "filtered", detail: ACCESS_UNMANAGED_DETAIL };
262
+ continue;
263
+ }
236
264
  if (entity.entityType === CLICKHOUSE_ENTITY_TYPES.grant) {
265
+ if (unobserved[name]) continue;
237
266
  unobserved[name] = {
238
267
  type: entity.entityType,
239
268
  reason: "unsupported-kind",
240
- detail: "a grant is compared as part of its grantees' grants, which chant sql plan reports (SQLCH273, SQLCH274)",
269
+ detail: "a grant is compared as part of its grantees' grants: what an apply would grant or revoke is listed as pending (SQLCH273, SQLCH274)",
241
270
  };
242
271
  continue;
243
272
  }
@@ -280,5 +309,40 @@ export async function observeResourcesDeep(
280
309
  unobserved[name] = { type: entity.entityType, reason: "read-failed", detail: `the server's definition does not parse: ${(err as Error).message}` };
281
310
  }
282
311
  }
283
- return deepObservation(resources, unobserved);
312
+ return deepObservation(resources, unobserved, pending);
313
+ }
314
+
315
+ /**
316
+ * The grants an apply would give or take from each declared grantee (#3733),
317
+ * as `chant sql plan` compares them: the declared grants against what
318
+ * `SHOW GRANTS FOR` prints, atom by atom. One change per grantee, its
319
+ * REVOKEs before its GRANTs as the applier sends them, with the grant
320
+ * declarations behind it. A grantee the server does not hold yet is given
321
+ * every declared grant.
322
+ */
323
+ export async function pendingGrants(
324
+ target: ClickHouseTarget,
325
+ entities: ReadonlyMap<string, { entityType: string; props: Record<string, unknown> }>,
326
+ ): Promise<DeepPendingChange[]> {
327
+ const declared = grantsByGrantee([...entities].map(([name, e]) => ({ export: name, type: e.entityType, ddl: String(e.props.ddl ?? "") })));
328
+ if (declared.length === 0) return [];
329
+ const live = await readLiveAccess(target, declared.map((g) => ({ kind: "grants", name: g.grantee })));
330
+ const held = new Map(live.filter((o) => o.type === CLICKHOUSE_ENTITY_TYPES.grant).map((o) => [o.name, grantsObject(o.statement, o.name, target.defaultDatabase).access ?? {}]));
331
+ const atom = (json: string) => JSON.parse(json) as GrantAtom;
332
+ const out: DeepPendingChange[] = [];
333
+ for (const g of declared) {
334
+ const want = grantsObject(g.ddl, g.grantee, target.defaultDatabase).access ?? {};
335
+ const have = held.get(g.grantee) ?? {};
336
+ const statements = [
337
+ ...Object.keys(have)
338
+ .filter((k) => !(k in want))
339
+ .map((k) => revokeStatement(atom(have[k]!), g.grantee)),
340
+ ...Object.keys(want)
341
+ .filter((k) => !(k in have))
342
+ .map((k) => grantStatement(atom(want[k]!), g.grantee)),
343
+ ];
344
+ if (statements.length === 0) continue;
345
+ out.push({ subject: `grants ${g.grantee}`, change: statements.join("; "), entities: [...g.exports].sort() });
346
+ }
347
+ return out;
284
348
  }
@@ -259,7 +259,10 @@ export function diffSchemas(before: readonly SchemaObject[], after: readonly Sch
259
259
  for (const m of matches) {
260
260
  if (m.kind === "created") changes.push(change(m.after.key, "object", "SQLCH200", undefined, qualified(m.after.canonical)));
261
261
  else if (m.kind === "matched") diffObject(m.after.key, m.before.canonical, m.after.canonical, changes, hints);
262
- else {
262
+ else if (m.before.canonical.foreign) {
263
+ // A migration runner's history table (#3676): not chant's to drop.
264
+ hints.push(`${qualified(m.before.canonical)} is kept by ${m.before.canonical.foreign}; it is not chant's to drop and is left alone`);
265
+ } else {
263
266
  const b = m.before;
264
267
  changes.push(change(b.key, "object", "SQLCH250", qualified(b.canonical), undefined, { destructive: b.canonical.kind === "table" || b.canonical.kind === "materializedView" }));
265
268
  }
@@ -98,6 +98,8 @@ export interface CanonicalObject {
98
98
  access?: Record<string, string>;
99
99
  /** `-- previously: <name>` before the statement: the object's previous name. */
100
100
  previously?: string;
101
+ /** The tool that keeps a live table (a migration runner's history table): a plan never drops it. */
102
+ foreign?: string;
101
103
  }
102
104
 
103
105
  const KEYWORDS = new Set(
@@ -560,6 +562,7 @@ export function scopeOf(o: { kind?: ObjectKind; type?: string; database?: string
560
562
  }
561
563
 
562
564
  /** 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
+ export function liveCanonical(o: { type: string; name: string; statement: string; foreign?: string }, defaultDatabase = "default"): CanonicalObject {
566
+ const canonical = o.type === "ClickHouse::Grant" ? grantsObject(o.statement, o.name, defaultDatabase) : canonicalObject(o.statement, defaultDatabase);
567
+ return o.foreign ? { ...canonical, foreign: o.foreign } : canonical;
565
568
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { canonicalCodec, canonicalExpression, canonicalObject, canonicalTtl, canonicalType } from "./normalize";
2
+ import { canonicalCodec, canonicalExpression, canonicalObject, canonicalTtl, canonicalType, liveCanonical } from "./normalize";
3
3
  import { diffSchemas, type SchemaObject } from "./diff";
4
4
  import { renderDiff } from "./report";
5
5
  import { CLASSIFIER_RULES } from "./rules";
@@ -184,6 +184,14 @@ describe("classifying a change", () => {
184
184
  expect(classify("CREATE VIEW events AS SELECT 1")).toEqual([["kind", "SQLCH224", "rebuild"]]);
185
185
  });
186
186
 
187
+ test("a migration runner's history table is named in a hint, never dropped (#3676)", () => {
188
+ const statement = "CREATE TABLE legacy.schema_migrations (version Int64, dirty UInt8, sequence UInt64) ENGINE = MergeTree ORDER BY sequence";
189
+ const live = [{ key: "legacy.schema_migrations", canonical: liveCanonical({ type: "ClickHouse::Table", name: "schema_migrations", statement, foreign: "a migration runner (Rails, golang-migrate, dbmate)" }) }];
190
+ const d = diffSchemas(live, []);
191
+ expect(d.changes).toEqual([]);
192
+ expect(d.hints).toEqual(["legacy.schema_migrations is kept by a migration runner (Rails, golang-migrate, dbmate); it is not chant's to drop and is left alone"]);
193
+ });
194
+
187
195
  test("against a server, previously before the statement matches the old name", () => {
188
196
  const live = [{ key: "default.events", canonical: canonicalObject(BASE) }];
189
197
  const declared = [{ key: "default.raw_events", canonical: canonicalObject(`-- previously: events\n${BASE.replace("TABLE events", "TABLE raw_events")}`) }];
@@ -19,11 +19,14 @@
19
19
  import type { CanonicalObject } from "./normalize";
20
20
  import type { SchemaDiff } from "./diff";
21
21
  import { migrationOpName, renderMigrationOps, type MigrationOpSuggestion } from "../../core/handoff";
22
+ import { collapsesRows } from "../rebuild/engines";
22
23
 
23
24
  export interface RebuildOpSuggestion extends MigrationOpSuggestion {
24
25
  /** `database.name`, the Op's `table`. */
25
26
  table: string;
26
27
  dualWrite: { mode: "materialized-view"; cutoverColumn: string } | { mode: "app" };
28
+ /** What to know before running it: set for an engine that collapses rows by sorting key (#3674, #3727). */
29
+ note?: string;
27
30
  }
28
31
 
29
32
  const TIME_TYPE = /^(?:Nullable\s*\(\s*)?(?:Date|Date32|DateTime|DateTime64)\b/;
@@ -42,11 +45,17 @@ export function rebuildOpSuggestions(diff: SchemaDiff, declared: ReadonlyMap<str
42
45
  const dualWrite: RebuildOpSuggestion["dualWrite"] = time ? { mode: "materialized-view", cutoverColumn: time.name } : { mode: "app" };
43
46
  const name = migrationOpName("rebuild", table);
44
47
  const dw = dualWrite.mode === "app" ? `{ mode: "app" }` : `{ mode: "materialized-view", cutoverColumn: ${JSON.stringify(dualWrite.cutoverColumn)} }`;
48
+ const note = collapsesRows(c.engineName)
49
+ ? `${table} is a ${c.engineName}: when the old table's engine collapses rows too, the Verify phase reads both tables under FINAL, so rows the old table has not merged yet compare as merged. ` +
50
+ `A new sorting key that collapses rows differently from the old one still fails it; OPTIMIZE TABLE ${table} FINAL before the run avoids that. ` +
51
+ `From an engine that keeps every row (a plain MergeTree), it groups both tables by the new sorting key instead and compares what a ${c.engineName} keeps per key (its sums, its highest version, the sum of its signs).`
52
+ : undefined;
45
53
  out.push({
46
54
  table,
47
55
  name,
48
56
  env,
49
57
  dualWrite,
58
+ ...(note ? { note } : {}),
50
59
  declaration: `export const { op } = ClickHouseRebuildOp({ name: ${JSON.stringify(name)}, env: ${JSON.stringify(env)}, table: ${JSON.stringify(table)}, dualWrite: ${dw} });`,
51
60
  });
52
61
  }
@@ -55,5 +64,6 @@ export function rebuildOpSuggestions(diff: SchemaDiff, declared: ReadonlyMap<str
55
64
 
56
65
  /** The suggestions as text, for the report. */
57
66
  export function renderRebuildOps(ops: readonly RebuildOpSuggestion[]): string[] {
58
- return renderMigrationOps(ops, { what: "the rebuild migration Op", exportName: "ClickHouseRebuildOp", importPath: "@intentius/chant-lexicon-sql/clickhouse" });
67
+ const notes = ops.flatMap((o) => (o.note ? [` Note: ${o.note}`] : []));
68
+ return [...renderMigrationOps(ops, { what: "the rebuild migration Op", exportName: "ClickHouseRebuildOp", importPath: "@intentius/chant-lexicon-sql/clickhouse" }), ...notes];
59
69
  }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Which table engines the rebuild reads as merged (#3674): the verification
3
+ * (`./verify.ts`) and the plan's hand-off note (`../plan/rebuild-handoff.ts`).
4
+ */
5
+
6
+ /**
7
+ * The MergeTree engines that collapse rows sharing a sorting key when parts
8
+ * merge, as `system.tables.engine` names them, replicated or shared too. A
9
+ * count of such a table depends on which parts have merged; under `FINAL` it
10
+ * does not.
11
+ */
12
+ const COLLAPSING_ENGINE = /^(?:Replicated|Shared)?(?:Summing|Replacing|Aggregating|Collapsing|VersionedCollapsing|Coalescing|Graphite)MergeTree$/;
13
+
14
+ /** Whether an engine collapses rows that share a sorting key (#3674). */
15
+ export const collapsesRows = (engine: string | undefined): boolean => COLLAPSING_ENGINE.test(engine ?? "");
16
+
17
+ /** A column of the new table, as `system.columns` reports it. */
18
+ export interface EngineColumn {
19
+ name: string;
20
+ type: string;
21
+ /** Part of the sorting key: the engine never merges its values. */
22
+ inSortingKey: boolean;
23
+ /** Part of the partition key: rows that merge share its value, so a SummingMergeTree does not sum it. */
24
+ inPartitionKey?: boolean;
25
+ }
26
+
27
+ /**
28
+ * How a collapsing engine merges the rows that share a sorting key, as what a
29
+ * merge leaves the same (#3727): for each key, the aggregates it preserves and
30
+ * whether it keeps the key at all. Both tables are grouped by the new table's
31
+ * sorting key and compared on these, so it does not matter which parts have
32
+ * merged, and a table that never collapses (a plain MergeTree) compares with
33
+ * one that does.
34
+ */
35
+ export interface CollapseSemantics {
36
+ /** The engine's name without `Replicated` or `Shared`. */
37
+ engine: string;
38
+ /** Per key: what merging leaves unchanged. SQL over the new table's column names. */
39
+ aggregates: string[];
40
+ /** Per key: whether the engine keeps a row for it. Absent: every key keeps one. */
41
+ having?: string;
42
+ /** One line for the summary and the error: what is compared per key. */
43
+ describe: string;
44
+ }
45
+
46
+ /** Split on the commas outside parentheses and quotes. */
47
+ export function splitTopLevel(text: string): string[] {
48
+ const out: string[] = [];
49
+ let depth = 0;
50
+ let quote: string | undefined;
51
+ let start = 0;
52
+ for (let i = 0; i < text.length; i++) {
53
+ const c = text[i]!;
54
+ if (quote) {
55
+ if (c === "\\") i++;
56
+ else if (c === quote) quote = undefined;
57
+ } else if (c === "'" || c === '"' || c === "`") quote = c;
58
+ else if (c === "(" || c === "[") depth++;
59
+ else if (c === ")" || c === "]") depth--;
60
+ else if (c === "," && depth === 0) {
61
+ out.push(text.slice(start, i).trim());
62
+ start = i + 1;
63
+ }
64
+ }
65
+ const last = text.slice(start).trim();
66
+ if (last || out.length > 0) out.push(last);
67
+ return out;
68
+ }
69
+
70
+ /** The engine's own arguments in `system.tables.engine_full`, without a `Replicated` engine's Keeper path and replica name. */
71
+ export function engineArguments(engineFull: string): string[] {
72
+ const m = /^\s*(\w+)\s*\(/.exec(engineFull);
73
+ if (!m) return [];
74
+ let depth = 0;
75
+ let quote: string | undefined;
76
+ const from = m[0].length;
77
+ let end = -1;
78
+ for (let i = from - 1; i < engineFull.length && end < 0; i++) {
79
+ const c = engineFull[i]!;
80
+ if (quote) {
81
+ if (c === "\\") i++;
82
+ else if (c === quote) quote = undefined;
83
+ } else if (c === "'") quote = c;
84
+ else if (c === "(") depth++;
85
+ else if (c === ")" && --depth === 0) end = i;
86
+ }
87
+ if (end < 0) return [];
88
+ const args = splitTopLevel(engineFull.slice(from, end)).filter((a) => a !== "");
89
+ if (/^(?:Replicated|Shared)/.test(m[1]!)) while (args.length > 0 && args[0]!.startsWith("'")) args.shift();
90
+ return args;
91
+ }
92
+
93
+ const NUMERIC = /^(?:U?Int(?:8|16|32|64|128|256)|Float(?:32|64)|BFloat16|Decimal(?:32|64|128|256)?\s*\(.*\))$/;
94
+ const FLOAT = /^(?:Float(?:32|64)|BFloat16)$/;
95
+ /** The SimpleAggregateFunction functions whose result a merge keeps exactly, whatever the order of the rows. */
96
+ const SIMPLE_ORDER_FREE = new Set(["sum", "sumWithOverflow", "min", "max", "groupBitAnd", "groupBitOr", "groupBitXor"]);
97
+
98
+ const quoted = (name: string) => `\`${name.replace(/\\/g, "\\\\").replace(/`/g, "\\`")}\``;
99
+ const typeLiteral = (type: string) => `'${type.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
100
+ /** A sum in the column's own type, as the engine stores it; a float to 32 bits, since the order rows are added in moves its last bits. */
101
+ const summed = (c: EngineColumn) => (FLOAT.test(c.type) ? `toFloat32(sum(${quoted(c.name)}))` : `CAST(sum(${quoted(c.name)}), ${typeLiteral(c.type)})`);
102
+
103
+ /**
104
+ * What a merge of `engine` keeps per sorting key, or undefined for an engine
105
+ * that does not collapse rows, or whose result depends on the order rows
106
+ * merge in (`GraphiteMergeTree` thins its rows by age).
107
+ */
108
+ export function collapseSemantics(engine: string, engineFull: string, columns: readonly EngineColumn[]): CollapseSemantics | undefined {
109
+ if (!collapsesRows(engine)) return undefined;
110
+ const base = engine.replace(/^(?:Replicated|Shared)/, "").replace(/MergeTree$/, "");
111
+ const args = engineArguments(engineFull);
112
+ const values = columns.filter((c) => !c.inSortingKey);
113
+ const named = (n: string | undefined) => (n ? columns.find((c) => c.name === n.replace(/^`|`$/g, "")) : undefined);
114
+ switch (base) {
115
+ case "Summing": {
116
+ // The columns listed, else every numeric column outside the sorting and partition keys; a key whose sums are all zero is removed.
117
+ const listed = args[0] ? splitTopLevel(args[0].replace(/^\((.*)\)$/s, "$1")).map(named) : undefined;
118
+ const sums = (listed ? listed.filter((c): c is EngineColumn => c !== undefined) : values).filter((c) => !c.inSortingKey && !c.inPartitionKey && NUMERIC.test(c.type));
119
+ const aggregates = sums.map(summed);
120
+ return {
121
+ engine: `${base}MergeTree`,
122
+ aggregates,
123
+ ...(aggregates.length > 0 ? { having: `NOT (${aggregates.map((a) => `${a} = 0`).join(" AND ")})` } : {}),
124
+ describe: sums.length > 0 ? `the sums of ${sums.map((c) => c.name).join(", ")}` : "the keys",
125
+ };
126
+ }
127
+ case "Replacing": {
128
+ // The row with the highest version is kept; without a version, the last one written, so only the key is compared.
129
+ const ver = named(args[0]);
130
+ return { engine: `${base}MergeTree`, aggregates: ver ? [`max(${quoted(ver.name)})`] : [], describe: ver ? `the highest ${ver.name}` : "the keys" };
131
+ }
132
+ case "Collapsing":
133
+ case "VersionedCollapsing": {
134
+ // A pair of rows with opposite signs cancels: the sum of the signs is what a merge keeps, and a key whose signs cancel out is removed.
135
+ const sign = named(args[0]);
136
+ if (!sign) return undefined;
137
+ const s = `sum(${quoted(sign.name)})`;
138
+ return { engine: `${base}MergeTree`, aggregates: [s], having: `${s} != 0`, describe: `the sum of ${sign.name}` };
139
+ }
140
+ case "Aggregating": {
141
+ const simple = values.flatMap((c) => {
142
+ const m = /^SimpleAggregateFunction\(\s*(\w+)\s*,/.exec(c.type);
143
+ return m && SIMPLE_ORDER_FREE.has(m[1]!) ? [{ c, fn: m[1]! }] : [];
144
+ });
145
+ return {
146
+ engine: `${base}MergeTree`,
147
+ aggregates: simple.map(({ c, fn }) => `${fn}(${quoted(c.name)})`),
148
+ describe: simple.length > 0 ? `${simple.map(({ c, fn }) => `${fn}(${c.name})`).join(", ")}` : "the keys",
149
+ };
150
+ }
151
+ case "Coalescing": {
152
+ // The last value that is not NULL is kept: whether a column has one is what a merge keeps.
153
+ const nullable = values.filter((c) => /^Nullable\(/.test(c.type));
154
+ return {
155
+ engine: `${base}MergeTree`,
156
+ aggregates: nullable.map((c) => `count(${quoted(c.name)}) > 0`),
157
+ describe: nullable.length > 0 ? `which of ${nullable.map((c) => c.name).join(", ")} hold a value` : "the keys",
158
+ };
159
+ }
160
+ default:
161
+ return undefined;
162
+ }
163
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * A rebuild of a SummingMergeTree with unmerged parts (#3674).
3
+ *
4
+ * Three rows for one key, in three parts the server is kept from merging.
5
+ * The copy inserts them in one block, which the new table collapses into one
6
+ * row as it writes it, so the raw counts differ (3 and 1) although the data
7
+ * is the same. The Verify phase reads both tables under FINAL, finds them
8
+ * equal, and the run reaches the swap gate.
9
+ *
10
+ * Runs against `CHANT_SQL_E2E_CLICKHOUSE_URL` when it is set (a shared
11
+ * server: everything is in the database `chant_e2e_3674`, dropped
12
+ * afterwards, with this test's receipts), otherwise a scratch server, which
13
+ * needs Docker and skips cleanly without it.
14
+ */
15
+
16
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
20
+ import { memoryGateLedgerPort, runOpLocally, loadProfiles, type ActivityFn, type OpConfig } from "@intentius/chant/op";
21
+ import { dockerAvailable, startScratchServer, type ScratchServer } from "../container";
22
+ import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
23
+ import { clickhouseImage } from "../../spec/pin";
24
+ import { clickhouseApply } from "../../op/activities/clickhouse-apply";
25
+ import * as rebuildActivities from "../../op/activities/clickhouse-rebuild";
26
+ import { ClickHouseRebuildOp } from "./op";
27
+ import { RECEIPTS_DATABASE, RECEIPTS_TABLE } from "./receipts";
28
+
29
+ const given = process.env.CHANT_SQL_E2E_CLICKHOUSE_URL;
30
+ const enabled = given ? true : await dockerAvailable();
31
+ const DB = "chant_e2e_3674";
32
+ const MARKER = { stack: "chant_e2e_3674", env: "test" };
33
+ let server: ScratchServer | undefined;
34
+ let endpoint: ClickHouseEndpoint;
35
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-rebuild-collapsing-"));
36
+
37
+ const q = <T = Record<string, unknown>>(sql: string) => clickhouseQuery<T>(endpoint, sql);
38
+ const count = async (from: string) => Number((await q<{ n: string }>(`SELECT count() AS n FROM ${from}`))[0]!.n);
39
+
40
+ async function cleanup(): Promise<void> {
41
+ await q(`DROP DATABASE IF EXISTS ${DB} SYNC`);
42
+ const receipts = await q<{ n: string }>(`SELECT count() AS n FROM system.tables WHERE database = '${RECEIPTS_DATABASE}' AND name = '${RECEIPTS_TABLE}'`);
43
+ if (Number(receipts[0]!.n) > 0) {
44
+ await clickhouseQuery(endpoint, `ALTER TABLE ${RECEIPTS_DATABASE}.${RECEIPTS_TABLE} DELETE WHERE position(address, '${MARKER.stack}') > 0`, { settings: { mutations_sync: "2" } });
45
+ }
46
+ }
47
+
48
+ beforeAll(async () => {
49
+ if (!enabled) return;
50
+ if (given) endpoint = { url: given, user: "default", password: process.env.CHANT_SQL_E2E_CLICKHOUSE_PASSWORD ?? "" };
51
+ else {
52
+ server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-rebuild-collapsing" });
53
+ endpoint = server.endpoint;
54
+ }
55
+ await cleanup();
56
+ }, 600_000);
57
+
58
+ afterAll(async () => {
59
+ if (enabled) await cleanup().catch(() => undefined);
60
+ await server?.stop();
61
+ rmSync(dir, { recursive: true, force: true });
62
+ });
63
+
64
+ const deps = () => ({ config: { ownership: MARKER }, env: { CLICKHOUSE_URL: endpoint.url, CLICKHOUSE_USER: endpoint.user, CLICKHOUSE_PASSWORD: endpoint.password }, log: () => undefined });
65
+
66
+ function writeBuild(file: string, orderBy: string): string {
67
+ const objects = [
68
+ { export: "db", type: "ClickHouse::Database", ddl: `CREATE DATABASE ${DB} ENGINE = Atomic`, dependsOn: [] },
69
+ {
70
+ export: "daily",
71
+ type: "ClickHouse::Table",
72
+ dependsOn: ["db"],
73
+ ddl: `CREATE TABLE ${DB}.daily (day Date, total Decimal(18, 2), n UInt64 DEFAULT 0) ENGINE = SummingMergeTree ORDER BY ${orderBy}`,
74
+ },
75
+ ];
76
+ writeFileSync(join(dir, file), JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects }));
77
+ return join(dir, file);
78
+ }
79
+
80
+ describe.skipIf(!enabled)("a SummingMergeTree with unmerged parts, rebuilt with a new sorting key", () => {
81
+ test("the Verify phase compares the tables under FINAL and the run reaches the swap gate", async () => {
82
+ await clickhouseApply({ buildPath: writeBuild("daily-v1.json", "day"), environment: "e2e" }, undefined, deps());
83
+ await q(`SYSTEM STOP MERGES ${DB}.daily`);
84
+ for (const total of [30, 30, 40]) await q(`INSERT INTO ${DB}.daily VALUES ('2026-10-10', ${total}, 0)`);
85
+ expect(await count(`${DB}.daily`)).toBe(3);
86
+ writeBuild("daily-v2.json", "(day, n)");
87
+
88
+ const { op } = ClickHouseRebuildOp({
89
+ name: "rebuild-daily",
90
+ env: "e2e",
91
+ table: `${DB}.daily`,
92
+ dualWrite: { mode: "materialized-view", cutoverColumn: "day", cutoverDelay: "1s" },
93
+ build: false,
94
+ path: dir,
95
+ output: "daily-v2.json",
96
+ stack: MARKER.stack,
97
+ ownershipEnv: MARKER.env,
98
+ });
99
+ const activities = new Map<string, ActivityFn>();
100
+ for (const [name, fn] of Object.entries(rebuildActivities)) {
101
+ if (typeof fn !== "function" || !name.startsWith("clickhouseRebuild")) continue;
102
+ activities.set(name, ((args: Record<string, unknown>, signal?: AbortSignal) =>
103
+ (fn as (a: unknown, s?: AbortSignal, d?: unknown) => Promise<unknown>)(args, signal, deps())) as ActivityFn);
104
+ }
105
+ const r = await runOpLocally((op as unknown as { props: OpConfig }).props, activities, await loadProfiles(), undefined, {
106
+ gates: memoryGateLedgerPort(),
107
+ now: new Date().toISOString(),
108
+ });
109
+
110
+ const verify = r.records.find((x) => x.fn === "clickhouseRebuildVerify");
111
+ expect(verify?.error).toBeUndefined();
112
+ expect(r.status).toBe("gated");
113
+ expect(r.gate).toMatchObject({ gate: "approve-rebuild-daily" });
114
+ const verification = r.records.flatMap((x) => x.outcomes ?? []).find((o) => o.name === "Verification")?.value;
115
+ expect(String(verification)).toMatch(/1 row\(s\).*read under FINAL \(SummingMergeTree\)/);
116
+ // The old table still holds its three unmerged rows; the new one holds them collapsed.
117
+ expect(await count(`${DB}.daily`)).toBe(3);
118
+ expect(await q(`SELECT toString(day) AS day, toString(total) AS total FROM ${DB}.daily__chant_new`)).toEqual([{ day: "2026-10-10", total: "100" }]);
119
+ }, 300_000);
120
+ });