@intentius/chant-lexicon-sql 0.118.1 → 0.121.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 (118) 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/inlined.d.ts +49 -0
  8. package/dist/clickhouse/import/inlined.d.ts.map +1 -0
  9. package/dist/clickhouse/import/live-export.d.ts +22 -0
  10. package/dist/clickhouse/import/live-export.d.ts.map +1 -1
  11. package/dist/clickhouse/live/bind.d.ts +8 -0
  12. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  13. package/dist/clickhouse/live/catalog.d.ts +15 -0
  14. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  15. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  16. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  17. package/dist/clickhouse/plan/deep.d.ts +19 -2
  18. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  19. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  20. package/dist/clickhouse/plan/normalize.d.ts +3 -0
  21. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  22. package/dist/clickhouse/plan/rebuild-handoff.d.ts +2 -0
  23. package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
  24. package/dist/clickhouse/rebuild/engines.d.ts +44 -0
  25. package/dist/clickhouse/rebuild/engines.d.ts.map +1 -0
  26. package/dist/clickhouse/rebuild/verify.d.ts +25 -1
  27. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  28. package/dist/clickhouse/testing/fake-server.d.ts +5 -0
  29. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  30. package/dist/config.d.ts +36 -8
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/core/foreign-tables.d.ts +13 -0
  33. package/dist/core/foreign-tables.d.ts.map +1 -0
  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 +72 -0
  64. package/src/clickhouse/import/inlined-functions.e2e.test.ts +87 -0
  65. package/src/clickhouse/import/inlined.test.ts +48 -0
  66. package/src/clickhouse/import/inlined.ts +200 -0
  67. package/src/clickhouse/import/live-export.ts +75 -2
  68. package/src/clickhouse/live/access.e2e.test.ts +71 -18
  69. package/src/clickhouse/live/bind.ts +10 -0
  70. package/src/clickhouse/live/catalog.ts +25 -2
  71. package/src/clickhouse/live/describe-resources.ts +5 -2
  72. package/src/clickhouse/live/live.test.ts +8 -0
  73. package/src/clickhouse/plan/commands.ts +6 -3
  74. package/src/clickhouse/plan/deep-grants.test.ts +67 -0
  75. package/src/clickhouse/plan/deep.ts +70 -6
  76. package/src/clickhouse/plan/diff.ts +4 -1
  77. package/src/clickhouse/plan/normalize.ts +5 -2
  78. package/src/clickhouse/plan/plan.test.ts +9 -1
  79. package/src/clickhouse/plan/rebuild-handoff.ts +11 -1
  80. package/src/clickhouse/rebuild/engines.ts +163 -0
  81. package/src/clickhouse/rebuild/rebuild-collapsing.e2e.test.ts +120 -0
  82. package/src/clickhouse/rebuild/rebuild-into-collapsing.e2e.test.ts +198 -0
  83. package/src/clickhouse/rebuild/rebuild.test.ts +78 -0
  84. package/src/clickhouse/rebuild/verify.ts +153 -24
  85. package/src/clickhouse/testing/fake-server.ts +3 -2
  86. package/src/codegen/classifier-page.ts +1 -1
  87. package/src/config.ts +18 -4
  88. package/src/core/foreign-tables.ts +28 -0
  89. package/src/files/clickhouse.ts +71 -5
  90. package/src/files/postgres.ts +2 -1
  91. package/src/import-parser.ts +2 -0
  92. package/src/migration-statements.test.ts +33 -0
  93. package/src/migration-statements.ts +2 -0
  94. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +1 -1
  95. package/src/op/activities/clickhouse-emulator.ts +22 -1
  96. package/src/op/activities/postgres-apply-lock-timeout.e2e.test.ts +87 -0
  97. package/src/op/activities/postgres-apply.test.ts +32 -0
  98. package/src/op/activities/postgres-emulator.test.ts +20 -0
  99. package/src/op/activities/postgres-emulator.ts +41 -1
  100. package/src/op/apply-gate-access-clickhouse.e2e.test.ts +195 -0
  101. package/src/op/apply-gate-access.e2e.test.ts +207 -0
  102. package/src/postgres/access/access.test.ts +26 -1
  103. package/src/postgres/access/changes.ts +39 -0
  104. package/src/postgres/apply/apply.ts +10 -7
  105. package/src/postgres/entities.ts +20 -1
  106. package/src/postgres/live/bind.ts +16 -4
  107. package/src/postgres/live/catalog.ts +3 -15
  108. package/src/postgres/live/client.ts +85 -0
  109. package/src/postgres/live/live.test.ts +54 -0
  110. package/src/postgres/live/lock-timeout.e2e.test.ts +70 -0
  111. package/src/postgres/plan/commands.ts +2 -14
  112. package/src/postgres/plan/deep.ts +44 -2
  113. package/src/serializer.test.ts +4 -0
  114. package/src/skills/chant-sql-postgres-plan.md +1 -1
  115. package/src/skills/chant-sql-rebuild.md +2 -0
  116. package/src/skills/chant-sql.md +1 -1
  117. package/src/sql-files.test.ts +78 -1
  118. package/src/sql-files.ts +2 -1
