@intentius/chant-lexicon-sql 0.112.1 → 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 (70) 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 +85 -1
  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 +12 -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 +112 -0
  19. package/dist/clickhouse/topology.d.ts.map +1 -0
  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/codegen/postgres-classifier-page.d.ts.map +1 -1
  24. package/dist/config.d.ts +46 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/core/statements.d.ts +106 -0
  27. package/dist/core/statements.d.ts.map +1 -0
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/integrity.json +2 -2
  31. package/dist/manifest.json +1 -1
  32. package/dist/migration-statements.d.ts +63 -0
  33. package/dist/migration-statements.d.ts.map +1 -0
  34. package/dist/postgres/apply/apply.d.ts.map +1 -1
  35. package/dist/postgres/apply/statements.d.ts +91 -1
  36. package/dist/postgres/apply/statements.d.ts.map +1 -1
  37. package/package.json +2 -2
  38. package/src/clickhouse/apply/apply.ts +35 -56
  39. package/src/clickhouse/apply/statements.ts +168 -31
  40. package/src/clickhouse/live/bind.ts +13 -1
  41. package/src/clickhouse/live/live.test.ts +10 -0
  42. package/src/clickhouse/plan/commands.ts +63 -9
  43. package/src/clickhouse/plan/deep.ts +2 -1
  44. package/src/clickhouse/plan/diff.ts +3 -1
  45. package/src/clickhouse/plan/normalize.ts +11 -2
  46. package/src/clickhouse/plan/schema.ts +6 -5
  47. package/src/clickhouse/rebuild/backfill.ts +1 -0
  48. package/src/clickhouse/rebuild/receipts.ts +14 -4
  49. package/src/clickhouse/rebuild/steps.ts +8 -5
  50. package/src/clickhouse/topology-replicated.e2e.test.ts +125 -0
  51. package/src/clickhouse/topology.e2e.test.ts +183 -0
  52. package/src/clickhouse/topology.test.ts +309 -0
  53. package/src/clickhouse/topology.ts +477 -0
  54. package/src/clickhouse.ts +16 -0
  55. package/src/codegen/classifier-page.ts +19 -0
  56. package/src/codegen/postgres-classifier-page.ts +17 -0
  57. package/src/config.ts +33 -0
  58. package/src/core/statements.ts +129 -0
  59. package/src/index.ts +15 -0
  60. package/src/migration-statements.test.ts +384 -0
  61. package/src/migration-statements.ts +262 -0
  62. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +93 -0
  63. package/src/op/activities/clickhouse-apply.ts +1 -1
  64. package/src/op/activities/clickhouse-rebuild.ts +1 -1
  65. package/src/op/apply-gate-create.e2e.test.ts +280 -0
  66. package/src/op/watch-drift.e2e.test.ts +302 -0
  67. package/src/op/watch-pipeline.test.ts +113 -0
  68. package/src/plugin.test.ts +9 -0
  69. package/src/postgres/apply/apply.ts +38 -96
  70. package/src/postgres/apply/statements.ts +145 -2
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The statements a schema change takes, rendered offline (#3644), the same
3
+ * shape in every dialect: what `diffStatements`
4
+ * (`../migration-statements.ts`) returns and `chant sql diff --statements
5
+ * --json` prints, for a migration file to hold.
6
+ *
7
+ * Every value is plain JSON: strings, booleans, arrays and objects, no
8
+ * `undefined` left in, so the document reads back as it was written. Steps
9
+ * are in the order they run.
10
+ */
11
+
12
+ /** A classified change, as a step names the changes it makes. */
13
+ export interface StatementChange {
14
+ /** The classifier rule, e.g. `SQLCH201`, `SQLPG240`. */
15
+ rule: string;
16
+ /** That rule's class, e.g. `metadata`, `rewrite`, `concurrently`. */
17
+ class: string;
18
+ /** What changed, e.g. `columns.region`, `orderBy`. */
19
+ field: string;
20
+ before?: string;
21
+ after?: string;
22
+ /** Data is removed and not recoverable. */
23
+ destructive?: boolean;
24
+ note?: string;
25
+ }
26
+
27
+ /** What every step says about the object it is for. */
28
+ export interface StepObject {
29
+ /** The object's export name: in the newer build, or the older one for an object it drops. */
30
+ object: string;
31
+ /** Its entity type, e.g. `ClickHouse::Table`, `Postgres::Index`. */
32
+ type: string;
33
+ /** Its name on the server: `database.name` or `schema.name`, or a database's, schema's or extension's own name. */
34
+ name: string;
35
+ }
36
+
37
+ /** One statement, as the applier would send it. */
38
+ export interface StatementStep extends StepObject {
39
+ kind: "statement";
40
+ sql: string;
41
+ /** The rule of the change it makes. */
42
+ rule: string;
43
+ /** That rule's class. */
44
+ class: string;
45
+ /** False for a statement that may not run inside a transaction block (Postgres `CONCURRENTLY`, `ALTER TYPE ... ADD VALUE`). Always false for ClickHouse, which has none. */
46
+ transactional: boolean;
47
+ /** ClickHouse: the statement starts a mutation; wait for it in `system.mutations` before the next one. */
48
+ waitsForMutation?: boolean;
49
+ /** The statement removes data that is not recoverable (a column or table drop). */
50
+ destructive?: boolean;
51
+ }
52
+
53
+ /** A change no statement makes in place, made by a migration Op. */
54
+ export interface OpStep extends StepObject {
55
+ kind: "op";
56
+ /** The Op's export name. */
57
+ op: "ClickHouseRebuildOp" | "PostgresMigrationOp";
58
+ /** The module the Op is imported from. */
59
+ importPath: string;
60
+ /** The Op's options as the hand-off suggests them: `name`, `env`, `table`, and `dualWrite` (ClickHouse) or `column` (Postgres). */
61
+ options: Record<string, unknown>;
62
+ /** The Op's declaration, ready for an `*.op.ts` file. */
63
+ declaration: string;
64
+ /** The changes the Op makes. */
65
+ changes: StatementChange[];
66
+ /** The refusal as the applier reports it: each rule, its restriction, and the Op. */
67
+ detail: string;
68
+ }
69
+
70
+ /**
71
+ * A change neither a statement nor an Op makes: a rebuild of a view or a
72
+ * database, an expand-and-contract change no Op covers, a change with no
73
+ * in-place statement, or the object's other changes that the applier holds
74
+ * back while one of its changes is refused.
75
+ */
76
+ export interface ManualStep extends StepObject {
77
+ kind: "manual";
78
+ changes: StatementChange[];
79
+ detail: string;
80
+ }
81
+
82
+ export type MigrationStep = StatementStep | OpStep | ManualStep;
83
+
84
+ export interface SchemaStatements {
85
+ dialect: "clickhouse" | "postgres";
86
+ /** ClickHouse: the database a bare name means. */
87
+ defaultDatabase?: string;
88
+ /** ClickHouse: the topology the statements are rendered for, in its string form (`single`, `cluster:<name>`, `replicated`, `cloud`). */
89
+ topology?: string;
90
+ /** Postgres: the schema a bare name means; the applier sets `search_path` to it. */
91
+ defaultSchema?: string;
92
+ /** Postgres: the major the changes were classified for. */
93
+ major?: number;
94
+ /** In the order they run: each object's in the newer build's creation order, then the drops. */
95
+ steps: MigrationStep[];
96
+ /** True when a step is an Op or manual: the schema change is more than statements. */
97
+ refused: boolean;
98
+ /** What the diff points out besides the changes (a likely rename written as a drop and an add). */
99
+ hints: string[];
100
+ }
101
+
102
+ /** A classified change as a step names it, with no `undefined` fields. */
103
+ export function statementChange(c: { rule: string; class: string; field: string; before?: string; after?: string; destructive?: boolean; note?: string }): StatementChange {
104
+ return {
105
+ rule: c.rule,
106
+ class: c.class,
107
+ field: c.field,
108
+ ...(c.before !== undefined ? { before: c.before } : {}),
109
+ ...(c.after !== undefined ? { after: c.after } : {}),
110
+ ...(c.destructive ? { destructive: true } : {}),
111
+ ...(c.note !== undefined ? { note: c.note } : {}),
112
+ };
113
+ }
114
+
115
+ /** The steps as a migration file's SQL: each statement after a comment naming its object, rule and class; an Op or manual step as a comment. */
116
+ export function renderStatements(doc: SchemaStatements): string {
117
+ const out: string[] = [];
118
+ for (const s of doc.steps) {
119
+ if (s.kind === "statement") {
120
+ const notes = [s.transactional || doc.dialect !== "postgres" ? "" : "outside a transaction", s.waitsForMutation ? "waits for its mutation" : "", s.destructive ? "destructive" : ""].filter(Boolean);
121
+ out.push(`-- ${s.object} (${s.name}): ${s.rule} ${s.class}${notes.length ? `, ${notes.join(", ")}` : ""}`, `${s.sql};`, "");
122
+ } else if (s.kind === "op") {
123
+ out.push(`-- ${s.object} (${s.name}): ${s.changes.map((c) => c.rule).join(", ")} made by ${s.op}, not a statement:`, `-- ${s.declaration}`, "");
124
+ } else {
125
+ out.push(`-- ${s.object} (${s.name}): ${s.changes.map((c) => c.rule).join(", ")} has no statement and no Op: ${s.detail}`, "");
126
+ }
127
+ }
128
+ return out.join("\n");
129
+ }
package/src/index.ts CHANGED
@@ -19,3 +19,18 @@ export { database, table, view, literal } from "./clickhouse/entities";
19
19
  // `@intentius/chant-lexicon-sql/postgres` is Postgres's. Declarations import
20
20
  // them from the dialect subpath.
21
21
  export { schema, index, sequence, type, domain, extension } from "./postgres/entities";
22
+
23
+ // A schema change's statements between two builds, rendered offline for a
24
+ // migration file (#3644); also at `@intentius/chant-lexicon-sql/migration-statements`.
25
+ export {
26
+ diffStatements,
27
+ renderStatements,
28
+ type DiffStatementsOptions,
29
+ type ManualStep,
30
+ type MigrationStep,
31
+ type OpStep,
32
+ type SchemaStatements,
33
+ type StatementChange,
34
+ type StatementStep,
35
+ type StepObject,
36
+ } from "./migration-statements";
@@ -0,0 +1,384 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, describe, expect, test, vi } from "vitest";
5
+ import { diffStatements, renderStatements, type SchemaStatements, type StatementStep } from "./migration-statements";
6
+ import { diffStatements as fromRoot } from "./index";
7
+ import { clickhouseApply } from "./op/activities/clickhouse-apply";
8
+ import { postgresApply } from "./op/activities/postgres-apply";
9
+ import { runDiff } from "./clickhouse/plan/commands";
10
+ import { writableClickHouse, type WritableServer } from "./clickhouse/testing/writable-server";
11
+ import { writablePostgres } from "./postgres/testing/writable-server";
12
+ import type { Topology } from "./clickhouse/topology";
13
+
14
+ interface Obj {
15
+ export: string;
16
+ type: string;
17
+ ddl: string;
18
+ dependsOn?: string[];
19
+ }
20
+
21
+ const dirs: string[] = [];
22
+ const servers: WritableServer[] = [];
23
+ afterAll(async () => {
24
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
25
+ for (const s of servers) await s.close();
26
+ });
27
+
28
+ const build = (dialect: string, objects: Obj[], extra: Record<string, unknown> = {}) => ({
29
+ dialect,
30
+ ...extra,
31
+ applyOrder: objects.map((o) => o.export),
32
+ objects: objects.map((o) => ({ dependsOn: [], ...o })),
33
+ });
34
+
35
+ function buildFile(doc: object): string {
36
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-statements-"));
37
+ dirs.push(dir);
38
+ const path = join(dir, "schema.json");
39
+ writeFileSync(path, JSON.stringify(doc));
40
+ return path;
41
+ }
42
+
43
+ const MARKER = { stack: "shop", env: "prod" };
44
+ const config = { ownership: { stack: "shop", env: "prod" } };
45
+ const sqlOf = (doc: SchemaStatements) => doc.steps.filter((s): s is StatementStep => s.kind === "statement").map((s) => s.sql);
46
+
47
+ // ── ClickHouse ─────────────────────────────────────────────────────────
48
+
49
+ const DB: Obj = { export: "analytics", type: "ClickHouse::Database", ddl: "CREATE DATABASE analytics ENGINE = Atomic" };
50
+ const EVENTS: Obj = {
51
+ export: "events",
52
+ type: "ClickHouse::Table",
53
+ dependsOn: ["analytics"],
54
+ ddl: "CREATE TABLE analytics.events (ts DateTime, user_id UInt64, kind String) ENGINE = MergeTree ORDER BY (ts, user_id) COMMENT 'Raw events'",
55
+ };
56
+ const EVENTS_V2: Obj = {
57
+ ...EVENTS,
58
+ ddl: "CREATE TABLE analytics.events (ts DateTime, user_id UInt64, region String DEFAULT 'eu', kind LowCardinality(String)) ENGINE = MergeTree ORDER BY (ts, user_id) COMMENT 'Raw events'",
59
+ };
60
+ const SESSIONS: Obj = { export: "sessions", type: "ClickHouse::Table", dependsOn: ["analytics"], ddl: "CREATE TABLE analytics.sessions (id UInt64, ts DateTime) ENGINE = MergeTree ORDER BY id" };
61
+ const SESSIONS_RESORTED: Obj = { ...SESSIONS, ddl: "CREATE TABLE analytics.sessions (id UInt64, ts DateTime) ENGINE = MergeTree ORDER BY (ts, id)" };
62
+ const OLD: Obj = { export: "old", type: "ClickHouse::Table", dependsOn: ["analytics"], ddl: "CREATE TABLE analytics.old (a UInt8) ENGINE = MergeTree ORDER BY a" };
63
+ const BY_KIND: Obj = { export: "byKind", type: "ClickHouse::View", dependsOn: ["events"], ddl: "CREATE VIEW analytics.by_kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind" };
64
+
65
+ const CH_BEFORE = build("clickhouse", [DB, EVENTS, SESSIONS, OLD]);
66
+ const CH_AFTER = build("clickhouse", [DB, EVENTS_V2, SESSIONS_RESORTED, BY_KIND]);
67
+
68
+ describe("ClickHouse", () => {
69
+ const doc = diffStatements(CH_BEFORE, CH_AFTER, { marker: MARKER });
70
+
71
+ test("the statements are ordered, each with its change's rule and class", () => {
72
+ expect(doc.dialect).toBe("clickhouse");
73
+ expect(doc.steps.map((s) => [s.kind, s.object, s.kind === "statement" ? s.rule : s.kind === "op" ? s.op : "", s.kind === "statement" ? s.class : ""])).toEqual([
74
+ ["statement", "events", "SQLCH201", "metadata"],
75
+ ["statement", "events", "SQLCH210", "rewrite"],
76
+ ["op", "sessions", "ClickHouseRebuildOp", ""],
77
+ ["statement", "byKind", "SQLCH200", "create"],
78
+ ["statement", "old", "SQLCH250", "drop"],
79
+ ]);
80
+ expect(sqlOf(doc)).toEqual([
81
+ "ALTER TABLE `analytics`.`events` ADD COLUMN region String DEFAULT 'eu' AFTER `user_id`",
82
+ "ALTER TABLE `analytics`.`events` MODIFY COLUMN `kind` LowCardinality(String)",
83
+ "CREATE VIEW analytics.by_kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind COMMENT '[chant managed-by=chant stack=shop env=prod]'",
84
+ "DROP TABLE `analytics`.`old` SYNC",
85
+ ]);
86
+ const rewrite = doc.steps[1] as StatementStep;
87
+ expect(rewrite.waitsForMutation).toBe(true);
88
+ expect((doc.steps[4] as StatementStep).destructive).toBe(true);
89
+ });
90
+
91
+ test("a rebuild is a step naming ClickHouseRebuildOp, never DDL", () => {
92
+ const op = doc.steps.find((s) => s.kind === "op");
93
+ expect(op).toMatchObject({
94
+ kind: "op",
95
+ object: "sessions",
96
+ type: "ClickHouse::Table",
97
+ name: "analytics.sessions",
98
+ op: "ClickHouseRebuildOp",
99
+ importPath: "@intentius/chant-lexicon-sql/clickhouse",
100
+ options: { name: "rebuild-analytics-sessions", env: "<env>", table: "analytics.sessions", dualWrite: { mode: "materialized-view", cutoverColumn: "ts" } },
101
+ changes: [{ rule: "SQLCH220", class: "rebuild", field: "orderBy" }],
102
+ });
103
+ expect(sqlOf(doc).some((s) => s.includes("sessions"))).toBe(false);
104
+ expect(doc.refused).toBe(true);
105
+ });
106
+
107
+ test("a view's rebuild has no Op: it is a manual step", () => {
108
+ const mv = { export: "mv", type: "ClickHouse::MaterializedView", ddl: "CREATE MATERIALIZED VIEW analytics.mv ENGINE = MergeTree ORDER BY kind AS SELECT kind FROM analytics.events" };
109
+ const d = diffStatements(build("clickhouse", [DB, EVENTS, mv]), build("clickhouse", [DB, EVENTS, { ...mv, ddl: mv.ddl.replace("ORDER BY kind", "ORDER BY tuple()") }]));
110
+ expect(d.steps).toEqual([expect.objectContaining({ kind: "manual", object: "mv", changes: [expect.objectContaining({ rule: "SQLCH243", class: "rebuild" })] })]);
111
+ expect((d.steps[0] as { detail: string }).detail).toMatch(/drop and create this object instead/);
112
+ });
113
+
114
+ test("two identical builds take no steps", () => {
115
+ expect(diffStatements(CH_BEFORE, CH_BEFORE)).toEqual({ dialect: "clickhouse", defaultDatabase: "default", topology: "single", steps: [], refused: false, hints: [] });
116
+ });
117
+
118
+ test("the applier sends the same statements for the same change", async () => {
119
+ const s = await writableClickHouse();
120
+ servers.push(s);
121
+ const deps = { config, env: { CLICKHOUSE_URL: s.url }, log: () => undefined };
122
+ await clickhouseApply({ buildPath: buildFile(CH_BEFORE), environment: "test" }, undefined, deps);
123
+ s.writes.length = 0;
124
+ const outcome = await clickhouseApply({ buildPath: buildFile(CH_AFTER), environment: "test", prune: true }, undefined, deps);
125
+ expect(s.writes).toEqual(sqlOf(doc));
126
+ // The refused rebuild is the same refusal, word for word.
127
+ const refused = outcome.notAttempted.find((n) => n.name === "analytics.sessions");
128
+ expect(refused?.detail).toBe((doc.steps.find((x) => x.kind === "op") as { detail: string }).detail);
129
+ });
130
+ });
131
+
132
+ // ── Postgres ───────────────────────────────────────────────────────────
133
+
134
+ const APP: Obj = { export: "app", type: "Postgres::Schema", ddl: "CREATE SCHEMA app" };
135
+ const USERS: Obj = {
136
+ export: "users",
137
+ type: "Postgres::Table",
138
+ dependsOn: ["app"],
139
+ ddl: "CREATE TABLE app.users (\n id bigint PRIMARY KEY,\n email text NOT NULL,\n nick text\n);\nCOMMENT ON TABLE app.users IS 'Accounts'",
140
+ };
141
+ const USERS_V2: Obj = { ...USERS, ddl: USERS.ddl.replace("nick text", "nick text,\n name text NOT NULL DEFAULT 'none'") };
142
+ const EMAIL_IDX: Obj = { export: "usersEmail", type: "Postgres::Index", dependsOn: ["users"], ddl: "CREATE INDEX CONCURRENTLY users_email_idx ON app.users (email)" };
143
+ const ORDERS: Obj = { export: "orders", type: "Postgres::Table", dependsOn: ["app"], ddl: "CREATE TABLE app.orders (\n id bigint,\n ref text\n)" };
144
+ const ORDERS_RENAMED: Obj = { ...ORDERS, ddl: "CREATE TABLE app.orders (\n id bigint,\n reference text -- previously: ref\n)" };
145
+ const EMAILS: Obj = { export: "emails", type: "Postgres::View", dependsOn: ["users"], ddl: "CREATE VIEW app.emails AS SELECT email FROM app.users" };
146
+
147
+ const PG_BEFORE = build("postgres", [APP, USERS, ORDERS, EMAILS], { postgresMajor: 18 });
148
+ const PG_AFTER = build("postgres", [APP, USERS_V2, EMAIL_IDX, ORDERS_RENAMED], { postgresMajor: 18 });
149
+
150
+ describe("Postgres", () => {
151
+ const doc = diffStatements(PG_BEFORE, PG_AFTER, { marker: MARKER, env: "prod" });
152
+
153
+ test("the statements are ordered, each with its change's rule and class and whether it may run in a transaction", () => {
154
+ expect(doc).toMatchObject({ dialect: "postgres", defaultSchema: "public", major: 18 });
155
+ expect(doc.steps.filter((s): s is StatementStep => s.kind === "statement").map((s) => [s.object, s.sql, s.rule, s.class, s.transactional])).toEqual([
156
+ ["users", "ALTER TABLE app.users ADD COLUMN name text DEFAULT 'none' NOT NULL", "SQLPG201", "metadata", true],
157
+ ["usersEmail", "CREATE INDEX CONCURRENTLY users_email_idx ON app.users (email)", "SQLPG240", "concurrently", false],
158
+ ["usersEmail", "COMMENT ON INDEX app.users_email_idx IS '[chant managed-by=chant stack=shop env=prod]'", "SQLPG240", "concurrently", true],
159
+ ["emails", "DROP VIEW app.emails", "SQLPG270", "drop", true],
160
+ ]);
161
+ });
162
+
163
+ test("a column rename is a step naming PostgresMigrationOp, never DDL", () => {
164
+ const op = doc.steps.find((s) => s.kind === "op");
165
+ expect(op).toMatchObject({
166
+ kind: "op",
167
+ object: "orders",
168
+ type: "Postgres::Table",
169
+ name: "app.orders",
170
+ op: "PostgresMigrationOp",
171
+ importPath: "@intentius/chant-lexicon-sql/postgres",
172
+ options: { name: "migrate-app-orders-reference", env: "prod", table: "app.orders", column: "reference" },
173
+ changes: [{ rule: "SQLPG205", class: "expand", field: "columns.reference", before: "ref", after: "reference" }],
174
+ });
175
+ expect((op as { declaration: string }).declaration).toContain('PostgresMigrationOp({ name: "migrate-app-orders-reference", env: "prod", table: "app.orders", column: "reference" })');
176
+ expect(sqlOf(doc).some((s) => s.includes("orders"))).toBe(false);
177
+ });
178
+
179
+ test("a type change across kinds is a PostgresMigrationOp step too", () => {
180
+ const typed = { ...ORDERS, ddl: "CREATE TABLE app.orders (\n id text,\n ref text\n)" };
181
+ const d = diffStatements(build("postgres", [APP, ORDERS]), build("postgres", [APP, typed]));
182
+ expect(d.steps).toEqual([expect.objectContaining({ kind: "op", op: "PostgresMigrationOp", options: expect.objectContaining({ column: "id" }), changes: [expect.objectContaining({ rule: "SQLPG208" })] })]);
183
+ });
184
+
185
+ test("an expand-and-contract change no Op makes is a manual step", () => {
186
+ const partitioned = { ...ORDERS, ddl: "CREATE TABLE app.orders (\n id bigint NOT NULL\n)" };
187
+ const d = diffStatements(build("postgres", [APP, { ...ORDERS, ddl: "CREATE TABLE app.orders (\n ref text\n)" }]), build("postgres", [APP, { ...partitioned, ddl: "CREATE TABLE app.orders (\n ref text,\n id bigint NOT NULL\n)" }]));
188
+ expect(d.steps).toEqual([expect.objectContaining({ kind: "manual", object: "orders", changes: [expect.objectContaining({ rule: "SQLPG203", class: "expand" })] })]);
189
+ expect(d.refused).toBe(true);
190
+ });
191
+
192
+ test("the applier sends the same statements for the same change", async () => {
193
+ const s = writablePostgres();
194
+ const deps = {
195
+ config,
196
+ env: { POSTGRES_URL: "postgres://fake:5432/shop" },
197
+ log: () => undefined,
198
+ connect: async () => s.client,
199
+ readLive: () => s.readLive(),
200
+ serverNormalize: async (_client: unknown, o: unknown) => o,
201
+ };
202
+ await postgresApply({ buildPath: buildFile(PG_BEFORE), environment: "test" }, undefined, deps as never);
203
+ const outcome = await postgresApply({ buildPath: buildFile(PG_AFTER), environment: "test", prune: true }, undefined, deps as never);
204
+ const offline = doc.steps.filter((x): x is StatementStep => x.kind === "statement");
205
+ expect(outcome.statements.map((r) => [r.sql, r.class])).toEqual(offline.map((x) => [x.sql, x.class]));
206
+ // What may not run in a transaction block ran outside one. (A prune's DROP may, and the applier runs each alone.)
207
+ expect(outcome.statements.filter((r, i) => !offline[i]!.transactional).map((r) => r.transaction)).toEqual([undefined]);
208
+ expect(outcome.statements.filter((r, i) => offline[i]!.transactional && offline[i]!.class !== "drop").every((r) => r.transaction !== undefined)).toBe(true);
209
+ const refused = outcome.notAttempted.find((n) => n.name === "app.orders");
210
+ expect(refused?.detail).toBe((doc.steps.find((x) => x.kind === "op") as { detail: string }).detail);
211
+ });
212
+ });
213
+
214
+ describe("the document", () => {
215
+ test("is plain JSON: it reads back as written", () => {
216
+ for (const doc of [diffStatements(CH_BEFORE, CH_AFTER), diffStatements(PG_BEFORE, PG_AFTER)]) {
217
+ expect(JSON.parse(JSON.stringify(doc))).toEqual(doc);
218
+ expect(JSON.stringify(doc)).not.toContain("undefined");
219
+ }
220
+ });
221
+
222
+ test("takes build outputs as JSON text or parsed, and is exported from the package root", () => {
223
+ expect(fromRoot(JSON.stringify(CH_BEFORE), JSON.stringify(CH_AFTER))).toEqual(diffStatements(CH_BEFORE, CH_AFTER));
224
+ });
225
+
226
+ test("takes the sql output of a multi-lexicon build", () => {
227
+ expect(diffStatements({ sql: PG_BEFORE }, { sql: PG_AFTER })).toEqual(diffStatements(PG_BEFORE, PG_AFTER));
228
+ });
229
+
230
+ test("refuses builds of two dialects", () => {
231
+ expect(() => diffStatements(CH_BEFORE, PG_AFTER)).toThrow(/different dialects/);
232
+ });
233
+
234
+ test("renders as a migration file's SQL, each statement after its rule and class, an Op as a comment", () => {
235
+ const sql = renderStatements(diffStatements(PG_BEFORE, PG_AFTER, { env: "prod" }));
236
+ expect(sql).toContain("-- usersEmail (app.users_email_idx): SQLPG240 concurrently, outside a transaction\nCREATE INDEX CONCURRENTLY users_email_idx ON app.users (email);");
237
+ expect(sql).toContain("-- orders (app.orders): SQLPG205 made by PostgresMigrationOp, not a statement:");
238
+ expect(sql).not.toMatch(/^ALTER TABLE app\.orders/m);
239
+ });
240
+ });
241
+
242
+ describe("chant sql diff --statements", () => {
243
+ const capture = async (args: string[]) => {
244
+ const lines: string[] = [];
245
+ const log = vi.spyOn(console, "log").mockImplementation((l: unknown) => void lines.push(String(l)));
246
+ try {
247
+ return { code: await runDiff({ verb: "diff", rawArgs: args }), out: lines.join("\n") };
248
+ } finally {
249
+ log.mockRestore();
250
+ }
251
+ };
252
+
253
+ test("--json prints the document diffStatements returns, and exits 2 for a step that is an Op", async () => {
254
+ const r = await capture([buildFile(CH_BEFORE), buildFile(CH_AFTER), "--statements", "--json"]);
255
+ expect(r.code).toBe(2);
256
+ expect(JSON.parse(r.out)).toEqual(diffStatements(CH_BEFORE, CH_AFTER));
257
+ });
258
+
259
+ test("without --json prints the SQL, and exits 0 when every step is a statement", async () => {
260
+ const after = build("postgres", [APP, USERS_V2, ORDERS, EMAILS], { postgresMajor: 18 });
261
+ const r = await capture([buildFile(PG_BEFORE), buildFile(after), "--statements"]);
262
+ expect(r.code).toBe(0);
263
+ expect(r.out).toBe(renderStatements(diffStatements(PG_BEFORE, after)));
264
+ expect(r.out).toContain("ALTER TABLE app.users ADD COLUMN name text DEFAULT 'none' NOT NULL;");
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
+ });