@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
@@ -20,9 +20,10 @@ import { isTrivia, tokenizeText, type Token } from "../tokens";
20
20
  import { parseCreate, type CreateNode, type Span, type TableNode, type ViewNode } from "../parser";
21
21
  import { canonicalObject, type CanonicalObject } from "../plan/normalize";
22
22
  import type { Change } from "../plan/diff";
23
- import { CLASSIFIER_RULES } from "../plan/rules";
23
+ import { CLASSIFIER_RULES, type ChangeClass, type ClassifierRuleId } from "../plan/rules";
24
24
  import { stampedComment } from "../ownership";
25
25
  import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "../entities";
26
+ import { renderFor, type Topology } from "../topology";
26
27
 
27
28
  /** A backquoted identifier. */
28
29
  export const ident = (name: string): string => `\`${name.replace(/`/g, "``")}\``;
@@ -53,11 +54,21 @@ export interface DeclaredObject {
53
54
  dependsOn: string[];
54
55
  }
55
56
 
56
- /** One statement to send, and whether it starts a background rewrite to wait on. */
57
+ /** One statement to send, the change it makes, and whether it starts a background rewrite to wait on. */
57
58
  export interface Step {
58
59
  sql: string;
59
60
  /** A mutation follows on the server; wait on `system.mutations` before the next step. */
60
61
  rewrite: boolean;
62
+ /** The classifier rule of the change it makes. */
63
+ rule: ClassifierRuleId;
64
+ /** That rule's class. */
65
+ class: ChangeClass;
66
+ }
67
+
68
+ /** The step for a statement that makes a change under `rule`: a rewrite-class rule waits on its mutation. */
69
+ export function stepFor(sql: string, rule: ClassifierRuleId): Step {
70
+ const cls = CLASSIFIER_RULES[rule].class;
71
+ return { sql, rewrite: cls === "rewrite", rule, class: cls };
61
72
  }
62
73
 
63
74
  interface Parsed {
@@ -259,7 +270,7 @@ function reorderSteps(t: string, tokens: Token[], node: TableNode, live: Canonic
259
270
  if (have === want) continue;
260
271
  current.splice(at, 1);
261
272
  current.splice(want === undefined ? 0 : current.indexOf(want) + 1, 0, name);
262
- steps.push({ sql: `ALTER TABLE ${t} MODIFY COLUMN ${ident(name)} ${columnType(tokens, node, name)} ${want === undefined ? "FIRST" : `AFTER ${ident(want)}`}`, rewrite: false });
273
+ steps.push(stepFor(`ALTER TABLE ${t} MODIFY COLUMN ${ident(name)} ${columnType(tokens, node, name)} ${want === undefined ? "FIRST" : `AFTER ${ident(want)}`}`, "SQLCH209"));
263
274
  }
264
275
  return steps;
265
276
  }
