@intentius/chant-lexicon-sql 0.118.1 → 0.121.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 (118) hide show
  1. package/dist/clickhouse/apply/apply.d.ts +1 -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/import/generator.d.ts +1 -1
  6. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  7. package/dist/clickhouse/import/inlined.d.ts +49 -0
  8. package/dist/clickhouse/import/inlined.d.ts.map +1 -0
  9. package/dist/clickhouse/import/live-export.d.ts +22 -0
  10. package/dist/clickhouse/import/live-export.d.ts.map +1 -1
  11. package/dist/clickhouse/live/bind.d.ts +8 -0
  12. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  13. package/dist/clickhouse/live/catalog.d.ts +15 -0
  14. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  15. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  16. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  17. package/dist/clickhouse/plan/deep.d.ts +19 -2
  18. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  19. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  20. package/dist/clickhouse/plan/normalize.d.ts +3 -0
  21. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  22. package/dist/clickhouse/plan/rebuild-handoff.d.ts +2 -0
  23. package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
  24. package/dist/clickhouse/rebuild/engines.d.ts +44 -0
  25. package/dist/clickhouse/rebuild/engines.d.ts.map +1 -0
  26. package/dist/clickhouse/rebuild/verify.d.ts +25 -1
  27. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  28. package/dist/clickhouse/testing/fake-server.d.ts +5 -0
  29. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  30. package/dist/config.d.ts +36 -8
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/core/foreign-tables.d.ts +13 -0
  33. package/dist/core/foreign-tables.d.ts.map +1 -0
  34. package/dist/files/clickhouse.d.ts +7 -3
  35. package/dist/files/clickhouse.d.ts.map +1 -1
  36. package/dist/files/postgres.d.ts +3 -0
  37. package/dist/files/postgres.d.ts.map +1 -1
  38. package/dist/import-parser.d.ts.map +1 -1
  39. package/dist/integrity.json +2 -2
  40. package/dist/manifest.json +1 -1
  41. package/dist/op/activities/clickhouse-emulator.d.ts +7 -1
  42. package/dist/op/activities/clickhouse-emulator.d.ts.map +1 -1
  43. package/dist/op/activities/postgres-emulator.d.ts +8 -1
  44. package/dist/op/activities/postgres-emulator.d.ts.map +1 -1
  45. package/dist/postgres/access/changes.d.ts +23 -0
  46. package/dist/postgres/access/changes.d.ts.map +1 -0
  47. package/dist/postgres/apply/apply.d.ts.map +1 -1
  48. package/dist/postgres/entities.d.ts.map +1 -1
  49. package/dist/postgres/live/bind.d.ts +8 -1
  50. package/dist/postgres/live/bind.d.ts.map +1 -1
  51. package/dist/postgres/live/catalog.d.ts +3 -3
  52. package/dist/postgres/live/catalog.d.ts.map +1 -1
  53. package/dist/postgres/live/client.d.ts +31 -0
  54. package/dist/postgres/live/client.d.ts.map +1 -1
  55. package/dist/postgres/plan/commands.d.ts.map +1 -1
  56. package/dist/postgres/plan/deep.d.ts.map +1 -1
  57. package/dist/sql-files.d.ts +2 -1
  58. package/dist/sql-files.d.ts.map +1 -1
  59. package/package.json +2 -2
  60. package/src/clickhouse/apply/apply.ts +8 -2
  61. package/src/clickhouse/apply/statements.ts +32 -5
  62. package/src/clickhouse/import/generator.ts +8 -3
  63. package/src/clickhouse/import/import.test.ts +72 -0
  64. package/src/clickhouse/import/inlined-functions.e2e.test.ts +87 -0
  65. package/src/clickhouse/import/inlined.test.ts +48 -0
  66. package/src/clickhouse/import/inlined.ts +200 -0
  67. package/src/clickhouse/import/live-export.ts +75 -2
  68. package/src/clickhouse/live/access.e2e.test.ts +71 -18
  69. package/src/clickhouse/live/bind.ts +10 -0
  70. package/src/clickhouse/live/catalog.ts +25 -2
  71. package/src/clickhouse/live/describe-resources.ts +5 -2
  72. package/src/clickhouse/live/live.test.ts +8 -0
  73. package/src/clickhouse/plan/commands.ts +6 -3
  74. package/src/clickhouse/plan/deep-grants.test.ts +67 -0
  75. package/src/clickhouse/plan/deep.ts +70 -6
  76. package/src/clickhouse/plan/diff.ts +4 -1
  77. package/src/clickhouse/plan/normalize.ts +5 -2
  78. package/src/clickhouse/plan/plan.test.ts +9 -1
  79. package/src/clickhouse/plan/rebuild-handoff.ts +11 -1
  80. package/src/clickhouse/rebuild/engines.ts +163 -0
  81. package/src/clickhouse/rebuild/rebuild-collapsing.e2e.test.ts +120 -0
  82. package/src/clickhouse/rebuild/rebuild-into-collapsing.e2e.test.ts +198 -0
  83. package/src/clickhouse/rebuild/rebuild.test.ts +78 -0
  84. package/src/clickhouse/rebuild/verify.ts +153 -24
  85. package/src/clickhouse/testing/fake-server.ts +3 -2
  86. package/src/codegen/classifier-page.ts +1 -1
  87. package/src/config.ts +18 -4
  88. package/src/core/foreign-tables.ts +28 -0
  89. package/src/files/clickhouse.ts +71 -5
  90. package/src/files/postgres.ts +2 -1
  91. package/src/import-parser.ts +2 -0
  92. package/src/migration-statements.test.ts +33 -0
  93. package/src/migration-statements.ts +2 -0
  94. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +1 -1
  95. package/src/op/activities/clickhouse-emulator.ts +22 -1
  96. package/src/op/activities/postgres-apply-lock-timeout.e2e.test.ts +87 -0
  97. package/src/op/activities/postgres-apply.test.ts +32 -0
  98. package/src/op/activities/postgres-emulator.test.ts +20 -0
  99. package/src/op/activities/postgres-emulator.ts +41 -1
  100. package/src/op/apply-gate-access-clickhouse.e2e.test.ts +195 -0
  101. package/src/op/apply-gate-access.e2e.test.ts +207 -0
  102. package/src/postgres/access/access.test.ts +26 -1
  103. package/src/postgres/access/changes.ts +39 -0
  104. package/src/postgres/apply/apply.ts +10 -7
  105. package/src/postgres/entities.ts +20 -1
  106. package/src/postgres/live/bind.ts +16 -4
  107. package/src/postgres/live/catalog.ts +3 -15
  108. package/src/postgres/live/client.ts +85 -0
  109. package/src/postgres/live/live.test.ts +54 -0
  110. package/src/postgres/live/lock-timeout.e2e.test.ts +70 -0
  111. package/src/postgres/plan/commands.ts +2 -14
  112. package/src/postgres/plan/deep.ts +44 -2
  113. package/src/serializer.test.ts +4 -0
  114. package/src/skills/chant-sql-postgres-plan.md +1 -1
  115. package/src/skills/chant-sql-rebuild.md +2 -0
  116. package/src/skills/chant-sql.md +1 -1
  117. package/src/sql-files.test.ts +78 -1
  118. package/src/sql-files.ts +2 -1
