@intentius/chant-lexicon-sql 0.113.0 → 0.114.1

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 +8 -0
  4. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  5. package/dist/clickhouse/live/bind.d.ts +11 -1
  6. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  7. package/dist/clickhouse/plan/commands.d.ts +7 -1
  8. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  9. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  10. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  11. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  12. package/dist/clickhouse/plan/schema.d.ts +4 -3
  13. package/dist/clickhouse/plan/schema.d.ts.map +1 -1
  14. package/dist/clickhouse/rebuild/backfill.d.ts +17 -0
  15. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  16. package/dist/clickhouse/rebuild/observe.d.ts +4 -3
  17. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  18. package/dist/clickhouse/rebuild/receipts.d.ts +5 -0
  19. package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
  20. package/dist/clickhouse/rebuild/steps.d.ts +5 -3
  21. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  22. package/dist/clickhouse/rebuild/verify.d.ts +41 -18
  23. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  24. package/dist/clickhouse/topology.d.ts +12 -0
  25. package/dist/clickhouse/topology.d.ts.map +1 -1
  26. package/dist/clickhouse.d.ts +1 -0
  27. package/dist/clickhouse.d.ts.map +1 -1
  28. package/dist/codegen/classifier-page.d.ts.map +1 -1
  29. package/dist/config.d.ts +46 -0
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/core/statements.d.ts +2 -0
  32. package/dist/core/statements.d.ts.map +1 -1
  33. package/dist/integrity.json +2 -2
  34. package/dist/manifest.json +1 -1
  35. package/dist/migration-statements.d.ts +8 -0
  36. package/dist/migration-statements.d.ts.map +1 -1
  37. package/dist/postgres/plan/deep.d.ts.map +1 -1
  38. package/package.json +2 -2
  39. package/src/clickhouse/apply/apply.ts +10 -3
  40. package/src/clickhouse/apply/statements.ts +19 -0
  41. package/src/clickhouse/live/bind.ts +13 -1
  42. package/src/clickhouse/live/live.test.ts +10 -0
  43. package/src/clickhouse/plan/commands.ts +42 -12
  44. package/src/clickhouse/plan/deep.ts +2 -1
  45. package/src/clickhouse/plan/diff.ts +3 -1
  46. package/src/clickhouse/plan/normalize.ts +11 -2
  47. package/src/clickhouse/plan/schema.ts +6 -5
  48. package/src/clickhouse/rebuild/backfill.ts +127 -5
  49. package/src/clickhouse/rebuild/observe.ts +4 -3
  50. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +106 -1
  51. package/src/clickhouse/rebuild/receipts.ts +14 -4
  52. package/src/clickhouse/rebuild/steps.ts +22 -9
  53. package/src/clickhouse/rebuild/verify.ts +131 -36
  54. package/src/clickhouse/topology-replicated.e2e.test.ts +125 -0
  55. package/src/clickhouse/topology.e2e.test.ts +84 -1
  56. package/src/clickhouse/topology.test.ts +33 -0
  57. package/src/clickhouse/topology.ts +26 -0
  58. package/src/clickhouse.ts +16 -0
  59. package/src/codegen/classifier-page.ts +2 -0
  60. package/src/config.ts +33 -0
  61. package/src/core/statements.ts +2 -0
  62. package/src/migration-statements.test.ts +120 -1
  63. package/src/migration-statements.ts +13 -3
  64. package/src/op/activities/clickhouse-apply.ts +1 -1
  65. package/src/op/activities/clickhouse-rebuild.ts +1 -1
  66. package/src/op/apply-gate-create.e2e.test.ts +280 -0
  67. package/src/plugin.test.ts +9 -0
  68. package/src/postgres/plan/deep.test.ts +76 -0
  69. package/src/postgres/plan/deep.ts +13 -2
  70. package/src/postgres/plan/plan.e2e.test.ts +26 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "7ab753de4cb73a505119a2b83d37d9b946d947c50dedc3a393c18829b64935a5",
4
+ "manifest.json": "82beced7beffe2bdd0dcf24728910539758d9d4dc85c94fe6418167bcbea9636",
5
5
  "meta.json": "5b7a5d171aa8bd21c0d55cfbe421143d67da02b7ef924a9b66166790c3fc7b22",
6
6
  "types/index.d.ts": "0286ebb367773e84c76818b0f69cb14cff8328fd2f8c65ed87563c8d5815458c",
7
7
  "rules/postgres-templates.ts": "f7244417426c441180ccb5c52da32cd812960d6d4eaba1fbdb17f47fcae707b2",
@@ -57,5 +57,5 @@
57
57
  "rules/sqlpg117.ts": "3d5c2d57ccdee3cf91c3ba3cb6363750787a850df8ebd75e9cc3f33113303827",
58
58
  "rules/sqlpg118.ts": "e2a40a88a9374c4d99d02118ca4c60877ba0c6eb1ce603a93b24f5fd23d81dde"
59
59
  },
