@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
@@ -70,6 +70,8 @@ ALTER TABLE \`analytics\`.\`events\` MODIFY COLUMN \`kind\` LowCardinality(Strin
70
70
 
71
71
  A rebuild is never DDL: it is a step naming \`ClickHouseRebuildOp\`, or for a view or a database, which the Op does not rebuild, a manual step. A column or object drop is a statement marked destructive. Each \`CREATE\` carries the project's ownership marker, as the applier stamps it. The command exits 2 when a step is not a statement. Two things the applier asks the server are left out: whether a difference is formatting only, and whether a database is empty before it is dropped.
72
72
 
73
+ \`--topology\` renders the statements for where they will run: \`single\` (the default), \`cluster:<name>\`, \`replicated\` or \`cloud\`. On a cluster every statement carries \`ON CLUSTER\` and a MergeTree-family table becomes \`Replicated*MergeTree\` with its Keeper path; in a \`Replicated\` database the tables are \`Replicated*MergeTree\` with no path and no \`ON CLUSTER\`. \`diffStatements(before, after, { topology })\` takes the same choice; see [Applying to a Server](../applying/#topology).
74
+
73
75
  ## Identity and renames
74
76
 
75
77
  An object is identified by its export name between two builds, so a changed name in the SQL under the same export is a rename. Against a server, which has no export names, an object is identified by \`database.name\`; a declaration renamed since the server last saw it says so with \`-- previously: <old name>\` before its CREATE.
package/src/config.ts CHANGED
@@ -45,6 +45,7 @@ import type { ChantConfig } from "@intentius/chant/config";
45
45
  import { PLANNED_SQL_DIALECTS, SQL_DIALECTS } from "./dialects";
46
46
  import { POSTGRES_PROVIDERS } from "./postgres/providers/types";
47
47
  import { POSTGRES_MAJORS } from "./spec/postgres-pin";
48
+ import { parseTopology } from "./clickhouse/topology";
48
49
 
49
50
  const envRef = z.strictObject({ env: z.string() });
50
51
 
@@ -53,6 +54,27 @@ const providerName = z.enum(POSTGRES_PROVIDERS, {
53
54
  error: () => `expected a Postgres provider (${POSTGRES_PROVIDERS.join(", ")})`,
54
55
  });
55
56
 
57
+ /** A ClickHouse topology, in its string form or its object form (`./clickhouse/topology.ts`). */
58
+ const topologySchema = z.union([
59
+ z.string().refine(
60
+ (v) => {
61
+ try {
62
+ parseTopology(v);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ },
68
+ { error: "expected single, cluster:<name>, replicated, replicated:<cluster> or cloud" },
69
+ ),
70
+ z.discriminatedUnion("kind", [
71
+ z.strictObject({ kind: z.literal("single") }),
72
+ z.strictObject({ kind: z.literal("cluster"), cluster: z.string().min(1), replicaPath: z.string().optional(), replicaName: z.string().optional() }),
73
+ z.strictObject({ kind: z.literal("replicated"), cluster: z.string().min(1).optional() }),
74
+ z.strictObject({ kind: z.literal("cloud") }),
75
+ ]),
76
+ ]);
77
+
56
78
  export const sqlProfileSchema = z.strictObject({
57
79
  /**
58
80
  * Where the server is. ClickHouse: the base URL of its HTTP interface
@@ -73,6 +95,17 @@ export const sqlProfileSchema = z.strictObject({
73
95
  databases: z.array(z.string()).optional(),
74
96
  /** The database an unqualified declaration is created in. `default` when omitted. */
75
97
  defaultDatabase: z.string().optional(),
98
+ /**
99
+ * ClickHouse: the topology this environment runs, which the applier, the
100
+ * plan and the rebuild migration render every statement for: `single`,
101
+ * `cluster:<name>` (`ON CLUSTER`, `Replicated*MergeTree` with a Keeper
102
+ * path), `replicated` (a `Replicated` database; `replicated:<cluster>`
103
+ * creates the databases on that cluster) or `cloud`. The object form sets
104
+ * a cluster's Keeper path and replica name:
105
+ * `{ kind: "cluster", cluster: "main", replicaPath, replicaName }`. When
106
+ * omitted, statements are sent as declared.
107
+ */
108
+ topology: topologySchema.optional(),
76
109
  /**
77
110
  * Postgres: the schemas this environment's schema lives in. Import and
78
111
  * observation read these and nothing else; when omitted, every schema except
@@ -85,6 +85,8 @@ export interface SchemaStatements {
85
85
  dialect: "clickhouse" | "postgres";
86
86
  /** ClickHouse: the database a bare name means. */
87
87
  defaultDatabase?: string;
88
+ /** ClickHouse: the topology the statements are rendered for, in its string form (`single`, `cluster:<name>`, `replicated`, `cloud`). */
89
+ topology?: string;
88
90
  /** Postgres: the schema a bare name means; the applier sets `search_path` to it. */
89
91
  defaultSchema?: string;
90
92
  /** Postgres: the major the changes were classified for. */
@@ -9,6 +9,7 @@ import { postgresApply } from "./op/activities/postgres-apply";
9
9
  import { runDiff } from "./clickhouse/plan/commands";
10
10
  import { writableClickHouse, type WritableServer } from "./clickhouse/testing/writable-server";
11
11
  import { writablePostgres } from "./postgres/testing/writable-server";
12
+ import type { Topology } from "./clickhouse/topology";
12
13
 
13
14
  interface Obj {
14
15
  export: string;
@@ -111,7 +112,7 @@ describe("ClickHouse", () => {
111
112
  });
112
113
 
113
114
  test("two identical builds take no steps", () => {
114
- expect(diffStatements(CH_BEFORE, CH_BEFORE)).toEqual({ dialect: "clickhouse", defaultDatabase: "default", steps: [], refused: false, hints: [] });
115
+ expect(diffStatements(CH_BEFORE, CH_BEFORE)).toEqual({ dialect: "clickhouse", defaultDatabase: "default", topology: "single", steps: [], refused: false, hints: [] });
115
116
  });
116
117
 
117
118
  test("the applier sends the same statements for the same change", async () => {
@@ -263,3 +264,121 @@ describe("chant sql diff --statements", () => {
263
264
  expect(r.out).toContain("ALTER TABLE app.users ADD COLUMN name text DEFAULT 'none' NOT NULL;");
264
265
  });
265
266
  });
267
+
268
+ // ── ClickHouse topologies (#3645) ──────────────────────────────────────
269
+
270
+ describe("ClickHouse statements per topology", () => {
271
+ const EMPTY = build("clickhouse", []);
272
+ const MV: Obj = {
273
+ export: "counts",
274
+ type: "ClickHouse::MaterializedView",
275
+ dependsOn: ["events"],
276
+ ddl: "CREATE MATERIALIZED VIEW analytics.counts ENGINE = SummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind",
277
+ };
278
+ const FIRST = build("clickhouse", [DB, EVENTS, SESSIONS, OLD, MV]);
279
+ const TOPOLOGIES: Array<[string, Topology]> = [
280
+ ["single", { kind: "single" }],
281
+ ["cluster:main", { kind: "cluster", cluster: "main" }],
282
+ ["replicated", { kind: "replicated" }],
283
+ ["cloud", { kind: "cloud" }],
284
+ ];
285
+ const created = (t: Topology) => sqlOf(diffStatements(EMPTY, FIRST, { marker: MARKER, topology: t })).map((s) => s.replace(/ COMMENT '[^']*\[chant[^']*'$/, ""));
286
+ const R = "'/clickhouse/tables/{uuid}/{shard}', '{replica}'";
287
+
288
+ test("single is the default, and the declarations as written", () => {
289
+ expect(diffStatements(EMPTY, FIRST, { marker: MARKER })).toEqual(diffStatements(EMPTY, FIRST, { marker: MARKER, topology: { kind: "single" } }));
290
+ expect(created({ kind: "single" })).toEqual([
291
+ "CREATE DATABASE analytics ENGINE = Atomic",
292
+ "CREATE TABLE analytics.events (ts DateTime, user_id UInt64, kind String) ENGINE = MergeTree ORDER BY (ts, user_id)",
293
+ "CREATE TABLE analytics.sessions (id UInt64, ts DateTime) ENGINE = MergeTree ORDER BY id",
294
+ "CREATE TABLE analytics.old (a UInt8) ENGINE = MergeTree ORDER BY a",
295
+ "CREATE MATERIALIZED VIEW analytics.counts ENGINE = SummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind",
296
+ ]);
297
+ expect(diffStatements(EMPTY, FIRST).topology).toBe("single");
298
+ });
299
+
300
+ test("a cluster creates everything ON CLUSTER, with Replicated engines and their Keeper path", () => {
301
+ expect(created({ kind: "cluster", cluster: "main" })).toEqual([
302
+ "CREATE DATABASE analytics ON CLUSTER `main` ENGINE = Atomic",
303
+ `CREATE TABLE analytics.events ON CLUSTER \`main\` (ts DateTime, user_id UInt64, kind String) ENGINE = ReplicatedMergeTree(${R}) ORDER BY (ts, user_id)`,
304
+ `CREATE TABLE analytics.sessions ON CLUSTER \`main\` (id UInt64, ts DateTime) ENGINE = ReplicatedMergeTree(${R}) ORDER BY id`,
305
+ `CREATE TABLE analytics.old ON CLUSTER \`main\` (a UInt8) ENGINE = ReplicatedMergeTree(${R}) ORDER BY a`,
306
+ `CREATE MATERIALIZED VIEW analytics.counts ON CLUSTER \`main\` ENGINE = ReplicatedSummingMergeTree(${R}) ORDER BY kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind`,
307
+ ]);
308
+ expect(diffStatements(EMPTY, FIRST, { topology: { kind: "cluster", cluster: "main" } }).topology).toBe("cluster:main");
309
+ });
310
+
311
+ test("a Replicated database: the database Replicated, its tables Replicated with no path, no ON CLUSTER", () => {
312
+ expect(created({ kind: "replicated" })).toEqual([
313
+ "CREATE DATABASE analytics ENGINE = Replicated('/clickhouse/databases/analytics', '{shard}', '{replica}')",
314
+ "CREATE TABLE analytics.events (ts DateTime, user_id UInt64, kind String) ENGINE = ReplicatedMergeTree ORDER BY (ts, user_id)",
315
+ "CREATE TABLE analytics.sessions (id UInt64, ts DateTime) ENGINE = ReplicatedMergeTree ORDER BY id",
316
+ "CREATE TABLE analytics.old (a UInt8) ENGINE = ReplicatedMergeTree ORDER BY a",
317
+ "CREATE MATERIALIZED VIEW analytics.counts ENGINE = ReplicatedSummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind",
318
+ ]);
319
+ });
320
+
321
+ test("Cloud takes the plain family", () => {
322
+ expect(created({ kind: "cloud" })).toEqual(created({ kind: "single" }));
323
+ });
324
+
325
+ test("a source written for a cluster renders back to plain DDL for a single node", () => {
326
+ const clustered = build("clickhouse", [
327
+ { ...EVENTS, ddl: `CREATE TABLE analytics.events ON CLUSTER prod (ts DateTime, user_id UInt64, kind String) ENGINE = ReplicatedMergeTree('/t/{shard}/events', '{replica}') ORDER BY (ts, user_id)` },
328
+ ]);
329
+ expect(sqlOf(diffStatements(EMPTY, clustered))[0]).toMatch(/^CREATE TABLE analytics\.events \(ts DateTime, user_id UInt64, kind String\) ENGINE = MergeTree\(\) ORDER BY \(ts, user_id\) COMMENT /);
330
+ });
331
+
332
+ test("the changes between two builds: ON CLUSTER on every ALTER and DROP on a cluster, as written elsewhere", () => {
333
+ const base = sqlOf(diffStatements(CH_BEFORE, CH_AFTER, { marker: MARKER }));
334
+ for (const t of [{ kind: "replicated" }, { kind: "cloud" }] as Topology[]) expect(sqlOf(diffStatements(CH_BEFORE, CH_AFTER, { marker: MARKER, topology: t }))).toEqual(base);
335
+ expect(sqlOf(diffStatements(CH_BEFORE, CH_AFTER, { marker: MARKER, topology: { kind: "cluster", cluster: "main" } }))).toEqual([
336
+ "ALTER TABLE `analytics`.`events` ON CLUSTER `main` ADD COLUMN region String DEFAULT 'eu' AFTER `user_id`",
337
+ "ALTER TABLE `analytics`.`events` ON CLUSTER `main` MODIFY COLUMN `kind` LowCardinality(String)",
338
+ "CREATE VIEW analytics.by_kind ON CLUSTER `main` AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind COMMENT '[chant managed-by=chant stack=shop env=prod]'",
339
+ "DROP TABLE `analytics`.`old` ON CLUSTER `main` SYNC",
340
+ ]);
341
+ });
342
+
343
+ test("an engine the topology changes is no change: a cluster's Replicated engine compares equal to the declared MergeTree", () => {
344
+ for (const [, t] of TOPOLOGIES) expect(diffStatements(FIRST, FIRST, { topology: t }).steps).toEqual([]);
345
+ });
346
+
347
+ test.each(TOPOLOGIES)("the applier sends the same statements as the offline renderer for %s", async (label, t) => {
348
+ const s = await writableClickHouse();
349
+ servers.push(s);
350
+ const deps = { config, env: { CLICKHOUSE_URL: s.url, CLICKHOUSE_TOPOLOGY: label }, log: () => undefined };
351
+ await clickhouseApply({ buildPath: buildFile(FIRST), environment: "test" }, undefined, deps);
352
+ expect(s.writes).toEqual(sqlOf(diffStatements(EMPTY, FIRST, { marker: MARKER, topology: t })));
353
+ // Applied again: nothing to send, the engines it created compare equal to the declarations rendered for it.
354
+ s.writes.length = 0;
355
+ await clickhouseApply({ buildPath: buildFile(FIRST), environment: "test" }, undefined, deps);
356
+ expect(s.writes).toEqual([]);
357
+ const after = build("clickhouse", [DB, EVENTS_V2, SESSIONS_RESORTED, BY_KIND, MV]);
358
+ await clickhouseApply({ buildPath: buildFile(after), environment: "test", prune: true }, undefined, deps);
359
+ expect(s.writes).toEqual(sqlOf(diffStatements(FIRST, after, { marker: MARKER, topology: t })));
360
+ });
361
+
362
+ test("chant sql diff --statements --topology renders for that topology", async () => {
363
+ const lines: string[] = [];
364
+ const log = vi.spyOn(console, "log").mockImplementation((l: unknown) => void lines.push(String(l)));
365
+ try {
366
+ expect(await runDiff({ verb: "diff", rawArgs: [buildFile(EMPTY), buildFile(FIRST), "--statements", "--json", "--topology", "cluster:main"] })).toBe(0);
367
+ expect(await runDiff({ verb: "diff", rawArgs: [buildFile(EMPTY), buildFile(FIRST), "--statements", "--json", "--topology=replicated"] })).toBe(0);
368
+ } finally {
369
+ log.mockRestore();
370
+ }
371
+ expect(JSON.parse(lines[0]!)).toEqual(diffStatements(EMPTY, FIRST, { topology: { kind: "cluster", cluster: "main" } }));
372
+ expect(JSON.parse(lines[1]!)).toEqual(diffStatements(EMPTY, FIRST, { topology: { kind: "replicated" } }));
373
+ });
374
+
375
+ test("chant sql diff refuses a topology it does not know", async () => {
376
+ const err = vi.spyOn(console, "error").mockImplementation(() => undefined);
377
+ try {
378
+ expect(await runDiff({ verb: "diff", rawArgs: [buildFile(EMPTY), buildFile(FIRST), "--statements", "--topology", "sharded"] })).toBe(1);
379
+ expect(err.mock.calls[0]![0]).toMatch(/--topology: unknown topology "sharded"/);
380
+ } finally {
381
+ err.mockRestore();
382
+ }
383
+ });
384
+ });
@@ -34,6 +34,7 @@ import type { OwnershipMarker } from "@intentius/chant/ownership";
34
34
  import { statementChange, type ManualStep, type MigrationStep, type SchemaStatements, type StepObject } from "./core/statements";