@@ -13,7 +13,11 @@
13
13
  * - a privilege granted by hand to a declared grantee is SQLCH274 and is
14
14
  * revoked, a declared one revoked by hand is SQLCH273 and is granted
15
15
  * again, a changed role setting is SQLCH270, a changed policy SQLCH272;
16
- * - the row policy filters rows for the user.
16
+ * the deep read lists both as pending, with the statements (#3733);
17
+ * - the row policy filters rows for the user;
18
+ * - with a profile that does not manage access (no `access: true`, #3716),
19
+ * none of it is applied, planned or read: each access declaration is
20
+ * reported filtered, and a hand change to a grant is not undone.
17
21
  *
18
22
  * Beside a Replicated database (two replicas of the pinned server in
19
23
  * Docker): access control is in no database, so the database's log does not
@@ -23,7 +27,7 @@
23
27
  * `CLICKHOUSE_PASSWORD`) names a running one, such as the one
24
28
  * `chant emulator up --lexicon sql` starts. Without it, a throwaway server
25
29
  * at the pin, skipped cleanly when Docker is not available. Users and roles
26
- * are global to the server, so the test names everything `chant_e2e_3682_access*`
30
+ * are global to the server, so the test names everything `chant_e2e_3716_access*`
27
31
  * and drops it afterwards.
28
32
  */
29
33
 
@@ -37,9 +41,10 @@ import { clickhouseImage } from "../../spec/pin";
37
41
  import { database, grant, policy, role, table, user } from "../entities";
38
42
  import { planAgainstServer } from "../plan/commands";
39
43
  import { observeResourcesDeep, sqlDeepNormalizationHooks } from "../plan/deep";
44
+ import { describeResources } from "./describe-resources";
40
45
  import { clickhouseApply } from "../../op/activities/clickhouse-apply";
41
46
 
42
- const DB = "chant_e2e_3682_access";
47
+ const DB = "chant_e2e_3716_access";
43
48
  const READER = `${DB}_reader`;
44
49
  const APP = `${DB}_app`;
45
50
  const fromEnv = process.env.CLICKHOUSE_URL;
@@ -76,13 +81,27 @@ afterAll(async () => {
76
81
  rmSync(dir, { recursive: true, force: true });
77
82
  });
78
83
 
79
- const envOf = (at: ClickHouseEndpoint, topology?: string) => ({
80
- CLICKHOUSE_URL: at.url,
81
- ...(at.user ? { CLICKHOUSE_USER: at.user } : {}),
82
- ...(at.password ? { CLICKHOUSE_PASSWORD: at.password } : {}),
83
- ...(topology ? { CLICKHOUSE_TOPOLOGY: topology } : {}),
84
+ const envOf = (at: ClickHouseEndpoint) => ({
85
+ ...(at.user ? { CH_USER: at.user } : {}),
86
+ ...(at.password ? { CH_PASSWORD: at.password } : {}),
84
87
  });
85
88
 
89
+ /** The `test` profile: the server, and whether it manages access (#3716). */
90
+ const configOf = (at: ClickHouseEndpoint, opts: { topology?: string; access?: boolean } = {}) => ({
91
+ ownership: { stack: "e2e3682", env: "test" },
92
+ sql: {
93
+ profiles: {
94
+ test: {
95
+ url: at.url,
96
+ ...(at.user ? { user: { env: "CH_USER" } } : {}),
97
+ ...(at.password ? { password: { env: "CH_PASSWORD" } } : {}),
98
+ ...(opts.topology ? { topology: opts.topology } : {}),
99
+ ...(opts.access !== false ? { access: true } : {}),
100
+ },
101
+ },
102
+ },
103
+ }) as never;
104
+
86
105
  type Entity = { entityType: string; props: { ddl: string }; dependsOn?: unknown };
87
106
  function writeBuild(file: string, declared: Record<string, Entity>): string {
88
107
  const path = join(dir, file);
@@ -91,11 +110,11 @@ function writeBuild(file: string, declared: Record<string, Entity>): string {
91
110
  return path;
92
111
  }
93
112
 
94
- async function apply(at: ClickHouseEndpoint, buildPath: string, topology?: string) {
113
+ async function apply(at: ClickHouseEndpoint, buildPath: string, opts: { topology?: string; access?: boolean } = {}) {
95
114
  const sent: string[] = [];
96
115
  const outcome = await clickhouseApply({ buildPath, environment: "test" }, undefined, {
97
- config: { ownership: { stack: "e2e3682", env: "test" } },
98
- env: envOf(at, topology),
116
+ config: configOf(at, opts),
117
+ env: envOf(at),
99
118
  log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
100
119
  });
101
120
  return { outcome, sent };
@@ -146,11 +165,12 @@ describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
146
165
  expect((await q<{ s: string }>(`SELECT auth_type[1] AS s FROM system.users WHERE name = '${APP}'`))[0]!.s).toBe("sha256_password");
147
166
 
148
167
  // The server prints the policy's condition and the grants its own way: still the declarations.
149
- const diff = await planAgainstServer("test", build, { config: {}, env: envOf(endpoint) });
168
+ const diff = await planAgainstServer("test", build, { config: configOf(endpoint), env: envOf(endpoint) });
150
169
  expect(diff.changes).toEqual([]);
151
170
  const entities = new Map(Object.entries(v1).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
152
- const deep = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config: {}, env: envOf(endpoint) });
171
+ const deep = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config: configOf(endpoint), env: envOf(endpoint) });
153
172
  expect(Object.keys(deep.unobserved ?? {}).sort()).toEqual(["readEvents", "readerToApp"]);
173
+ expect(deep.pending).toBeUndefined();
154
174
  for (const k of ["reader", "app", "tenant"] as const) expect(compared(deep.resources[k]!.properties), k).toEqual(compared(v1[k].props));
155
175
 
156
176
  // The policy filters rows for the user.
@@ -161,8 +181,14 @@ describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
161
181
  // Changed by hand: a privilege granted, a declared one revoked. Changed in the declaration: a setting and the condition.
162
182
  await q(`GRANT INSERT ON ${DB}.events TO ${READER}`);
163
183
  await q(`REVOKE ${READER} FROM ${APP}`);
184
+ // The deep read lists what an apply would grant and revoke (#3733), so an approval's plan digest moves.
185
+ const handMade = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config: configOf(endpoint), env: envOf(endpoint) });
186
+ expect(handMade.pending).toEqual([
187
+ { subject: `grants ${READER}`, change: `REVOKE INSERT ON \`${DB}\`.\`events\` FROM \`${READER}\``, entities: ["readEvents"] },
188
+ { subject: `grants ${APP}`, change: `GRANT \`${READER}\` TO \`${APP}\``, entities: ["readerToApp"] },
189
+ ]);
164
190
  const v2 = declarations({ memory: "2000000000", tenant: "b" });
165
- const planned = await planAgainstServer("test", writeBuild("v2.json", v2), { config: {}, env: envOf(endpoint) });
191
+ const planned = await planAgainstServer("test", writeBuild("v2.json", v2), { config: configOf(endpoint), env: envOf(endpoint) });
166
192
  expect(planned.changes.map((c) => [c.object, c.field, c.rule])).toEqual([
167
193
  [`reader (role ${READER})`, "settings", "SQLCH270"],
168
194
  // Revoking the role took it off the user's default roles too.
@@ -180,8 +206,35 @@ describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
180
206
  `GRANT \`${READER}\` TO \`${APP}\``,
181
207
  `ALTER USER \`${APP}\` DEFAULT ROLE ${READER}`,
182
208
  ]);
183
- expect((await planAgainstServer("test", writeBuild("v2.json", v2), { config: {}, env: envOf(endpoint) })).changes).toEqual([]);
209
+ expect((await planAgainstServer("test", writeBuild("v2.json", v2), { config: configOf(endpoint), env: envOf(endpoint) })).changes).toEqual([]);
184
210
  expect(Number((await q<{ n: string }>(`SELECT count() AS n FROM ${DB}.events`, asApp))[0]!.n)).toBe(1);
211
+
212
+ // A profile that does not manage access (#3716): nothing access-related is read, planned or applied.
213
+ await q(`GRANT INSERT ON ${DB}.events TO ${READER}`);
214
+ const off = { access: false };
215
+ const v3 = declarations({ memory: "3000000000", tenant: "a" });
216
+ const offBuild = writeBuild("v3.json", v3);
217
+ const offPlan = await planAgainstServer("test", offBuild, { config: configOf(endpoint, off), env: envOf(endpoint) });
218
+ expect(offPlan.changes).toEqual([]);
219
+ expect(offPlan.hints).toContain("5 access declarations are not planned: test's profile does not manage access (sql.profiles.<env>.access)");
220
+ const offApply = await apply(endpoint, offBuild, off);
221
+ expect(offApply.outcome.failed).toEqual([]);
222
+ expect(offApply.sent).toEqual([]);
223
+ expect(offApply.outcome.notAttempted.map((n) => [n.name, n.reason])).toEqual([
224
+ [`role ${READER}`, "filtered"],
225
+ [`user ${APP}`, "filtered"],
226
+ [`row policy ${DB}_tenant ON ${DB}.events`, "filtered"],
227
+ [`grants ${READER}`, "filtered"],
228
+ [`grants ${APP}`, "filtered"],
229
+ ]);
230
+ expect((await q<{ s: string }>(`SHOW GRANTS FOR ${READER}`)).map((r) => Object.values(r)[0]).join("\n")).toMatch(/GRANT INSERT\b.* ON chant_e2e_3716_access\.events TO/);
231
+ const v3entities = new Map(Object.entries(v3).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
232
+ const offDeep = await observeResourcesDeep({ environment: "test", entityNames: [...v3entities.keys()], entities: v3entities, config: configOf(endpoint, off), env: envOf(endpoint) });
233
+ expect(Object.entries(offDeep.unobserved ?? {}).map(([k, u]) => [k, u.reason]).sort()).toEqual(
234
+ ["app", "readEvents", "reader", "readerToApp", "tenant"].map((k) => [k, "filtered"]),
235
+ );
236
+ const offThin = await describeResources({ environment: "test", entityNames: [...v3entities.keys()], entities: v3entities, config: configOf(endpoint, off), env: envOf(endpoint) });
237
+ expect(Object.keys(offThin.unobserved ?? {}).sort()).toEqual(["app", "readEvents", "reader", "readerToApp", "tenant"]);
185
238
  }, 300_000);
