@intentius/chant-lexicon-sql 0.113.0 → 0.114.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 (57) hide show
  1. package/dist/clickhouse/apply/apply.d.ts +6 -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/live/bind.d.ts +11 -1
  6. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  7. package/dist/clickhouse/plan/commands.d.ts +7 -1
  8. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  9. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  10. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  11. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  12. package/dist/clickhouse/plan/schema.d.ts +4 -3
  13. package/dist/clickhouse/plan/schema.d.ts.map +1 -1
  14. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  15. package/dist/clickhouse/rebuild/receipts.d.ts +5 -0
  16. package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
  17. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  18. package/dist/clickhouse/topology.d.ts +12 -0
  19. package/dist/clickhouse/topology.d.ts.map +1 -1
  20. package/dist/clickhouse.d.ts +1 -0
  21. package/dist/clickhouse.d.ts.map +1 -1
  22. package/dist/codegen/classifier-page.d.ts.map +1 -1
  23. package/dist/config.d.ts +46 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/core/statements.d.ts +2 -0
  26. package/dist/core/statements.d.ts.map +1 -1
  27. package/dist/integrity.json +2 -2
  28. package/dist/manifest.json +1 -1
  29. package/dist/migration-statements.d.ts +8 -0
  30. package/dist/migration-statements.d.ts.map +1 -1
  31. package/package.json +2 -2
  32. package/src/clickhouse/apply/apply.ts +10 -3
  33. package/src/clickhouse/apply/statements.ts +19 -0
  34. package/src/clickhouse/live/bind.ts +13 -1
  35. package/src/clickhouse/live/live.test.ts +10 -0
  36. package/src/clickhouse/plan/commands.ts +42 -12
  37. package/src/clickhouse/plan/deep.ts +2 -1
  38. package/src/clickhouse/plan/diff.ts +3 -1
  39. package/src/clickhouse/plan/normalize.ts +11 -2
  40. package/src/clickhouse/plan/schema.ts +6 -5
  41. package/src/clickhouse/rebuild/backfill.ts +1 -0
  42. package/src/clickhouse/rebuild/receipts.ts +14 -4
  43. package/src/clickhouse/rebuild/steps.ts +8 -5
  44. package/src/clickhouse/topology-replicated.e2e.test.ts +125 -0
  45. package/src/clickhouse/topology.e2e.test.ts +84 -1
  46. package/src/clickhouse/topology.test.ts +33 -0
  47. package/src/clickhouse/topology.ts +26 -0
  48. package/src/clickhouse.ts +16 -0
  49. package/src/codegen/classifier-page.ts +2 -0
  50. package/src/config.ts +33 -0
  51. package/src/core/statements.ts +2 -0
  52. package/src/migration-statements.test.ts +120 -1
  53. package/src/migration-statements.ts +13 -3
  54. package/src/op/activities/clickhouse-apply.ts +1 -1
  55. package/src/op/activities/clickhouse-rebuild.ts +1 -1
  56. package/src/op/apply-gate-create.e2e.test.ts +280 -0
  57. package/src/plugin.test.ts +9 -0
@@ -65,6 +65,16 @@ describe("binding an environment to a server", () => {
65
65
  expect(isUnresolvedTarget(t) && t.reason).toBe("no-credentials");
66
66
  });
67
67
 
