@intentius/chant-lexicon-sql 0.116.0 → 0.118.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 (259) hide show
  1. package/dist/clickhouse/access.d.ts +132 -0
  2. package/dist/clickhouse/access.d.ts.map +1 -0
  3. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  4. package/dist/clickhouse/apply/statements.d.ts +2 -0
  5. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  6. package/dist/clickhouse/entities.d.ts +171 -2
  7. package/dist/clickhouse/entities.d.ts.map +1 -1
  8. package/dist/clickhouse/http.d.ts +3 -0
  9. package/dist/clickhouse/http.d.ts.map +1 -1
  10. package/dist/clickhouse/import/generator.d.ts +28 -0
  11. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  12. package/dist/clickhouse/import/ir.d.ts.map +1 -1
  13. package/dist/clickhouse/import/parser.d.ts +3 -3
  14. package/dist/clickhouse/import/parser.d.ts.map +1 -1
  15. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  16. package/dist/clickhouse/live/catalog.d.ts +41 -6
  17. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  18. package/dist/clickhouse/live/describe-resources.d.ts +3 -2
  19. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  20. package/dist/clickhouse/parser.d.ts +54 -3
  21. package/dist/clickhouse/parser.d.ts.map +1 -1
  22. package/dist/clickhouse/plan/commands.d.ts +3 -1
  23. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  24. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  25. package/dist/clickhouse/plan/diff.d.ts +12 -0
  26. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  27. package/dist/clickhouse/plan/normalize.d.ts +61 -1
  28. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  29. package/dist/clickhouse/plan/report.d.ts.map +1 -1
  30. package/dist/clickhouse/plan/rules.d.ts +7 -0
  31. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  32. package/dist/clickhouse/plan/schema.d.ts +8 -4
  33. package/dist/clickhouse/plan/schema.d.ts.map +1 -1
  34. package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
  35. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  36. package/dist/clickhouse/rebuild/observe.d.ts +7 -3
  37. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  38. package/dist/clickhouse/rebuild/op.d.ts +8 -0
  39. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  40. package/dist/clickhouse/rebuild/steps.d.ts +48 -0
  41. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  42. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  43. package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
  44. package/dist/clickhouse/topology.d.ts.map +1 -1
  45. package/dist/clickhouse.d.ts +1 -1
  46. package/dist/clickhouse.d.ts.map +1 -1
  47. package/dist/codegen/classifier-page.d.ts.map +1 -1
  48. package/dist/codegen/registry.d.ts.map +1 -1
  49. package/dist/config.d.ts +70 -7
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/core/references.d.ts +4 -2
  52. package/dist/core/references.d.ts.map +1 -1
  53. package/dist/core/statements.d.ts +11 -0
  54. package/dist/core/statements.d.ts.map +1 -1
  55. package/dist/files/build-root.d.ts +26 -0
  56. package/dist/files/build-root.d.ts.map +1 -0
  57. package/dist/files/clickhouse.d.ts +19 -0
  58. package/dist/files/clickhouse.d.ts.map +1 -0
  59. package/dist/files/common.d.ts +25 -0
  60. package/dist/files/common.d.ts.map +1 -0
  61. package/dist/files/postgres.d.ts +36 -0
  62. package/dist/files/postgres.d.ts.map +1 -0
  63. package/dist/import-parser.d.ts +25 -0
  64. package/dist/import-parser.d.ts.map +1 -0
  65. package/dist/index.d.ts +2 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/integrity.json +8 -8
  68. package/dist/lint/rules/sqlch001.d.ts +1 -1
  69. package/dist/lint/rules/sqlch001.d.ts.map +1 -1
  70. package/dist/lint/rules/sqlch002.d.ts.map +1 -1
  71. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  72. package/dist/lint/rules/templates.d.ts +1 -1
  73. package/dist/lint/rules/templates.d.ts.map +1 -1
  74. package/dist/lsp/postgres-template.d.ts.map +1 -1
  75. package/dist/manifest.json +1 -1
  76. package/dist/meta.json +50 -0
  77. package/dist/okf/index.md +11 -1
  78. package/dist/okf/rules/SQLCH001.md +2 -2
  79. package/dist/okf/types/Dictionary.md +9 -0
  80. package/dist/okf/types/Function.md +9 -0
  81. package/dist/okf/types/Grant.md +9 -0
  82. package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
  83. package/dist/okf/types/PostgresGrant.md +9 -0
  84. package/dist/okf/types/PostgresPolicy.md +9 -0
  85. package/dist/okf/types/PostgresRole.md +9 -0
  86. package/dist/okf/types/Role.md +9 -0
  87. package/dist/okf/types/RowPolicy.md +9 -0
  88. package/dist/okf/types/User.md +9 -0
  89. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  90. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  91. package/dist/op/activity-contracts.d.ts +19 -0
  92. package/dist/op/activity-contracts.d.ts.map +1 -1
  93. package/dist/plugin.d.ts.map +1 -1
  94. package/dist/postgres/access/acl.d.ts +119 -0
  95. package/dist/postgres/access/acl.d.ts.map +1 -0
  96. package/dist/postgres/access/import.d.ts +13 -0
  97. package/dist/postgres/access/import.d.ts.map +1 -0
  98. package/dist/postgres/access/live.d.ts +24 -0
  99. package/dist/postgres/access/live.d.ts.map +1 -0
  100. package/dist/postgres/apply/apply.d.ts.map +1 -1
  101. package/dist/postgres/apply/statements.d.ts +71 -0
  102. package/dist/postgres/apply/statements.d.ts.map +1 -1
  103. package/dist/postgres/entities.d.ts +106 -4
  104. package/dist/postgres/entities.d.ts.map +1 -1
  105. package/dist/postgres/entity-types.d.ts +4 -0
  106. package/dist/postgres/entity-types.d.ts.map +1 -1
  107. package/dist/postgres/import/generator.d.ts +37 -1
  108. package/dist/postgres/import/generator.d.ts.map +1 -1
  109. package/dist/postgres/import/live-export.d.ts +6 -0
  110. package/dist/postgres/import/live-export.d.ts.map +1 -1
  111. package/dist/postgres/live/bind.d.ts +2 -0
  112. package/dist/postgres/live/bind.d.ts.map +1 -1
  113. package/dist/postgres/live/catalog.d.ts +11 -2
  114. package/dist/postgres/live/catalog.d.ts.map +1 -1
  115. package/dist/postgres/live/client.d.ts +3 -0
  116. package/dist/postgres/live/client.d.ts.map +1 -1
  117. package/dist/postgres/live/describe-resources.d.ts +9 -0
  118. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  119. package/dist/postgres/migrate/op.d.ts +29 -0
  120. package/dist/postgres/migrate/op.d.ts.map +1 -1
  121. package/dist/postgres/parser.d.ts +76 -3
  122. package/dist/postgres/parser.d.ts.map +1 -1
  123. package/dist/postgres/plan/commands.d.ts +12 -0
  124. package/dist/postgres/plan/commands.d.ts.map +1 -1
  125. package/dist/postgres/plan/deep.d.ts.map +1 -1
  126. package/dist/postgres/plan/diff.d.ts +10 -1
  127. package/dist/postgres/plan/diff.d.ts.map +1 -1
  128. package/dist/postgres/plan/normalize.d.ts +8 -1
  129. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  130. package/dist/postgres/plan/rules.d.ts +8 -0
  131. package/dist/postgres/plan/rules.d.ts.map +1 -1
  132. package/dist/postgres/plan/schema.d.ts +2 -1
  133. package/dist/postgres/plan/schema.d.ts.map +1 -1
  134. package/dist/postgres/plan/server-normalize.d.ts +4 -1
  135. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  136. package/dist/postgres.d.ts +1 -1
  137. package/dist/postgres.d.ts.map +1 -1
  138. package/dist/rules/postgres-templates.ts +2 -2
  139. package/dist/rules/sqlch001.ts +26 -4
  140. package/dist/rules/sqlch002.ts +3 -2
  141. package/dist/rules/sqlpg001.ts +7 -4
  142. package/dist/rules/templates.ts +2 -2
  143. package/dist/serializer.d.ts +2 -1
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/sql-files.d.ts +79 -0
  146. package/dist/sql-files.d.ts.map +1 -0
  147. package/dist/token-source.d.ts +88 -0
  148. package/dist/token-source.d.ts.map +1 -0
  149. package/dist/validate.d.ts.map +1 -1
  150. package/package.json +2 -2
  151. package/src/clickhouse/access.test.ts +203 -0
  152. package/src/clickhouse/access.ts +595 -0
  153. package/src/clickhouse/apply/apply.ts +29 -20
  154. package/src/clickhouse/apply/statements.ts +105 -8
  155. package/src/clickhouse/dictionary.test.ts +177 -0
  156. package/src/clickhouse/entities.ts +356 -6
  157. package/src/clickhouse/function.test.ts +91 -0
  158. package/src/clickhouse/http.ts +11 -2
  159. package/src/clickhouse/import/generator.ts +81 -46
  160. package/src/clickhouse/import/import.test.ts +9 -3
  161. package/src/clickhouse/import/ir.ts +6 -2
  162. package/src/clickhouse/import/parser.ts +10 -18
  163. package/src/clickhouse/live/access.e2e.test.ts +218 -0
  164. package/src/clickhouse/live/bind.ts +17 -6
  165. package/src/clickhouse/live/catalog.ts +106 -10
  166. package/src/clickhouse/live/describe-resources.ts +23 -10
  167. package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
  168. package/src/clickhouse/live/functions.e2e.test.ts +155 -0
  169. package/src/clickhouse/live/live.test.ts +2 -2
  170. package/src/clickhouse/parser.ts +136 -4
  171. package/src/clickhouse/plan/commands.ts +18 -7
  172. package/src/clickhouse/plan/deep.ts +71 -11
  173. package/src/clickhouse/plan/diff.ts +65 -2
  174. package/src/clickhouse/plan/normalize.ts +200 -6
  175. package/src/clickhouse/plan/report.ts +14 -3
  176. package/src/clickhouse/plan/rules.ts +44 -1
  177. package/src/clickhouse/plan/schema.ts +40 -17
  178. package/src/clickhouse/plan/server-format.ts +1 -1
  179. package/src/clickhouse/rebuild/backfill.ts +5 -25
  180. package/src/clickhouse/rebuild/observe.ts +7 -3
  181. package/src/clickhouse/rebuild/op.ts +9 -0
  182. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
  183. package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
  184. package/src/clickhouse/rebuild/steps.ts +136 -1
  185. package/src/clickhouse/testing/fake-server.ts +2 -0
  186. package/src/clickhouse/testing/writable-server.ts +2 -0
  187. package/src/clickhouse/topology.ts +17 -2
  188. package/src/clickhouse.ts +19 -0
  189. package/src/codegen/classifier-page.ts +2 -0
  190. package/src/codegen/registry.ts +10 -0
  191. package/src/config.ts +22 -3
  192. package/src/core/references.ts +5 -3
  193. package/src/core/statements.ts +11 -1
  194. package/src/files/build-root.ts +62 -0
  195. package/src/files/clickhouse.ts +52 -0
  196. package/src/files/common.ts +54 -0
  197. package/src/files/postgres.ts +371 -0
  198. package/src/generated/lexicon-sql.json +50 -0
  199. package/src/import-parser.ts +61 -0
  200. package/src/index.ts +14 -1
  201. package/src/lint/rules/postgres-templates.ts +2 -2
  202. package/src/lint/rules/sqlch001.ts +26 -4
  203. package/src/lint/rules/sqlch002.ts +3 -2
  204. package/src/lint/rules/sqlpg001.ts +7 -4
  205. package/src/lint/rules/templates.ts +2 -2
  206. package/src/lsp/postgres-template.ts +2 -0
  207. package/src/lsp/template.ts +3 -3
  208. package/src/mcp/index.ts +6 -6
  209. package/src/mcp/postgres.ts +1 -1
  210. package/src/migration-statements.test.ts +25 -0
  211. package/src/migration-statements.ts +8 -0
  212. package/src/op/activities/clickhouse-rebuild.ts +1 -0
  213. package/src/op/activities/postgres-migration.ts +27 -2
  214. package/src/op/activity-contracts.ts +2 -0
  215. package/src/plugin.ts +29 -5
  216. package/src/postgres/access/access.e2e.test.ts +191 -0
  217. package/src/postgres/access/access.test.ts +147 -0
  218. package/src/postgres/access/acl.ts +400 -0
  219. package/src/postgres/access/import.ts +64 -0
  220. package/src/postgres/access/live.ts +123 -0
  221. package/src/postgres/apply/apply.ts +117 -4
  222. package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
  223. package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
  224. package/src/postgres/apply/statements.test.ts +138 -6
  225. package/src/postgres/apply/statements.ts +210 -18
  226. package/src/postgres/entities.ts +287 -11
  227. package/src/postgres/entity-types.ts +4 -0
  228. package/src/postgres/import/generator.ts +129 -46
  229. package/src/postgres/import/live-export.ts +12 -2
  230. package/src/postgres/live/bind.ts +20 -6
  231. package/src/postgres/live/catalog.ts +79 -3
  232. package/src/postgres/live/client.ts +10 -2
  233. package/src/postgres/live/describe-resources.ts +27 -3
  234. package/src/postgres/migrate/migrate.test.ts +24 -1
  235. package/src/postgres/migrate/op.ts +80 -35
  236. package/src/postgres/migrate/outer.e2e.test.ts +163 -0
  237. package/src/postgres/parser.ts +247 -8
  238. package/src/postgres/plan/commands.ts +60 -7
  239. package/src/postgres/plan/deep.ts +24 -3
  240. package/src/postgres/plan/diff.ts +71 -6
  241. package/src/postgres/plan/normalize.ts +115 -2
  242. package/src/postgres/plan/plan.test.ts +2 -2
  243. package/src/postgres/plan/rules.ts +49 -5
  244. package/src/postgres/plan/schema.ts +4 -2
  245. package/src/postgres/plan/server-normalize.ts +29 -1
  246. package/src/postgres.ts +11 -0
  247. package/src/serializer.ts +14 -9
  248. package/src/skill-defs.ts +1 -1
  249. package/src/skills/chant-sql-postgres-migration.md +3 -1
  250. package/src/skills/chant-sql-postgres-plan.md +3 -3
  251. package/src/skills/chant-sql-postgres.md +9 -2
  252. package/src/skills/chant-sql-rebuild.md +3 -2
  253. package/src/skills/chant-sql.md +7 -1
  254. package/src/sql-files.test.ts +188 -0
  255. package/src/sql-files.ts +218 -0
  256. package/src/token-source.e2e.test.ts +142 -0
  257. package/src/token-source.test.ts +119 -0
  258. package/src/token-source.ts +189 -0
  259. package/src/validate.ts +10 -0