@@ -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
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * A plain MergeTree rebuilt into a SummingMergeTree (#3727).
3
+ *
4
+ * The old table keeps every row it was given; the new one keeps one row per
5
+ * sorting key, its sums, and drops a key whose sums are all zero. So the row
6
+ * counts differ by design. The Verify phase groups both tables by the new
7
+ * sorting key and compares each key's sums, finds them equal, and the run
8
+ * swaps once approved. A key the new table lost, or a sum it changed, still
9
+ * fails the verification.
10
+ *
11
+ * Runs against `CHANT_SQL_E2E_CLICKHOUSE_URL` when it is set (a shared
12
+ * server: everything is in the databases `chant_e2e_3727` and
13
+ * `chant_e2e_3727_loss`, dropped afterwards, with this test's receipts),
14
+ * otherwise a scratch server, which needs Docker and skips cleanly without it.
15
+ */
16
+
17
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
21
+ import { memoryGateLedgerPort, runOpLocally, loadProfiles, type ActivityFn, type GateLedgerPort, type OpConfig } from "@intentius/chant/op";
22
+ import { dockerAvailable, startScratchServer, type ScratchServer } from "../container";
23
+ import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
24
+ import { clickhouseImage } from "../../spec/pin";
25
+ import { clickhouseApply } from "../../op/activities/clickhouse-apply";
26
+ import * as rebuildActivities from "../../op/activities/clickhouse-rebuild";
27
+ import { ClickHouseRebuildOp } from "./op";
28
+ import { RECEIPTS_DATABASE, RECEIPTS_TABLE } from "./receipts";
29
+
30
+ const given = process.env.CHANT_SQL_E2E_CLICKHOUSE_URL;
31
+ const enabled = given ? true : await dockerAvailable();
32
+ const DATABASES = ["chant_e2e_3727", "chant_e2e_3727_loss"];
33
+ const MARKER = { stack: "chant_e2e_3727", env: "test" };
34
+ let server: ScratchServer | undefined;
35
+ let endpoint: ClickHouseEndpoint;
36
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-rebuild-into-collapsing-"));
37
+
38
+ const q = <T = Record<string, unknown>>(sql: string) => clickhouseQuery<T>(endpoint, sql);
39
+ const count = async (from: string) => Number((await q<{ n: string }>(`SELECT count() AS n FROM ${from}`))[0]!.n);
40
+ const mutate = (sql: string) => clickhouseQuery(endpoint, sql, { settings: { mutations_sync: "2" } });
41
+
42
+ async function cleanup(): Promise<void> {
43
+ for (const db of DATABASES) await q(`DROP DATABASE IF EXISTS ${db} SYNC`);
44
+ const receipts = await q<{ n: string }>(`SELECT count() AS n FROM system.tables WHERE database = '${RECEIPTS_DATABASE}' AND name = '${RECEIPTS_TABLE}'`);
45
+ if (Number(receipts[0]!.n) > 0) await mutate(`ALTER TABLE ${RECEIPTS_DATABASE}.${RECEIPTS_TABLE} DELETE WHERE position(address, '${MARKER.stack}') > 0`);
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-into-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(db: string, file: string, engine: 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, shop UInt32, total Decimal(18, 2), n UInt64) ENGINE = ${engine} PARTITION BY toYYYYMM(day) ORDER BY (day, shop)`,
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
+ /** Eleven rows: shop 1 three times and shop 2 twice on one day, shop 3 whose rows sum to zero, and six single rows across two months. */
81
+ async function seed(db: string): Promise<void> {
82
+ await q(
83
+ `INSERT INTO ${db}.daily VALUES ` +
84
+ `('2026-09-01', 1, 10, 1), ('2026-09-01', 1, 20, 1), ('2026-09-01', 1, 30, 1), ('2026-09-01', 2, 5, 1), ('2026-09-01', 2, 7, 2), ` +
85
+ `('2026-09-01', 3, 4, 0), ('2026-09-01', 3, -4, 0), ` +
86
+ `('2026-09-02', 1, 1, 1), ('2026-09-03', 1, 2, 1), ('2026-10-01', 1, 3, 1), ('2026-10-02', 2, 4, 1)`,
87
+ );
88
+ }
89
+
90
+ const activities = (): Map<string, ActivityFn> => {
91
+ const out = new Map<string, ActivityFn>();
92
+ for (const [name, fn] of Object.entries(rebuildActivities)) {
93
+ if (typeof fn !== "function" || !name.startsWith("clickhouseRebuild")) continue;
94
+ out.set(name, ((args: Record<string, unknown>, signal?: AbortSignal) => (fn as (a: unknown, s?: AbortSignal, d?: unknown) => Promise<unknown>)(args, signal, deps())) as ActivityFn);
95
+ }
96
+ return out;
97
+ };
98
+
99
+ /** An in-memory gate ledger: each approval answers the gate the last run stopped at, for the plan it recorded. */
100
+ class Ledger {
101
+ private pending: unknown[] = [];
102
+ private resolutions: unknown[] = [];
103
+ port: GateLedgerPort = memoryGateLedgerPort();
104
+
105
+ approveLast(): void {
106
+ const last = (this.port as ReturnType<typeof memoryGateLedgerPort>).appended.at(-1)!;
107
+ this.pending.push(...(this.port as ReturnType<typeof memoryGateLedgerPort>).appended);
108
+ this.resolutions.push({
109
+ version: 1,
110
+ kind: "resolution",
111
+ op: last.op,
112
+ gate: last.gate,
113
+ resolvedBy: "e2e",
114
+ timestamp: new Date(Date.parse(last.timestamp) + 1000).toISOString(),
115
+ ...(last.planDigest ? { planDigest: last.planDigest } : {}),
116
+ });
117
+ this.port = memoryGateLedgerPort({ pending: this.pending as never, resolutions: this.resolutions as never });
118
+ }
119
+ }
120
+
121
+ describe.skipIf(!enabled)("a MergeTree rebuilt into a SummingMergeTree", () => {
122
+ const DB = "chant_e2e_3727";
123
+ test("the Verify phase compares each sorting key's sums, and the run swaps once approved", async () => {
124
+ await clickhouseApply({ buildPath: writeBuild(DB, "daily-v1.json", "MergeTree"), environment: "e2e" }, undefined, deps());
125
+ await seed(DB);
126
+ writeBuild(DB, "daily-v2.json", "SummingMergeTree");
127
+
128
+ const { op } = ClickHouseRebuildOp({
129
+ name: "rebuild-daily-3727",
130
+ env: "e2e",
131
+ table: `${DB}.daily`,
132
+ dualWrite: { mode: "materialized-view", cutoverColumn: "day", cutoverDelay: "1s" },
133
+ build: false,
134
+ path: dir,
135
+ output: "daily-v2.json",
136
+ stack: MARKER.stack,
137
+ ownershipEnv: MARKER.env,
138
+ });
139
+ const props = (op as unknown as { props: OpConfig }).props;
140
+ const ledger = new Ledger();
141
+ const run = async () => runOpLocally(props, activities(), await loadProfiles(), undefined, { gates: ledger.port, now: new Date().toISOString() });
142
+
143
+ const first = await run();
144
+ expect(first.records.find((x) => x.fn === "clickhouseRebuildVerify")?.error).toBeUndefined();
145
+ expect(first.status).toBe("gated");
146
+ expect(first.gate).toMatchObject({ gate: "approve-rebuild-daily-3727" });
147
+ const verification = first.records.flatMap((x) => x.outcomes ?? []).find((o) => o.name === "Verification")?.value;
148
+ expect(String(verification)).toMatch(/2 partition\(s\), 11 row\(s\) of the old table as 6 key\(s\).*grouped by the sorting key of the new SummingMergeTree and compared on the sums of total, n/);
149
+
150
+ ledger.approveLast();
151
+ const swapped = await run();
152
+ expect(swapped.records.find((x) => x.fn === "clickhouseRebuildSwap")?.status).toBe("ok");
153
+ expect(swapped.gate).toMatchObject({ gate: "approve-rebuild-daily-3727-drop" });
154
+ expect(await q<{ engine: string }>(`SELECT engine FROM system.tables WHERE database = '${DB}' AND name = 'daily'`)).toEqual([{ engine: "SummingMergeTree" }]);
155
+ expect(await q(`SELECT toString(day) AS day, shop, toString(total) AS total, toString(n) AS n FROM ${DB}.daily FINAL WHERE day = '2026-09-01' ORDER BY shop`)).toEqual([
156
+ { day: "2026-09-01", shop: 1, total: "60", n: "3" },
157
+ { day: "2026-09-01", shop: 2, total: "12", n: "3" },
158
+ ]);
159
+ }, 300_000);
160
+ });
161
+
162
+ describe.skipIf(!enabled)("a MergeTree rebuilt into a SummingMergeTree that lost rows", () => {
163
+ const DB = "chant_e2e_3727_loss";
164
+ const args = {
165
+ table: `${DB}.daily`,
166
+ buildPath: "loss-v2.json",
167
+ environment: "e2e",
168
+ dualWrite: { mode: "materialized-view" as const, cutoverColumn: "day", cutoverDelay: "1s" },
169
+ stack: MARKER.stack,
170
+ ownershipEnv: MARKER.env,
171
+ cwd: dir,
172
+ };
173
+
174
+ test("a changed sum, or a key missing from the new table, fails the verification and nothing is swapped", async () => {
175
+ await clickhouseApply({ buildPath: writeBuild(DB, "loss-v1.json", "MergeTree"), environment: "e2e" }, undefined, deps());
176
+ await seed(DB);
177
+ writeBuild(DB, "loss-v2.json", "SummingMergeTree");
178
+ await rebuildActivities.clickhouseRebuildCreate(args, undefined, deps());
179
+ await rebuildActivities.clickhouseRebuildDualWrite(args, undefined, deps());
180
+ await rebuildActivities.clickhouseRebuildBackfill(args, undefined, deps());
181
+ expect(await rebuildActivities.clickhouseRebuildVerify(args, undefined, deps())).toMatchObject({ rows: 11, partitions: 2 });
182
+
183
+ // A sum the copy got wrong.
184
+ await mutate(`ALTER TABLE ${DB}.daily__chant_new UPDATE total = total + 1 WHERE shop = 2 AND day = '2026-09-01'`);
185
+ await expect(rebuildActivities.clickhouseRebuildVerify(args, undefined, deps())).rejects.toThrow(
186
+ /does not match the old one in 1 partition\(s\): \(?202609\)?: old 4 keys.*new 4 keys.*compared per key on the sums of total, n/,
187
+ );
188
+ await mutate(`ALTER TABLE ${DB}.daily__chant_new UPDATE total = total - 1 WHERE shop = 2 AND day = '2026-09-01'`);
189
+ expect(await rebuildActivities.clickhouseRebuildVerify(args, undefined, deps())).toMatchObject({ rows: 11 });
190
+
191
+ // A key the copy lost: the swap compares again before the EXCHANGE and stops.
192
+ await mutate(`ALTER TABLE ${DB}.daily__chant_new DELETE WHERE shop = 1 AND day = '2026-10-01'`);
193
+ await expect(rebuildActivities.clickhouseRebuildSwap(args, undefined, deps())).rejects.toThrow(/does not match the old one in 1 partition\(s\): \(?202610\)?: old 2 keys.*new 1 keys/);
194
+ expect(await q<{ engine: string }>(`SELECT engine FROM system.tables WHERE database = '${DB}' AND name = 'daily'`)).toEqual([{ engine: "MergeTree" }]);
195
+ expect(await count(`${DB}.daily`)).toBe(11);
196
+ await rebuildActivities.clickhouseRebuildCompensate(args, undefined, deps());
197
+ }, 300_000);
198
+ });
@@ -24,6 +24,8 @@ import { partitionEffect } from "./backfill";
24
24
  import { insertTarget, waitForCutover, type CutoverProbe } from "./steps";
25
25
  import { intoShard, onShard, onShardServers, shardTable } from "./shards";
26
26
  import { renameColumns, sourcePartitionExpression } from "./partitions";
27
+ import { collapseSemantics, collapsesRows, engineArguments, splitTopLevel, type EngineColumn } from "./engines";
28
+ import { RebuildVerificationError } from "./verify";
27
29
  import { canonicalObject } from "../plan/normalize";
28
30
  import { diffSchemas } from "../plan/diff";
29
31
  import { renderDiff } from "../plan/report";
@@ -226,6 +228,42 @@ describe("the plan hands a rebuild to the Op", () => {
226
228
  });
227
229
  });
228
230
 
231
+ describe("a table whose engine collapses rows by sorting key (#3674)", () => {
232
+ test("the collapsing MergeTree engines are read under FINAL, replicated or shared too; the others are not", () => {
233
+ for (const e of ["SummingMergeTree", "ReplacingMergeTree", "AggregatingMergeTree", "CollapsingMergeTree", "VersionedCollapsingMergeTree", "ReplicatedSummingMergeTree", "SharedReplacingMergeTree"]) {
234
+ expect(collapsesRows(e), e).toBe(true);
235
+ }
236
+ for (const e of ["MergeTree", "ReplicatedMergeTree", "Log", "Distributed", undefined]) expect(collapsesRows(e), String(e)).toBe(false);
237
+ });
238
+
239
+ test("a difference on such a table names the engine and the OPTIMIZE that merges it, not only a late write", () => {
240
+ const mismatch = [{ partition: "all", old: { partition: "all", rows: 3, checksum: "1" }, new: { partition: "all", rows: 1, checksum: "2" } }];
241
+ const collapsing = new RebuildVerificationError("db.daily", mismatch, "SummingMergeTree").message;
242
+ expect(collapsing).toMatch(/Nothing was swapped\. db\.daily is a SummingMergeTree, .*compared under FINAL/);
243
+ expect(collapsing).toContain("run OPTIMIZE TABLE db.daily FINAL");
244
+ const plain = new RebuildVerificationError("db.events", mismatch).message;
245
+ expect(plain).not.toMatch(/FINAL/);
246
+ expect(plain).toMatch(/The usual cause is in materialized-view mode a row written to the old table later than the cut-over delay/);
247
+ });
248
+
249
+ test("from a plain MergeTree, a difference names what was compared per key", () => {
250
+ const mismatch = [{ partition: "202610", old: { partition: "202610", rows: 2, checksum: "1" }, new: { partition: "202610", rows: 1, checksum: "2" } }];
251
+ const message = new RebuildVerificationError("db.daily", mismatch, { engine: "SummingMergeTree", how: "keys", describe: "the sums of total" }).message;
252
+ expect(message).toContain("202610: old 2 keys, checksum 1, new 1 keys, checksum 2");
253
+ expect(message).toMatch(/The new table is a SummingMergeTree, .*compared per key on the sums of total/);
254
+ expect(message).not.toMatch(/OPTIMIZE/);
255
+ });
256
+
257
+ test("the plan's hand-off says so for such a table", () => {
258
+ const ddl = (o: string) => `CREATE TABLE db.daily (day Date, total Decimal(18, 2), n UInt64) ENGINE = SummingMergeTree ORDER BY ${o}`;
259
+ const after = [{ key: "daily", canonical: canonicalObject(ddl("(day, n)")) }];
260
+ const diff = diffSchemas([{ key: "daily", canonical: canonicalObject(ddl("day")) }], after);
261
+ const ops = rebuildOpSuggestions(diff, new Map(after.map((o) => [o.key, o.canonical])), "prod");
262
+ expect(ops[0]!.note).toMatch(/^db\.daily is a SummingMergeTree: when the old table's engine collapses rows too, the Verify phase reads both tables under FINAL/);
263
+ expect(renderDiff({ ...diff, rebuildOps: ops })).toContain(` Note: ${ops[0]!.note}`);
264
+ });
265
+ });
266
+
229
267
  describe("a rebuild across the shards of a cluster (#3663)", () => {
230
268
  const sharding = { cluster: "main", shards: [{ num: 1, macro: "s1", slot: 0 }, { num: 2, macro: "s2", slot: 1 }] };
231
269
  const shard2 = sharding.shards[1]!;
@@ -294,3 +332,43 @@ describe("the wait for the cut-over (#3700)", () => {
294
332
  expect(insertTarget("SELECT * FROM shop.events", "shop")).toBeUndefined();
295
333
  });
296
334
  });
335
+
336
+ describe("what a collapsing engine keeps per sorting key (#3727)", () => {
337
+ const col = (name: string, type: string, inSortingKey = false): EngineColumn => ({ name, type, inSortingKey });
338
+ const columns = [col("day", "Date", true), col("shop", "UInt32", true), col("total", "Decimal(18, 2)"), col("n", "UInt64"), col("ratio", "Float64"), col("note", "String"), col("sign", "Int8"), col("ver", "UInt32")];
339
+
340
+ test("splitTopLevel and engineArguments read engine_full, without a Replicated engine's Keeper path", () => {
341
+ expect(splitTopLevel("toStartOfInterval(ts, toIntervalDay(1)), id")).toEqual(["toStartOfInterval(ts, toIntervalDay(1))", "id"]);
342
+ expect(splitTopLevel("")).toEqual([]);
343
+ expect(engineArguments("SummingMergeTree((total, n)) PARTITION BY toYYYYMM(day) ORDER BY (day, shop) SETTINGS index_granularity = 8192")).toEqual(["(total, n)"]);
344
+ expect(engineArguments("ReplicatedCollapsingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}', sign) ORDER BY id")).toEqual(["sign"]);
345
+ expect(engineArguments("SummingMergeTree ORDER BY id")).toEqual([]);
346
+ });
347
+
348
+ test("SummingMergeTree: the sums of every numeric column outside the key, or of those listed, in their own types; a key whose sums are zero is removed", () => {
349
+ const all = collapseSemantics("SummingMergeTree", "SummingMergeTree ORDER BY (day, shop)", columns)!;
350
+ expect(all.aggregates).toEqual(["CAST(sum(`total`), 'Decimal(18, 2)')", "CAST(sum(`n`), 'UInt64')", "toFloat32(sum(`ratio`))", "CAST(sum(`sign`), 'Int8')", "CAST(sum(`ver`), 'UInt32')"]);
351
+ expect(all.having).toBe(`NOT (${all.aggregates.map((a) => `${a} = 0`).join(" AND ")})`);
352
+ const listed = collapseSemantics("ReplicatedSummingMergeTree", "ReplicatedSummingMergeTree('/p', '{replica}', (total, n)) ORDER BY (day, shop)", columns)!;
353
+ expect(listed).toMatchObject({ engine: "SummingMergeTree", aggregates: ["CAST(sum(`total`), 'Decimal(18, 2)')", "CAST(sum(`n`), 'UInt64')"], describe: "the sums of total, n" });
354
+ const partitioned = collapseSemantics("SummingMergeTree", "SummingMergeTree PARTITION BY g ORDER BY k", [col("k", "UInt32", true), { ...col("g", "UInt8"), inPartitionKey: true }, col("a", "UInt32")])!;
355
+ expect(partitioned.aggregates).toEqual(["CAST(sum(`a`), 'UInt32')"]);
356
+ });
357
+
358
+ test("Replacing keeps the highest version, Collapsing the sum of its signs, Aggregating its order-free simple functions, Coalescing which columns hold a value", () => {
359
+ expect(collapseSemantics("ReplacingMergeTree", "ReplacingMergeTree(ver) ORDER BY id", columns)).toMatchObject({ aggregates: ["max(`ver`)"], describe: "the highest ver" });
360
+ expect(collapseSemantics("ReplacingMergeTree", "ReplacingMergeTree ORDER BY id", columns)).toMatchObject({ aggregates: [], describe: "the keys" });
361
+ expect(collapseSemantics("CollapsingMergeTree", "CollapsingMergeTree(sign) ORDER BY id", columns)).toMatchObject({ aggregates: ["sum(`sign`)"], having: "sum(`sign`) != 0" });
362
+ expect(collapseSemantics("VersionedCollapsingMergeTree", "VersionedCollapsingMergeTree(sign, ver) ORDER BY id", columns)).toMatchObject({ aggregates: ["sum(`sign`)"], having: "sum(`sign`) != 0" });
363
+ const agg = [col("k", "UInt32", true), col("hi", "SimpleAggregateFunction(max, UInt64)"), col("any", "SimpleAggregateFunction(anyLast, String)"), col("u", "AggregateFunction(uniq, UInt64)")];
364
+ expect(collapseSemantics("AggregatingMergeTree", "AggregatingMergeTree ORDER BY k", agg)).toMatchObject({ aggregates: ["max(`hi`)"], describe: "max(hi)" });
365
+ const coalescing = [col("k", "UInt32", true), col("v", "Nullable(String)"), col("w", "UInt8")];
366
+ expect(collapseSemantics("CoalescingMergeTree", "CoalescingMergeTree ORDER BY k", coalescing)).toMatchObject({ aggregates: ["count(`v`) > 0"] });
367
+ });
368
+
369
+ test("an engine that keeps every row, or whose rows depend on the order of merges, has none", () => {
370
+ expect(collapseSemantics("MergeTree", "MergeTree ORDER BY id", columns)).toBeUndefined();
371
+ expect(collapseSemantics("GraphiteMergeTree", "GraphiteMergeTree('graphite_rollup') ORDER BY id", columns)).toBeUndefined();
372
+ expect(collapseSemantics("CollapsingMergeTree", "CollapsingMergeTree(missing) ORDER BY id", columns)).toBeUndefined();
373
+ });
374
+ });