@@ -267,11 +278,11 @@ function reorderSteps(t: string, tokens: Token[], node: TableNode, live: Canonic
267
278
  function tableSteps(obj: DeclaredObject, changes: readonly Change[], live: CanonicalObject | undefined): Step[] {
268
279
  const { tokens, node } = parsed(obj) as { tokens: Token[]; node: TableNode };
269
280
  const t = objectIdent(obj);
270
- const alter = (clause: string, rewrite = false): Step => ({ sql: `ALTER TABLE ${t} ${clause}`, rewrite });
281
+ const alter = (clause: string, rule: ClassifierRuleId): Step => stepFor(`ALTER TABLE ${t} ${clause}`, rule);
271
282
  const steps: Step[] = [];
272
283
  const of = (rule: string) => changes.filter((c) => c.rule === rule);
273
284
 
274
- for (const c of of("SQLCH212")) steps.push(alter(`RENAME COLUMN ${ident(c.before!)} TO ${ident(c.after!)}`));
285
+ for (const c of of("SQLCH212")) steps.push(alter(`RENAME COLUMN ${ident(c.before!)} TO ${ident(c.after!)}`, "SQLCH212"));
275
286
 
276
287
  // Added columns, in declared order, each at its declared place. Appending
277
288
  // new columns to the sorting key has to happen in the same ALTER as adding
@@ -281,10 +292,10 @@ function tableSteps(obj: DeclaredObject, changes: readonly Change[], live: Canon
281
292
  .sort((a, b) => node.columns.findIndex((c) => c.name === a) - node.columns.findIndex((c) => c.name === b));
282
293
  const adds = added.map((name) => `ADD COLUMN ${columnDefinition(tokens, node, name)} ${position(node, name)}`);
283
294
  const sortKey = of("SQLCH216")[0];
284
- if (sortKey) steps.push(alter([...adds, `MODIFY ORDER BY ${text(tokens, node.orderBy)}`].join(", ")));
285
- else for (const a of adds) steps.push(alter(a));
295
+ if (sortKey) steps.push(alter([...adds, `MODIFY ORDER BY ${text(tokens, node.orderBy)}`].join(", "), "SQLCH216"));
296
+ else for (const a of adds) steps.push(alter(a, "SQLCH201"));
286
297
 
287
- for (const c of of("SQLCH202")) steps.push(alter(`DROP COLUMN ${ident(columnField(c.field)!.column)}`));
298
+ for (const c of of("SQLCH202")) steps.push(alter(`DROP COLUMN ${ident(columnField(c.field)!.column)}`, "SQLCH202"));
288
299
 
289
300
  for (const c of changes) {
290
301
  const f = columnField(c.field);
@@ -293,59 +304,56 @@ function tableSteps(obj: DeclaredObject, changes: readonly Change[], live: Canon
293
304
  const name = ident(f.column);
294
305
  switch (c.rule) {
295
306
  case "SQLCH210":
296
- steps.push(alter(`MODIFY COLUMN ${name} ${columnType(tokens, node, f.column)}`, true));
307
+ steps.push(alter(`MODIFY COLUMN ${name} ${columnType(tokens, node, f.column)}`, c.rule));
297
308
  break;
298
309
  case "SQLCH207":
299
- if (col.default) steps.push(alter(`MODIFY COLUMN ${name} ${col.default.kind}${col.default.expr ? ` ${text(tokens, col.default.expr)}` : ""}`));
300
- else steps.push(alter(`MODIFY COLUMN ${name} REMOVE ${(c.before ?? "DEFAULT").split(" ")[0]}`));
310
+ if (col.default) steps.push(alter(`MODIFY COLUMN ${name} ${col.default.kind}${col.default.expr ? ` ${text(tokens, col.default.expr)}` : ""}`, c.rule));
311
+ else steps.push(alter(`MODIFY COLUMN ${name} REMOVE ${(c.before ?? "DEFAULT").split(" ")[0]}`, c.rule));
301
312
  break;
302
313
  case "SQLCH208":
303
- steps.push(alter(col.codec ? `MODIFY COLUMN ${name} CODEC(${text(tokens, col.codec)})` : `MODIFY COLUMN ${name} REMOVE CODEC`));
314
+ steps.push(alter(col.codec ? `MODIFY COLUMN ${name} CODEC(${text(tokens, col.codec)})` : `MODIFY COLUMN ${name} REMOVE CODEC`, c.rule));
304
315
  break;
305
316
  case "SQLCH205":
306
- steps.push(alter(col.ttl ? `MODIFY COLUMN ${name} TTL ${text(tokens, col.ttl)}` : `MODIFY COLUMN ${name} REMOVE TTL`, true));
317
+ steps.push(alter(col.ttl ? `MODIFY COLUMN ${name} TTL ${text(tokens, col.ttl)}` : `MODIFY COLUMN ${name} REMOVE TTL`, c.rule));
307
318
  break;
308
319
  case "SQLCH203":
309
- steps.push(alter(`COMMENT COLUMN ${name} ${sqlString(obj.canonical.columns.find((x) => x.name === f.column)?.comment ?? "")}`));
320
+ steps.push(alter(`COMMENT COLUMN ${name} ${sqlString(obj.canonical.columns.find((x) => x.name === f.column)?.comment ?? "")}`, c.rule));
310
321
  break;
311
322
  }
312
323
  }
313
324
 
314
325
  for (const c of of("SQLCH204")) {
315
326
  const name = c.field.slice("indexes.".length);
316
- if (c.before !== undefined) steps.push(alter(`DROP INDEX ${ident(name)}`));
327
+ if (c.before !== undefined) steps.push(alter(`DROP INDEX ${ident(name)}`, c.rule));
317
328
  const ix = node.indexes.find((x) => x.name === name);
318
329
  if (ix) {
319
330
  steps.push(
320
- alter(`ADD INDEX ${ident(name)} ${text(tokens, ix.expr)} TYPE ${text(tokens, ix.type)}${ix.granularity ? ` GRANULARITY ${text(tokens, ix.granularity)}` : ""}`),
331
+ alter(`ADD INDEX ${ident(name)} ${text(tokens, ix.expr)} TYPE ${text(tokens, ix.type)}${ix.granularity ? ` GRANULARITY ${text(tokens, ix.granularity)}` : ""}`, c.rule),
321
332
  );
322
333
  }
323
334
  }
324
335
  for (const c of of("SQLCH214")) {
325
336
  const name = c.field.slice("projections.".length);
326
- if (c.before !== undefined) steps.push(alter(`DROP PROJECTION ${ident(name)}`));
337
+ if (c.before !== undefined) steps.push(alter(`DROP PROJECTION ${ident(name)}`, c.rule));
327
338
  const p = node.projections.find((x) => x.name === name);
328
- if (p) steps.push(alter(`ADD PROJECTION ${ident(name)} (${text(tokens, p.body)})`));
339
+ if (p) steps.push(alter(`ADD PROJECTION ${ident(name)} (${text(tokens, p.body)})`, c.rule));
329
340
  }
330
341
  for (const c of of("SQLCH215")) {
331
342
  const name = c.field.slice("constraints.".length);
332
- if (c.before !== undefined) steps.push(alter(`DROP CONSTRAINT ${ident(name)}`));
343
+ if (c.before !== undefined) steps.push(alter(`DROP CONSTRAINT ${ident(name)}`, c.rule));
333
344
  const k = node.constraints.find((x) => x.name === name);
334
- if (k) steps.push(alter(`ADD CONSTRAINT ${ident(name)} ${k.kind} ${text(tokens, k.expr)}`));
345
+ if (k) steps.push(alter(`ADD CONSTRAINT ${ident(name)} ${k.kind} ${text(tokens, k.expr)}`, c.rule));
335
346
  }
336
347
 
337
348
  for (const c of of("SQLCH205").filter((x) => x.field === "ttl")) {
338
- steps.push(alter(node.ttl ? `MODIFY TTL ${text(tokens, node.ttl)}` : "REMOVE TTL", true));
339
- }
340
- for (const c of of("SQLCH217")) {
341
- void c;
342
- steps.push(alter(node.sampleBy ? `MODIFY SAMPLE BY ${text(tokens, node.sampleBy)}` : "REMOVE SAMPLE BY"));
349
+ steps.push(alter(node.ttl ? `MODIFY TTL ${text(tokens, node.ttl)}` : "REMOVE TTL", c.rule));
343
350
  }
351
+ for (const c of of("SQLCH217")) steps.push(alter(node.sampleBy ? `MODIFY SAMPLE BY ${text(tokens, node.sampleBy)}` : "REMOVE SAMPLE BY", c.rule));
344
352
  for (const c of of("SQLCH206")) {
345
353
  const key = c.field.slice("settings.".length);
346
354
  const s = node.settings?.find((x) => x.key === key);
347
355
  // A setting declared at its default compares as absent: set it as written.
348
- steps.push(alter(s ? `MODIFY SETTING ${key} = ${text(tokens, s.value)}` : `RESET SETTING ${key}`));
356
+ steps.push(alter(s ? `MODIFY SETTING ${key} = ${text(tokens, s.value)}` : `RESET SETTING ${key}`, c.rule));
349
357
  }
350
358
 
351
359
  if (of("SQLCH209").length > 0) steps.push(...reorderSteps(t, tokens, node, live, changes));
@@ -357,11 +365,11 @@ function viewSteps(obj: DeclaredObject, changes: readonly Change[], marker: Owne
357
365
  const t = objectIdent(obj);
358
366
  const steps: Step[] = [];
359
367
  if (!node.materialized) {
360
- if (changes.some((c) => c.rule === "SQLCH240")) steps.push({ sql: createStatement(obj, marker, { orReplace: true }), rewrite: false });
368
+ if (changes.some((c) => c.rule === "SQLCH240")) steps.push(stepFor(createStatement(obj, marker, { orReplace: true }), "SQLCH240"));
361
369
  return steps;
362
370
  }
363
- if (changes.some((c) => c.rule === "SQLCH241")) steps.push({ sql: `ALTER TABLE ${t} MODIFY QUERY ${text(tokens, node.select)}`, rewrite: false });
364
- if (changes.some((c) => c.rule === "SQLCH244") && node.refresh) steps.push({ sql: `ALTER TABLE ${t} MODIFY REFRESH ${text(tokens, node.refresh)}`, rewrite: false });
371
+ if (changes.some((c) => c.rule === "SQLCH241")) steps.push(stepFor(`ALTER TABLE ${t} MODIFY QUERY ${text(tokens, node.select)}`, "SQLCH241"));
372
+ if (changes.some((c) => c.rule === "SQLCH244") && node.refresh) steps.push(stepFor(`ALTER TABLE ${t} MODIFY REFRESH ${text(tokens, node.refresh)}`, "SQLCH244"));
365
373
  return steps;
366
374
  }
367
375
 
@@ -376,11 +384,11 @@ function viewSteps(obj: DeclaredObject, changes: readonly Change[], marker: Owne
376
384
  */
377
385
  export function alterSteps(obj: DeclaredObject, changes: readonly Change[], opts: { live?: CanonicalObject; marker?: OwnershipMarker } = {}): Step[] {
378
386
  const steps: Step[] = [];
379
- for (const c of changes.filter((x) => x.rule === "SQLCH231")) steps.push({ sql: `RENAME DATABASE ${ident(c.before!)} TO ${ident(c.after!)}`, rewrite: false });
387
+ for (const c of changes.filter((x) => x.rule === "SQLCH231")) steps.push(stepFor(`RENAME DATABASE ${ident(c.before!)} TO ${ident(c.after!)}`, "SQLCH231"));
380
388
  for (const c of changes.filter((x) => x.rule === "SQLCH230")) {
381
389
  const from = splitQualified(c.before!);
382
390
  const to = splitQualified(c.after!);
383
- steps.push({ sql: `RENAME TABLE ${qualifiedIdent(from.database, from.name)} TO ${qualifiedIdent(to.database, to.name)}`, rewrite: false });
391
+ steps.push(stepFor(`RENAME TABLE ${qualifiedIdent(from.database, from.name)} TO ${qualifiedIdent(to.database, to.name)}`, "SQLCH230"));
384
392
  }
385
393
  if (obj.type === CLICKHOUSE_ENTITY_TYPES.table) steps.push(...tableSteps(obj, changes, opts.live));
386
394
  else if (obj.type === CLICKHOUSE_ENTITY_TYPES.view || obj.type === CLICKHOUSE_ENTITY_TYPES.materializedView) steps.push(...viewSteps(obj, changes, opts.marker));
@@ -391,3 +399,132 @@ export function alterSteps(obj: DeclaredObject, changes: readonly Change[], opts
391
399
  export function stepsSetComment(obj: DeclaredObject, changes: readonly Change[]): boolean {
392
400
  return obj.type === CLICKHOUSE_ENTITY_TYPES.view && changes.some((c) => c.rule === "SQLCH240");
393
401
  }
402
+
403
+ // ── a schema's statements ─────────────────────────────────────────────
404
+
405
+ /**
406
+ * What one declared object takes, as the applier and the offline renderer
407
+ * (`../../migration-statements.ts`) both see it:
408
+ *
409
+ * - `create` or `alter`: the statements, in the order they run. An `alter`
410
+ * with none is an object already as declared.
411
+ * - `rebuild`: a change ALTER cannot make; nothing is sent for the object and
412
+ * the rebuild migration Op makes it (`detail` names the Op).
413
+ * - `withheld`: a column drop, which destroys data, in a plan not allowed to
414
+ * delete.
415
+ */
416
+ export type ObjectStatements =
417
+ | { verdict: "create" | "alter"; obj: DeclaredObject; changes: Change[]; steps: Step[] }
418
+ | { verdict: "rebuild"; obj: DeclaredObject; changes: Change[]; refused: Change[]; detail: string }
419
+ | { verdict: "withheld"; obj: DeclaredObject; changes: Change[]; withheld: Change[]; detail: string };
420
+
421
+ /** An object a schema no longer declares, and the `DROP` for it. */
422
+ export interface DropStatement {
423
+ /** The key the changes name it by. */
424
+ key: string;
425
+ type: ClickHouseEntityType;
426
+ database?: string;
427
+ name: string;
428
+ step: Step;
429
+ }
430
+
431
+ export interface StatementPlanInput {
432
+ /** The declared objects, in creation order. */
433
+ declared: readonly DeclaredObject[];
434
+ /** The classified changes from the current schema to the declared one. */
435
+ changes: readonly Change[];
436
+ /** The current definitions, keyed as the changes key objects. */
437
+ current: ReadonlyMap<string, CanonicalObject>;
438
+ /** How the changes key a declared object: `database.name` against a server (the default), the export name between two builds. */
439
+ keyOf?: (obj: DeclaredObject) => string;
440
+ /** The marker every `CREATE` and restamped comment carries. */
441
+ marker?: OwnershipMarker;
442
+ /** Column drops are planned, not withheld. Default: off. */
443
+ allowDestructive?: boolean;
444
+ /** Whether the current object (by its key in `current`) carries the marker already; one that does not is restamped. Default: it does. */
445
+ carriesMarker?: (currentKey: string) => boolean;
446
+ /**
447
+ * The topology every statement is rendered for (`../topology.ts`): `ON
448
+ * CLUSTER` and the engine. The declarations should be rendered for it too
449
+ * (`declaredObjects(json, db, topology)`), so the changes compare the
450
+ * engine the topology runs. Default: the statements as declared.
451
+ */
452
+ topology?: Topology;
453
+ }
454
+
455
+ export interface StatementPlan {
456
+ /** One entry per declared object, in creation order. */
457
+ objects: ObjectStatements[];
458
+ /** The objects the declarations no longer hold, in drop order: what reads from a table first, a database last. */
459
+ drops: DropStatement[];
460
+ }
461
+
462
+ /** Drop order: what reads from a table before the table, a database last. */
463
+ const DROP_ORDER: Record<string, number> = { materializedView: 0, view: 1, table: 2, database: 3 };
464
+
465
+ /**
466
+ * The statements that take the current schema to the declared one, from the
467
+ * classified changes between them. Pure: nothing is read or sent. The applier
468
+ * (`./apply.ts`) runs the steps against a server; `diffStatements`
469
+ * (`../../migration-statements.ts`) renders the same steps for a migration
470
+ * file. Anything that changes the SQL sent for a change belongs here, so
471
+ * both see it.
472
+ */
473
+ export function planStatements(input: StatementPlanInput): StatementPlan {
474
+ const plan = planDeclared(input);
475
+ if (!input.topology) return plan;
476
+ const topology = input.topology;
477
+ const render = (step: Step): Step => ({ ...step, sql: renderFor(step.sql, topology) });
478
+ return {
479
+ objects: plan.objects.map((o) => (o.verdict === "create" || o.verdict === "alter" ? { ...o, steps: o.steps.map(render) } : o)),
480
+ drops: plan.drops.map((d) => ({ ...d, step: render(d.step) })),
481
+ };
482
+ }
483
+
484
+ function planDeclared(input: StatementPlanInput): StatementPlan {
485
+ const keyOf = input.keyOf ?? ((o: DeclaredObject) => o.key);
486
+ const byObject = new Map<string, Change[]>();
487
+ for (const c of input.changes) byObject.set(c.object, [...(byObject.get(c.object) ?? []), c]);
488
+
489
+ const objects: ObjectStatements[] = input.declared.map((obj): ObjectStatements => {
490
+ const key = keyOf(obj);
491
+ const mine = byObject.get(key) ?? [];
492
+ const refused = mine.filter(isRebuild);
493
+ if (refused.length > 0) return { verdict: "rebuild", obj, changes: mine, refused, detail: refusalDetail(refused, obj.key, obj.type) };
494
+ const destructive = mine.filter(isDestructiveAlter);
495
+ if (destructive.length > 0 && !input.allowDestructive) {
496
+ return {
497
+ verdict: "withheld",
498
+ obj,
499
+ changes: mine,
500
+ withheld: destructive,
501
+ detail: `drops ${destructive.map((c) => c.field).join(", ")}, which destroys the data in it; an apply that may delete (prune, ApplyOp delete "owned-only" or "gated") makes it`,
502
+ };
503
+ }
504
+ if (mine.some((c) => c.rule === "SQLCH200")) return { verdict: "create", obj, changes: mine, steps: [stepFor(createStatement(obj, input.marker), "SQLCH200")] };
505
+
506
+ const renamedFrom = mine.find((c) => c.rule === "SQLCH230" || c.rule === "SQLCH231")?.before;
507
+ const currentKey = renamedFrom !== undefined && input.current.has(renamedFrom) ? renamedFrom : key;
508
+ const live = input.current.get(currentKey);
509
+ const restamp = !stepsSetComment(obj, mine) && (mine.some((c) => c.field === "comment") || !(input.carriesMarker?.(currentKey) ?? true));
510
+ const steps = [
511
+ ...alterSteps(obj, mine, { ...(live ? { live } : {}), ...(input.marker ? { marker: input.marker } : {}) }),
512
+ ...(restamp ? [stepFor(commentStatement(obj, input.marker), "SQLCH203")] : []),
513
+ ];
514
+ return { verdict: "alter", obj, changes: mine, steps };
515
+ });
516
+
517
+ const drops: DropStatement[] = [];
518
+ for (const c of input.changes) {
519
+ if (c.rule !== "SQLCH250") continue;
520
+ const o = input.current.get(c.object);
521
+ if (!o) continue;
522
+ const type = CLICKHOUSE_ENTITY_TYPES[o.kind as keyof typeof CLICKHOUSE_ENTITY_TYPES];
523
+ const database = o.kind === "database" ? undefined : o.database;
524
+ drops.push({ key: c.object, type, ...(database !== undefined ? { database } : {}), name: o.name, step: stepFor(dropStatement(type, database, o.name), "SQLCH250") });
525
+ }
526
+ drops.sort((a, b) => (DROP_ORDER[kindOf(a.type)] ?? 9) - (DROP_ORDER[kindOf(b.type)] ?? 9));
527
+ return { objects, drops };
528
+ }
529
+
530
+ const kindOf = (type: string): string => Object.entries(CLICKHOUSE_ENTITY_TYPES).find(([, t]) => t === type)?.[0] ?? "table";
@@ -7,6 +7,7 @@
7
7
  import type { ChantConfig } from "@intentius/chant/config";
8
8
  import type { UnobservedReason } from "@intentius/chant/observation";
9
9
  import { ClickHouseQueryError, clickhouseQuery, type ClickHouseEndpoint } from "../http";
10
+ import { parseTopology, toTopology, type Topology } from "../topology";
10
11
 
11
12
  export interface ClickHouseTarget {
12
13
  endpoint: ClickHouseEndpoint;
@@ -15,6 +16,13 @@ export interface ClickHouseTarget {
15
16
  /** The databases in scope; undefined is every database but the server's own. */
16
17
  databases?: string[];
17
18
  defaultDatabase: string;
19
+ /**
20
+ * The topology the environment runs (`sql.profiles.<env>.topology`, or
21
+ * `CLICKHOUSE_TOPOLOGY`): what the applier, the plan and the rebuild
22
+ * render their statements for (`../topology.ts`). Undefined: statements
23
+ * as declared.
24
+ */
25
+ topology?: Topology;
18
26
  }
19
27
 
20
28
  export interface UnresolvedTarget {
@@ -29,7 +37,9 @@ export const isUnresolvedTarget = (v: ClickHouseTarget | UnresolvedTarget): v is
29
37
  *
30
38
  * 1. `sql.profiles.<environment>`. A profile naming a credential variable that
31
39
  * is not set is `no-credentials`: the profile said how to authenticate.
32
- * 2. `CLICKHOUSE_URL`, with `CLICKHOUSE_USER` and `CLICKHOUSE_PASSWORD` when set.
40
+ * 2. `CLICKHOUSE_URL`, with `CLICKHOUSE_USER` and `CLICKHOUSE_PASSWORD` when set,
41
+ * and `CLICKHOUSE_TOPOLOGY` (`single`, `cluster:<name>`, `replicated`,
42
+ * `cloud`).
33
43
  * 3. Otherwise `no-binding`.
34
44
  */
35
45
  export function resolveClickHouseTarget(input: {
@@ -54,6 +64,7 @@ export function resolveClickHouseTarget(input: {
54
64
  source,
55
65
  ...(profile.databases ? { databases: profile.databases } : {}),
56
66
  defaultDatabase: profile.defaultDatabase ?? "default",
67
+ ...(profile.topology !== undefined ? { topology: toTopology(profile.topology) } : {}),
57
68
  };
58
69
  }
59
70
  const url = env.CLICKHOUSE_URL;
@@ -69,6 +80,7 @@ export function resolveClickHouseTarget(input: {
69
80
  },
70
81
  source: "env CLICKHOUSE_URL",
71
82
  defaultDatabase: "default",
83
+ ...(env.CLICKHOUSE_TOPOLOGY ? { topology: parseTopology(env.CLICKHOUSE_TOPOLOGY) } : {}),
72
84
  };
73
85
  }
74
86
 
@@ -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]
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
@@ -14,6 +14,17 @@
14
14
  * rebuild, which a plan refuses to make in place, and 0 otherwise; for each
15
15
  * refused table they name the `ClickHouseRebuildOp` to run instead
16
16
  * (`rebuildOps` in `--json`, `./rebuild-handoff.ts`).
17
+ *
18
+ * `diff --statements` prints the statements instead, for a migration file:
19
+ * each one the applier would send, with its change's rule and class, and a
20
+ * refused change as the Op that makes it (`../../migration-statements.ts`).
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`.
17
28
  */
18
29
 
19
30
  import type { CommandGroup, CommandGroupContext } from "@intentius/chant/cli/command-group";
@@ -24,6 +35,7 @@ import { dropFormattingOnly } from "./server-format";
24
35
  import { bindClickHouse } from "../live/bind";
25
36
  import { rebuildOpSuggestions } from "./rebuild-handoff";
26
37
  import { readFileSync } from "node:fs";
38
+ import { parseTopology, type Topology } from "../topology";
27
39
 
28
40
  /** The dialect a build output names. */
29
41
  function outputDialect(path: string): string | undefined {
@@ -34,8 +46,42 @@ function outputDialect(path: string): string | undefined {
34
46
  }
35
47
  }
36
48
 
37
- function split(args: string[]): { positional: string[]; json: boolean } {
38
- return { positional: args.filter((a) => !a.startsWith("--")), json: args.includes("--json") };
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 } : {}) };
67
+ }
68
+
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. */
70
+ async function runDiffStatements(before: string, after: string, json: boolean, topology: Topology | undefined): Promise<number> {
71
+ const { diffStatements, renderStatements } = await import("../../migration-statements");
72
+ const { resolveOwnershipMarker } = await import("../../core/apply");
73
+ let marker;
74
+ try {
75
+ const { loadChantConfig } = await import("@intentius/chant/config");
76
+ const config = await loadChantConfig(process.cwd()).then((r) => r.config).catch(() => undefined);
77
+ marker = resolveOwnershipMarker({}, config, "chant sql diff --statements");
78
+ } catch (err) {
79
+ console.error(err instanceof Error ? err.message : String(err));
80
+ return 1;
81
+ }
82
+ const doc = diffStatements(readFileSync(before, "utf-8"), readFileSync(after, "utf-8"), { ...(marker ? { marker } : {}), ...(topology ? { topology } : {}) });
83
+ console.log(json ? JSON.stringify(doc, null, 2) : renderStatements(doc));
84
+ return doc.refused ? 2 : 0;
39
85
  }
40
86
 
41
87
  function emit(diff: SchemaDiff, json: boolean, title: string): number {
@@ -44,17 +90,25 @@ function emit(diff: SchemaDiff, json: boolean, title: string): number {
44
90
  }
45
91
 
46
92
  export async function runDiff(ctx: CommandGroupContext): Promise<number> {
47
- const { positional, json } = split(ctx.rawArgs);
93
+ const { positional, json, statements, topology: topologyArg } = split(ctx.rawArgs);
48
94
  if (positional.length !== 2) {
49
- console.error("usage: chant sql diff <before.json> <after.json> [--json]");
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}`);
50
103
  return 1;
51
104
  }
105
+ if (statements) return runDiffStatements(positional[0]!, positional[1]!, json, topology);
52
106
  if (outputDialect(positional[1]!) === "postgres") {
53
107
  const { diffPgBuildFiles, emitPg, projectMajor } = await import("../../postgres/plan/commands");
54
108
  return emitPg(diffPgBuildFiles(positional[0]!, positional[1]!, await projectMajor()), json, `${positional[0]} -> ${positional[1]}`);
55
109
  }
56
- const after = schemaFromBuildFile(positional[1]!);
57
- 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);
58
112
  const rebuildOps = rebuildOpSuggestions(diff, new Map(after.map((o) => [o.key, o.canonical])), "<env>");
59
113
  return emit(rebuildOps.length > 0 ? { ...diff, rebuildOps } : diff, json, `${positional[0]} -> ${positional[1]}`);
60
114
  }
@@ -62,7 +116,7 @@ export async function runDiff(ctx: CommandGroupContext): Promise<number> {
62
116
  /** The declared objects against the server, keys and labels by `database.name`. */
63
117
  export async function planAgainstServer(environment: string, buildFile: string, options: Parameters<typeof bindClickHouse>[0] = {}): Promise<SchemaDiff> {
64
118
  const target = await bindClickHouse({ ...options, environment });
65
- const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase));
119
+ const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase, target.topology));
66
120
  const databases = new Set(declared.map((o) => o.canonical.database ?? o.canonical.name));
67
121
  const live = await schemaFromServer(target, databases);
68
122
  const diff = diffSchemas(live, declared);
@@ -95,7 +149,7 @@ export const sqlCommands: CommandGroup = {
95
149
  name: "sql",
96
150
  description: "Schema changes, classified: between two builds, or a build and a live server",
97
151
  commands: [
98
- { name: "diff", description: "Classify the change between two chant build outputs (offline)", 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 },
99
153
  { name: "plan", description: "Classify the change from an environment's server to a chant build output", handler: runPlan },
100
154
  ],
101
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;