@@ -21,6 +21,7 @@ import * as activitiesModule from "../../op/activities";
21
21
  import { ClickHouseRebuildOp, type ClickHouseRebuildOpConfig } from "./op";
22
22
  import { copiedColumns } from "./observe";
23
23
  import { partitionEffect } from "./backfill";
24
+ import { insertTarget, waitForCutover, type CutoverProbe } from "./steps";
24
25
  import { intoShard, onShard, onShardServers, shardTable } from "./shards";
25
26
  import { renameColumns, sourcePartitionExpression } from "./partitions";
26
27
  import { canonicalObject } from "../plan/normalize";
@@ -240,3 +241,56 @@ describe("a rebuild across the shards of a cluster (#3663)", () => {
240
241
  expect(onShardServers(shard2)).toBe("getMacro('shard') = 's2'");
241
242
  });
242
243
  });
244
+
245
+ describe("the wait for the cut-over (#3700)", () => {
246
+ const CUT = Date.parse("2026-10-10T12:00:05Z");
247
+ /** A probe on a fake clock that starts at `start` and moves with real time, and writes that finish after `writesFor` polls. */
248
+ function probe(start: number, writesFor: number): CutoverProbe & { polls: number } {
249
+ const t0 = Date.now();
250
+ const p = {
251
+ polls: 0,
252
+ now: async () => start + (Date.now() - t0),
253
+ pendingWrites: async (cutover: number) => {
254
+ expect(cutover).toBe(CUT);
255
+ p.polls++;
256
+ return p.polls <= writesFor ? ["insert-1"] : [];
257
+ },
258
+ };
259
+ return p;
260
+ }
261
+ const run = (lines: string[] = []) => ({ log: (l: string) => void lines.push(l) });
262
+
263
+ test("it ends as soon as the clock has passed the cut-over and no write begun before it is running", async () => {
264
+ const lines: string[] = [];
265
+ const p = probe(CUT - 300, 2);
266
+ const began = Date.now();
267
+ await waitForCutover(run(lines), CUT, p, { pollMs: 10 });
268
+ expect(Date.now() - began).toBeLessThan(2_000);
269
+ expect(p.polls).toBe(3);
270
+ expect(lines[0]).toMatch(/^-- waiting 1s for the cut-over at 2026-10-10T12:00:05.000Z/);
271
+ expect(lines.filter((l) => l.includes("insert-1"))).toHaveLength(1);
272
+ });
273
+
274
+ test("a quiet table past its cut-over does not wait at all", async () => {
275
+ const p = probe(CUT + 1, 0);
276
+ const began = Date.now();
277
+ await waitForCutover(run(), CUT, p);
278
+ expect(Date.now() - began).toBeLessThan(100);
279
+ expect(p.polls).toBe(1);
280
+ });
281
+
282
+ test("a write still running at the timeout stops the step, naming it", async () => {
283
+ await expect(waitForCutover(run(), CUT, probe(CUT + 1, Infinity), { timeoutMs: 50, pollMs: 10 })).rejects.toThrow(
284
+ /begun before the cut-over at 2026-10-10T12:00:05.000Z are still running after 0s: insert-1\. .*cutoverTimeout/,
285
+ );
286
+ });
287
+
288
+ test("the table an INSERT writes to", () => {
289
+ expect(insertTarget("INSERT INTO shop.events VALUES (1)", "default")).toEqual({ database: "shop", name: "events" });
290
+ expect(insertTarget(" /* app */ insert into `shop`.`events` (ts) FORMAT JSONEachRow", "default")).toEqual({ database: "shop", name: "events" });
291
+ expect(insertTarget("INSERT INTO TABLE events SELECT 1", "shop")).toEqual({ database: "shop", name: "events" });
292
+ expect(insertTarget('INSERT INTO "shop" . "events" VALUES', "default")).toEqual({ database: "shop", name: "events" });
293
+ expect(insertTarget("INSERT INTO FUNCTION remote('h', shop.events) VALUES (1)", "shop")).toBeUndefined();
294
+ expect(insertTarget("SELECT * FROM shop.events", "shop")).toBeUndefined();
295
+ });
296
+ });
@@ -42,6 +42,8 @@ export interface RebuildRun {
42
42
  mutationTimeoutMs?: number;
43
43
  /** In a Replicated database, how long a step waits for this replica to fetch what the others wrote. Default: two minutes. */
44
44
  replicaTimeoutMs?: number;
45
+ /** How long the backfill waits, once the cut-over has passed, for writes into the old table begun before it. Default: ten minutes. */
46
+ cutoverTimeoutMs?: number;
45
47
  log: (line: string) => void;
46
48
  signal?: AbortSignal;
47
49
  /** The run's id, recorded on the receipts it writes. */
@@ -181,7 +183,7 @@ export async function startDualWrite(run: RebuildRun): Promise<DualWriteResult>
181
183
  // On a whole second: a DateTime column holds whole seconds, and a row written
182
184
  // during the cut-over's second would otherwise carry a time before it and
183
185
  // reach the old table after the backfill has started.
184
- const cutover = Math.ceil(((await serverNow(run.target)) + parseDuration(run.dualWrite.cutoverDelay ?? "1m")) / 1000) * 1000;
186
+ const cutover = Math.ceil(((await serverNow(run.target)) + parseDuration(run.dualWrite.cutoverDelay ?? CUTOVER_DELAY)) / 1000) * 1000;
185
187
  const iso = new Date(cutover).toISOString();
186
188
  const select = o.copied.map((c) => (c.source === c.name ? ident(c.name) : `${ident(c.source)} AS ${ident(c.name)}`)).join(", ");
187
189
  const comment = stampedComment(`chant rebuild of ${o.names.key}: rows at or after the cut-over`, run.marker, working(o.names.key, "dual", { cutover: iso }));
@@ -193,6 +195,9 @@ export async function startDualWrite(run: RebuildRun): Promise<DualWriteResult>
193
195
  return { state: o.state, mode, cutover: iso, created: true };
194
196
  }