60
- "composite": "037aaec584d5d150c300e93cf38e051a570ff45b611d7a38eaea5736a39f71b9"
60
+ "composite": "619bc77b9a85523857c04bc2fd6ab9a538a97bca5c6824aadf06c3b58a517b76"
61
61
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sql",
3
- "version": "0.113.0",
3
+ "version": "0.114.1",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "SQL",
6
6
  "intrinsics": [],
@@ -31,6 +31,7 @@
31
31
  */
32
32
  import type { OwnershipMarker } from "@intentius/chant/ownership";
33
33
  import { type SchemaStatements } from "./core/statements.js";
34
+ import { type Topology } from "./clickhouse/topology.js";
34
35
  export type { ManualStep, MigrationStep, OpStep, SchemaStatements, StatementChange, StatementStep, StepObject } from "./core/statements.js";
35
36
  export { renderStatements } from "./core/statements.js";
36
37
  export interface DiffStatementsOptions {
@@ -40,6 +41,13 @@ export interface DiffStatementsOptions {
40
41
  env?: string;
41
42
  /** ClickHouse: the database a bare name means. Default: `default`. */
42
43
  defaultDatabase?: string;
44
+ /**
45
+ * ClickHouse: the topology to render the statements for
46
+ * (`./clickhouse/topology.ts`): `ON CLUSTER` and the engine, on both
47
+ * builds' declarations before they are compared, so the steps are the ones
48
+ * the applier sends to an environment with that topology. Default: `single`.
49
+ */
50
+ topology?: Topology;
43
51
  /** Postgres: the schema a bare name means. Default: `public`. */
44
52
  defaultSchema?: string;
45
53
  /** Postgres: the major to classify for. Default: the newer build's `postgresMajor`, else the older's, else the newest pinned. */
@@ -1 +1 @@
1
- {"version":3,"file":"migration-statements.d.ts","sourceRoot":"","sources":["../src/migration-statements.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAwD,KAAK,gBAAgB,EAAmB,MAAM,mBAAmB,CAAC;AAajI,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,EAAE,gBAAgB,EAAE,eAAe,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACzI,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,qBAAqB;IACpC,sIAAsI;IACtI,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,kFAAkF;IAClF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iEAAiE;IACjE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iIAAiI;IACjI,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,KAAK,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAWlC;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,UAAU,EAAE,OAAO,GAAE,qBAA0B,GAAG,gBAAgB,CAQ3H"}
1
+ {"version":3,"file":"migration-statements.d.ts","sourceRoot":"","sources":["../src/migration-statements.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAwD,KAAK,gBAAgB,EAAmB,MAAM,mBAAmB,CAAC;AAGjI,OAAO,EAAiB,KAAK,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAWrE,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,EAAE,gBAAgB,EAAE,eAAe,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACzI,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,qBAAqB;IACpC,sIAAsI;IACtI,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,kFAAkF;IAClF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,iEAAiE;IACjE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iIAAiI;IACjI,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,KAAK,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAWlC;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,UAAU,EAAE,OAAO,GAAE,qBAA0B,GAAG,gBAAgB,CAQ3H"}
@@ -1 +1 @@
1
- {"version":3,"file":"deep.d.ts","sourceRoot":"","sources":["../../../src/postgres/plan/deep.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAmB,KAAK,qBAAqB,EAAgC,MAAM,mCAAmC,CAAC;AAE9H,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AACvF,OAAO,EAA2B,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAI7E,OAAO,EAAkD,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErG,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAcrC,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,CAAC,EAAE,YAAY,GAAG,KAAK,CAGhD;AA6CD,uGAAuG;AACvG,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,iBAAiB,EAAE,CAAC,EAAE,iBAAiB,GAAG,KAAK,CAwCpH;AAED,wBAAsB,oBAAoB,CACxC,OAAO,EAAE;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;CAC/E,GAAG,IAAI,CAAC,WAAW,EAAE,aAAa,CAAC,GACnC,OAAO,CAAC,qBAAqB,CAAC,CAwChC"}
1
+ {"version":3,"file":"deep.d.ts","sourceRoot":"","sources":["../../../src/postgres/plan/deep.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAmB,KAAK,qBAAqB,EAAgC,MAAM,mCAAmC,CAAC;AAE9H,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AACvF,OAAO,EAA2B,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAI7E,OAAO,EAAkD,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErG,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAcrC,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,CAAC,EAAE,YAAY,GAAG,KAAK,CAGhD;AA6CD,uGAAuG;AACvG,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,iBAAiB,EAAE,CAAC,EAAE,iBAAiB,GAAG,KAAK,CAmDpH;AAED,wBAAsB,oBAAoB,CACxC,OAAO,EAAE;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;CAC/E,GAAG,IAAI,CAAC,WAAW,EAAE,aAAa,CAAC,GACnC,OAAO,CAAC,qBAAqB,CAAC,CAwChC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-sql",
3
- "version": "0.113.0",
3
+ "version": "0.114.1",
4
4
  "description": "SQL lexicon for chant — database schema as typed declarations, one dialect at a time; ClickHouse first, with types read from a pinned server's catalog",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -70,7 +70,7 @@
70
70
  "typescript": "^5.9.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@intentius/chant": "^0.113.0",
73
+ "@intentius/chant": "^0.114.1",
74
74
  "typescript": "^5.9.3"
75
75
  }
76
76
  }
@@ -51,6 +51,7 @@ import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "../entities"
51
51
  import { carriesMarker, isChantManaged } from "../ownership";
52
52
  import { planStatements, sqlString, type DeclaredObject, type DropStatement, type Step } from "./statements";
53
53
  import { waitForMutations } from "./mutations";
54
+ import { renderFor, type Topology } from "../topology";
54
55
 
55
56
  export type { FailedObject } from "../../core/apply";
56
57
 
@@ -58,15 +59,20 @@ export type { FailedObject } from "../../core/apply";
58
59
  * The objects a `chant build` output declares, in creation order. Takes the
59
60
  * sql lexicon's primary output, or a multi-lexicon output holding it under
60
61
  * `sql` (the shared core's reader).
62
+ *
63
+ * With a `topology`, each declaration is rendered for it first
64
+ * (`../topology.ts`): the engine and `ON CLUSTER` it runs with there, which
65
+ * is what the changes compare and every `CREATE` sends.
61
66
  */
62
- export function declaredObjects(json: string, defaultDatabase = "default"): DeclaredObject[] {
67
+ export function declaredObjects(json: string, defaultDatabase = "default", topology?: Topology): DeclaredObject[] {
63
68
  return readBuildObjects(json, "clickhouse", (o) => {
64
- const canonical = canonicalObject(o.ddl, defaultDatabase);
69
+ const ddl = renderFor(o.ddl, topology);
70
+ const canonical = canonicalObject(ddl, defaultDatabase);
65
71
  return {
66
72
  exportName: o.export,
67
73
  type: o.type as ClickHouseEntityType,
68
74
  key: canonical.database !== undefined ? `${canonical.database}.${canonical.name}` : canonical.name,
69
- ddl: o.ddl,
75
+ ddl,
70
76
  canonical,
71
77
  dependsOn: o.dependsOn,
72
78
  };
@@ -165,6 +171,7 @@ export async function applyClickHouse(target: ClickHouseTarget, declared: readon
165
171
  current: live,
166
172
  allowDestructive: opts.prune === true,
167
173
  carriesMarker: (key) => carriesMarker(liveByKey.get(key)?.comment, opts.marker),
174
+ ...(target.topology ? { topology: target.topology } : {}),
168
175
  ...(opts.marker ? { marker: opts.marker } : {}),
169
176
  });
170
177
 
@@ -23,6 +23,7 @@ import type { Change } from "../plan/diff";
23
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, "``")}\``;
@@ -442,6 +443,13 @@ export interface StatementPlanInput {
442
443
  allowDestructive?: boolean;
443
444
  /** Whether the current object (by its key in `current`) carries the marker already; one that does not is restamped. Default: it does. */
444
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;
445
453
  }
446
454
 
447
455
  export interface StatementPlan {
@@ -463,6 +471,17 @@ const DROP_ORDER: Record<string, number> = { materializedView: 0, view: 1, table
463
471
  * both see it.
464
472
  */
465
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 {
466
485
  const keyOf = input.keyOf ?? ((o: DeclaredObject) => o.key);
467
486
  const byObject = new Map<string, Change[]>();
468
487
  for (const c of input.changes) byObject.set(c.object, [...(byObject.get(c.object) ?? []), c]);
@@ -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] [--statements]
7
+ * chant sql diff <before.json> <after.json> [--json] [--statements] [--topology <topology>]
8
8
  * chant sql plan <env> <build.json> [--json]
9
9
  *
10
10
  * `diff` is offline: it compares two `chant build` outputs, say a pull
@@ -19,6 +19,12 @@
19
19
  * each one the applier would send, with its change's rule and class, and a
20
20
  * refused change as the Op that makes it (`../../migration-statements.ts`).
21
21
  * The marker in each `CREATE` is the project's `ownership` config's.
22
+ *
23
+ * `--topology` (`single`, `cluster:<name>`, `replicated`, `replicated:<cluster>`,
24
+ * `cloud`; `../topology.ts`) renders a ClickHouse diff's declarations and
25
+ * statements for that topology: `ON CLUSTER` and the engine. `--statements`
26
+ * renders for `single` without it; the plain diff compares the declarations as
27
+ * written. `plan` takes the topology from `sql.profiles.<env>.topology`.
22
28
  */
23
29
 
24
30
  import type { CommandGroup, CommandGroupContext } from "@intentius/chant/cli/command-group";
@@ -29,6 +35,7 @@ import { dropFormattingOnly } from "./server-format";
29
35
  import { bindClickHouse } from "../live/bind";
30
36
  import { rebuildOpSuggestions } from "./rebuild-handoff";
31
37
  import { readFileSync } from "node:fs";
38
+ import { parseTopology, type Topology } from "../topology";
32
39
 
33
40
  /** The dialect a build output names. */
34
41
  function outputDialect(path: string): string | undefined {
@@ -39,12 +46,28 @@ function outputDialect(path: string): string | undefined {
39
46
  }
40
47
  }
41
48
 
42
- function split(args: string[]): { positional: string[]; json: boolean; statements: boolean } {
43
- return { positional: args.filter((a) => !a.startsWith("--")), json: args.includes("--json"), statements: args.includes("--statements") };
49
+ interface DiffArgs {
50
+ positional: string[];
51
+ json: boolean;
52
+ statements: boolean;
53
+ /** `--topology <t>` or `--topology=<t>`, as written. */
54
+ topology?: string;
55
+ }
56
+
57
+ function split(args: string[]): DiffArgs {
58
+ const positional: string[] = [];
59
+ let topology: string | undefined;
60
+ for (let i = 0; i < args.length; i++) {
61
+ const a = args[i]!;
62
+ if (a === "--topology") topology = args[++i] ?? "";
63
+ else if (a.startsWith("--topology=")) topology = a.slice("--topology=".length);
64
+ else if (!a.startsWith("--")) positional.push(a);
65
+ }
66
+ return { positional, json: args.includes("--json"), statements: args.includes("--statements"), ...(topology !== undefined ? { topology } : {}) };
44
67
  }
45
68
 
46
69
  /** `chant sql diff --statements`: the statements between two builds, as SQL with comments or as JSON. Exits 2 when a step is an Op or manual. */
47
- async function runDiffStatements(before: string, after: string, json: boolean): Promise<number> {
70
+ async function runDiffStatements(before: string, after: string, json: boolean, topology: Topology | undefined): Promise<number> {
48
71
  const { diffStatements, renderStatements } = await import("../../migration-statements");
49
72
  const { resolveOwnershipMarker } = await import("../../core/apply");
50
73
  let marker;
@@ -56,7 +79,7 @@ async function runDiffStatements(before: string, after: string, json: boolean):
56
79
  console.error(err instanceof Error ? err.message : String(err));
57
80
  return 1;
58
81
  }
59
- const doc = diffStatements(readFileSync(before, "utf-8"), readFileSync(after, "utf-8"), marker ? { marker } : {});
82
+ const doc = diffStatements(readFileSync(before, "utf-8"), readFileSync(after, "utf-8"), { ...(marker ? { marker } : {}), ...(topology ? { topology } : {}) });
60
83
  console.log(json ? JSON.stringify(doc, null, 2) : renderStatements(doc));
61
84
  return doc.refused ? 2 : 0;
62
85
  }
@@ -67,18 +90,25 @@ function emit(diff: SchemaDiff, json: boolean, title: string): number {
67
90
  }
68
91
 
69
92
  export async function runDiff(ctx: CommandGroupContext): Promise<number> {
70
- const { positional, json, statements } = split(ctx.rawArgs);
93
+ const { positional, json, statements, topology: topologyArg } = split(ctx.rawArgs);
71
94
  if (positional.length !== 2) {
72
- console.error("usage: chant sql diff <before.json> <after.json> [--json] [--statements]");
95
+ console.error("usage: chant sql diff <before.json> <after.json> [--json] [--statements] [--topology <topology>]");
96
+ return 1;
97
+ }
98
+ let topology: Topology | undefined;
99
+ try {
100
+ topology = topologyArg !== undefined ? parseTopology(topologyArg) : undefined;
101
+ } catch (err) {
102
+ console.error(`--topology: ${(err as Error).message}`);
73
103
  return 1;
74
104
  }
75
- if (statements) return runDiffStatements(positional[0]!, positional[1]!, json);
105
+ if (statements) return runDiffStatements(positional[0]!, positional[1]!, json, topology);
76
106
  if (outputDialect(positional[1]!) === "postgres") {
77
107
  const { diffPgBuildFiles, emitPg, projectMajor } = await import("../../postgres/plan/commands");
78
108
  return emitPg(diffPgBuildFiles(positional[0]!, positional[1]!, await projectMajor()), json, `${positional[0]} -> ${positional[1]}`);
79
109
  }
80
- const after = schemaFromBuildFile(positional[1]!);
81
- const diff = diffSchemas(schemaFromBuildFile(positional[0]!), after);
110
+ const after = schemaFromBuildFile(positional[1]!, "default", topology);
111
+ const diff = diffSchemas(schemaFromBuildFile(positional[0]!, "default", topology), after);
82
112
  const rebuildOps = rebuildOpSuggestions(diff, new Map(after.map((o) => [o.key, o.canonical])), "<env>");
83
113
  return emit(rebuildOps.length > 0 ? { ...diff, rebuildOps } : diff, json, `${positional[0]} -> ${positional[1]}`);
84
114
  }
@@ -86,7 +116,7 @@ export async function runDiff(ctx: CommandGroupContext): Promise<number> {
86
116
  /** The declared objects against the server, keys and labels by `database.name`. */
87
117
  export async function planAgainstServer(environment: string, buildFile: string, options: Parameters<typeof bindClickHouse>[0] = {}): Promise<SchemaDiff> {
88
118
  const target = await bindClickHouse({ ...options, environment });
89
- const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase));
119
+ const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase, target.topology));
90
120
  const databases = new Set(declared.map((o) => o.canonical.database ?? o.canonical.name));
91
121
  const live = await schemaFromServer(target, databases);
92
122
  const diff = diffSchemas(live, declared);
@@ -119,7 +149,7 @@ export const sqlCommands: CommandGroup = {
119
149
  name: "sql",
120
150
  description: "Schema changes, classified: between two builds, or a build and a live server",
121
151
  commands: [
122
- { name: "diff", description: "Classify the change between two chant build outputs (offline); --statements prints the statements it takes", handler: runDiff },
152
+ { name: "diff", description: "Classify the change between two chant build outputs (offline); --statements prints the statements it takes, --topology renders them for single, cluster:<name>, replicated or cloud", handler: runDiff },
123
153
  { name: "plan", description: "Classify the change from an environment's server to a chant build output", handler: runPlan },
124
154
  ],
125
155
  };
@@ -20,6 +20,7 @@ import { bindClickHouse, classifyClickHouseFailure, type BindOptions } from "../
20
20
  import { readLiveSchema, type LiveObject } from "../live/catalog";
21
21
  import { database, table, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
22
22
  import { canonicalObject, type CanonicalColumn, type CanonicalObject } from "./normalize";
23
+ import { renderFor } from "../topology";
23
24
 
24
25
  type Props = Record<string, unknown>;
25
26
 
@@ -109,7 +110,7 @@ export async function observeResourcesDeep(
109
110
  const o = byKey.get(`${db ?? ""}.${String(props.name)}`);
110
111
  if (!o) continue;
111
112
  try {
112
- const d = canonicalObject(String(props.ddl), target.defaultDatabase);
113
+ const d = canonicalObject(renderFor(String(props.ddl), target.topology), target.defaultDatabase);
113
114
  const l = canonicalObject(o.statement, target.defaultDatabase);
114
115
  resources[name] = {
115
116
  type: o.type,
@@ -127,7 +127,9 @@ function diffObject(key: string, before: CanonicalObject, after: CanonicalObject
127
127
  if (before.comment !== after.comment) out.push(change(key, "comment", "SQLCH203", before.comment, after.comment));
128
128
 
129
129
  if (before.kind === "database") {
130
- if (before.engine !== after.engine) out.push(change(key, "engine", "SQLCH232", before.engine, after.engine));
130
+ // A declaration with no ENGINE takes the server's default database engine
131
+ // (Atomic; Replicated on Cloud), whatever the server says it is (#3645).
132
+ if (after.engine !== undefined && before.engine !== after.engine) out.push(change(key, "engine", "SQLCH232", before.engine, after.engine));
131
133
  return;
132
134
  }
133
135
 
@@ -275,8 +275,17 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
275
275
  // `ENGINE = ReplicatedMergeTree` with no Keeper path and replica name is
276
276
  // printed back with the server's defaults filled in; the two say the same.
277
277
  if (/^Replicated.*MergeTree$/.test(engine.name) && args[0] === DEFAULT_REPLICA_PATH && args[1] === DEFAULT_REPLICA_NAME) args = args.slice(2);
278
- obj.engineName = engine.name;
279
- obj.engine = args.length ? `${engine.name}(${args.join(", ")})` : engine.name;
278
+ // ClickHouse Cloud turns a declared MergeTree-family engine into its
279
+ // `Shared*` one and prints it back with the path and replica it chose
280
+ // (#3645, `../topology.ts`): the declaration said the plain family.
281
+ let name = engine.name;
282
+ const shared = /^Shared(\w*MergeTree)$/.exec(name);
283
+ if (shared) {
284
+ name = shared[1]!;
285
+ if (/^'.*'$/s.test(args[0] ?? "") && /^'.*'$/s.test(args[1] ?? "")) args = args.slice(2);
286
+ }
287
+ obj.engineName = name;
288
+ obj.engine = args.length ? `${name}(${args.join(", ")})` : name;
280
289
  }
281
290
  const comment = stringValue(spanText(tokens, node.comment));
282
291
  if (comment !== undefined && comment !== "") obj.comment = comment;
@@ -8,23 +8,24 @@ import { canonicalObject } from "./normalize";
8
8
  import type { SchemaObject } from "./diff";
9
9
  import type { ClickHouseTarget } from "../live/bind";
10
10
  import { readLiveSchema } from "../live/catalog";
11
+ import { renderFor, type Topology } from "../topology";
11
12
 
12
13
  interface OutputDoc {
13
14
  dialect?: string;
14
15
  objects?: Array<{ export: string; ddl: string }>;
15
16
  }
16
17
 
17
- /** The objects a `chant build` output holds, keyed by export name. */
18
- export function schemaFromBuildOutput(json: string, defaultDatabase = "default"): SchemaObject[] {
18
+ /** The objects a `chant build` output holds, keyed by export name; with a `topology`, each declaration as rendered for it (`../topology.ts`). */
19
+ export function schemaFromBuildOutput(json: string, defaultDatabase = "default", topology?: Topology): SchemaObject[] {
19
20
  const doc = JSON.parse(json) as OutputDoc;
20
21
  if (doc.dialect !== "clickhouse" || !Array.isArray(doc.objects)) {
21
22
  throw new Error("not a sql lexicon build output: expected { dialect: \"clickhouse\", objects: [...] }");
22
23
  }
23
- return doc.objects.map((o) => ({ key: o.export, canonical: canonicalObject(o.ddl, defaultDatabase) }));
24
+ return doc.objects.map((o) => ({ key: o.export, canonical: canonicalObject(renderFor(o.ddl, topology), defaultDatabase) }));
24
25
  }
25
26
 
26
- export function schemaFromBuildFile(path: string, defaultDatabase = "default"): SchemaObject[] {
27
- return schemaFromBuildOutput(readFileSync(path, "utf-8"), defaultDatabase);
27
+ export function schemaFromBuildFile(path: string, defaultDatabase = "default", topology?: Topology): SchemaObject[] {
28
+ return schemaFromBuildOutput(readFileSync(path, "utf-8"), defaultDatabase, topology);
28
29
  }
29
30
 
30
31
  /**
@@ -41,6 +41,21 @@
41
41
  * With a replica down (#3270) the kill goes on without it, and each sync
42
42
  * either finds nothing that replica alone has and returns, or stops the step
43
43
  * naming it (`./replicas.ts`).
44
+ *
45
+ * In materialized-view mode a partition is copied in two parts
46
+ * (INTENTIUS/sql-yodeler#45). The rows before the cut-over are copied as
47
+ * above. The rows at or after it are the view's only when they were written
48
+ * after the view was made; the ones the old table already held (a booking
49
+ * next month, an expiry date) reach the new table only through the backfill.
50
+ * The two kinds cannot be told apart by their values, so the second part
51
+ * copies the difference: for each distinct row at or after the cut-over, as
52
+ * many copies as the old table has more of it than the new one. That copy
53
+ * converges on its own, so a run killed during it, or a partition copied
54
+ * again, copies only what is still missing. A row the view is a moment from
55
+ * committing can be copied by both: the copy is followed by a check for rows
56
+ * the new table holds more often than the old one, which deletes and copies
57
+ * those again. Whatever is left, the verification finds, and the swap does
58
+ * not go ahead.
44
59
  */
45
60
 
46
61
  import { EffectReceipt, receiptExpectation } from "@intentius/chant/effect-receipt";
@@ -50,6 +65,7 @@ import { clickhouseReceiptStore, receiptAddress, type ClickHouseReceiptStore } f
50
65
  import { RebuildRefusal, type RebuildObservation } from "./observe";
51
66
  import { DDL_SETTINGS } from "./replicas";
52
67
  import { sourcePartitionExpression, sourcePartitions } from "./partitions";
68
+ import { rowHash } from "./verify";
53
69
  import { cutoverOf, observe, serverNow, syncReplica, utcLiteral, waitOn, type RebuildRun } from "./steps";
54
70
 
55
71
  export interface BackfillResult {
@@ -62,6 +78,8 @@ export interface BackfillResult {
62
78
  skipped: number;
63
79
  /** Partitions this run found partly copied and cleared before copying again. */
64
80
  cleared: number;
81
+ /** Distinct rows at or after the cut-over found in the new table too often (copied while the view was writing them), deleted and copied again. */
82
+ repaired: number;
65
83
  }
66
84
 
67
85
  /** The effect a partition's copy is, and its receipt's address suffix. */
@@ -74,7 +92,15 @@ export function partitionExpectation(o: RebuildObservation, partition: string, c
74
92
  EffectReceipt(effect, {
75
93
  effect,
76
94
  flavor: "hash",
77
- inputs: { table: o.names.key, newTable: o.newTable!.uuid, partition, copied: o.copied, cutover: cutover === undefined ? null : new Date(cutover).toISOString() },
95
+ inputs: {
96
+ table: o.names.key,
97
+ newTable: o.newTable!.uuid,
98
+ partition,
99
+ copied: o.copied,
100
+ cutover: cutover === undefined ? null : new Date(cutover).toISOString(),
101
+ // The rows at or after the cut-over the old table held are copied too (sql-yodeler#45); a receipt from before that copied fewer.
102
+ ...(cutover === undefined ? {} : { after: "difference" }),
103
+ },
78
104
  }),
79
105
  );
80
106
  }
@@ -88,7 +114,7 @@ export interface BackfillDeps {
88
114
 
89
115
  export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promise<BackfillResult> {
90
116
  const o = await observe(run);
91
- const result: BackfillResult = { state: o.state, partitions: 0, copied: 0, skipped: 0, cleared: 0 };
117
+ const result: BackfillResult = { state: o.state, partitions: 0, copied: 0, skipped: 0, cleared: 0, repaired: 0 };
92
118
  if (o.state !== "rebuild") return result;
93
119
  if (!o.newTable) throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
94
120
  const n = o.names;
@@ -113,6 +139,7 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
113
139
  const fromOld = sourcePartitionExpression(o.live.partitionKey, o.newTable.partitionKey, o.copied);
114
140
  const columns = o.copied.map((c) => ident(c.name)).join(", ");
115
141
  const select = o.copied.map((c) => ident(c.source)).join(", ");
142
+ const later = cutColumn && cutover !== undefined ? await laterRowsCopy(run, o, cutColumn, cutover, fromOld) : undefined;
116
143
 
117
144
  const identity = { ...(run.marker?.stack ? { stack: run.marker.stack } : {}), ...(run.marker?.env ? { env: run.marker.env } : {}) };
118
145
  const receipts =
@@ -120,6 +147,7 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
120
147
  clickhouseReceiptStore(run.target.endpoint, identity, {
121
148
  ...(run.runId ? { runId: run.runId } : {}),
122
149
  ...(o.replicated ? { replicatedIn: n.database } : {}),
150
+ ...(run.target.topology ? { topology: run.target.topology } : {}),
123
151
  ...(run.replicaTimeoutMs !== undefined ? { replicaTimeoutMs: run.replicaTimeoutMs } : {}),
124
152
  });
125
153
  const recorded = await receipts.readAll(receiptAddress(identity, `rebuild/${n.key}/`));
@@ -136,17 +164,19 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
136
164
  }
137
165
 
138
166
  const queryId = `chant-rebuild-${o.newTable.uuid}-${p}`;
167
+ const laterQueryId = `${queryId}-after`;
168
+ const killWhere = `query_id IN (${sqlString(queryId)}, ${sqlString(laterQueryId)})`;
139
169
  if (o.replicated) {
140
170
  // On every replica that answers: the copy may be running on another one
141
171
  // than this run's. A replica that is down is skipped once Keeper sees it
142
172
  // inactive; nothing of its can be running a copy that still writes,
143
173
  // and a part it wrote before going down is waited for by the sync below.
144
- await clickhouseQuery(run.target.endpoint, `KILL QUERY ON CLUSTER ${sqlString(n.database)} WHERE query_id = ${sqlString(queryId)} SYNC`, {
174
+ await clickhouseQuery(run.target.endpoint, `KILL QUERY ON CLUSTER ${sqlString(n.database)} WHERE ${killWhere} SYNC`, {
145
175
  settings: DDL_SETTINGS,
146
176
  });
147
177
  await syncReplica(run, o, n.database, n.newName);
148
178
  } else {
149
- await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE query_id = ${sqlString(queryId)} SYNC`);
179
+ await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE ${killWhere} SYNC`);
150
180
  }
151
181
  const mine = `${fromOld} = ${sqlString(p)}${targetRange}`;
152
182
  const [left] = await clickhouseQuery<{ n: string | number }>(run.target.endpoint, `SELECT count() AS n FROM ${n.newTable} WHERE ${mine}`);
@@ -162,15 +192,107 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
162
192
  const sql = `INSERT INTO ${n.newTable} (${columns}) SELECT ${select} FROM ${n.table} WHERE _partition_id = ${sqlString(p)}${sourceRange}`;
163
193
  run.log(sql);
164
194
  await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0" }, ...(run.signal ? { signal: run.signal } : {}) });
195
+ if (later) result.repaired += await copyAfterCutover(run, o, later, p, laterQueryId);
165
196
  // The receipt, last, on success only.
166
197
  await receipts.write({ name: effect, effect, flavor: "hash", inputs: {} }, expectation);
167
198
  result.copied++;
168
199
  await deps.afterPartition?.(p);
169
200
  }
170
- run.log(`-- backfill of ${n.key}: ${result.partitions} partition(s), ${result.copied} copied, ${result.skipped} already copied, ${result.cleared} cleared first`);
201
+ run.log(`-- backfill of ${n.key}: ${result.partitions} partition(s), ${result.copied} copied, ${result.skipped} already copied, ${result.cleared} cleared first${result.repaired > 0 ? `, ${result.repaired} row(s) after the cut-over copied twice and repaired` : ""}`);
171
202
  return result;
172
203
  }
173
204
 
205
+ /** The statements that copy one old partition's rows at or after the cut-over. */
206
+ interface LaterRows {
207
+ /**
208
+ * Per distinct row (by its hash), the old table's copies past the number
209
+ * the new table holds, into the new table.
210
+ */
211
+ copy: (partition: string) => string;
212
+ /** The hashes of the rows the new table holds more copies of than the old one. */
213
+ extras: (partition: string) => string;
214
+ /** Delete every copy of those rows from the new table. */
215
+ drop: (partition: string, hashes: readonly string[]) => string;
216
+ }
217
+
218
+ /** The most extra rows one partition's repair deletes and copies again; past that the verification decides. */
219
+ const REPAIR_LIMIT = 1000;
220
+ const REPAIR_ROUNDS = 3;
221
+
222
+ /**
223
+ * The statements for the rows at or after the cut-over. The old side casts
224
+ * each column to its type in the new table before hashing, as the
225
+ * verification does, so a row and its copy hash the same.
226
+ */
227
+ async function laterRowsCopy(
228
+ run: RebuildRun,
229
+ o: RebuildObservation,
230
+ cut: RebuildObservation["copied"][number],
231
+ cutover: number,
232
+ fromOld: string,
233
+ ): Promise<LaterRows> {
234
+ const n = o.names;
235
+ const types = new Map(
236
+ (
237
+ await clickhouseQuery<{ name: string; type: string }>(
238
+ run.target.endpoint,
239
+ `SELECT name, type FROM system.columns WHERE database = ${sqlString(n.database)} AND table = ${sqlString(n.newName)}`,
240
+ )
241
+ ).map((c) => [c.name, c.type]),
242
+ );
243
+ const oldHash = rowHash(o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`));
244
+ const newHash = rowHash(o.copied.map((c) => ident(c.name)));
245
+ const columns = o.copied.map((c) => ident(c.name)).join(", ");
246
+ const aliases = o.copied.map((_, i) => `__chant_c${i}`);
247
+ const fromOldSide = o.copied.map((c, i) => `${ident(c.source)} AS ${aliases[i]}`).join(", ");
248
+ const oldRows = (p: string) => `FROM ${n.table} WHERE _partition_id = ${sqlString(p)} AND ${ident(cut.source)} >= ${utcLiteral(cutover)}`;
249
+ const newRows = (p: string) => `FROM ${n.newTable} WHERE ${fromOld} = ${sqlString(p)} AND ${ident(cut.name)} >= ${utcLiteral(cutover)}`;
250
+ return {
251
+ copy: (p) =>
252
+ `INSERT INTO ${n.newTable} (${columns}) SELECT ${aliases.join(", ")} FROM (` +
253
+ `SELECT ${fromOldSide}, ${oldHash} AS __chant_h, row_number() OVER (PARTITION BY __chant_h) AS __chant_k ${oldRows(p)}` +
254
+ `) AS old_rows LEFT JOIN (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p)} GROUP BY __chant_h) AS new_rows ` +
255
+ `USING (__chant_h) WHERE __chant_k > ifNull(__chant_n, 0)`,
256
+ extras: (p) =>
257
+ `SELECT toString(__chant_h) AS h FROM (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p)} GROUP BY __chant_h) AS new_rows ` +
258
+ `LEFT JOIN (SELECT ${oldHash} AS __chant_h, count() AS __chant_o ${oldRows(p)} GROUP BY __chant_h) AS old_rows ` +
259
+ `USING (__chant_h) WHERE __chant_n > ifNull(__chant_o, 0) LIMIT ${REPAIR_LIMIT + 1}`,
260
+ drop: (p, hashes) => `ALTER TABLE ${n.newTable} DELETE WHERE ${fromOld} = ${sqlString(p)} AND ${ident(cut.name)} >= ${utcLiteral(cutover)} AND ${newHash} IN (${hashes.join(", ")})`,
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Copy one old partition's rows at or after the cut-over that the new table
266
+ * lacks. A row the view had written to the old table but not yet to the new
267
+ * one when the copy read them is copied by both, and is then in the new
268
+ * table once too often: every copy of such a row is deleted from the new
269
+ * table and the difference copied again, a few rounds at most. What is left
270
+ * after that, the verification finds. Returns the rows repaired.
271
+ */
272
+ async function copyAfterCutover(run: RebuildRun, o: RebuildObservation, later: LaterRows, p: string, queryId: string): Promise<number> {
273
+ const n = o.names;
274
+ const insert = async () => {
275
+ const sql = later.copy(p);
276
+ run.log(sql);
277
+ await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0" }, ...(run.signal ? { signal: run.signal } : {}) });
278
+ };
279
+ await insert();
280
+ let repaired = 0;
281
+ for (let round = 0; round < REPAIR_ROUNDS; round++) {
282
+ await syncReplica(run, o, n.database, n.newName);
283
+ const extras = (await clickhouseQuery<{ h: string }>(run.target.endpoint, later.extras(p))).map((r) => r.h);
284
+ if (extras.length === 0 || extras.length > REPAIR_LIMIT) break;
285
+ run.log(`-- partition ${p}: ${extras.length} row(s) after the cut-over in the new table more often than in the old one; deleting them and copying again`);
286
+ const sql = later.drop(p, extras);
287
+ run.log(sql);
288
+ await clickhouseQuery(run.target.endpoint, sql);
289
+ await waitOn(run, n.database, n.newName);
290
+ repaired += extras.length;
291
+ await insert();
292
+ }
293
+ return repaired;
294
+ }
295
+
174
296
  function sleep(ms: number, signal?: AbortSignal): Promise<void> {
175
297
  return new Promise((resolve, reject) => {
176
298
  const t = setTimeout(resolve, ms);