186
239
  });
187
240
 
@@ -198,12 +251,12 @@ describe.skipIf(!docker)("access control beside a Replicated database (#3682)",
198
251
  const [r1, r2] = cluster!.replicas as [ClickHouseEndpoint, ClickHouseEndpoint];
199
252
  const { shop, events, reader, tenant, readEvents } = declarations();
200
253
  const build = writeBuild("repl.json", { shop, events, reader, tenant, readEvents });
201
- const first = await apply(r1, build, "replicated");
254
+ const first = await apply(r1, build, { topology: "replicated" });
202
255
  expect(first.outcome.failed).toEqual([]);
203
256
  expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER/);
204
257
  await q(first.sent[0]!, r2);
205
258
  await q(`SYSTEM SYNC DATABASE REPLICA ${DB}`, r2);
206
- const second = await apply(r2, build, "replicated");
259
+ const second = await apply(r2, build, { topology: "replicated" });
207
260
  expect(second.outcome.failed).toEqual([]);
208
261
  expect(second.outcome.applied.filter((a) => a.action === "created").map((a) => a.name)).toEqual([
209
262
  `role ${READER}`,
@@ -212,7 +265,7 @@ describe.skipIf(!docker)("access control beside a Replicated database (#3682)",
212
265
  ]);
213
266
  for (const at of [r1, r2]) {
214
267
  expect(Number((await q<{ n: string }>(`SELECT count() AS n FROM system.row_policies WHERE short_name = '${DB}_tenant'`, at))[0]!.n)).toBe(1);
215
- expect((await apply(at, build, "replicated")).sent).toEqual([]);
268
+ expect((await apply(at, build, { topology: "replicated" })).sent).toEqual([]);
216
269
  }
217
270
  }, 300_000);