68
+ test("the topology comes from the profile, or CLICKHOUSE_TOPOLOGY, and is undefined when neither names one (#3645)", () => {
69
+ const profile = (topology: unknown) =>
70
+ resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "http://ch:8123", topology } } } } as never, env: {} });
71
+ expect(profile("cluster:main")).toMatchObject({ topology: { kind: "cluster", cluster: "main" } });
72
+ expect(profile({ kind: "replicated", cluster: "all" })).toMatchObject({ topology: { kind: "replicated", cluster: "all" } });
73
+ expect(resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123", CLICKHOUSE_TOPOLOGY: "cloud" } })).toMatchObject({ topology: { kind: "cloud" } });
74
+ expect("topology" in resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123" } })).toBe(false);
75
+ expect(() => resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123", CLICKHOUSE_TOPOLOGY: "sharded" } })).toThrow(/unknown topology/);
76
+ });
77
+
68
78
  test("no profile and no CLICKHOUSE_URL is no-binding", () => {
69
79
  const t = resolveClickHouseTarget({ environment: "dev", config: {}, env: {} });
70
80
  expect(isUnresolvedTarget(t) && t.reason).toBe("no-binding");
@@ -4,7 +4,7 @@
4
4
  * says `"dialect": "postgres"`) or a Postgres binding goes to
5
5
  * `../../postgres/plan/commands.ts`.
6
6
  *
7
- * chant sql diff <before.json> <after.json> [--json] [--statements]
7
+ * chant sql diff <before.json> <after.json> [--json] [--statements] [--topology <topology>]
8
8
  * chant sql plan <env> <build.json> [--json]
9
9
  *
10
10
  * `diff` is offline: it compares two `chant build` outputs, say a pull
@@ -19,6 +19,12 @@
19
19
  * each one the applier would send, with its change's rule and class, and a
20
20
  * refused change as the Op that makes it (`../../migration-statements.ts`).
21
21
  * The marker in each `CREATE` is the project's `ownership` config's.
22
+ *
23
+ * `--topology` (`single`, `cluster:<name>`, `replicated`, `replicated:<cluster>`,
24
+ * `cloud`; `../topology.ts`) renders a ClickHouse diff's declarations and
25
+ * statements for that topology: `ON CLUSTER` and the engine. `--statements`
26
+ * renders for `single` without it; the plain diff compares the declarations as
27
+ * written. `plan` takes the topology from `sql.profiles.<env>.topology`.
22
28
  */
23
29
 
24
30
  import type { CommandGroup, CommandGroupContext } from "@intentius/chant/cli/command-group";
@@ -29,6 +35,7 @@ import { dropFormattingOnly } from "./server-format";
29
35
  import { bindClickHouse } from "../live/bind";
30
36
  import { rebuildOpSuggestions } from "./rebuild-handoff";
31
37
  import { readFileSync } from "node:fs";
38
+ import { parseTopology, type Topology } from "../topology";
32
39
 
33
40
  /** The dialect a build output names. */
34
41
  function outputDialect(path: string): string | undefined {
@@ -39,12 +46,28 @@ function outputDialect(path: string): string | undefined {
39
46
  }
40
47
  }
41
48
 
42
- function split(args: string[]): { positional: string[]; json: boolean; statements: boolean } {
43
- return { positional: args.filter((a) => !a.startsWith("--")), json: args.includes("--json"), statements: args.includes("--statements") };
49
+ interface DiffArgs {
50
+ positional: string[];
51
+ json: boolean;
52
+ statements: boolean;
53
+ /** `--topology <t>` or `--topology=<t>`, as written. */
54
+ topology?: string;
55
+ }
56
+
57
+ function split(args: string[]): DiffArgs {
58
+ const positional: string[] = [];
59
+ let topology: string | undefined;
60
+ for (let i = 0; i < args.length; i++) {
61
+ const a = args[i]!;
62
+ if (a === "--topology") topology = args[++i] ?? "";
63
+ else if (a.startsWith("--topology=")) topology = a.slice("--topology=".length);
64
+ else if (!a.startsWith("--")) positional.push(a);
65
+ }
66
+ return { positional, json: args.includes("--json"), statements: args.includes("--statements"), ...(topology !== undefined ? { topology } : {}) };
44
67
  }
45
68
 
46
69
  /** `chant sql diff --statements`: the statements between two builds, as SQL with comments or as JSON. Exits 2 when a step is an Op or manual. */
47
- async function runDiffStatements(before: string, after: string, json: boolean): Promise<number> {
70
+ async function runDiffStatements(before: string, after: string, json: boolean, topology: Topology | undefined): Promise<number> {
48
71
  const { diffStatements, renderStatements } = await import("../../migration-statements");
49
72
  const { resolveOwnershipMarker } = await import("../../core/apply");
50
73
  let marker;
@@ -56,7 +79,7 @@ async function runDiffStatements(before: string, after: string, json: boolean):
56
79
  console.error(err instanceof Error ? err.message : String(err));
57
80
  return 1;
58
81
  }
59
- const doc = diffStatements(readFileSync(before, "utf-8"), readFileSync(after, "utf-8"), marker ? { marker } : {});
82
+ const doc = diffStatements(readFileSync(before, "utf-8"), readFileSync(after, "utf-8"), { ...(marker ? { marker } : {}), ...(topology ? { topology } : {}) });
60
83
  console.log(json ? JSON.stringify(doc, null, 2) : renderStatements(doc));
61
84
  return doc.refused ? 2 : 0;
62
85
  }
@@ -67,18 +90,25 @@ function emit(diff: SchemaDiff, json: boolean, title: string): number {
67
90
  }
68
91
 
69
92
  export async function runDiff(ctx: CommandGroupContext): Promise<number> {
70
- const { positional, json, statements } = split(ctx.rawArgs);
93
+ const { positional, json, statements, topology: topologyArg } = split(ctx.rawArgs);
71
94
  if (positional.length !== 2) {
72
- console.error("usage: chant sql diff <before.json> <after.json> [--json] [--statements]");
95
+ console.error("usage: chant sql diff <before.json> <after.json> [--json] [--statements] [--topology <topology>]");
96
+ return 1;
97
+ }
98
+ let topology: Topology | undefined;
99
+ try {
100
+ topology = topologyArg !== undefined ? parseTopology(topologyArg) : undefined;
101
+ } catch (err) {
102
+ console.error(`--topology: ${(err as Error).message}`);
73
103
  return 1;
74
104
  }
75
- if (statements) return runDiffStatements(positional[0]!, positional[1]!, json);
105
+ if (statements) return runDiffStatements(positional[0]!, positional[1]!, json, topology);
76
106
  if (outputDialect(positional[1]!) === "postgres") {
77
107
  const { diffPgBuildFiles, emitPg, projectMajor } = await import("../../postgres/plan/commands");
78
108
  return emitPg(diffPgBuildFiles(positional[0]!, positional[1]!, await projectMajor()), json, `${positional[0]} -> ${positional[1]}`);
79
109
  }
80
- const after = schemaFromBuildFile(positional[1]!);
81
- const diff = diffSchemas(schemaFromBuildFile(positional[0]!), after);
110
+ const after = schemaFromBuildFile(positional[1]!, "default", topology);
111
+ const diff = diffSchemas(schemaFromBuildFile(positional[0]!, "default", topology), after);
82
112
  const rebuildOps = rebuildOpSuggestions(diff, new Map(after.map((o) => [o.key, o.canonical])), "<env>");
83
113
  return emit(rebuildOps.length > 0 ? { ...diff, rebuildOps } : diff, json, `${positional[0]} -> ${positional[1]}`);
84
114
  }
@@ -86,7 +116,7 @@ export async function runDiff(ctx: CommandGroupContext): Promise<number> {
86
116
  /** The declared objects against the server, keys and labels by `database.name`. */
87
117
  export async function planAgainstServer(environment: string, buildFile: string, options: Parameters<typeof bindClickHouse>[0] = {}): Promise<SchemaDiff> {
88
118
  const target = await bindClickHouse({ ...options, environment });
89
- const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase));
119
+ const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase, target.topology));
90
120
  const databases = new Set(declared.map((o) => o.canonical.database ?? o.canonical.name));
91
121
  const live = await schemaFromServer(target, databases);
92
122
  const diff = diffSchemas(live, declared);
@@ -119,7 +149,7 @@ export const sqlCommands: CommandGroup = {
119
149
  name: "sql",
120
150
  description: "Schema changes, classified: between two builds, or a build and a live server",
121
151
  commands: [
122
- { name: "diff", description: "Classify the change between two chant build outputs (offline); --statements prints the statements it takes", handler: runDiff },
152
+ { name: "diff", description: "Classify the change between two chant build outputs (offline); --statements prints the statements it takes, --topology renders them for single, cluster:<name>, replicated or cloud", handler: runDiff },
123
153
  { name: "plan", description: "Classify the change from an environment's server to a chant build output", handler: runPlan },
124
154
  ],
125
155
  };
@@ -20,6 +20,7 @@ import { bindClickHouse, classifyClickHouseFailure, type BindOptions } from "../
20
20
  import { readLiveSchema, type LiveObject } from "../live/catalog";
21
21
  import { database, table, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
22
22
  import { canonicalObject, type CanonicalColumn, type CanonicalObject } from "./normalize";
23
+ import { renderFor } from "../topology";
23
24
 
24
25
  type Props = Record<string, unknown>;
25
26
 
@@ -109,7 +110,7 @@ export async function observeResourcesDeep(
109
110
  const o = byKey.get(`${db ?? ""}.${String(props.name)}`);
110
111
  if (!o) continue;
111
112
  try {
112
- const d = canonicalObject(String(props.ddl), target.defaultDatabase);
113
+ const d = canonicalObject(renderFor(String(props.ddl), target.topology), target.defaultDatabase);
113
114
  const l = canonicalObject(o.statement, target.defaultDatabase);
114
115
  resources[name] = {
115
116
  type: o.type,
@@ -127,7 +127,9 @@ function diffObject(key: string, before: CanonicalObject, after: CanonicalObject
127
127
  if (before.comment !== after.comment) out.push(change(key, "comment", "SQLCH203", before.comment, after.comment));
128
128
 
129
129
  if (before.kind === "database") {
130
- if (before.engine !== after.engine) out.push(change(key, "engine", "SQLCH232", before.engine, after.engine));
130
+ // A declaration with no ENGINE takes the server's default database engine
131
+ // (Atomic; Replicated on Cloud), whatever the server says it is (#3645).
132
+ if (after.engine !== undefined && before.engine !== after.engine) out.push(change(key, "engine", "SQLCH232", before.engine, after.engine));
131
133
  return;
132
134
  }
133
135
 
@@ -275,8 +275,17 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
275
275
  // `ENGINE = ReplicatedMergeTree` with no Keeper path and replica name is
276
276
  // printed back with the server's defaults filled in; the two say the same.
277
277
  if (/^Replicated.*MergeTree$/.test(engine.name) && args[0] === DEFAULT_REPLICA_PATH && args[1] === DEFAULT_REPLICA_NAME) args = args.slice(2);
278
- obj.engineName = engine.name;
279
- obj.engine = args.length ? `${engine.name}(${args.join(", ")})` : engine.name;
278
+ // ClickHouse Cloud turns a declared MergeTree-family engine into its
279
+ // `Shared*` one and prints it back with the path and replica it chose
280
+ // (#3645, `../topology.ts`): the declaration said the plain family.
281
+ let name = engine.name;
282
+ const shared = /^Shared(\w*MergeTree)$/.exec(name);
283
+ if (shared) {
284
+ name = shared[1]!;
285
+ if (/^'.*'$/s.test(args[0] ?? "") && /^'.*'$/s.test(args[1] ?? "")) args = args.slice(2);
286
+ }
287
+ obj.engineName = name;
288
+ obj.engine = args.length ? `${name}(${args.join(", ")})` : name;
280
289
  }
281
290
  const comment = stringValue(spanText(tokens, node.comment));
282
291
  if (comment !== undefined && comment !== "") obj.comment = comment;
@@ -8,23 +8,24 @@ import { canonicalObject } from "./normalize";
8
8
  import type { SchemaObject } from "./diff";
9
9
  import type { ClickHouseTarget } from "../live/bind";
10
10
  import { readLiveSchema } from "../live/catalog";
11
+ import { renderFor, type Topology } from "../topology";
11
12
 
12
13
  interface OutputDoc {
13
14
  dialect?: string;
14
15
  objects?: Array<{ export: string; ddl: string }>;
15
16
  }
16
17
 
17
- /** The objects a `chant build` output holds, keyed by export name. */
18
- export function schemaFromBuildOutput(json: string, defaultDatabase = "default"): SchemaObject[] {
18
+ /** The objects a `chant build` output holds, keyed by export name; with a `topology`, each declaration as rendered for it (`../topology.ts`). */
19
+ export function schemaFromBuildOutput(json: string, defaultDatabase = "default", topology?: Topology): SchemaObject[] {
19
20
  const doc = JSON.parse(json) as OutputDoc;
20
21
  if (doc.dialect !== "clickhouse" || !Array.isArray(doc.objects)) {
21
22
  throw new Error("not a sql lexicon build output: expected { dialect: \"clickhouse\", objects: [...] }");
22
23
  }
23
- return doc.objects.map((o) => ({ key: o.export, canonical: canonicalObject(o.ddl, defaultDatabase) }));
24
+ return doc.objects.map((o) => ({ key: o.export, canonical: canonicalObject(renderFor(o.ddl, topology), defaultDatabase) }));
24
25
  }
25
26
 
26
- export function schemaFromBuildFile(path: string, defaultDatabase = "default"): SchemaObject[] {
27
- return schemaFromBuildOutput(readFileSync(path, "utf-8"), defaultDatabase);
27
+ export function schemaFromBuildFile(path: string, defaultDatabase = "default", topology?: Topology): SchemaObject[] {
28
+ return schemaFromBuildOutput(readFileSync(path, "utf-8"), defaultDatabase, topology);
28
29
  }
29
30
 
30
31
  /**
@@ -120,6 +120,7 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
120
120
  clickhouseReceiptStore(run.target.endpoint, identity, {
121
121
  ...(run.runId ? { runId: run.runId } : {}),
122
122
  ...(o.replicated ? { replicatedIn: n.database } : {}),
123
+ ...(run.target.topology ? { topology: run.target.topology } : {}),
123
124
  ...(run.replicaTimeoutMs !== undefined ? { replicaTimeoutMs: run.replicaTimeoutMs } : {}),
124
125
  });
125
126
  const recorded = await receipts.readAll(receiptAddress(identity, `rebuild/${n.key}/`));
@@ -50,6 +50,7 @@ import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
50
50
  import { ident, sqlString } from "../apply/statements";
51
51
  import { CLICKHOUSE_COMMENT_OWNERSHIP_KEYS, RECEIPTS_TRAILER_KEY } from "../ownership";
52
52
  import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
53
+ import { renderFor, type Topology } from "../topology";
53
54
 
54
55
  /** The database the receipts live in. Its comment marks it as chant's, so schema reads leave it out. */
55
56
  export const RECEIPTS_DATABASE = "chant_receipts";
@@ -79,22 +80,31 @@ export type ClickHouseReceiptStore = SqlReceiptStore;
79
80
  * project's ownership stack and env, which every address starts with.
80
81
  * `replicatedIn` names a Replicated database to keep them in instead, and
81
82
  * `replicaTimeoutMs` how long a read waits for this replica to catch up there.
83
+ * `topology` is the environment's (`../topology.ts`): the database and table
84
+ * are created for it, `ON CLUSTER` and a `Replicated*` engine on a cluster.
85
+ * In a Replicated database they are created for that database whatever it says.
82
86
  */
83
87
  export function clickhouseReceiptStore(
84
88
  endpoint: ClickHouseEndpoint,
85
89
  identity: { stack?: string; env?: string },
86
- opts: { runId?: string; replicatedIn?: string; replicaTimeoutMs?: number } = {},
90
+ opts: { runId?: string; replicatedIn?: string; replicaTimeoutMs?: number; topology?: Topology } = {},
87
91
  ): ClickHouseReceiptStore {
88
92
  const where = receiptsTable(opts.replicatedIn);
93
+ const topology: Topology | undefined = opts.replicatedIn ? { kind: "replicated" } : opts.topology;
89
94
  const TABLE = `${ident(where.database)}.${ident(where.name)}`;
90
95
  let ensured = false;
91
96
  const ensure = async () => {
92
97
  if (ensured) return;
93
- if (!opts.replicatedIn) await clickhouseQuery(endpoint, `CREATE DATABASE IF NOT EXISTS ${ident(RECEIPTS_DATABASE)} COMMENT ${sqlString(COMMENT)}`);
98
+ if (!opts.replicatedIn) {
99
+ await clickhouseQuery(endpoint, renderFor(`CREATE DATABASE IF NOT EXISTS ${ident(RECEIPTS_DATABASE)} COMMENT ${sqlString(COMMENT)}`, topology), { settings: DDL_SETTINGS });
100
+ }
94
101
  await clickhouseQuery(
95
102
  endpoint,
96
- `CREATE TABLE IF NOT EXISTS ${TABLE} (address String, effect String, expectation String, run_id String, written_at DateTime64(3) DEFAULT now64(3)) ` +
97
- `ENGINE = ${opts.replicatedIn ? "ReplicatedReplacingMergeTree" : "ReplacingMergeTree"}(written_at) ORDER BY address COMMENT ${sqlString(COMMENT)}`,
103
+ renderFor(
104
+ `CREATE TABLE IF NOT EXISTS ${TABLE} (address String, effect String, expectation String, run_id String, written_at DateTime64(3) DEFAULT now64(3)) ` +
105
+ `ENGINE = ReplacingMergeTree(written_at) ORDER BY address COMMENT ${sqlString(COMMENT)}`,
106
+ topology,
107
+ ),
98
108
  { settings: DDL_SETTINGS },
99
109
  );
100
110
  ensured = true;
@@ -14,6 +14,7 @@ import type { ClickHouseTarget } from "../live/bind";
14
14
  import { carriesMarker, readTrailerPairs, REBUILD_TRAILER_KEY, stampedComment } from "../ownership";
15
15
  import { createStatement, ident, qualifiedIdent, renamedDeclaration, sqlString, type DeclaredObject } from "../apply/statements";
16
16
  import { waitForMutations } from "../apply/mutations";
17
+ import { renderFor } from "../topology";
17
18
  import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
18
19
  import {
19
20
  changesBetween,
@@ -60,12 +61,14 @@ export const utcLiteral = (ms: number): string => `toDateTime64(${sqlString(sqlU
60
61
  export const observe = (run: RebuildRun): Promise<RebuildObservation> => observeRebuild(run.target, run.declared, run.marker);
61
62
 
62
63
  /**
63
- * One DDL statement. In a Replicated database it goes on without the
64
- * replicas that are down, which run it from the database's log when they
65
- * come back (`./replicas.ts`).
64
+ * One DDL statement, rendered for the environment's topology when it names
65
+ * one (`../topology.ts`: `ON CLUSTER` and the engine). In a Replicated
66
+ * database it goes on without the replicas that are down, which run it from
67
+ * the database's log when they come back (`./replicas.ts`).
66
68
  */
67
- const q = (run: RebuildRun, sql: string) => {
69
+ const q = (run: RebuildRun, statement: string) => {
68
70
  run.signal?.throwIfAborted();
71
+ const sql = renderFor(statement, run.target.topology);
69
72
  run.log(sql);
70
73
  return clickhouseQuery(run.target.endpoint, sql, { settings: DDL_SETTINGS, ...(run.signal ? { signal: run.signal } : {}) });
71
74
  };
@@ -373,7 +376,7 @@ export async function compensate(run: Pick<RebuildRun, "target" | "declared" | "
373
376
  const row = rows.find((r) => r.name === name);
374
377
  const pairs = readTrailerPairs(row?.comment);
375
378
  if (!row || pairs?.get(REBUILD_TRAILER_KEY) !== n.key || pairs.get("role") !== role || !carriesMarker(row.comment, run.marker)) continue;
376
- const sql = `DROP ${what} ${qualifiedIdent(n.database, name)} SYNC`;
379
+ const sql = renderFor(`DROP ${what} ${qualifiedIdent(n.database, name)} SYNC`, run.target.topology);
377
380
  run.log(sql);
378
381
  await clickhouseQuery(run.target.endpoint, sql, { settings: DDL_SETTINGS });
379
382
  dropped.push(`${n.database}.${name}`);
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The replicated topology against a Replicated database (#3645): two
3
+ * replicas of the pinned server, Keeper embedded in the first (the scratch
4
+ * cluster the Replicated rebuild runs on).
5
+ *
6
+ * The source is written for a single node: a plain database, MergeTree
7
+ * tables. Applied with `CLICKHOUSE_TOPOLOGY=replicated`, the database is a
8
+ * `Replicated` one and its tables `Replicated*MergeTree` with no path, so
9
+ * the second replica gets them from the database's log and rows written on
10
+ * one reach the other. Applied again on the other replica, the server's
11
+ * printed engines (with the default Keeper path filled in) compare equal to
12
+ * the declarations, and nothing is sent; a change applies there and reaches
13
+ * the first.
14
+ *
15
+ * The scratch cluster names no cluster in `remote_servers`, so the second
16
+ * replica joins the database with the `CREATE DATABASE` the applier sent to
17
+ * the first, as an operator would (`replicated:<cluster>` sends it `ON
18
+ * CLUSTER` instead). Needs Docker; skips cleanly without it.
19
+ */
20
+
21
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
25
+ import { dockerAvailable, startScratchCluster, type ScratchCluster } from "./container";
26
+ import { clickhouseQuery, type ClickHouseEndpoint } from "./http";
27
+ import { clickhouseImage } from "../spec/pin";
28
+ import { clickhouseApply } from "../op/activities/clickhouse-apply";
29
+ import { renderStatement } from "./topology";
30
+
31
+ const DB = "yodel_3645";
32
+ const enabled = await dockerAvailable();
33
+ let cluster: ScratchCluster | undefined;
34
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-topology-repl-"));
35
+
36
+ beforeAll(async () => {
37
+ if (!enabled) return;
38
+ cluster = await startScratchCluster(clickhouseImage(), { replicas: 2, namePrefix: "chant-sql-topology-repl" });
39
+ }, 600_000);
40
+
41
+ afterAll(async () => {
42
+ await cluster?.stop();
43
+ rmSync(dir, { recursive: true, force: true });
44
+ });
45
+
46
+ interface Obj {
47
+ export: string;
48
+ type: string;
49
+ ddl: string;
50
+ dependsOn?: string[];
51
+ }
52
+
53
+ const writeBuild = (file: string, objects: Obj[]): string => {
54
+ const path = join(dir, file);
55
+ writeFileSync(path, JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
56
+ return path;
57
+ };
58
+
59
+ const objects = (extraColumn = ""): Obj[] => [
60
+ { export: "db", type: "ClickHouse::Database", ddl: `CREATE DATABASE ${DB}` },
61
+ {
62
+ export: "events",
63
+ type: "ClickHouse::Table",
64
+ dependsOn: ["db"],
65
+ ddl: `CREATE TABLE ${DB}.events (id UInt64, ts DateTime, kind String${extraColumn}) ENGINE = ReplacingMergeTree(ts) ORDER BY id`,
66
+ },
67
+ {
68
+ export: "byKind",
69
+ type: "ClickHouse::MaterializedView",
70
+ dependsOn: ["events"],
71
+ ddl: `CREATE MATERIALIZED VIEW ${DB}.by_kind ENGINE = SummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM ${DB}.events GROUP BY kind`,
72
+ },
73
+ ];
74
+
75
+ const q = <T = Record<string, unknown>>(at: ClickHouseEndpoint, sql: string) => clickhouseQuery<T>(at, sql);
76
+
77
+ async function apply(at: ClickHouseEndpoint, buildPath: string) {
78
+ const sent: string[] = [];
79
+ const outcome = await clickhouseApply({ buildPath, environment: "test" }, undefined, {
80
+ config: { ownership: { stack: "yodel", env: "e2e" } },
81
+ env: { CLICKHOUSE_URL: at.url, CLICKHOUSE_TOPOLOGY: "replicated" },
82
+ log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
83
+ });
84
+ return { outcome, sent };
85
+ }
86
+
87
+ describe.skipIf(!enabled)("the replicated topology on a Replicated database (#3645)", () => {
88
+ test("a single node's source becomes a Replicated database that both replicas hold, and converges on either", async () => {
89
+ const [r1, r2] = cluster!.replicas as [ClickHouseEndpoint, ClickHouseEndpoint];
90
+ expect(renderStatement(`CREATE DATABASE ${DB}`, { kind: "replicated" })).toBe(`CREATE DATABASE ${DB} ENGINE = Replicated('/clickhouse/databases/${DB}', '{shard}', '{replica}')`);
91
+
92
+ const v1 = writeBuild("v1.json", objects());
93
+ const first = await apply(r1, v1);
94
+ expect(first.outcome.failed).toEqual([]);
95
+ // The second replica joins the database with the statement the applier sent to the first, and replays its tables from the log.
96
+ expect(first.sent[0]).toMatch(new RegExp(`^CREATE DATABASE ${DB} ENGINE = Replicated\\('/clickhouse/databases/${DB}', '\\{shard\\}', '\\{replica\\}'\\) COMMENT `));
97
+ await q(r2, first.sent[0]!);
98
+ await q(r2, `SYSTEM SYNC DATABASE REPLICA ${DB}`);
99
+ expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER/);
100
+ expect(first.sent[1]).toMatch(/ENGINE = ReplicatedReplacingMergeTree\(ts\) ORDER BY id/);
101
+
102
+ await q(r1, `INSERT INTO ${DB}.events (id, ts, kind) VALUES (1, now(), 'click'), (2, now(), 'view')`);
103
+ await q(r2, `SYSTEM SYNC REPLICA ${DB}.events`);
104
+ expect(Number((await q<{ n: string | number }>(r2, `SELECT count() AS n FROM ${DB}.events`))[0]!.n)).toBe(2);
105
+ const engines = await q<{ name: string; engine: string }>(r2, `SELECT name, engine FROM system.tables WHERE database = '${DB}' ORDER BY name`);
106
+ expect(engines.map((t) => [t.name.startsWith(".inner") ? ".inner" : t.name, t.engine])).toEqual([
107
+ [".inner", "ReplicatedSummingMergeTree"],
108
+ ["by_kind", "MaterializedView"],
109
+ ["events", "ReplicatedReplacingMergeTree"],
110
+ ]);
111
+ expect((await q<{ engine: string }>(r2, `SELECT engine FROM system.databases WHERE name = '${DB}'`))[0]!.engine).toBe("Replicated");
112
+
113
+ // On the other replica: the printed engines (default Keeper path filled in) are the declarations; nothing to send.
114
+ const again = await apply(r2, v1);
115
+ expect(again.sent).toEqual([]);
116
+ expect(again.outcome.notAttempted).toEqual([]);
117
+
118
+ // A change there reaches the first replica through the database's log.
119
+ const change = await apply(r2, writeBuild("v2.json", objects(", source String DEFAULT 'web'")));
120
+ expect(change.sent).toEqual([`ALTER TABLE \`${DB}\`.\`events\` ADD COLUMN source String DEFAULT 'web' AFTER \`kind\``]);
121
+ await q(r1, `SYSTEM SYNC DATABASE REPLICA ${DB}`);
122
+ const cols = await q<{ name: string }>(r1, `SELECT name FROM system.columns WHERE database = '${DB}' AND table = 'events' ORDER BY position`);
123
+ expect(cols.map((c) => c.name)).toEqual(["id", "ts", "kind", "source"]);
124
+ }, 300_000);
125
+ });
@@ -2,7 +2,9 @@
2
2
  * The single-node rendering against a server (#3645): one migration source
3
3
  * written for a cluster (`ON CLUSTER`, `Replicated*MergeTree` with Keeper
4
4
  * paths, a `Replicated` database) is refused by a single node as written
5
- * and applies once rendered for it, with the plain MergeTree family.
5
+ * and applies once rendered for it, with the plain MergeTree family: as
6
+ * statements, and through the applier with `CLICKHOUSE_TOPOLOGY=single`,
7
+ * which then finds nothing left to change.
6
8
  *
7
9
  * Runs against `CLICKHOUSE_URL` when it is set (the emulator, `chant
8
10
  * emulator up --lexicon sql`), else a scratch server of the pinned image.
@@ -10,7 +12,11 @@
10
12
  * other; skips cleanly without Docker.
11
13
  */
12
14
 
15
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
13
18
  import { afterAll, beforeAll, describe, expect, test } from "vitest";
19
+ import { clickhouseApply } from "../op/activities/clickhouse-apply";
14
20
  import { dockerAvailable, startScratchServer, type ScratchServer } from "./container";
15
21
  import { clickhouseQuery, type ClickHouseEndpoint } from "./http";
16
22
  import { clickhouseImage } from "../spec/pin";
@@ -39,9 +45,12 @@ beforeAll(async () => {
39
45
  await q(`DROP DATABASE IF EXISTS ${DB} SYNC`);
40
46
  }, 600_000);
41
47
 
48
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-topology-"));
49
+
42
50
  afterAll(async () => {
43
51
  if (enabled) await q(`DROP DATABASE IF EXISTS ${DB} SYNC`).catch(() => undefined);
44
52
  await server?.stop();
53
+ rmSync(dir, { recursive: true, force: true });
45
54
  });
46
55
 
47
56
  const SINGLE: Topology = { kind: "single" };
@@ -98,3 +107,77 @@ describe.skipIf(!enabled)("the single-node rendering against a server (#3645)",
98
107
  expect(cols.map((c) => c.name)).toEqual(["id", "ts", "kind", "source"]);
99
108
  });
100
109
  });
110
+
111
+ describe.skipIf(!enabled)("the applier with a single-node topology (#3645)", () => {
112
+ interface Obj {
113
+ export: string;
114
+ type: string;
115
+ ddl: string;
116
+ dependsOn?: string[];
117
+ }
118
+ const writeBuild = (file: string, objects: Obj[]): string => {
119
+ const path = join(dir, file);
120
+ writeFileSync(path, JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
121
+ return path;
122
+ };
123
+ const objects = (extraColumn = ""): Obj[] => [
124
+ { export: "db", type: "ClickHouse::Database", ddl: `CREATE DATABASE ${DB} ON CLUSTER main ENGINE = Replicated('/clickhouse/databases/${DB}', '{shard}', '{replica}')` },
125
+ {
126
+ export: "events",
127
+ type: "ClickHouse::Table",
128
+ dependsOn: ["db"],
129
+ ddl: `CREATE TABLE ${DB}.events ON CLUSTER main (id UInt64, ts DateTime, kind String${extraColumn}) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/${DB}/events', '{replica}', ts) ORDER BY id`,
130
+ },
131
+ {
132
+ export: "byKind",
133
+ type: "ClickHouse::MaterializedView",
134
+ dependsOn: ["events"],
135
+ ddl: `CREATE MATERIALIZED VIEW ${DB}.by_kind ON CLUSTER main ENGINE = ReplicatedSummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM ${DB}.events GROUP BY kind`,
136
+ },
137
+ ];
138
+ const apply = async (buildPath: string, topology?: string) => {
139
+ const sent: string[] = [];
140
+ const env: Record<string, string> = { CLICKHOUSE_URL: endpoint.url, ...(topology ? { CLICKHOUSE_TOPOLOGY: topology } : {}) };
141
+ if (endpoint.user) env.CLICKHOUSE_USER = endpoint.user;
142
+ if (endpoint.password) env.CLICKHOUSE_PASSWORD = endpoint.password;
143
+ const outcome = await clickhouseApply({ buildPath, environment: "test" }, undefined, {
144
+ config: { ownership: { stack: "yodel", env: "e2e" } },
145
+ env,
146
+ // The statements it sent; the log's other lines report each object.
147
+ log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
148
+ }).catch((err: unknown) => ({ error: err as Error }));
149
+ return { outcome, sent };
150
+ };
151
+
152
+ test("as declared, the cluster's source fails on a single node; rendered for it, it applies and converges", async () => {
153
+ await q(`DROP DATABASE IF EXISTS ${DB} SYNC`);
154
+ const v1 = writeBuild("v1.json", objects());
155
+
156
+ const asDeclared = await apply(v1);
157
+ expect("error" in asDeclared.outcome || asDeclared.outcome.failed.length > 0).toBe(true);
158
+ await q(`DROP DATABASE IF EXISTS ${DB} SYNC`);
159
+
160
+ const first = await apply(v1, "single");
161
+ expect("error" in first.outcome).toBe(false);
162
+ expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER|Replicated/);
163
+ expect(first.sent).toHaveLength(3);
164
+ const tables = await q<{ name: string; engine: string }>(`SELECT name, engine FROM system.tables WHERE database = '${DB}' ORDER BY name`);
165
+ expect(tables.map((t) => [t.name.startsWith(".inner") ? ".inner" : t.name, t.engine])).toEqual([
166
+ [".inner", "SummingMergeTree"],
167
+ ["by_kind", "MaterializedView"],
168
+ ["events", "ReplacingMergeTree"],
169
+ ]);
170
+
171
+ // Again: the server's plain engines are what the source renders to on a single node, so nothing is sent.
172
+ const again = await apply(v1, "single");
173
+ expect(again.sent).toEqual([]);
174
+ expect("error" in again.outcome ? again.outcome.error : again.outcome.notAttempted).toEqual([]);
175
+
176
+ // A change to the cluster's source applies as a single node's ALTER.
177
+ const v2 = writeBuild("v2.json", objects(", source String DEFAULT 'web'"));
178
+ const change = await apply(v2, "single");
179
+ expect(change.sent).toEqual([`ALTER TABLE \`${DB}\`.\`events\` ADD COLUMN source String DEFAULT 'web' AFTER \`kind\``]);
180
+ const cols = await q<{ name: string }>(`SELECT name FROM system.columns WHERE database = '${DB}' AND table = 'events' ORDER BY position`);
181
+ expect(cols.map((c) => c.name)).toEqual(["id", "ts", "kind", "source"]);
182
+ });
183
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import { canonicalObject } from "./plan/normalize";
3
+ import { diffSchemas } from "./plan/diff";
3
4
  import type { Change } from "./plan/diff";
4
5
  import { alterSteps, createStatement, dropStatement, type DeclaredObject } from "./apply/statements";
5
6
  import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "./entities";
@@ -274,3 +275,35 @@ describe("the applier's own statements", () => {
274
275
  expect(renderSteps(steps, SINGLE)).toEqual(steps);
275
276
  });
276
277
  });
278
+
279
+ describe("the classifier compares the engine a topology runs", () => {
280
+ const declared = "CREATE TABLE db.t (id UInt64, v UInt64) ENGINE = ReplacingMergeTree(v) ORDER BY id";
281
+ const changes = (live: string, topology: Topology) =>
282
+ diffSchemas([{ key: "t", canonical: canonicalObject(live) }], [{ key: "t", canonical: canonicalObject(renderStatement(declared, topology)) }]).changes;
283
+
284
+ test("a cluster's table, as the server prints it, is the rendered declaration", () => {
285
+ expect(changes("CREATE TABLE db.t (`id` UInt64, `v` UInt64) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}', v) ORDER BY id", CLUSTER)).toEqual([]);
286
+ });
287
+
288
+ test("a Replicated database's table, printed with the default path and replica, is the declaration with none", () => {
289
+ expect(changes("CREATE TABLE db.t (`id` UInt64, `v` UInt64) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}', v) ORDER BY id", REPLICATED)).toEqual([]);
290
+ });
291
+
292
+ test("Cloud's Shared engine is the plain family it was declared as", () => {
293
+ expect(changes("CREATE TABLE db.t (`id` UInt64, `v` UInt64) ENGINE = SharedReplacingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}', v) ORDER BY id", CLOUD)).toEqual([]);
294
+ });
295
+
296
+ test("a database declared with no engine takes whatever the server's default is, so a single node's Atomic is no change", () => {
297
+ const db = (live: string, declared: string) => diffSchemas([{ key: "d", canonical: canonicalObject(live) }], [{ key: "d", canonical: canonicalObject(declared) }]).changes;
298
+ const declaredForCluster = "CREATE DATABASE d ON CLUSTER main ENGINE = Replicated('/clickhouse/databases/d', '{shard}', '{replica}')";
299
+ expect(db("CREATE DATABASE d\nENGINE = Atomic", renderStatement(declaredForCluster, SINGLE))).toEqual([]);
300
+ expect(db("CREATE DATABASE d\nENGINE = Replicated('/clickhouse/databases/d', '{shard}', '{replica}')", renderStatement("CREATE DATABASE d", REPLICATED))).toEqual([]);
301
+ expect(db("CREATE DATABASE d\nENGINE = Atomic", "CREATE DATABASE d ENGINE = Lazy(60)")).toEqual([expect.objectContaining({ rule: "SQLCH232" })]);
302
+ });
303
+
304
+ test("the engine a topology does not run is still a change", () => {
305
+ expect(changes("CREATE TABLE db.t (`id` UInt64, `v` UInt64) ENGINE = ReplacingMergeTree(v) ORDER BY id", CLUSTER)).toEqual([
306
+ expect.objectContaining({ field: "engine", rule: "SQLCH223", class: "rebuild" }),
307
+ ]);
308
+ });
309
+ });
@@ -449,3 +449,29 @@ export function renderStatement(sql: string, topology: Topology): string {
449
449
  export function renderSteps<S extends { sql: string }>(steps: readonly S[], topology: Topology): S[] {
450
450
  return steps.map((s) => ({ ...s, sql: renderStatement(s.sql, topology) }));
451
451
  }
452
+
453
+ /**
454
+ * A statement for a topology that may not be configured: with none, the
455
+ * statement as the source wrote it. The applier, the plan and the rebuild
456
+ * migration render only when an environment names its topology
457
+ * (`sql.profiles.<env>.topology`, `CLICKHOUSE_TOPOLOGY`), so a project that
458
+ * names none sends what it declared, as before.
459
+ */
460
+ export const renderFor = (sql: string, topology: Topology | undefined): string => (topology ? renderStatement(sql, topology) : sql);
461
+
462
+ /** A topology from its string form or its object form (`sql.profiles.<env>.topology`). */
463
+ export function toTopology(value: string | Topology): Topology {
464
+ return typeof value === "string" ? parseTopology(value) : value;
465
+ }
466
+
467
+ /** The topology's string form, as `parseTopology` reads it. Replica path options are not part of it. */
468
+ export function topologyLabel(topology: Topology): string {
469
+ switch (topology.kind) {
470
+ case "cluster":
471
+ return `cluster:${topology.cluster}`;
472
+ case "replicated":
473
+ return topology.cluster ? `replicated:${topology.cluster}` : "replicated";
474
+ default:
475
+ return topology.kind;
476
+ }
477
+ }
package/src/clickhouse.ts CHANGED
@@ -61,3 +61,19 @@ export {
61
61
  } from "./clickhouse/rebuild/op";
62
62
  export type { DualWrite } from "./clickhouse/rebuild/observe";
63
63
  export { RECEIPTS_DATABASE, RECEIPTS_TABLE } from "./clickhouse/rebuild/receipts";
64
+ export {
65
+ DEFAULT_REPLICA_NAME,
66
+ DEFAULT_REPLICA_PATH,
67
+ onClusterClause,
68
+ parseTopology,
69
+ renderEngine,
70
+ renderFor,
71
+ renderStatement,
72
+ renderSteps,
73
+ replicatedDatabasePath,
74
+ topologyLabel,
75
+ toTopology,
76
+ type EngineClause,
77
+ type Topology,
78
+ type TopologyKind,
79
+ } from "./clickhouse/topology";