195
197
 
198
+ /** How far after the dual-write view is made the cut-over falls, unless `cutoverDelay` says otherwise. */
199
+ export const CUTOVER_DELAY = "5s";
200
+
196
201
  /** The cut-over in milliseconds, from the dual-write view; undefined in app mode. */
197
202
  export function cutoverOf(run: RebuildRun, o: RebuildObservation): number | undefined {
198
203
  if (run.dualWrite.mode === "app") return undefined;
@@ -201,6 +206,136 @@ export function cutoverOf(run: RebuildRun, o: RebuildObservation): number | unde
201
206
  return Date.parse(iso);
202
207
  }
203
208
 
209
+ /**
210
+ * What the backfill waits on before it reads the rows before the cut-over.
211
+ * The default reads the server (`serverCutoverProbe`); a test passes its own.
212
+ */
213
+ export interface CutoverProbe {
214
+ /** The server's clock, in milliseconds. */
215
+ now(): Promise<number>;
216
+ /**
217
+ * The writes into the old table begun before `cutover` that have not
218
+ * finished: INSERTs still running, and asynchronous inserts still queued.
219
+ * Each is named by its query id.
220
+ */
221
+ pendingWrites(cutover: number): Promise<string[]>;
222
+ }
223
+
224
+ /** How long the backfill waits, once the cut-over has passed, for the writes begun before it. Default: ten minutes. */
225
+ export const CUTOVER_TIMEOUT_MS = 10 * 60_000;
226
+
227
+ /**
228
+ * Wait until no row before the cut-over is still to arrive in the old table,
229
+ * then return. Two conditions, both polled: the server's clock has passed
230
+ * the cut-over (until then a row can still be written with a time before
231
+ * it), and every write into the old table that began before the cut-over has
232
+ * finished (a long INSERT, or an asynchronous insert still in its buffer,
233
+ * can carry such rows after the clock has passed). The second wait is
234
+ * bounded by `timeoutMs`; past it the step stops naming the writes, rather
235
+ * than copy without their rows. A row whose time lags further behind its
236
+ * write than `cutoverDelay` is neither: the verification finds it.
237
+ */
238
+ export async function waitForCutover(
239
+ run: Pick<RebuildRun, "log" | "signal">,
240
+ cutover: number,
241
+ probe: CutoverProbe,
242
+ opts: { timeoutMs?: number; pollMs?: number } = {},
243
+ ): Promise<void> {
244
+ const at = new Date(cutover).toISOString();
245
+ for (;;) {
246
+ const now = await probe.now();
247
+ if (now > cutover) break;
248
+ run.log(`-- waiting ${Math.ceil((cutover - now) / 1000)}s for the cut-over at ${at}`);
249
+ await sleep(Math.min(cutover - now + 50, 10_000), run.signal);
250
+ }
251
+ const timeoutMs = opts.timeoutMs ?? CUTOVER_TIMEOUT_MS;
252
+ const deadline = Date.now() + timeoutMs;
253
+ let pollMs = opts.pollMs ?? 100;
254
+ let logged = "";
255
+ for (;;) {
256
+ const pending = await probe.pendingWrites(cutover);
257
+ if (pending.length === 0) return;
258
+ if (Date.now() >= deadline) {
259
+ throw new RebuildRefusal(
260
+ `writes into the old table begun before the cut-over at ${at} are still running after ${Math.round(timeoutMs / 1000)}s: ${pending.join(", ")}. ` +
261
+ `Let them finish, or stop them, and run again; cutoverTimeout sets how long the backfill waits.`,
262
+ );
263
+ }
264
+ const line = pending.join(", ");
265
+ if (line !== logged) run.log(`-- waiting for writes begun before the cut-over at ${at}: ${line}`);
266
+ logged = line;
267
+ await sleep(pollMs, run.signal);
268
+ pollMs = Math.min(pollMs * 2, 2_000);
269
+ }
270
+ }
271
+
272
+ /** The table an `INSERT INTO` statement writes to, or undefined for anything else (`INSERT INTO FUNCTION` included). */
273
+ export function insertTarget(query: string, currentDatabase: string): { database: string; name: string } | undefined {
274
+ const text = query.replace(/^(?:\s+|--[^\n]*\n|\/\*[\s\S]*?\*\/)*/, "");
275
+ const part = "(`(?:[^`\\\\]|\\\\.)+`|\"(?:[^\"\\\\]|\\\\.)+\"|[A-Za-z_][A-Za-z0-9_]*)";
276
+ const m = new RegExp(`^INSERT\\s+INTO\\s+(?:TABLE\\s+)?${part}(?:\\s*\\.\\s*${part})?`, "i").exec(text);
277
+ if (!m || /^FUNCTION$/i.test(m[1]!)) return undefined;
278
+ const unquote = (s: string) => (/^[`"]/.test(s) ? s.slice(1, -1).replace(/\\(.)/g, "$1") : s);
279
+ return m[2] !== undefined ? { database: unquote(m[1]!), name: unquote(m[2]) } : { database: currentDatabase, name: unquote(m[1]!) };
280
+ }
281
+
282
+ /**
283
+ * The probe against the server. On a cluster of shards it reads every
284
+ * server's queries and buffers, and in a Replicated database every
285
+ * replica's, since a write can be running on any of them. A server that
286
+ * cannot be reached is left out rather than failing the wait.
287
+ */
288
+ export function serverCutoverProbe(run: RebuildRun, o: Pick<RebuildObservation, "names" | "replicated">): CutoverProbe {
289
+ const { database, name } = o.names;
290
+ const cluster = run.sharding?.cluster ?? (o.replicated ? database : undefined);
291
+ const from = (table: string) => (cluster ? `clusterAllReplicas(${sqlString(cluster)}, system.${table})` : `system.${table}`);
292
+ // A server that cannot be reached runs no write the backfill could miss
293
+ // from here: its parts reach this one by replication, which the backfill
294
+ // waits for (`syncReplica`), or the verification finds them missing. So an
295
+ // unreachable replica or shard is skipped, as every other step goes on
296
+ // with the live ones; `clusterAllReplicas` counts each replica as a shard.
297
+ const opts = { ...(cluster ? { settings: { skip_unavailable_shards: "1" } } : {}), ...(run.signal ? { signal: run.signal } : {}) };
298
+ return {
299
+ now: () => serverNow(run.target),
300
+ async pendingWrites(cutover) {
301
+ const running = await clickhouseQuery<{ query_id: string; query: string; current_database: string; elapsed: number; now: string | number }>(
302
+ run.target.endpoint,
303
+ `SELECT query_id, query, current_database, elapsed, toUnixTimestamp64Milli(now64(3)) AS now FROM ${from("processes")} WHERE query_kind = 'Insert'`,
304
+ opts,
305
+ );
306
+ const queued = await clickhouseQuery<{ ids: string[] }>(
307
+ run.target.endpoint,
308
+ `SELECT entries.query_id AS ids FROM ${from("asynchronous_inserts")} ` +
309
+ `WHERE database = ${sqlString(database)} AND table = ${sqlString(name)} AND toUnixTimestamp64Milli(first_update) < ${cutover}`,
310
+ opts,
311
+ );
312
+ const ids = running
313
+ .filter((r) => {
314
+ const t = insertTarget(r.query, r.current_database);
315
+ return t?.database === database && t.name === name && Number(r.now) - Number(r.elapsed) * 1000 < cutover;
316
+ })
317
+ .map((r) => r.query_id);
318
+ for (const r of queued) ids.push(...r.ids);
319
+ return [...new Set(ids)];
320
+ },
321
+ };
322
+ }
323
+
324
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
325
+ return new Promise((resolve, reject) => {
326
+ signal?.throwIfAborted();
327
+ const t = setTimeout(resolve, ms);
328
+ signal?.addEventListener(
329
+ "abort",
330
+ () => {
331
+ clearTimeout(t);
332
+ reject(signal.reason);
333
+ },
334
+ { once: true },
335
+ );
336
+ });
337
+ }
338
+
204
339
  // ── swap ───────────────────────────────────────────────────────────────
205
340
 
206
341
  export interface SwapResult {
@@ -52,6 +52,8 @@ export async function fakeClickHouse(objects: FakeObject[], options: FakeOptions
52
52
  .join(""),
53
53
  );
54
54
  }
55
+ // No SQL user-defined functions.
56
+ if (/FROM system\.functions/.test(body)) return ok("");
55
57
  const show = /^SHOW CREATE (DATABASE|TABLE) (`(?:[^`]|``)*`)(?:\.(`(?:[^`]|``)*`))?$/.exec(body.trim());
56
58
  if (show) {
57
59
  const [db, name] = show[3] ? [unquote(show[2]!), unquote(show[3])] : [undefined, unquote(show[2]!)];
@@ -79,6 +79,8 @@ export async function writableClickHouse(initial: StoredObject[] = []): Promise<
79
79
  .join(""),
80
80
  };
81
81
  }
82
+ // No SQL user-defined functions.
83
+ if (/FROM system\.functions/.test(sql)) return { status: 200, text: "" };
82
84
  if (/FROM system\.mutations/.test(sql)) {
83
85
  const m = /database = '([^']*)' AND table = '([^']*)'/.exec(sql);
84
86
  return {
@@ -331,9 +331,10 @@ function renderCreate(tokens: Token[], node: CreateNode, topology: Topology): st
331
331
  return applyEdits(tokens, edits);
332
332
  }
333
333
 
334
- const cluster = createClusterEdit(tokens, node, objectCluster(topology));
334
+ // A function belongs to no database, so in the replicated topology it reaches every replica only through the cluster.
335
+ const cluster = createClusterEdit(tokens, node, node.statement === "function" ? databaseCluster(topology, true) : objectCluster(topology));
335
336
  if (cluster) edits.push(cluster);
336
- if (node.engine) {
337
+ if ((node.statement === "table" || node.statement === "view") && node.engine) {
337
338
  const declared: EngineClause = { name: node.engine.name, ...(node.engine.args ? { args: node.engine.args.map((a) => text(tokens, a)) } : {}) };
338
339
  const rendered = renderEngine(declared, topology);
339
340
  if (engineText(rendered) !== engineText(declared)) {
@@ -350,6 +351,13 @@ function renderOtherCreate(tokens: Token[], topology: Topology): string {
350
351
  w.accept("CREATE");
351
352
  if (w.accept("OR")) w.accept("REPLACE");
352
353
  w.accept("TEMPORARY");
354
+ // Access control (`./access.ts`) belongs to no database: like a database's own DDL, it reaches every replica through the cluster.
355
+ if (w.accept("USER", "ROLE", "POLICY", "QUOTA") || w.acceptSeq("ROW", "POLICY") || w.acceptSeq("SETTINGS", "PROFILE")) {
356
+ w.acceptSeq("IF", "NOT", "EXISTS") || w.acceptSeq("OR", "REPLACE");
357
+ if (!w.name()) return applyEdits(tokens, []);
358
+ const edit = onClusterEdit(w, databaseCluster(topology, true), w.end());
359
+ return applyEdits(tokens, edit ? [edit] : []);
360
+ }
353
361
  let database = false;
354
362
  if (w.accept("DATABASE")) database = true;
355
363
  else if (w.accept("MATERIALIZED", "LIVE", "WINDOW")) w.accept("VIEW");
@@ -421,10 +429,17 @@ export function renderStatement(sql: string, topology: Topology): string {
421
429
  w.p++;
422
430
  if (word(head, "DROP", "DETACH", "ATTACH")) w.accept("TEMPORARY");
423
431
  if (w.accept("DATABASE")) return renderAfterName(tokens, w, databaseCluster(topology, word(head, "DROP")));
432
+ // A function and access control belong to no database: like the database's own DDL, they reach every replica through the cluster.
433
+ if (w.accept("FUNCTION", "USER", "ROLE", "POLICY") || w.acceptSeq("ROW", "POLICY")) return renderAfterName(tokens, w, databaseCluster(topology, true));
424
434
  // `TRUNCATE t` may leave out TABLE.
425
435
  if (w.accept("TABLE", "VIEW", "DICTIONARY") || word(head, "TRUNCATE")) return renderAfterName(tokens, w, objectCluster(topology));
426
436
  return sql;
427
437
  }
438
+ if (word(head, "GRANT", "REVOKE")) {
439
+ w.p++;
440
+ const edit = onClusterEdit(w, databaseCluster(topology, true), w.end());
441
+ return applyEdits(tokens, edit ? [edit] : []);
442
+ }
428
443
  if (word(head, "RENAME")) {
429
444
  w.p++;
430
445
  if (w.accept("DATABASE")) return renderAtEnd(tokens, w, databaseCluster(topology));
package/src/clickhouse.ts CHANGED
@@ -12,6 +12,12 @@ export {
12
12
  database,
13
13
  table,
14
14
  view,
15
+ dictionary,
16
+ func,
17
+ user,
18
+ role,
19
+ policy,
20
+ grant,
15
21
  literal,
16
22
  SqlLiteral,
17
23
  SqlTemplateError,
@@ -22,6 +28,19 @@ export {
22
28
  quoteIdentifier,
23
29
  type ClickHouseEntityType,
24
30
  type ClickHouseDatabase,
31
+ type ClickHouseDictionary,
32
+ type ClickHouseFunction,
33
+ type FunctionProps,
34
+ type ClickHouseUser,
35
+ type ClickHouseRole,
36
+ type ClickHouseRowPolicy,
37
+ type ClickHouseGrant,
38
+ type UserProps,
39
+ type RoleProps,
40
+ type RowPolicyProps,
41
+ type GrantProps,
42
+ type DictionaryAttributeDef,
43
+ type DictionaryProps,
25
44
  type ClickHouseRelation,
26
45
  type ClickHouseTable,
27
46
  type ClickHouseView,
@@ -47,6 +47,8 @@ chant sql plan prod schema.json # a build output against the prod server
47
47
 
48
48
  Both exit 2 when a change needs a rebuild. \`chant sql diff\` compares a pull request's base and head builds with no server. \`chant sql plan\` reads the server \`sql.profiles.<env>\` binds, and asks that server's formatter about any expression the normalization rules leave different, so the server's own rewriting (\`INTERVAL 1 DAY\` as \`toIntervalDay(1)\`, \`a+b*2\` as \`a + (b * 2)\`) is not reported as a change.
49
49
 
50
+ \`chant sql plan\` also exits 2 when a declared database holds an object whose definition it cannot read. It names each one under \`Refused\` (\`unreadable\` in \`--json\`) instead of leaving it out. Dictionaries are read and planned like the other objects: any change but a comment replaces one (SQLCH245).
51
+
50
52
  ## After the plan
51
53
 
52
54
  The plan only reports. [\`clickhouseApply\`](../applying/) makes the metadata-only and background-rewrite changes, and refuses a rebuild the same way the plan does, sending nothing for that object. A rebuild runs as \`ClickHouseRebuildOp\`, a gated migration Op that creates the new table, backfills it, verifies it and swaps it in: see [Rebuilding a Table](../rebuild/). Both commands print the Op declaration to start from for each refused table.
@@ -17,6 +17,12 @@ const KINDS: Record<string, string> = {
17
17
  Table: CLICKHOUSE_ENTITY_TYPES.table,
18
18
  View: CLICKHOUSE_ENTITY_TYPES.view,
19
19
  MaterializedView: CLICKHOUSE_ENTITY_TYPES.materializedView,
20
+ Dictionary: CLICKHOUSE_ENTITY_TYPES.dictionary,
21
+ Function: CLICKHOUSE_ENTITY_TYPES.function,
22
+ User: CLICKHOUSE_ENTITY_TYPES.user,
23
+ Role: CLICKHOUSE_ENTITY_TYPES.role,
24
+ RowPolicy: CLICKHOUSE_ENTITY_TYPES.rowPolicy,
25
+ Grant: CLICKHOUSE_ENTITY_TYPES.grant,
20
26
  PostgresSchema: POSTGRES_ENTITY_TYPES.schema,
21
27
  PostgresTable: POSTGRES_ENTITY_TYPES.table,
22
28
  PostgresIndex: POSTGRES_ENTITY_TYPES.index,
@@ -29,6 +35,10 @@ const KINDS: Record<string, string> = {
29
35
  PostgresFunction: POSTGRES_ENTITY_TYPES.function,
30
36
  PostgresProcedure: POSTGRES_ENTITY_TYPES.procedure,
31
37
  PostgresTrigger: POSTGRES_ENTITY_TYPES.trigger,
38
+ PostgresPolicy: POSTGRES_ENTITY_TYPES.policy,
39
+ PostgresRole: POSTGRES_ENTITY_TYPES.role,
40
+ PostgresGrant: POSTGRES_ENTITY_TYPES.grant,
41
+ PostgresDefaultPrivileges: POSTGRES_ENTITY_TYPES.defaultPrivileges,
32
42
  };
33
43
 
34
44
  export function buildRegistry(): string {
package/src/config.ts CHANGED
@@ -20,7 +20,8 @@
20
20
  * A profile is keyed by chant environment, as `grafana.profiles.<env>` is:
21
21
  * the server `chant import --from <env>`, `chant lifecycle diff <env> --live`
22
22
  * and `plan` read. Credentials are named by their environment variable, never
23
- * written in the config. An environment with no profile falls back to
23
+ * written in the config, or minted as a short-lived token (`password:
24
+ * { token: "rds-iam" }`; `./token-source.ts`). An environment with no profile falls back to
24
25
  * `CLICKHOUSE_URL`, `CLICKHOUSE_USER` and `CLICKHOUSE_PASSWORD`, or for
25
26
  * Postgres `POSTGRES_URL`, `POSTGRES_USER` and `POSTGRES_PASSWORD`. A profile
26
27
  * whose `url` is `postgres://` or `postgresql://` is a Postgres server.
@@ -46,6 +47,7 @@ import { PLANNED_SQL_DIALECTS, SQL_DIALECTS } from "./dialects";
46
47
  import { POSTGRES_PROVIDERS } from "./postgres/providers/types";
47
48
  import { POSTGRES_MAJORS } from "./spec/postgres-pin";
48
49
  import { parseTopology } from "./clickhouse/topology";
50
+ import { tokenSourceSchema } from "./token-source";
49
51
 
50
52
  const envRef = z.strictObject({ env: z.string() });
51
53
 
@@ -85,8 +87,15 @@ export const sqlProfileSchema = z.strictObject({
85
87
  url: z.string(),
86
88
  /** The user, named by its environment variable. `default` when omitted. */
87
89
  user: envRef.optional(),
88
- /** The password, named by its environment variable. */
89
- password: envRef.optional(),
90
+ /**
91
+ * The password, named by its environment variable, or a token source that
92
+ * mints a short-lived one when a connection needs it (#3685):
93
+ * `{ token: "rds-iam" }`, `{ token: "cloud-sql-iam" }`, `{ token: "entra" }`
94
+ * (Postgres), or `{ token: "command", command: ["./mint.sh"] }`, whose
95
+ * standard output is the password. A token is minted again before it
96
+ * expires. See `./token-source.ts`.
97
+ */
98
+ password: z.union([envRef, tokenSourceSchema]).optional(),
90
99
  /**
91
100
  * The databases this environment's schema lives in. Import reads these and
92
101
  * nothing else; when omitted, every database except the server's own
@@ -146,6 +155,16 @@ export const sqlProfileSchema = z.strictObject({
146
155
  * `CONCURRENTLY` index build), in milliseconds. 0, no limit, when omitted.
147
156
  */
148
157
  scanTimeoutMs: z.number().int().nonnegative().optional(),
158
+ /**
159
+ * Postgres: whether chant manages access in this environment (#3681): the
160
+ * declared row-level security policies and each table's row-level security,
161
+ * the declared roles (created when missing, never dropped), and the
162
+ * privileges on the declared objects and the default privileges, which a
163
+ * plan compares and an apply grants and revokes. Off when omitted: the
164
+ * access declarations are not planned or applied, and the server's access
165
+ * is not read, so a project that manages access elsewhere is not affected.
166
+ */
167
+ access: z.boolean().optional(),
149
168
  });
150
169
 
151
170
  const dialectName = z.enum(SQL_DIALECTS);
@@ -45,14 +45,16 @@ export function lineageJson(edges: readonly LineageEdge[], names: Map<Declarable
45
45
  }
46
46
 
47
47
  /**
48
- * Export names in creation order: an object after everything it references.
48
+ * Export names in creation order: an object after everything it references,
49
+ * and after the names in `extra` (an object read from a `.sql` file that a
50
+ * template names as text, `../sql-files.ts`).
49
51
  * Ties break by export name, so the order is the same however the files were
50
52
  * discovered. A reference cycle is an error naming the cycle.
51
53
  */
52
- export function applyOrder(objects: Map<string, Referencing>, names: Map<Declarable, string>): string[] {
54
+ export function applyOrder(objects: Map<string, Referencing>, names: Map<Declarable, string>, extra?: ReadonlyMap<string, readonly string[]>): string[] {
53
55
  const deps = new Map<string, string[]>();
54
56
  for (const [name, obj] of objects) {
55
- const on = new Set<string>();
57
+ const on = new Set<string>(extra?.get(name)?.filter((d) => d !== name && objects.has(d)) ?? []);
56
58
  for (const ref of obj.dependsOn) {
57
59
  const parent = isAttrRefLike(ref) ? (ref.parent.deref() as Declarable | undefined) : (ref as Declarable);
58
60
  const dep = parent ? names.get(parent) : undefined;
@@ -48,6 +48,14 @@ export interface StatementStep extends StepObject {
48
48
  waitsForMutation?: boolean;
49
49
  /** The statement removes data that is not recoverable (a column or table drop). */
50
50
  destructive?: boolean;
51
+ /**
52
+ * Postgres: the statement can fail on the rows already there (a NULL under
53
+ * SET NOT NULL, a duplicate under a unique index, a row a check or foreign
54
+ * key refuses). `sql` returns one row with one column, `n`, the count of
55
+ * rows it would fail on; run it before the first statement and stop when
56
+ * it is not zero (#3686).
57
+ */
58
+ precheck?: { sql: string; detail: string };
51
59
  }
52
60
 
53
61
  /** A change no statement makes in place, made by a migration Op. */
@@ -118,7 +126,9 @@ export function renderStatements(doc: SchemaStatements): string {
118
126
  for (const s of doc.steps) {
119
127
  if (s.kind === "statement") {
120
128
  const notes = [s.transactional || doc.dialect !== "postgres" ? "" : "outside a transaction", s.waitsForMutation ? "waits for its mutation" : "", s.destructive ? "destructive" : ""].filter(Boolean);
121
- out.push(`-- ${s.object} (${s.name}): ${s.rule} ${s.class}${notes.length ? `, ${notes.join(", ")}` : ""}`, `${s.sql};`, "");
129
+ out.push(`-- ${s.object} (${s.name}): ${s.rule} ${s.class}${notes.length ? `, ${notes.join(", ")}` : ""}`);
130
+ if (s.precheck) out.push(`-- pre-check, must return 0 (${s.precheck.detail}): ${s.precheck.sql}`);
131
+ out.push(`${s.sql};`, "");
122
132
  } else if (s.kind === "op") {
123
133
  out.push(`-- ${s.object} (${s.name}): ${s.changes.map((c) => c.rule).join(", ")} made by ${s.op}, not a statement:`, `-- ${s.declaration}`, "");
124
134
  } else {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The `.sql` files in a project's source directory, read into declarations
3
+ * at build time (the plugin's `buildRoots` hook): each file's objects join
4
+ * the tagged templates' as if an import had written them, with the
5
+ * references between the two as interpolations (`../sql-files.ts`).
6
+ *
7
+ * The files are found by the walk source discovery uses, so `dist`,
8
+ * git-ignored paths, child projects and the project's `exclude` globs are
9
+ * left out. A file whose first lines carry `-- chant-discovery-skip` (or the
10
+ * `/* chant-discovery-skip *\/` form) is not read, for SQL that is not schema:
11
+ * seed data, a migration, a query.
12
+ *
13
+ * The dialect is the declarations': a project whose templates are Postgres
14
+ * reads its files as Postgres. With no templates it is `sql.dialect`, and
15
+ * ClickHouse when that is unset, as for the rest of the lexicon.
16
+ */
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { relative, resolve } from "node:path";
20
+ import type { Declarable } from "@intentius/chant/declarable";
21
+ import type { BuildRootContext, BuildRootContribution } from "@intentius/chant/lexicon";
22
+ import { findSourceFiles, hasDiscoveryMarkerSync } from "@intentius/chant/discovery/files";
23
+ import { readHead } from "@intentius/chant/discovery/walk";
24
+ import { SQL_DIALECTS, type SqlDialect } from "../dialects";
25
+ import { isPostgresObject } from "../postgres/entities";
26
+ import { isClickHouseObject } from "../clickhouse/entities";
27
+ import { sqlFileEntities } from "../sql-files";
28
+
29
+ /** `-- chant-discovery-skip` on a line of its own, near the top. */
30
+ const SQL_SKIP_MARKER = /^[ \t]*--[ \t]*chant-discovery-skip(?![\w-])/m;
31
+
32
+ /** Whether a file is a `.sql` file to read: the name, and no skip or generated marker. */
33
+ export function isSqlSourceFile(name: string, full: string): boolean {
34
+ if (!name.endsWith(".sql")) return false;
35
+ if (hasDiscoveryMarkerSync(full)) return false;
36
+ return !SQL_SKIP_MARKER.test(readHead(full, 1024));
37
+ }
38
+
39
+ /** The dialect the project's `.sql` files are read as. */
40
+ export function filesDialect(entities: ReadonlyMap<string, Declarable> | undefined, config: Record<string, unknown>): SqlDialect {
41
+ for (const e of entities?.values() ?? []) {
42
+ if (isPostgresObject(e)) return "postgres";
43
+ if (isClickHouseObject(e)) return "clickhouse";
44
+ }
45
+ const dialect = (config.sql as { dialect?: unknown } | undefined)?.dialect;
46
+ if (typeof dialect === "string" && (SQL_DIALECTS as readonly string[]).includes(dialect)) return dialect as SqlDialect;
47
+ if (Array.isArray(dialect)) {
48
+ if (dialect.length === 1 && (SQL_DIALECTS as readonly string[]).includes(String(dialect[0]))) return dialect[0] as SqlDialect;
49
+ throw new Error(`sql: the project's .sql files hold no declaration that says their dialect, and sql.dialect lists ${dialect.join(", ")}; set sql.dialect to one, or declare one object with a tagged template`);
50
+ }
51
+ return "clickhouse";
52
+ }
53
+
54
+ /** The `buildRoots` contribution: every `.sql` file's declarations, or nothing when the source directory holds none. */
55
+ export async function sqlFilesBuildRoot(ctx: BuildRootContext): Promise<BuildRootContribution> {
56
+ const sourceDir = ctx.sourceDir ?? resolve(ctx.projectRoot, typeof ctx.config.sourceDir === "string" ? ctx.config.sourceDir : ".");
57
+ const files = (await findSourceFiles(sourceDir, isSqlSourceFile)).sort();
58
+ if (files.length === 0) return { entities: new Map() };
59
+ const dialect = filesDialect(ctx.entities, ctx.config);
60
+ const sources = files.map((f) => ({ origin: relative(ctx.projectRoot, f) || f, ddl: readFileSync(f, "utf8") }));
61
+ return { entities: sqlFileEntities(dialect, sources, { known: ctx.entities ?? new Map() }) };
62
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A file of ClickHouse DDL as the objects it declares: one per `CREATE
3
+ * DATABASE`, `TABLE`, `VIEW`, `MATERIALIZED VIEW`, `DICTIONARY` or `FUNCTION`, as `chant import
4
+ * <file>.sql` reads them. Any other statement, and any statement the parser
5
+ * refuses, is a problem naming it.
6
+ *
7
+ * With `schema` (a database), an object's unqualified name is qualified with
8
+ * it; a function belongs to no database and keeps its name.
9
+ */
10
+
11
+ import type { Declarable } from "@intentius/chant/declarable";
12
+ import { isTrivia, tokenizeText } from "../clickhouse/tokens";
13
+ import { parseCreate } from "../clickhouse/parser";
14
+ import { database, dictionary, func, table, view } from "../clickhouse/entities";
15
+ import { CLICKHOUSE_TAG_OF } from "../clickhouse/import/generator";
16
+ import { describeStatement, type ImportedObject } from "../clickhouse/import/ir";
17
+ import { firstLine, quoteBare, splitTokens, tokensText, type ReadOptions } from "./common";
18
+
19
+ type Tag = (strings: TemplateStringsArray, ...values: unknown[]) => Declarable;
20
+ const TAGS: Record<string, Tag> = { database, table, view, dictionary, func };
21
+
22
+ /** The tag that declares an entity type. */
23
+ export function clickhouseTag(type: string): Tag | undefined {
24
+ const name = CLICKHOUSE_TAG_OF[type];
25
+ return name ? TAGS[name] : undefined;
26
+ }
27
+
28
+ /** The objects a file of ClickHouse DDL declares. Problems are collected, not thrown, so a file is reported whole. */
29
+ export function readClickHouse(ddl: string, options: ReadOptions, problems: string[]): ImportedObject[] {
30
+ const objects: ImportedObject[] = [];
31
+ for (const stmt of splitTokens(tokenizeText(ddl, 0), isTrivia)) {
32
+ let sql = tokensText(stmt);
33
+ if (!/^CREATE\b/i.test(sql)) {
34
+ problems.push(`not a CREATE statement: ${firstLine(sql)}`);
35
+ continue;
36
+ }
37
+ try {
38
+ const tokens = tokenizeText(sql, 0);
39
+ const node = parseCreate(tokens);
40
+ const name = tokens.slice(node.name.from, node.name.to).filter((t) => !isTrivia(t));
41
+ const [only] = name;
42
+ if (options.schema && node.statement !== "database" && node.statement !== "function" && only && name.length === 1) {
43
+ const at = tokens.indexOf(only);
44
+ sql = tokensText(tokens.slice(0, at)) + `${quoteBare(options.schema)}.` + tokensText(tokens.slice(at));
45
+ }
46
+ objects.push(describeStatement(sql));
47
+ } catch (e) {
48
+ problems.push(`does not parse (${e instanceof Error ? e.message : String(e)}): ${firstLine(sql)}`);
49
+ }
50
+ }
51
+ return objects;
52
+ }
@@ -0,0 +1,54 @@
1
+ /** What reading a file of DDL shares across dialects. */
2
+
3
+ import type { Token } from "../core/tokens";
4
+
5
+ export interface ReadOptions {
6
+ /** Names the DDL in errors: a file's path, a command. */
7
+ origin: string;
8
+ /**
9
+ * The Postgres schema (ClickHouse database) for the names the DDL leaves
10
+ * unqualified, as an ORM does when the schema comes from its connection.
11
+ */
12
+ schema?: string;
13
+ }
14
+
15
+ /** A file of DDL with statements that declare nothing chant can hold: each problem names its statement. */
16
+ export class SqlFileError extends Error {
17
+ constructor(
18
+ readonly origin: string,
19
+ readonly problems: readonly string[],
20
+ ) {
21
+ super(
22
+ `${origin}: ${problems.length === 1 ? "a statement" : `${String(problems.length)} statements`} chant cannot read as declarations:\n${problems.map((p) => ` - ${p}`).join("\n")}`,
23
+ );
24
+ this.name = "SqlFileError";
25
+ }
26
+ }
27
+
28
+ /** The statements of a file, each as its tokens (trivia kept inside, trimmed at the ends), split on top-level `;`. */
29
+ export function splitTokens(tokens: readonly Token[], isTrivia: (t: Token) => boolean): Token[][] {
30
+ const out: Token[][] = [];
31
+ let current: Token[] = [];
32
+ const flush = () => {
33
+ let from = 0;
34
+ let to = current.length;
35
+ while (from < to && isTrivia(current[from]!)) from++;
36
+ while (to > from && isTrivia(current[to - 1]!)) to--;
37
+ if (to > from) out.push(current.slice(from, to));
38
+ current = [];
39
+ };
40
+ for (const t of tokens) {
41
+ if (t.kind === "punct" && t.text === ";") flush();
42
+ else current.push(t);
43
+ }
44
+ flush();
45
+ return out;
46
+ }
47
+
48
+ export const tokensText = (tokens: readonly Token[]): string => tokens.map((t) => t.text).join("");
49
+
50
+ /** A statement's first line, for a message. */
51
+ export const firstLine = (s: string): string => (s.split("\n")[0] ?? "").slice(0, 100);
52
+
53
+ /** A name as SQL text: bare when it is a plain lower-case identifier, else double-quoted. */
54
+ export const quoteBare = (name: string): string => (/^[a-z_][a-z0-9_]*$/.test(name) ? name : `"${name.replace(/"/g, '""')}"`);