218
271
  });
@@ -24,6 +24,14 @@ export interface ClickHouseTarget {
24
24
  * as declared.
25
25
  */
26
26
  topology?: Topology;
27
+ /**
28
+ * Whether chant manages access here (`sql.profiles.<env>.access`, #3716):
29
+ * the declared users, roles, row policies and grants. Off when omitted, as
30
+ * on Postgres, and off for a server bound by `CLICKHOUSE_URL`.
31
+ */
32
+ access?: boolean;
33
+ /** `sql.profiles.<env>.importFunctions` (#3718): the functions an import adopts besides those the imported objects call. */
34
+ importFunctions?: string[];
27
35
  }
28
36
 
29
37
  export interface UnresolvedTarget {
@@ -75,6 +83,8 @@ export function resolveClickHouseTarget(input: {
75
83
  ...(profile.databases ? { databases: profile.databases } : {}),
76
84
  defaultDatabase: profile.defaultDatabase ?? "default",
77
85
  ...(profile.topology !== undefined ? { topology: toTopology(profile.topology) } : {}),
86
+ ...(profile.access === true ? { access: true } : {}),
87
+ ...(profile.importFunctions ? { importFunctions: profile.importFunctions } : {}),
78
88
  };
79
89
  }
80
90
  const url = env.CLICKHOUSE_URL;
@@ -9,6 +9,7 @@ import { clickhouseQuery } from "../http";
9
9
  import type { ClickHouseTarget } from "./bind";
10
10
  import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "../entities";
11
11
  import { isChantWorkingObject, stripMarkerFromStatement } from "../ownership";
12
+ import { foreignTool } from "../../core/foreign-tables";
12
13
 
13
14
  /** The server's own databases, never part of a declared schema. */
14
15
  export const SYSTEM_DATABASES = new Set(["system", "information_schema", "INFORMATION_SCHEMA"]);
@@ -26,6 +27,8 @@ export interface LiveObject {
26
27
  statement: string;
27
28
  /** A row policy's table. */
28
29
  table?: string;
30
+ /** The tool that keeps it, for a migration runner's history table (`../../core/foreign-tables.ts`): never chant's to import, change or drop. */
31
+ foreign?: string;
29
32
  }
30
33
 
31
34
  const quote = (s: string) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
@@ -46,6 +49,11 @@ export function entityTypeOfEngine(engine: string): ClickHouseEntityType {
46
49
  * `SELECT` on the database sees it without a grant on `system.dictionaries`;
47
50
  * a table with the `Dictionary` engine is a table.
48
51
  *
52
+ * A table another tool keeps its migration history in (`schema_migrations`,
53
+ * `goose_db_version`, `../../core/foreign-tables.ts`) is read and marked with
54
+ * the tool, as the Postgres reader marks it (#3676): import leaves it out
55
+ * with a warning, and a plan names it in a hint instead of dropping it.
56
+ *
49
57
  * So are chant's own working objects (`../ownership.ts`
50
58
  * `isChantWorkingObject`): a rebuild migration's new, dual-write and retained
51
59
  * tables, and the effect receipts database. None of them is a declaration, so
@@ -87,13 +95,16 @@ export async function readLiveSchema(target: ClickHouseTarget, opts: { withState
87
95
  }
88
96
  for (const t of tables) {
89
97
  if (working.has(t.database) || isChantWorkingObject(t.comment)) continue;
98
+ const type = Number(t.dictionary) === 1 ? CLICKHOUSE_ENTITY_TYPES.dictionary : entityTypeOfEngine(t.engine);
99
+ const foreign = type === CLICKHOUSE_ENTITY_TYPES.table ? foreignTool(t.name) : undefined;
90
100
  out.push({
91
- type: Number(t.dictionary) === 1 ? CLICKHOUSE_ENTITY_TYPES.dictionary : entityTypeOfEngine(t.engine),
101
+ type,
92
102
  database: t.database,
93
103
  name: t.name,
94
104
  engine: t.engine,
95
105
  ...(t.uuid && !/^0{8}-/.test(t.uuid) ? { uuid: t.uuid } : {}),
96
106
  ...(t.comment ? { comment: t.comment } : {}),
107
+ ...(foreign ? { foreign } : {}),
97
108
  statement: await statement("TABLE", `${ident(t.database)}.${ident(t.name)}`),
98
109
  });
99
110
  }
@@ -168,7 +179,19 @@ export async function readLiveAccess(target: ClickHouseTarget, declared: readonl
168
179
  return out;
169
180
  }
170
181
 
171
- const ACCESS_KINDS = new Set(["user", "role", "rowPolicy", "grants"]);
182
+ /** The canonical kinds a profile's `access` switch decides (#3716). */
183
+ export const ACCESS_KINDS: ReadonlySet<string> = new Set(["user", "role", "rowPolicy", "grants"]);
184
+
185
+ /** The entity types a profile's `access` switch decides (#3716). */
186
+ export const ACCESS_ENTITY_TYPES: ReadonlySet<string> = new Set([CLICKHOUSE_ENTITY_TYPES.user, CLICKHOUSE_ENTITY_TYPES.role, CLICKHOUSE_ENTITY_TYPES.rowPolicy, CLICKHOUSE_ENTITY_TYPES.grant]);
187
+
188
+ /** Why an access declaration is not read, planned or applied where the profile does not manage access. */
189
+ export const ACCESS_UNMANAGED_DETAIL = "access is not managed in this environment: set sql.profiles.<env>.access to plan and apply users, roles, row policies and grants";
190
+
191
+ /** The hint a plan gives for the access declarations it left out. */
192
+ export function accessUnmanagedHint(n: number, environment: string): string {
193
+ return `${n} access declaration${n === 1 ? " is" : "s are"} not planned: ${environment}'s profile does not manage access (sql.profiles.<env>.access)`;
194
+ }
172
195
 
173
196
  /** The access objects among declared canonical objects, for `readLiveAccess`. */
174
197
  export function declaredAccess(objects: ReadonlyArray<{ kind: string; name: string; database?: string; table?: string }>): DeclaredAccess[] {
@@ -24,7 +24,7 @@ import {
24
24
  type ObserverAdapter,
25
25
  } from "@intentius/chant/observation";
26
26
  import { bindClickHouse, classifyClickHouseFailure, type BindOptions, type ClickHouseTarget } from "./bind";
27
- import { accessOf, readLiveAccess, readLiveSchema, type DeclaredAccess, type LiveObject } from "./catalog";
27
+ import { ACCESS_ENTITY_TYPES, ACCESS_UNMANAGED_DETAIL, accessOf, readLiveAccess, readLiveSchema, type DeclaredAccess, type LiveObject } from "./catalog";
28
28
  import { CLICKHOUSE_ENTITY_TYPES } from "../entities";
29
29
  import { objectKey } from "../plan/normalize";
30
30
  import { isChantManaged, readMarker, stripMarker } from "../ownership";
@@ -54,7 +54,7 @@ function adapter(options: BindOptions & { owned?: boolean; access?: (defaultData
54
54
  async bind() {
55
55
  const target = await bindClickHouse(options);
56
56
  const byKey = new Map<string, LiveObject>();
57
- const access = await readLiveAccess(target, options.access?.(target.defaultDatabase) ?? [], { withStatements: false });
57
+ const access = target.access === true ? await readLiveAccess(target, options.access?.(target.defaultDatabase) ?? [], { withStatements: false }) : [];
58
58
  for (const o of [...(await readLiveSchema(target, { withStatements: false })), ...access]) {
59
59
  byKey.set(objectKey(o), o);
60
60
  }
@@ -65,6 +65,9 @@ function adapter(options: BindOptions & { owned?: boolean; access?: (defaultData
65
65
  if (!entity.type.startsWith("ClickHouse::")) {
66
66
  return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
67
67
  }
68
+ if (target.access !== true && ACCESS_ENTITY_TYPES.has(entity.type)) {
69
+ return { unobserved: { reason: "filtered", detail: ACCESS_UNMANAGED_DETAIL } };
70
+ }
68
71
  if (entity.type === CLICKHOUSE_ENTITY_TYPES.grant) {
69
72
  return { unobserved: { reason: "unsupported-kind", detail: "a grant is compared as part of its grantees' grants, which chant sql plan reports" } };
70
73
  }
@@ -60,6 +60,14 @@ describe("binding an environment to a server", () => {
60
60
  });
61
61
  });
62
62
 
63
+ test("access is managed only where the profile says so (#3716)", () => {
64
+ const on = resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "http://ch:8123", access: true } } } } as never, env: {} });
65
+ expect(on).toMatchObject({ access: true });
66
+ const off = resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "http://ch:8123" } } } } as never, env: {} });
67
+ expect(off).not.toHaveProperty("access");
68
+ expect(resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123" } })).not.toHaveProperty("access");
69
+ });
70
+
63
71
  test("a credential variable the profile names but nobody set is no-credentials", () => {
64
72
  const t = resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "x", password: { env: "P" } } } } }, env: {} });