35
35
  import { declaredObjects as clickhouseDeclared } from "./clickhouse/apply/apply";
36
36
  import { planStatements, type DeclaredObject } from "./clickhouse/apply/statements";
37
+ import { topologyLabel, type Topology } from "./clickhouse/topology";
37
38
  import { diffSchemas, type Change } from "./clickhouse/plan/diff";
38
39
  import { rebuildOpSuggestions } from "./clickhouse/plan/rebuild-handoff";
39
40
  import { declaredObjects as postgresDeclared } from "./postgres/apply/apply";
@@ -54,6 +55,13 @@ export interface DiffStatementsOptions {
54
55
  env?: string;
55
56
  /** ClickHouse: the database a bare name means. Default: `default`. */
56
57
  defaultDatabase?: string;
58
+ /**
59
+ * ClickHouse: the topology to render the statements for
60
+ * (`./clickhouse/topology.ts`): `ON CLUSTER` and the engine, on both
61
+ * builds' declarations before they are compared, so the steps are the ones
62
+ * the applier sends to an environment with that topology. Default: `single`.
63
+ */
64
+ topology?: Topology;
57
65
  /** Postgres: the schema a bare name means. Default: `public`. */
58
66
  defaultSchema?: string;
59
67
  /** Postgres: the major to classify for. Default: the newer build's `postgresMajor`, else the older's, else the newest pinned. */
@@ -93,8 +101,9 @@ const at = (object: string, type: string, name: string): StepObject => ({ object
93
101
  function clickhouseStatements(beforeJson: string, afterJson: string, options: DiffStatementsOptions): SchemaStatements {
94
102
  const defaultDatabase = options.defaultDatabase ?? "default";
95
103
  const env = options.env ?? "<env>";
96
- const before = clickhouseDeclared(beforeJson, defaultDatabase);
97
- const after = clickhouseDeclared(afterJson, defaultDatabase);
104
+ const topology: Topology = options.topology ?? { kind: "single" };
105
+ const before = clickhouseDeclared(beforeJson, defaultDatabase, topology);
106
+ const after = clickhouseDeclared(afterJson, defaultDatabase, topology);
98
107
  const keyed = (objs: readonly DeclaredObject[]) => objs.map((o) => ({ key: o.exportName, canonical: o.canonical }));
99
108
  const diff = diffSchemas(keyed(before), keyed(after));
100
109
  const plan = planStatements({
@@ -103,6 +112,7 @@ function clickhouseStatements(beforeJson: string, afterJson: string, options: Di
103
112
  current: new Map(before.map((o) => [o.exportName, o.canonical])),
104
113
  keyOf: (o) => o.exportName,
105
114
  allowDestructive: true,
115
+ topology,
106
116
  ...(options.marker ? { marker: options.marker } : {}),
107
117
  });
108
118
  const declaredByExport = new Map(after.map((o) => [o.exportName, o.canonical]));
@@ -156,7 +166,7 @@ function clickhouseStatements(beforeJson: string, afterJson: string, options: Di
156
166
  ...(change?.destructive ? { destructive: true } : {}),
157
167
  });
158
168
  }
159
- return { dialect: "clickhouse", defaultDatabase, steps, refused: steps.some((s) => s.kind !== "statement"), hints: diff.hints };
169
+ return { dialect: "clickhouse", defaultDatabase, topology: topologyLabel(topology), steps, refused: steps.some((s) => s.kind !== "statement"), hints: diff.hints };
160
170
  }
161
171
 
162
172
  // ── Postgres ───────────────────────────────────────────────────────────
@@ -84,7 +84,7 @@ export async function clickhouseApply(args: ClickHouseApplyArgs, signal?: AbortS
84
84
  if (!(err instanceof ClickHouseBindingError)) throw err;
85
85
  return refused(declaredObjects(json), err.unresolved.reason, err.unresolved.detail);
86
86
  }
87
- const declared = declaredObjects(json, target.defaultDatabase);
87
+ const declared = declaredObjects(json, target.defaultDatabase, target.topology);
88
88
  let outcome: ClickHouseApplyOutcome;
89
89
  try {
90
90
  outcome = await applyClickHouse(target, declared, {
@@ -59,7 +59,7 @@ export async function rebuildRun(args: ClickHouseRebuildArgs, signal: AbortSigna
59
59
  const config = deps.config ?? (await loadConfig(cwd));
60
60
  const marker = resolveMarker(args, config);
61
61
  const target = await bindClickHouse({ ...(args.environment !== undefined ? { environment: args.environment } : {}), config: config ?? {}, ...(deps.env ? { env: deps.env } : {}) });
62
- const declared = declaredTable(declaredObjects(readFileSync(resolve(cwd, args.buildPath), "utf8"), target.defaultDatabase), args.table);
62
+ const declared = declaredTable(declaredObjects(readFileSync(resolve(cwd, args.buildPath), "utf8"), target.defaultDatabase, target.topology), args.table);
63
63
  const runId = currentOpRun()?.runId;
64
64
  return {
65
65
  target,
@@ -0,0 +1,280 @@
1
+ /**
2
+ * An approval for a plan that creates an object does not survive an edit to
3
+ * that object (#3652), end to end on both dialects.
4
+ *
5
+ * For each dialect, a project in a temporary directory declares a table that
6
+ * does not exist yet and a gated `ApplyOp`. `chant run` stops at the gate and
7
+ * prints the `chant approve ... --plan <digest>` that answers it; the test
8
+ * approves that digest, adds a column to the declared table, and runs again.
9
+ * The gate refuses with "is approved, but not for this plan" and nothing is
10
+ * created. Approving the new plan then applies the edited table.
11
+ *
12
+ * Before #3652 the plan digest was taken over a diff that listed the table by
13
+ * name only, so the second run applied the edited table under the first
14
+ * approval.
15
+ *
16
+ * Everything is written to the database (ClickHouse) or schema (Postgres)
17
+ * `yodel_3652`.
18
+ *
19
+ * The servers: `CHANT_SQL_GATE_CLICKHOUSE_URL` and
20
+ * `CHANT_SQL_GATE_POSTGRES_URL` (a `postgres://user@host:port/db` URL, with
21
+ * `CHANT_SQL_GATE_POSTGRES_PASSWORD`) name running ones, such as the servers
22
+ * `chant emulator up --lexicon sql` starts. Without them, each dialect starts
23
+ * a throwaway server at the same pin, and skips cleanly when Docker is not
24
+ * available.
25
+ */
26
+
27
+ import { execFile } from "node:child_process";
28
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { promisify } from "node:util";
32
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
33
+ import { dockerAvailable, startScratchServer, type ScratchServer } from "../clickhouse/container";
34
+ import { clickhouseQuery, type ClickHouseEndpoint } from "../clickhouse/http";
35
+ import { clickhouseImage } from "../spec/pin";
36
+ import { startTestPostgres, type TestPostgres } from "../postgres/testing/server";
37
+ import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../postgres/live/client";
38
+
39
+ const exec = promisify(execFile);
40
+
41
+ const REPO = join(import.meta.dirname, "../../../..");
42
+ const BIN = join(REPO, "node_modules/.bin");
43
+ const NAME = "yodel_3652";
44
+ const OP = "schema-apply";
45
+
46
+ const givenClickHouse = process.env.CHANT_SQL_GATE_CLICKHOUSE_URL;
47
+ const givenPostgres = process.env.CHANT_SQL_GATE_POSTGRES_URL;
48
+ const docker = givenClickHouse && givenPostgres ? false : await dockerAvailable();
49
+
50
+ interface Run {
51
+ code: number;
52
+ output: string;
53
+ }
54
+
55
+ const strip = (s: string) => s.replace(/\u001b\[[0-9;]*m/g, "");
56
+
57
+ /**
58
+ * A project directory chant can run in: the repository's `node_modules`
59
+ * linked in, so `@intentius/chant` and the sql lexicon resolve, and a git
60
+ * repository, which the gate ledger and snapshots are written to.
61
+ */
62
+ async function project(files: Record<string, string>, env: Record<string, string>) {
63
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-gate-"));
64
+ symlinkSync(join(REPO, "node_modules"), join(dir, "node_modules"), "dir");
65
+ const write = (all: Record<string, string>) => {
66
+ for (const [path, text] of Object.entries(all)) {
67
+ mkdirSync(join(dir, path, ".."), { recursive: true });
68
+ writeFileSync(join(dir, path), text);
69
+ }
70
+ };
71
+ write({
72
+ "package.json": JSON.stringify({ name: "sql-gate-e2e", private: true, type: "module", scripts: { build: "chant build src --lexicon sql -o dist/schema.json" } }),
73
+ ".gitignore": "node_modules\ndist\n.chant\n",
74
+ ...files,
75
+ });
76
+ const git = (...args: string[]) => exec("git", ["-c", "user.email=e2e@chant.invalid", "-c", "user.name=e2e", ...args], { cwd: dir });
77
+ await git("init", "-q");
78
+ await git("add", "-A");
79
+ await git("commit", "-qm", "the schema");
80
+ const chant = async (...args: string[]): Promise<Run> => {
81
+ try {
82
+ const r = await exec(join(BIN, "chant"), args, {
83
+ cwd: dir,
84
+ env: { ...process.env, ...env, PATH: `${BIN}:${process.env.PATH ?? ""}` },
85
+ maxBuffer: 16 * 1024 * 1024,
86
+ });
87
+ return { code: 0, output: strip(`${r.stdout}${r.stderr}`) };
88
+ } catch (err) {
89
+ const e = err as { code?: number; stdout?: string; stderr?: string };
90
+ return { code: typeof e.code === "number" ? e.code : 1, output: strip(`${e.stdout ?? ""}${e.stderr ?? ""}`) };
91
+ }
92
+ };
93
+ return {
94
+ dir,
95
+ chant,
96
+ async edit(path: string, text: string) {
97
+ write({ [path]: text });
98
+ await git("commit", "-qam", `edit ${path}`);
99
+ },
100
+ };
101
+ }
102
+
103
+ /** The `--plan <digest>` the stopped run printed in its approve command. */
104
+ function approvedPlan(run: Run): string {
105
+ const m = /chant approve \S+ \S+ .*?--plan (jcs1-sha256:[0-9a-f]{64})/.exec(run.output);
106
+ if (!m) throw new Error(`the run printed no approve command with a plan:\n${run.output}`);
107
+ return m[1];
108
+ }
109
+
110
+ function gateOf(run: Run): string {
111
+ const m = /chant approve \S+ (\S+)/.exec(run.output);
112
+ if (!m) throw new Error(`the run printed no approve command:\n${run.output}`);
113
+ return m[1];
114
+ }
115
+
116
+ const OPS = (target: string) => ({
117
+ "ops/apply.op.ts": `import { ApplyOp } from "@intentius/chant/op";
118
+ const { op } = ApplyOp({ name: "${OP}", env: "admin", target: "${target}", gate: { gate: "approve-schema" } });
119
+ export default op;
120
+ `,
121
+ });
122
+
123
+ const config = (sql: Record<string, unknown>) => `import type { ChantConfig } from "@intentius/chant";
124
+ import "@intentius/chant-lexicon-sql";
125
+
126
+ export default {
127
+ lexicons: ["sql"],
128
+ ownership: { stack: "yodel-3652", env: "admin" },
129
+ sql: ${JSON.stringify(sql, null, 2)},
130
+ } satisfies ChantConfig;
131
+ `;
132
+
133
+ /**
134
+ * The shared scenario: approve a create, edit the declared object, rerun.
135
+ * `columns` reads the live table's column names.
136
+ */
137
+ async function approveEditRerun(
138
+ p: Awaited<ReturnType<typeof project>>,
139
+ edited: { path: string; text: string },
140
+ columns: () => Promise<string[]>,
141
+ ): Promise<void> {
142
+ const first = await p.chant("run", OP);
143
+ expect(first.output).toMatch(/MISSING \(declared, provider reports not in cloud\):[\s\S]*- events .*\[definition [0-9a-f]{64}\]/);
144
+ const gate = gateOf(first);
145
+ const digest = approvedPlan(first);
146
+
147
+ const approve = await p.chant("approve", OP, gate, "--plan", digest);
148
+ expect(approve.code, approve.output).toBe(0);
149
+
150
+ await p.edit(edited.path, edited.text);
151
+
152
+ const second = await p.chant("run", OP);
153
+ expect(second.output).toContain(`Gate "${gate}" is approved, but not for this plan.`);
154
+ expect(second.output).toContain(`approved: ${digest}`);
155
+ expect(second.output).not.toMatch(/Op "schema-apply" completed/);
156
+ expect(await columns()).toEqual([]);
157
+
158
+ // Approving the plan the second run produced applies the edited table.
159
+ const fresh = approvedPlan(second);
160
+ expect(fresh).not.toBe(digest);
161
+ const reapprove = await p.chant("approve", OP, gate, "--plan", fresh);
162
+ expect(reapprove.code, reapprove.output).toBe(0);
163
+ const third = await p.chant("run", OP);
164
+ expect(third.output).toMatch(/Op "schema-apply" completed/);
165
+ expect(await columns()).toContain("note");
166
+ }
167
+
168
+ // ── ClickHouse ──────────────────────────────────────────────────────────────
169
+
170
+ const CH_SCHEMA = (extra: string) => `import { database, table } from "@intentius/chant-lexicon-sql/clickhouse";
171
+
172
+ export const db = database\`CREATE DATABASE ${NAME} ENGINE = Atomic COMMENT 'gate binds creates'\`;
173
+
174
+ export const events = table\`
175
+ CREATE TABLE \${db}.events (
176
+ user_id UInt64,
177
+ ts DateTime${extra}
178
+ )
179
+ ENGINE = MergeTree
180
+ ORDER BY (user_id, ts)
181
+ COMMENT 'Raw events'\`;
182
+ `;
183
+
184
+ describe.skipIf(!givenClickHouse && !docker)("a gated ApplyOp over ClickHouse refuses an edited create (#3652)", () => {
185
+ let server: ScratchServer | undefined;
186
+ let admin: ClickHouseEndpoint;
187
+ let p: Awaited<ReturnType<typeof project>>;
188
+ const q = (sql: string) => clickhouseQuery(admin, sql);
189
+
190
+ beforeAll(async () => {
191
+ if (givenClickHouse) admin = { url: givenClickHouse };
192
+ else {
193
+ server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-gate" });
194
+ admin = server.endpoint;
195
+ }
196
+ await q(`DROP DATABASE IF EXISTS ${NAME} SYNC`);
197
+ p = await project(
198
+ {
199
+ ...OPS("clickhouse"),
200
+ "chant.config.ts": config({ dialect: "clickhouse", profiles: { admin: { url: admin.url, databases: [NAME] } } }),
201
+ "src/schema.ts": CH_SCHEMA(""),
202
+ },
203
+ {},
204
+ );
205
+ }, 600_000);
206
+
207
+ afterAll(async () => {
208
+ if (admin) await q(`DROP DATABASE IF EXISTS ${NAME} SYNC`).catch(() => undefined);
209
+ await server?.stop();
210
+ if (p) rmSync(p.dir, { recursive: true, force: true });
211
+ });
212
+
213
+ test("approve a create, add a column, rerun: the gate refuses", async () => {
214
+ await approveEditRerun(p, { path: "src/schema.ts", text: CH_SCHEMA(",\n note String") }, async () => {
215
+ const rows = await clickhouseQuery<{ name: string }>(admin, `SELECT name FROM system.columns WHERE database = '${NAME}' AND table = 'events' ORDER BY position`);
216
+ return rows.map((r) => r.name);
217
+ });
218
+ }, 600_000);
219
+ });
220
+
221
+ // ── Postgres ────────────────────────────────────────────────────────────────
222
+
223
+ const PG_SCHEMA = (extra: string) => `import { schema, table } from "@intentius/chant-lexicon-sql/postgres";
224
+
225
+ export const app = schema\`CREATE SCHEMA ${NAME};
226
+ COMMENT ON SCHEMA ${NAME} IS 'gate binds creates'\`;
227
+
228
+ export const events = table\`
229
+ CREATE TABLE \${app}.events (
230
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
231
+ kind text NOT NULL${extra}
232
+ )\`;
233
+ `;
234
+
235
+ describe.skipIf(!givenPostgres && !docker)("a gated ApplyOp over Postgres refuses an edited create (#3652)", () => {
236
+ let server: TestPostgres | undefined;
237
+ let endpoint: PostgresEndpoint;
238
+ let admin: PostgresClient;
239
+ let p: Awaited<ReturnType<typeof project>>;
240
+ const q = (sql: string) => admin.query(sql);
241
+
242
+ beforeAll(async () => {
243
+ if (givenPostgres) endpoint = { url: givenPostgres, password: process.env.CHANT_SQL_GATE_POSTGRES_PASSWORD };
244
+ else {
245
+ server = await startTestPostgres();
246
+ endpoint = server.endpoint();
247
+ }
248
+ admin = await connectPostgres(endpoint);
249
+ await q(`DROP SCHEMA IF EXISTS ${NAME} CASCADE`);
250
+ p = await project(
251
+ {
252
+ ...OPS("postgres"),
253
+ "chant.config.ts": config({
254
+ dialect: "postgres",
255
+ profiles: { admin: { url: endpoint.url, password: { env: "ADMIN_PASSWORD" }, schemas: [NAME] } },
256
+ }),
257
+ "src/schema.ts": PG_SCHEMA(""),
258
+ },
259
+ { ADMIN_PASSWORD: endpoint.password ?? "" },
260
+ );
261
+ }, 600_000);
262
+
263
+ afterAll(async () => {
264
+ if (admin) {
265
+ await q(`DROP SCHEMA IF EXISTS ${NAME} CASCADE`).catch(() => undefined);
266
+ await admin.end();
267
+ }
268
+ await server?.stop();
269
+ if (p) rmSync(p.dir, { recursive: true, force: true });
270
+ });
271
+
272
+ test("approve a create, add a column, rerun: the gate refuses", async () => {
273
+ await approveEditRerun(p, { path: "src/schema.ts", text: PG_SCHEMA(",\n note text") }, async () => {
274
+ const rows = await admin.query<{ column_name: string }>(
275
+ `SELECT column_name FROM information_schema.columns WHERE table_schema = '${NAME}' AND table_name = 'events' ORDER BY ordinal_position`,
276
+ );
277
+ return rows.map((r) => r.column_name);
278
+ });
279
+ }, 600_000);
280
+ });
@@ -39,6 +39,15 @@ describe("the sql config namespace", () => {
39
39
  expect(sqlConfigSchema.safeParse({ provider: "heroku" }).success).toBe(false);
40
40
  });
41
41
 
42
+ test("a profile takes a ClickHouse topology in its string or object form (#3645)", () => {
43
+ for (const topology of ["single", "cluster:main", "cluster:{cluster}", "replicated", "replicated:all", "cloud", { kind: "cluster", cluster: "main", replicaPath: "/ch/{shard}/{table}" }, { kind: "replicated" }]) {
44
+ expect(sqlConfigSchema.safeParse({ profiles: { prod: { url: "http://x:8123", topology } } }).success, JSON.stringify(topology)).toBe(true);
45
+ }
46
+ for (const topology of ["cluster", "sharded", { kind: "cluster" }, { kind: "single", cluster: "x" }]) {
47
+ expect(sqlConfigSchema.safeParse({ profiles: { prod: { url: "http://x:8123", topology } } }).success, JSON.stringify(topology)).toBe(false);
48
+ }
49
+ });
50
+
42
51
  test("accepts a list of dialects", () => {
43
52
  expect(sqlConfigSchema.safeParse({ dialect: ["clickhouse"] }).success).toBe(true);
44
53
  expect(sqlConfigSchema.safeParse({ dialect: ["clickhouse", "postgres"] }).success).toBe(true);