@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.
- package/dist/clickhouse/apply/apply.d.ts +6 -1
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +85 -1
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts +11 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +12 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +4 -3
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/receipts.d.ts +5 -0
- package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts +112 -0
- package/dist/clickhouse/topology.d.ts.map +1 -0
- package/dist/clickhouse.d.ts +1 -0
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/postgres-classifier-page.d.ts.map +1 -1
- package/dist/config.d.ts +46 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/core/statements.d.ts +106 -0
- package/dist/core/statements.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/migration-statements.d.ts +63 -0
- package/dist/migration-statements.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +91 -1
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/apply.ts +35 -56
- package/src/clickhouse/apply/statements.ts +168 -31
- package/src/clickhouse/live/bind.ts +13 -1
- package/src/clickhouse/live/live.test.ts +10 -0
- package/src/clickhouse/plan/commands.ts +63 -9
- package/src/clickhouse/plan/deep.ts +2 -1
- package/src/clickhouse/plan/diff.ts +3 -1
- package/src/clickhouse/plan/normalize.ts +11 -2
- package/src/clickhouse/plan/schema.ts +6 -5
- package/src/clickhouse/rebuild/backfill.ts +1 -0
- package/src/clickhouse/rebuild/receipts.ts +14 -4
- package/src/clickhouse/rebuild/steps.ts +8 -5
- package/src/clickhouse/topology-replicated.e2e.test.ts +125 -0
- package/src/clickhouse/topology.e2e.test.ts +183 -0
- package/src/clickhouse/topology.test.ts +309 -0
- package/src/clickhouse/topology.ts +477 -0
- package/src/clickhouse.ts +16 -0
- package/src/codegen/classifier-page.ts +19 -0
- package/src/codegen/postgres-classifier-page.ts +17 -0
- package/src/config.ts +33 -0
- package/src/core/statements.ts +129 -0
- package/src/index.ts +15 -0
- package/src/migration-statements.test.ts +384 -0
- package/src/migration-statements.ts +262 -0
- package/src/op/__snapshots__/watch-pipeline.test.ts.snap +93 -0
- package/src/op/activities/clickhouse-apply.ts +1 -1
- package/src/op/activities/clickhouse-rebuild.ts +1 -1
- package/src/op/apply-gate-create.e2e.test.ts +280 -0
- package/src/op/watch-drift.e2e.test.ts +302 -0
- package/src/op/watch-pipeline.test.ts +113 -0
- package/src/plugin.test.ts +9 -0
- package/src/postgres/apply/apply.ts +38 -96
- 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(
|
|
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,
|
|
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)}`,
|
|
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`,
|
|
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",
|
|
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(
|
|
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(
|
|
364
|
-
if (changes.some((c) => c.rule === "SQLCH244") && node.refresh) steps.push(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
38
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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)
|
|
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
|
-
|
|
97
|
-
`
|
|
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;
|