65
73
  expect(isUnresolvedTarget(t) && t.reason).toBe("no-credentials");
@@ -35,7 +35,7 @@ import { renderDiff } from "./report";
35
35
  import { keyedByQualifiedName, schemaFromBuildFile, schemaFromServer } from "./schema";
36
36
  import { dropFormattingOnly } from "./server-format";
37
37
  import { scopeOf } from "./normalize";
38
- import { declaredAccess } from "../live/catalog";
38
+ import { ACCESS_KINDS, accessUnmanagedHint, declaredAccess } from "../live/catalog";
39
39
  import { bindClickHouse } from "../live/bind";
40
40
  import { rebuildOpSuggestions } from "./rebuild-handoff";
41
41
  import { readFileSync } from "node:fs";
@@ -120,7 +120,10 @@ export async function runDiff(ctx: CommandGroupContext): Promise<number> {
120
120
  /** The declared objects against the server, keys and labels by `database.name`. */
121
121
  export async function planAgainstServer(environment: string, buildFile: string, options: Parameters<typeof bindClickHouse>[0] = {}): Promise<SchemaDiff> {
122
122
  const target = await bindClickHouse({ ...options, environment });
123
- const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase, target.topology));
123
+ const all = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase, target.topology));
124
+ // Access declarations a profile does not manage are not planned, and the server's access is not read (#3716).
125
+ const declared = target.access === true ? all : all.filter((o) => !ACCESS_KINDS.has(o.canonical.kind));
126
+ const unmanaged = all.length - declared.length;
124
127
  const databases = new Set(declared.map((o) => scopeOf(o.canonical)));
125
128
  const unreadable: UnreadableEntry[] = [];
126
129
  const live = await schemaFromServer(target, databases, unreadable, declaredAccess(declared.map((o) => o.canonical)));
@@ -136,7 +139,7 @@ export async function planAgainstServer(environment: string, buildFile: string,
136
139
  return {
137
140
  changes: relabel,
138
141
  rebuilds: relabel.filter((c) => c.class === "rebuild"),
139
- hints: diff.hints,
142
+ hints: unmanaged > 0 ? [...diff.hints, accessUnmanagedHint(unmanaged, environment)] : diff.hints,
140
143
  ...(rebuildOps.length > 0 ? { rebuildOps } : {}),
141
144
  ...(unreadable.length > 0 ? { unreadable } : {}),
142
145
  };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The grants the deep read reports as pending (#3733): what an apply would
3
+ * grant or revoke from each declared grantee, so a privilege granted by hand
4
+ * after an approval moves the plan digest. The server is a stub answering the
5
+ * queries `readLiveAccess` sends.
6
+ */
7
+
8
+ import { describe, expect, test, vi } from "vitest";
9
+ import { grant } from "../entities";
10
+ import type { ClickHouseTarget } from "../live/bind";
11
+
12
+ /** What `SHOW GRANTS FOR` prints, by grantee; a grantee missing here does not exist. */
13
+ let held: Record<string, string[]> = {};
14
+
15
+ vi.mock("../http", async (importOriginal) => {
16
+ const actual = await importOriginal<typeof import("../http")>();
17
+ return {
18
+ ...actual,
19
+ clickhouseQuery: async (_endpoint: unknown, sql: string) => {
20
+ if (/FROM system\.users/.test(sql)) return Object.keys(held).filter((n) => sql.includes(`'${n}'`)).map((name) => ({ name }));
21
+ if (/FROM system\.roles/.test(sql)) return [];
22
+ const show = /^SHOW GRANTS FOR `(.+)`$/.exec(sql.trim());
23
+ if (show) return (held[show[1]!] ?? []).map((line) => ({ [`GRANTS FOR ${show[1]}`]: line }));
24
+ throw new Error(`unexpected query: ${sql}`);
25
+ },
26
+ };
27
+ });
28
+
29
+ const { pendingGrants } = await import("./deep");
30
+
31
+ const target: ClickHouseTarget = { endpoint: { url: "http://stub:8123" }, source: "sql.profiles.test", defaultDatabase: "default", access: true };
32
+
33
+ const entities = (all: Record<string, ReturnType<typeof grant>>) =>
34
+ new Map(Object.entries(all).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
35
+
36
+ const declared = entities({
37
+ readEvents: grant`GRANT SELECT ON shop.events TO app`,
38
+ readOrders: grant`GRANT SELECT ON shop.orders TO app`,
39
+ });
40
+
41
+ describe("pendingGrants (#3733)", () => {
42
+ test("a grantee holding what is declared has nothing pending", async () => {
43
+ held = { app: ["GRANT SELECT ON shop.events TO app", "GRANT SELECT ON shop.orders TO app"] };
44
+ expect(await pendingGrants(target, declared)).toEqual([]);
45
+ });
46
+
47
+ test("a privilege granted by hand is a REVOKE, one revoked by hand a GRANT, revokes first", async () => {
48
+ held = { app: ["GRANT INSERT, SELECT ON shop.events TO app"] };
49
+ expect(await pendingGrants(target, declared)).toEqual([
50
+ {
51
+ subject: "grants app",
52
+ change: "REVOKE INSERT ON `shop`.`events` FROM `app`; GRANT SELECT ON `shop`.`orders` TO `app`",
53
+ entities: ["readEvents", "readOrders"],
54
+ },
55
+ ]);
56
+ });
57
+
58
+ test("a grantee the server does not hold yet is given every declared grant", async () => {
59
+ held = {};
60
+ expect((await pendingGrants(target, declared)).map((p) => p.change)).toEqual(["GRANT SELECT ON `shop`.`events` TO `app`; GRANT SELECT ON `shop`.`orders` TO `app`"]);
61
+ });
62
+
63
+ test("no grant declarations read nothing", async () => {
64
+ held = {};
65
+ expect(await pendingGrants(target, new Map())).toEqual([]);
66
+ });
67
+ });
@@ -17,17 +17,29 @@
17
17
  * (#3664). When a server's copy of a table differs from the profile
18
18
  * server's, that copy is what is reported, with the server in `observedOn`,
19
19
  * which the drift report prints as `seen on`.
20
+ *
21
+ * Where the profile manages access, the grants an apply would give or take
22
+ * from each declared grantee are reported as `pending` (#3733), the same
23
+ * statements `chant sql plan` plans (SQLCH273, SQLCH274), so a privilege
24
+ * granted or revoked by hand after an approval moves the plan digest.
20
25
  */
21
26
 
22
- import { deepObservation, type DeepObservationResult, type DeepResourceObservation, type DeepNormalizationHooks } from "@intentius/chant/deep-observation";
27
+ import {
28
+ deepObservation,
29
+ type DeepObservationResult,
30
+ type DeepPendingChange,
31
+ type DeepResourceObservation,
32
+ type DeepNormalizationHooks,
33
+ } from "@intentius/chant/deep-observation";
23
34
  import type { UnobservedEntity } from "@intentius/chant/lexicon";
24
35
  import { bindClickHouse, classifyClickHouseFailure, type BindOptions, type ClickHouseTarget } from "../live/bind";
25
- import { accessOf, readLiveAccess, readLiveSchema, SYSTEM_DATABASES, type LiveObject } from "../live/catalog";
36
+ import { ACCESS_ENTITY_TYPES, ACCESS_UNMANAGED_DETAIL, accessOf, readLiveAccess, readLiveSchema, SYSTEM_DATABASES, type LiveObject } from "../live/catalog";
26
37
  import { clickhouseQuery } from "../http";
27
38
  import { sqlString } from "../apply/statements";
28
39
  import { stripMarkerFromStatement } from "../ownership";
29
40
  import { database, dictionary, func, policy, role, table, user, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
30
- import { canonicalObject, objectKey, type CanonicalColumn, type CanonicalObject } from "./normalize";
41
+ import { canonicalObject, grantsObject, objectKey, type CanonicalColumn, type CanonicalObject } from "./normalize";
42
+ import { grantStatement, grantsByGrantee, revokeStatement, type GrantAtom } from "../access";
31
43
  import { renderFor } from "../topology";
32
44
 
33
45
  type Props = Record<string, unknown>;
@@ -207,7 +219,7 @@ export async function observeResourcesDeep(
207
219
  let live: LiveObject[];
208
220
  try {
209
221
  target = await bindClickHouse(options);
210
- live = [...(await readLiveSchema(target)), ...(await readLiveAccess(target, accessOf(options.entities, target.defaultDatabase)))];
222
+ live = [...(await readLiveSchema(target)), ...(target.access === true ? await readLiveAccess(target, accessOf(options.entities, target.defaultDatabase)) : [])];
211
223
  } catch (err) {
212
224
  const why = classifyClickHouseFailure(err);
213
225
  for (const name of options.entityNames) {
@@ -227,17 +239,34 @@ export async function observeResourcesDeep(
227
239
  }
228
240
  return deepObservation({}, unobserved);
229
241
  }
242
+ const pending: DeepPendingChange[] = [];
243
+ if (target.access === true) {
244
+ try {
245
+ pending.push(...(await pendingGrants(target, options.entities)));
246
+ } catch (err) {
247
+ const why = classifyClickHouseFailure(err);
248
+ for (const name of options.entityNames) {
249
+ if (options.entities.get(name)?.entityType !== CLICKHOUSE_ENTITY_TYPES.grant) continue;
250
+ unobserved[name] = { type: CLICKHOUSE_ENTITY_TYPES.grant, reason: "read-failed", detail: `the server's grants could not be compared: ${why.detail}` };
251
+ }
252
+ }
253
+ }
230
254
  for (const name of options.entityNames) {
231
255
  const entity = options.entities.get(name);
232
256
  if (!entity || !entity.entityType.startsWith("ClickHouse::")) {
233
257
  unobserved[name] = { type: entity?.entityType ?? "", reason: "unsupported-kind" };
234
258
  continue;
235
259
  }
260
+ if (target.access !== true && ACCESS_ENTITY_TYPES.has(entity.entityType)) {
261
+ unobserved[name] = { type: entity.entityType, reason: "filtered", detail: ACCESS_UNMANAGED_DETAIL };
262
+ continue;
263
+ }
236
264
  if (entity.entityType === CLICKHOUSE_ENTITY_TYPES.grant) {
265
+ if (unobserved[name]) continue;
237
266
  unobserved[name] = {
238
267
  type: entity.entityType,
239
268
  reason: "unsupported-kind",
240
- detail: "a grant is compared as part of its grantees' grants, which chant sql plan reports (SQLCH273, SQLCH274)",
269
+ detail: "a grant is compared as part of its grantees' grants: what an apply would grant or revoke is listed as pending (SQLCH273, SQLCH274)",
241
270
  };
242
271
  continue;
243
272
  }
@@ -280,5 +309,40 @@ export async function observeResourcesDeep(
280
309
  unobserved[name] = { type: entity.entityType, reason: "read-failed", detail: `the server's definition does not parse: ${(err as Error).message}` };
281
310
  }
282
311
  }
283
- return deepObservation(resources, unobserved);
312
+ return deepObservation(resources, unobserved, pending);
313
+ }
314
+
315
+ /**
316
+ * The grants an apply would give or take from each declared grantee (#3733),
317
+ * as `chant sql plan` compares them: the declared grants against what
318
+ * `SHOW GRANTS FOR` prints, atom by atom. One change per grantee, its
319
+ * REVOKEs before its GRANTs as the applier sends them, with the grant
320
+ * declarations behind it. A grantee the server does not hold yet is given
321
+ * every declared grant.
322
+ */
323
+ export async function pendingGrants(
324
+ target: ClickHouseTarget,
325
+ entities: ReadonlyMap<string, { entityType: string; props: Record<string, unknown> }>,
326
+ ): Promise<DeepPendingChange[]> {
327
+ const declared = grantsByGrantee([...entities].map(([name, e]) => ({ export: name, type: e.entityType, ddl: String(e.props.ddl ?? "") })));
328
+ if (declared.length === 0) return [];
329
+ const live = await readLiveAccess(target, declared.map((g) => ({ kind: "grants", name: g.grantee })));
330
+ const held = new Map(live.filter((o) => o.type === CLICKHOUSE_ENTITY_TYPES.grant).map((o) => [o.name, grantsObject(o.statement, o.name, target.defaultDatabase).access ?? {}]));
331
+ const atom = (json: string) => JSON.parse(json) as GrantAtom;
332
+ const out: DeepPendingChange[] = [];
333
+ for (const g of declared) {
334
+ const want = grantsObject(g.ddl, g.grantee, target.defaultDatabase).access ?? {};
335
+ const have = held.get(g.grantee) ?? {};
336
+ const statements = [
337
+ ...Object.keys(have)
338
+ .filter((k) => !(k in want))
339
+ .map((k) => revokeStatement(atom(have[k]!), g.grantee)),
340
+ ...Object.keys(want)
341
+ .filter((k) => !(k in have))
342
+ .map((k) => grantStatement(atom(want[k]!), g.grantee)),
343
+ ];
344
+ if (statements.length === 0) continue;
345
+ out.push({ subject: `grants ${g.grantee}`, change: statements.join("; "), entities: [...g.exports].sort() });
346
+ }
347
+ return out;
284
348
  }
@@ -259,7 +259,10 @@ export function diffSchemas(before: readonly SchemaObject[], after: readonly Sch
259
259
  for (const m of matches) {
260
260
  if (m.kind === "created") changes.push(change(m.after.key, "object", "SQLCH200", undefined, qualified(m.after.canonical)));
261
261
  else if (m.kind === "matched") diffObject(m.after.key, m.before.canonical, m.after.canonical, changes, hints);
262
- else {
262
+ else if (m.before.canonical.foreign) {
263
+ // A migration runner's history table (#3676): not chant's to drop.
264
+ hints.push(`${qualified(m.before.canonical)} is kept by ${m.before.canonical.foreign}; it is not chant's to drop and is left alone`);
265
+ } else {
263
266
  const b = m.before;
264
267
  changes.push(change(b.key, "object", "SQLCH250", qualified(b.canonical), undefined, { destructive: b.canonical.kind === "table" || b.canonical.kind === "materializedView" }));
265
268
  }
@@ -98,6 +98,8 @@ export interface CanonicalObject {
98
98
  access?: Record<string, string>;
99
99
  /** `-- previously: <name>` before the statement: the object's previous name. */
100
100
  previously?: string;
101
+ /** The tool that keeps a live table (a migration runner's history table): a plan never drops it. */
102
+ foreign?: string;
101
103
  }
102
104
 
103
105
  const KEYWORDS = new Set(
@@ -560,6 +562,7 @@ export function scopeOf(o: { kind?: ObjectKind; type?: string; database?: string
560
562
  }
561
563
 
562
564
  /** A live object's canonical form: a grantee's grants from its `SHOW GRANTS` lines, anything else from its `SHOW CREATE`. */
563
- export function liveCanonical(o: { type: string; name: string; statement: string }, defaultDatabase = "default"): CanonicalObject {
564
- return o.type === "ClickHouse::Grant" ? grantsObject(o.statement, o.name, defaultDatabase) : canonicalObject(o.statement, defaultDatabase);
565
+ export function liveCanonical(o: { type: string; name: string; statement: string; foreign?: string }, defaultDatabase = "default"): CanonicalObject {
566
+ const canonical = o.type === "ClickHouse::Grant" ? grantsObject(o.statement, o.name, defaultDatabase) : canonicalObject(o.statement, defaultDatabase);
567
+ return o.foreign ? { ...canonical, foreign: o.foreign } : canonical;
565
568
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { canonicalCodec, canonicalExpression, canonicalObject, canonicalTtl, canonicalType } from "./normalize";
2
+ import { canonicalCodec, canonicalExpression, canonicalObject, canonicalTtl, canonicalType, liveCanonical } from "./normalize";
3
3
  import { diffSchemas, type SchemaObject } from "./diff";
4
4
  import { renderDiff } from "./report";
5
5
  import { CLASSIFIER_RULES } from "./rules";
@@ -184,6 +184,14 @@ describe("classifying a change", () => {
184
184
  expect(classify("CREATE VIEW events AS SELECT 1")).toEqual([["kind", "SQLCH224", "rebuild"]]);
185
185
  });
186
186
 
187
+ test("a migration runner's history table is named in a hint, never dropped (#3676)", () => {
188
+ const statement = "CREATE TABLE legacy.schema_migrations (version Int64, dirty UInt8, sequence UInt64) ENGINE = MergeTree ORDER BY sequence";
189
+ const live = [{ key: "legacy.schema_migrations", canonical: liveCanonical({ type: "ClickHouse::Table", name: "schema_migrations", statement, foreign: "a migration runner (Rails, golang-migrate, dbmate)" }) }];
190
+ const d = diffSchemas(live, []);
191
+ expect(d.changes).toEqual([]);
192
+ expect(d.hints).toEqual(["legacy.schema_migrations is kept by a migration runner (Rails, golang-migrate, dbmate); it is not chant's to drop and is left alone"]);
193
+ });
194
+
187
195
  test("against a server, previously before the statement matches the old name", () => {
188
196
  const live = [{ key: "default.events", canonical: canonicalObject(BASE) }];
189
197
  const declared = [{ key: "default.raw_events", canonical: canonicalObject(`-- previously: events\n${BASE.replace("TABLE events", "TABLE raw_events")}`) }];
@@ -19,11 +19,14 @@
19
19
  import type { CanonicalObject } from "./normalize";
20
20
  import type { SchemaDiff } from "./diff";
21
21
  import { migrationOpName, renderMigrationOps, type MigrationOpSuggestion } from "../../core/handoff";
22
+ import { collapsesRows } from "../rebuild/engines";
22
23
 
23
24
  export interface RebuildOpSuggestion extends MigrationOpSuggestion {
24
25
  /** `database.name`, the Op's `table`. */
25
26
  table: string;
26
27
  dualWrite: { mode: "materialized-view"; cutoverColumn: string } | { mode: "app" };
28
+ /** What to know before running it: set for an engine that collapses rows by sorting key (#3674, #3727). */
29
+ note?: string;
27
30
  }
28
31
 
29
32
  const TIME_TYPE = /^(?:Nullable\s*\(\s*)?(?:Date|Date32|DateTime|DateTime64)\b/;
@@ -42,11 +45,17 @@ export function rebuildOpSuggestions(diff: SchemaDiff, declared: ReadonlyMap<str
42
45
  const dualWrite: RebuildOpSuggestion["dualWrite"] = time ? { mode: "materialized-view", cutoverColumn: time.name } : { mode: "app" };
43
46
  const name = migrationOpName("rebuild", table);
44
47
  const dw = dualWrite.mode === "app" ? `{ mode: "app" }` : `{ mode: "materialized-view", cutoverColumn: ${JSON.stringify(dualWrite.cutoverColumn)} }`;
48
+ const note = collapsesRows(c.engineName)
49
+ ? `${table} is a ${c.engineName}: when the old table's engine collapses rows too, the Verify phase reads both tables under FINAL, so rows the old table has not merged yet compare as merged. ` +
50
+ `A new sorting key that collapses rows differently from the old one still fails it; OPTIMIZE TABLE ${table} FINAL before the run avoids that. ` +
51
+ `From an engine that keeps every row (a plain MergeTree), it groups both tables by the new sorting key instead and compares what a ${c.engineName} keeps per key (its sums, its highest version, the sum of its signs).`
52
+ : undefined;
45
53
  out.push({
46
54
  table,
47
55
  name,
48
56
  env,
49
57
  dualWrite,
58
+ ...(note ? { note } : {}),
50
59
  declaration: `export const { op } = ClickHouseRebuildOp({ name: ${JSON.stringify(name)}, env: ${JSON.stringify(env)}, table: ${JSON.stringify(table)}, dualWrite: ${dw} });`,
51
60
  });
52
61
  }
@@ -55,5 +64,6 @@ export function rebuildOpSuggestions(diff: SchemaDiff, declared: ReadonlyMap<str
55
64
 
56
65
  /** The suggestions as text, for the report. */
57
66
  export function renderRebuildOps(ops: readonly RebuildOpSuggestion[]): string[] {
58
- return renderMigrationOps(ops, { what: "the rebuild migration Op", exportName: "ClickHouseRebuildOp", importPath: "@intentius/chant-lexicon-sql/clickhouse" });
67
+ const notes = ops.flatMap((o) => (o.note ? [` Note: ${o.note}`] : []));
68
+ return [...renderMigrationOps(ops, { what: "the rebuild migration Op", exportName: "ClickHouseRebuildOp", importPath: "@intentius/chant-lexicon-sql/clickhouse" }), ...notes];
59
69
  }