@intentius/chant-lexicon-sql 0.114.0 → 0.115.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 (77) hide show
  1. package/dist/clickhouse/apply/mutations.d.ts +7 -1
  2. package/dist/clickhouse/apply/mutations.d.ts.map +1 -1
  3. package/dist/clickhouse/container.d.ts +22 -7
  4. package/dist/clickhouse/container.d.ts.map +1 -1
  5. package/dist/clickhouse/plan/deep.d.ts +5 -0
  6. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  7. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  8. package/dist/clickhouse/rebuild/backfill.d.ts +28 -5
  9. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  10. package/dist/clickhouse/rebuild/observe.d.ts +4 -3
  11. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  12. package/dist/clickhouse/rebuild/op.d.ts +29 -0
  13. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  14. package/dist/clickhouse/rebuild/partitions.d.ts +8 -0
  15. package/dist/clickhouse/rebuild/partitions.d.ts.map +1 -1
  16. package/dist/clickhouse/rebuild/receipts.d.ts +4 -6
  17. package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
  18. package/dist/clickhouse/rebuild/shards.d.ts +59 -0
  19. package/dist/clickhouse/rebuild/shards.d.ts.map +1 -0
  20. package/dist/clickhouse/rebuild/steps.d.ts +8 -3
  21. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  22. package/dist/clickhouse/rebuild/verify.d.ts +45 -18
  23. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  24. package/dist/config.d.ts +14 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/core/receipts.d.ts +5 -3
  27. package/dist/core/receipts.d.ts.map +1 -1
  28. package/dist/integrity.json +2 -2
  29. package/dist/manifest.json +1 -1
  30. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  31. package/dist/op/activities/index.d.ts +13 -1
  32. package/dist/op/activities/index.d.ts.map +1 -1
  33. package/dist/op/activities/sql-exec.d.ts +41 -0
  34. package/dist/op/activities/sql-exec.d.ts.map +1 -0
  35. package/dist/op/activity-contracts.d.ts +20 -0
  36. package/dist/op/activity-contracts.d.ts.map +1 -1
  37. package/dist/postgres/live/catalog.d.ts +1 -1
  38. package/dist/postgres/live/catalog.d.ts.map +1 -1
  39. package/dist/postgres/migrate/receipts.d.ts +8 -5
  40. package/dist/postgres/migrate/receipts.d.ts.map +1 -1
  41. package/dist/postgres/plan/deep.d.ts.map +1 -1
  42. package/dist/receipts.d.ts +102 -0
  43. package/dist/receipts.d.ts.map +1 -0
  44. package/package.json +2 -2
  45. package/src/clickhouse/apply/mutations.ts +9 -3
  46. package/src/clickhouse/container.ts +39 -11
  47. package/src/clickhouse/plan/cluster-drift.e2e.test.ts +117 -0
  48. package/src/clickhouse/plan/deep.ts +102 -3
  49. package/src/clickhouse/plan/normalize.ts +10 -0
  50. package/src/clickhouse/plan/plan.test.ts +10 -0
  51. package/src/clickhouse/rebuild/backfill.ts +190 -26
  52. package/src/clickhouse/rebuild/observe.ts +4 -3
  53. package/src/clickhouse/rebuild/op.ts +80 -35
  54. package/src/clickhouse/rebuild/partitions.ts +11 -0
  55. package/src/clickhouse/rebuild/rebuild-sharded.e2e.test.ts +161 -0
  56. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +216 -5
  57. package/src/clickhouse/rebuild/rebuild.test.ts +56 -4
  58. package/src/clickhouse/rebuild/receipts.ts +4 -6
  59. package/src/clickhouse/rebuild/shards.ts +104 -0
  60. package/src/clickhouse/rebuild/steps.ts +18 -4
  61. package/src/clickhouse/rebuild/verify.ts +140 -36
  62. package/src/config.ts +7 -0
  63. package/src/core/receipts.ts +5 -3
  64. package/src/op/activities/clickhouse-rebuild.ts +32 -7
  65. package/src/op/activities/index.ts +21 -4
  66. package/src/op/activities/sql-exec.ts +84 -0
  67. package/src/op/activity-contracts.ts +15 -0
  68. package/src/op/effect-receipts.e2e.test.ts +277 -0
  69. package/src/postgres/live/catalog.ts +3 -1
  70. package/src/postgres/migrate/migrate.test.ts +3 -2
  71. package/src/postgres/migrate/receipts.ts +18 -10
  72. package/src/postgres/plan/deep.test.ts +76 -0
  73. package/src/postgres/plan/deep.ts +13 -2
  74. package/src/postgres/plan/plan.e2e.test.ts +26 -0
  75. package/src/receipts.test.ts +91 -0
  76. package/src/receipts.ts +259 -0
  77. package/src/skills/chant-sql-rebuild.md +5 -1
@@ -12,9 +12,9 @@ import type { ChantConfig } from "@intentius/chant/config";
12
12
  import { currentOpRun, parseDuration } from "@intentius/chant/op";
13
13
  import { bindClickHouse } from "../../clickhouse/live/bind";
14
14
  import { declaredObjects } from "../../clickhouse/apply/apply";
15
- import { declaredTable, observeRebuild, rebuildPlanDigest } from "../../clickhouse/rebuild/observe";
15
+ import { declaredTable, observeRebuild, rebuildPlanDigest, RebuildRefusal } from "../../clickhouse/rebuild/observe";
16
16
  import { backfill, type BackfillDeps, type BackfillResult } from "../../clickhouse/rebuild/backfill";
17
- import { verifyRebuild, type VerifyResult } from "../../clickhouse/rebuild/verify";
17
+ import { RebuildVerificationError, verifyRebuild, type VerifyResult } from "../../clickhouse/rebuild/verify";
18
18
  import {
19
19
  compensate,
20
20
  createNewTable,
@@ -32,6 +32,7 @@ import {
32
32
  } from "../../clickhouse/rebuild/steps";
33
33
  import type { ClickHouseRebuildArgs } from "../../clickhouse/rebuild/op";
34
34
  import { CLASSIFIER_RULES } from "../../clickhouse/plan/rules";
35
+ import { clusterSharding } from "../../clickhouse/rebuild/shards";
35
36
  import { resolveMarker } from "./clickhouse-apply";
36
37
 
37
38
  export type { ClickHouseRebuildArgs } from "../../clickhouse/rebuild/op";
@@ -61,6 +62,7 @@ export async function rebuildRun(args: ClickHouseRebuildArgs, signal: AbortSigna
61
62
  const target = await bindClickHouse({ ...(args.environment !== undefined ? { environment: args.environment } : {}), config: config ?? {}, ...(deps.env ? { env: deps.env } : {}) });
62
63
  const declared = declaredTable(declaredObjects(readFileSync(resolve(cwd, args.buildPath), "utf8"), target.defaultDatabase, target.topology), args.table);
63
64
  const runId = currentOpRun()?.runId;
65
+ const sharding = await clusterSharding(target);
64
66
  return {
65
67
  target,
66
68
  declared,
@@ -72,9 +74,28 @@ export async function rebuildRun(args: ClickHouseRebuildArgs, signal: AbortSigna
72
74
  log: deps.log ?? ((line: string) => console.log(line)),
73
75
  ...(signal ? { signal } : {}),
74
76
  ...(runId ? { runId } : {}),
77
+ ...(sharding ? { sharding } : {}),
75
78
  };
76
79
  }
77
80
 
81
+ /**
82
+ * With `onFailure: "keep"` (#3658) no onFailure drops the new table, so a
83
+ * step that fails says what a person does instead: a refusal or a failed
84
+ * verification would otherwise fail every run after it the same way.
85
+ */
86
+ async function noting<T>(args: ClickHouseRebuildArgs, fn: () => Promise<T>): Promise<T> {
87
+ try {
88
+ return await fn();
89
+ } catch (err) {
90
+ if (args.keepOnFailure && (err instanceof RebuildRefusal || err instanceof RebuildVerificationError)) {
91
+ err.message +=
92
+ ` This rebuild keeps its new table on failure (onFailure: "keep"), so nothing drops it: ` +
93
+ `drop ${args.table}__chant_new and ${args.table}__chant_dual by hand to start again from a new table.`;
94
+ }
95
+ throw err;
96
+ }
97
+ }
98
+
78
99
  export interface RebuildPlanResult {
79
100
  state: "rebuild" | "swapped" | "done";
80
101
  table: string;
@@ -87,6 +108,10 @@ export interface RebuildPlanResult {
87
108
 
88
109
  /** Plan: classify the table's change against the server and say how far the rebuild has got. */
89
110
  export async function clickhouseRebuildPlan(args: ClickHouseRebuildArgs, signal?: AbortSignal, deps: ClickHouseRebuildDeps = {}): Promise<RebuildPlanResult> {
111
+ return noting(args, () => plan(args, signal, deps));
112
+ }
113
+
114
+ async function plan(args: ClickHouseRebuildArgs, signal: AbortSignal | undefined, deps: ClickHouseRebuildDeps): Promise<RebuildPlanResult> {
90
115
  const run = await rebuildRun(args, signal, deps);
91
116
  const o = await observeRebuild(run.target, run.declared, run.marker);
92
117
  const changes = o.changes.map((c) => `${c.rule} ${c.field}${c.before !== undefined || c.after !== undefined ? `: ${c.before ?? "none"} -> ${c.after ?? "none"}` : ""}`);
@@ -103,23 +128,23 @@ export async function clickhouseRebuildPlan(args: ClickHouseRebuildArgs, signal?
103
128
  }
104
129
 
105
130
  export async function clickhouseRebuildCreate(args: ClickHouseRebuildArgs, signal?: AbortSignal, deps: ClickHouseRebuildDeps = {}): Promise<CreateResult> {
106
- return createNewTable(await rebuildRun(args, signal, deps));
131
+ return noting(args, async () => createNewTable(await rebuildRun(args, signal, deps)));
107
132
  }
108
133
 
109
134
  export async function clickhouseRebuildDualWrite(args: ClickHouseRebuildArgs, signal?: AbortSignal, deps: ClickHouseRebuildDeps = {}): Promise<DualWriteResult> {
110
- return startDualWrite(await rebuildRun(args, signal, deps));
135
+ return noting(args, async () => startDualWrite(await rebuildRun(args, signal, deps)));
111
136
  }
112
137
 
113
138
  export async function clickhouseRebuildBackfill(args: ClickHouseRebuildArgs, signal?: AbortSignal, deps: ClickHouseRebuildDeps = {}): Promise<BackfillResult> {
114
- return backfill(await rebuildRun(args, signal, deps), deps.backfill);
139
+ return noting(args, async () => backfill(await rebuildRun(args, signal, deps), deps.backfill));
115
140
  }
116
141
 
117
142
  export async function clickhouseRebuildVerify(args: ClickHouseRebuildArgs, signal?: AbortSignal, deps: ClickHouseRebuildDeps = {}): Promise<VerifyResult> {
118
- return verifyRebuild(await rebuildRun(args, signal, deps));
143
+ return noting(args, async () => verifyRebuild(await rebuildRun(args, signal, deps)));
119
144
  }
120
145
 
121
146
  export async function clickhouseRebuildSwap(args: ClickHouseRebuildArgs, signal?: AbortSignal, deps: ClickHouseRebuildDeps = {}): Promise<SwapResult> {
122
- return swapTables(await rebuildRun(args, signal, deps));
147
+ return noting(args, async () => swapTables(await rebuildRun(args, signal, deps)));
123
148
  }
124
149
 
125
150
  export async function clickhouseRebuildRetain(args: ClickHouseRebuildArgs, signal?: AbortSignal, deps: ClickHouseRebuildDeps = {}): Promise<RetainResult> {
@@ -4,8 +4,12 @@
4
4
  * applier (`clickhouseApply`, #3208), the Postgres applier (`postgresApply`,
5
5
  * #3280) and their envelope projection (`toApplyResult`, one for both), which
6
6
  * core's apply activity looks up by name for the `clickhouse` and `postgres`
7
- * targets, the emulator lifecycles, and the steps of the two migration Ops.
7
+ * targets, the emulator lifecycles, the steps of the two migration Ops, and
8
+ * the receipt store and SQL step of an Op of `effect()` batches.
8
9
  */
10
+ import type { ReceiptReadArgs, ReceiptStalenessArgs, ReceiptWriteArgs } from "@intentius/chant/op/receipt-store";
11
+ import { sqlReceiptActivities } from "../../receipts";
12
+
9
13
  export { clickhouseApply, toApplyResult, resolveMarker } from "./clickhouse-apply";
10
14
  export type { ClickHouseApplyArgs, ClickHouseApplyDeps, ClickHouseApplyOutcome } from "./clickhouse-apply";
11
15
  export { clickhouseUp, clickhouseDown, CLICKHOUSE_EMULATOR, CLICKHOUSE_EMULATOR_SPEC } from "./clickhouse-emulator";
@@ -13,11 +17,24 @@ export { postgresApply } from "./postgres-apply";
13
17
  export type { PostgresApplyArgs, PostgresApplyDeps, PostgresApplyOutcome } from "./postgres-apply";
14
18
  export { postgresUp, postgresDown, POSTGRES_EMULATOR, POSTGRES_EMULATOR_SPEC } from "./postgres-emulator";
15
19
 
20
+ // effect() steps (#3657): their receipts are kept on the environment's
21
+ // database server (../../receipts.ts, `chant_receipts.receipts`), and
22
+ // `sqlExec` runs a batch's SQL there. The receipt activities are fallbacks
23
+ // (core's ACTIVITY_FALLBACKS): those names are global to a run, and a project
24
+ // that also lists aws or k8s keeps its receipts in that lexicon's row.
25
+
26
+ /** The activities this module provides only when no other configured lexicon does. */
27
+ export const ACTIVITY_FALLBACKS: readonly string[] = ["receiptRead", "receiptWrite", "receiptStaleness"];
28
+ // Bound per call: the environment (`chant run --env`) and the project are read when a step runs.
29
+ export const receiptRead = (args: ReceiptReadArgs, signal?: AbortSignal) => sqlReceiptActivities().receiptRead(args, signal);
30
+ export const receiptWrite = (args: ReceiptWriteArgs, signal?: AbortSignal) => sqlReceiptActivities().receiptWrite(args, signal);
31
+ export const receiptStaleness = (args: ReceiptStalenessArgs, signal?: AbortSignal) => sqlReceiptActivities().receiptStaleness(args, signal);
32
+ export { sqlExec } from "./sql-exec";
33
+ export type { SqlExecArgs, SqlExecResult, SqlExecDeps } from "./sql-exec";
34
+
16
35
  // The rebuild migration (#3198): one activity per step of ClickHouseRebuildOp
17
36
  // (../../clickhouse/rebuild/op.ts). Its receipts are kept in ClickHouse and
18
- // reached by the backfill directly, so this module exports no receiptRead or
19
- // receiptWrite: those names are global to a run and belong to the receipt row
20
- // of a project's aws or k8s lexicon.
37
+ // reached by the backfill directly, with the copy's own identity.
21
38
  export {
22
39
  clickhouseRebuildPlan,
23
40
  clickhouseRebuildCreate,
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `sqlExec` (#3657): run SQL on the environment's database server, the step
3
+ * an `effect()` batch of a sql project is made of.
4
+ *
5
+ * effect(EffectReceipt("country-202601", { effect: "country/202601", flavor: "existence" }), [
6
+ * activity("sqlExec", { sql: "ALTER TABLE shop.orders UPDATE country = 'NL' WHERE toYYYYMM(ts) = 202601" }, "atMostOnce"),
7
+ * ])
8
+ *
9
+ * The server is the one `effect()`'s receipts are kept on
10
+ * (`../../receipts.ts`): `sql.profiles.<env>` for the environment
11
+ * `chant run --env` sets, else `CLICKHOUSE_URL` or `POSTGRES_URL`. `environment`
12
+ * in the arguments names another. On Postgres the SQL runs on a connection of
13
+ * its own with the role's default `search_path`, as one implicit transaction;
14
+ * on ClickHouse, `settings` are sent with the query.
15
+ */
16
+
17
+ import type { ChantConfig } from "@intentius/chant/config";
18
+ import { clickhouseQuery } from "../../clickhouse/http";
19
+ import { bindClickHouse } from "../../clickhouse/live/bind";
20
+ import { resolveBoundTarget } from "../../postgres/live/bind";
21
+ import { connectPostgres } from "../../postgres/live/client";
22
+ import { environmentOf, receiptDialect } from "../../receipts";
23
+
24
+ export interface SqlExecArgs {
25
+ /** The SQL to run: one statement on ClickHouse; one or more on Postgres. */
26
+ sql: string;
27
+ /** The chant environment whose server it runs on. Default: `CHANT_ENV`, then a literal `ownership.env`. */
28
+ environment?: string;
29
+ /** ClickHouse: query settings (`mutations_sync`, `max_execution_time`). */
30
+ settings?: Record<string, string | number>;
31
+ /** The project directory chant.config.ts is read from. Default: the working directory. */
32
+ cwd?: string;
33
+ }
34
+
35
+ export interface SqlExecResult {
36
+ dialect: "clickhouse" | "postgres";
37
+ /** Where the server binding came from: `sql.profiles.prod` or `env CLICKHOUSE_URL`. */
38
+ source: string;
39
+ /** Rows the statement returned (a `SELECT`); 0 for one that returns none. */
40
+ rows: number;
41
+ }
42
+
43
+ /** What a test may inject instead of the project's config and the process env. */
44
+ export interface SqlExecDeps {
45
+ config?: Pick<ChantConfig, "sql" | "ownership">;
46
+ env?: Record<string, string | undefined>;
47
+ log?: (line: string) => void;
48
+ }
49
+
50
+ async function loadConfig(cwd: string): Promise<Pick<ChantConfig, "sql" | "ownership"> | undefined> {
51
+ try {
52
+ const { loadChantConfig } = await import("@intentius/chant/config");
53
+ return (await loadChantConfig(cwd)).config;
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ export async function sqlExec(args: SqlExecArgs, signal?: AbortSignal, deps: SqlExecDeps = {}): Promise<SqlExecResult> {
60
+ if (typeof args?.sql !== "string" || args.sql.trim() === "") throw new Error('sqlExec: needs { sql: "<statement>" }');
61
+ signal?.throwIfAborted();
62
+ const env = deps.env ?? process.env;
63
+ const config = deps.config ?? (await loadConfig(args.cwd ?? process.cwd()));
64
+ const environment = environmentOf(args, config, env);
65
+ const log = deps.log ?? ((line: string) => console.log(line));
66
+ const where = environment !== undefined ? { environment } : {};
67
+ log(args.sql);
68
+ if (receiptDialect(config, environment, env) === "postgres") {
69
+ const target = await resolveBoundTarget({ ...where, config: config ?? {}, env });
70
+ const client = await connectPostgres(target.endpoint, { applicationName: "chant sqlExec" });
71
+ try {
72
+ // The catalog reader's empty search_path is not what a hand-written statement expects.
73
+ await client.query("RESET search_path");
74
+ const rows = await client.query(args.sql);
75
+ return { dialect: "postgres", source: target.source, rows: Array.isArray(rows) ? rows.length : 0 };
76
+ } finally {
77
+ await client.end();
78
+ }
79
+ }
80
+ const target = await bindClickHouse({ ...where, config: config ?? {}, env });
81
+ const settings = args.settings ? Object.fromEntries(Object.entries(args.settings).map(([k, v]) => [k, String(v)])) : undefined;
82
+ const rows = await clickhouseQuery(target.endpoint, args.sql, { ...(settings ? { settings } : {}), ...(signal ? { signal } : {}) });
83
+ return { dialect: "clickhouse", source: target.source, rows: rows.length };
84
+ }
@@ -34,6 +34,7 @@ const rebuildArgs = z.strictObject({
34
34
  stack: z.string().optional(),
35
35
  ownershipEnv: z.string().optional(),
36
36
  cwd: z.string().optional(),
37
+ keepOnFailure: z.boolean().optional(),
37
38
  });
38
39
 
39
40
  const state = z.enum(["rebuild", "swapped", "done"]);
@@ -208,3 +209,17 @@ export const postgresMigrationCompensateContract = activityContract(
208
209
  z.object({ dropped: z.array(z.string()) }),
209
210
  tableEntity,
210
211
  );
212
+
213
+ // ── effect() batches (#3657) ───────────────────────────────────────────
214
+
215
+ /** `SqlExecArgs` (`./activities/sql-exec.ts`): one batch's SQL on the environment's server. */
216
+ export const sqlExecContract = activityContract(
217
+ "sqlExec",
218
+ z.strictObject({
219
+ sql: z.string().min(1),
220
+ environment: z.string().optional(),
221
+ settings: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
222
+ cwd: z.string().optional(),
223
+ }),
224
+ z.object({ dialect: z.enum(["clickhouse", "postgres"]), source: z.string(), rows: z.number() }),
225
+ );
@@ -0,0 +1,277 @@
1
+ /**
2
+ * A project that uses only the sql lexicon runs an Op of `effect()` batches
3
+ * with `chant run`, and its receipts are kept on the environment's server
4
+ * (#3657), end to end on both dialects.
5
+ *
6
+ * For each dialect, a project in a temporary directory declares an Op of
7
+ * three batches. Each batch is an `effect()` whose steps run SQL with
8
+ * `sqlExec`: the batch records itself in a log table. The second batch fails
9
+ * while a stop table holds a row. The first `chant run --env admin` runs the
10
+ * first batch, fails on the second, and leaves one receipt. With the stop
11
+ * row deleted, the second run skips the first batch (the log holds it once),
12
+ * runs the other two, and leaves three receipts, in
13
+ * `chant_receipts.receipts` under `chant_e2e_3657/admin/`. A third run runs
14
+ * nothing.
15
+ *
16
+ * The test's own objects are in the database (ClickHouse) or schema
17
+ * (Postgres) `chant_e2e_3657`; its receipts are the rows of
18
+ * `chant_receipts.receipts` whose address starts `chant_e2e_3657/`, deleted
19
+ * afterwards.
20
+ *
21
+ * The servers: `CHANT_SQL_RECEIPTS_CLICKHOUSE_URL` and
22
+ * `CHANT_SQL_RECEIPTS_POSTGRES_URL` (a `postgres://user@host:port/db` URL, with
23
+ * `CHANT_SQL_RECEIPTS_POSTGRES_PASSWORD`) name running ones, such as the
24
+ * servers `chant emulator up --lexicon sql` starts. Without them, each dialect
25
+ * starts a throwaway server at the same pin, and skips cleanly when Docker is
26
+ * not available.
27
+ */
28
+
29
+ import { execFile } from "node:child_process";
30
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+ import { promisify } from "node:util";
34
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
35
+ import { dockerAvailable, startScratchServer, type ScratchServer } from "../clickhouse/container";
36
+ import { clickhouseQuery, type ClickHouseEndpoint } from "../clickhouse/http";
37
+ import { clickhouseImage } from "../spec/pin";
38
+ import { startTestPostgres, type TestPostgres } from "../postgres/testing/server";
39
+ import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../postgres/live/client";
40
+
41
+ const exec = promisify(execFile);
42
+
43
+ const REPO = join(import.meta.dirname, "../../../..");
44
+ const BIN = join(REPO, "node_modules/.bin");
45
+ const NAME = "chant_e2e_3657";
46
+ const OP = "fill-batches";
47
+ const ENV = "admin";
48
+ const PREFIX = `${NAME}/${ENV}/`;
49
+
50
+ const givenClickHouse = process.env.CHANT_SQL_RECEIPTS_CLICKHOUSE_URL;
51
+ const givenPostgres = process.env.CHANT_SQL_RECEIPTS_POSTGRES_URL;
52
+ const docker = givenClickHouse && givenPostgres ? false : await dockerAvailable();
53
+
54
+ interface Run {
55
+ code: number;
56
+ output: string;
57
+ }
58
+
59
+ const strip = (s: string) => s.replace(/\u001b\[[0-9;]*m/g, "");
60
+
61
+ /** A project directory chant can run in: the repository's `node_modules` linked in, and a git repository. */
62
+ async function project(files: Record<string, string>, env: Record<string, string>) {
63
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-receipts-"));
64
+ symlinkSync(join(REPO, "node_modules"), join(dir, "node_modules"), "dir");
65
+ for (const [path, text] of Object.entries({
66
+ "package.json": JSON.stringify({ name: "sql-receipts-e2e", private: true, type: "module" }),
67
+ ".gitignore": "node_modules\ndist\n.chant\n",
68
+ ...files,
69
+ })) {
70
+ mkdirSync(join(dir, path, ".."), { recursive: true });
71
+ writeFileSync(join(dir, path), text);
72
+ }
73
+ const git = (...args: string[]) => exec("git", ["-c", "user.email=e2e@chant.invalid", "-c", "user.name=e2e", ...args], { cwd: dir });
74
+ await git("init", "-q");
75
+ await git("add", "-A");
76
+ await git("commit", "-qm", "the op");
77
+ const chant = async (...args: string[]): Promise<Run> => {
78
+ try {
79
+ const r = await exec(join(BIN, "chant"), args, {
80
+ cwd: dir,
81
+ env: { ...process.env, ...env, PATH: `${BIN}:${process.env.PATH ?? ""}` },
82
+ maxBuffer: 16 * 1024 * 1024,
83
+ });
84
+ return { code: 0, output: strip(`${r.stdout}${r.stderr}`) };
85
+ } catch (err) {
86
+ const e = err as { code?: number; stdout?: string; stderr?: string };
87
+ return { code: typeof e.code === "number" ? e.code : 1, output: strip(`${e.stdout ?? ""}${e.stderr ?? ""}`) };
88
+ }
89
+ };
90
+ return { dir, chant };
91
+ }
92
+
93
+ const config = (sql: Record<string, unknown>) => `import type { ChantConfig } from "@intentius/chant";
94
+ import "@intentius/chant-lexicon-sql";
95
+
96
+ export default {
97
+ lexicons: ["sql"],
98
+ ownership: { stack: "${NAME}", env: "${ENV}" },
99
+ sql: ${JSON.stringify(sql, null, 2)},
100
+ } satisfies ChantConfig;
101
+ `;
102
+
103
+ /** The Op: three batches; each runs `stop` (which fails while the stop table holds a row) and then `record`. */
104
+ const opFile = (stop: string, record: (batch: string) => string) => `import { EffectReceipt } from "@intentius/chant";
105
+ import { Op, activity, effect, phase } from "@intentius/chant/op";
106
+
107
+ const batch = (b: string, steps: ReturnType<typeof activity>[]) =>
108
+ effect(EffectReceipt(\`fill-\${b}\`, { effect: \`fill/\${b}\`, flavor: "existence" }), steps);
109
+ const record = (b: string) => activity("sqlExec", { sql: ${JSON.stringify(record("__B__"))}.replace("__B__", b) }, "atMostOnce");
110
+
111
+ const op = Op({
112
+ name: "${OP}",
113
+ overview: "three batches, each with its receipt",
114
+ phases: [
115
+ phase("Fill", [
116
+ batch("b1", [record("b1")]),
117
+ batch("b2", [activity("sqlExec", { sql: ${JSON.stringify(stop)} }, "atMostOnce"), record("b2")]),
118
+ batch("b3", [record("b3")]),
119
+ ]),
120
+ ],
121
+ });
122
+ export default op;
123
+ `;
124
+
125
+ /**
126
+ * The shared scenario. `log` reads the batches the log table holds, in
127
+ * order; `receipts` the receipt addresses under the test's prefix; `unstop`
128
+ * deletes the stop row.
129
+ */
130
+ async function failThenResume(p: Awaited<ReturnType<typeof project>>, io: { log: () => Promise<string[]>; receipts: () => Promise<string[]>; unstop: () => Promise<void> }) {
131
+ const first = await p.chant("run", OP, "--env", ENV);
132
+ expect(first.code, first.output).not.toBe(0);
133
+ expect(await io.log()).toEqual(["b1"]);
134
+ expect(await io.receipts()).toEqual([`${PREFIX}fill/b1`]);
135
+
136
+ await io.unstop();
137
+ const second = await p.chant("run", OP, "--env", ENV);
138
+ expect(second.code, second.output).toBe(0);
139
+ // b1 ran once: the second run read its receipt and skipped it.
140
+ expect(await io.log()).toEqual(["b1", "b2", "b3"]);
141
+ expect(await io.receipts()).toEqual([`${PREFIX}fill/b1`, `${PREFIX}fill/b2`, `${PREFIX}fill/b3`]);
142
+
143
+ const third = await p.chant("run", OP, "--env", ENV);
144
+ expect(third.code, third.output).toBe(0);
145
+ expect(await io.log()).toEqual(["b1", "b2", "b3"]);
146
+ }
147
+
148
+ // ── ClickHouse ──────────────────────────────────────────────────────────────
149
+
150
+ describe.skipIf(!givenClickHouse && !docker)("effect() batches on ClickHouse keep their receipts in chant_receipts.receipts (#3657)", () => {
151
+ let server: ScratchServer | undefined;
152
+ let admin: ClickHouseEndpoint;
153
+ let p: Awaited<ReturnType<typeof project>>;
154
+ const q = <T = Record<string, unknown>>(sql: string) => clickhouseQuery<T>(admin, sql);
155
+ const forget = async () => {
156
+ const [t] = await q<{ n: string }>("SELECT count() AS n FROM system.tables WHERE database = 'chant_receipts' AND name = 'receipts'");
157
+ if (Number(t?.n ?? 0) > 0) await clickhouseQuery(admin, `ALTER TABLE chant_receipts.receipts DELETE WHERE startsWith(address, '${NAME}/')`, { settings: { mutations_sync: "1" } });
158
+ };
159
+
160
+ beforeAll(async () => {
161
+ if (givenClickHouse) admin = { url: givenClickHouse };
162
+ else {
163
+ server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-receipts" });
164
+ admin = server.endpoint;
165
+ }
166
+ await q(`DROP DATABASE IF EXISTS ${NAME} SYNC`);
167
+ await forget();
168
+ await q(`CREATE DATABASE ${NAME}`);
169
+ await q(`CREATE TABLE ${NAME}.log (batch String, at DateTime64(3) DEFAULT now64(3)) ENGINE = MergeTree ORDER BY at`);
170
+ await q(`CREATE TABLE ${NAME}.stop (x UInt8) ENGINE = MergeTree ORDER BY x`);
171
+ await q(`INSERT INTO ${NAME}.stop VALUES (1)`);
172
+ p = await project(
173
+ {
174
+ "chant.config.ts": config({ dialect: "clickhouse", profiles: { [ENV]: { url: admin.url, databases: [NAME] } } }),
175
+ "ops/fill.op.ts": opFile(`SELECT throwIf(count() > 0, 'stopped') FROM ${NAME}.stop`, (b) => `INSERT INTO ${NAME}.log (batch) VALUES ('${b}')`),
176
+ },
177
+ {},
178
+ );
179
+ }, 600_000);
180
+
181
+ afterAll(async () => {
182
+ if (admin) {
183
+ await q(`DROP DATABASE IF EXISTS ${NAME} SYNC`).catch(() => undefined);
184
+ await forget().catch(() => undefined);
185
+ }
186
+ await server?.stop();
187
+ if (p) rmSync(p.dir, { recursive: true, force: true });
188
+ });
189
+
190
+ test("a failed run keeps the finished batch's receipt; the rerun skips it", async () => {
191
+ await failThenResume(p, {
192
+ log: async () => (await q<{ batch: string }>(`SELECT batch FROM ${NAME}.log ORDER BY at, batch`)).map((r) => r.batch),
193
+ receipts: async () =>
194
+ (await q<{ address: string }>(`SELECT DISTINCT address FROM chant_receipts.receipts WHERE startsWith(address, '${NAME}/') ORDER BY address`)).map((r) => r.address),
195
+ unstop: async () => {
196
+ await q(`TRUNCATE TABLE ${NAME}.stop`);
197
+ },
198
+ });
199
+ // The receipts database is chant's: its comment carries the receipts key.
200
+ const [db] = await q<{ comment: string }>("SELECT comment FROM system.databases WHERE name = 'chant_receipts'");
201
+ expect(db?.comment).toContain("receipts=effects");
202
+ }, 600_000);
203
+ });
204
+
205
+ // ── Postgres ────────────────────────────────────────────────────────────────
206
+
207
+ describe.skipIf(!givenPostgres && !docker)("effect() batches on Postgres keep their receipts in chant_receipts.receipts (#3657)", () => {
208
+ let server: TestPostgres | undefined;
209
+ let endpoint: PostgresEndpoint;
210
+ let admin: PostgresClient;
211
+ let p: Awaited<ReturnType<typeof project>>;
212
+ /** Whether chant_receipts was there before the test: the test drops it afterwards only when it made it. */
213
+ let hadReceiptsSchema = true;
214
+ const q = <T = Record<string, unknown>>(sql: string) => admin.query<T>(sql);
215
+ const forget = async () => {
216
+ const [t] = await q<{ present: boolean }>("SELECT pg_catalog.to_regclass('chant_receipts.receipts') IS NOT NULL AS present");
217
+ if (t?.present) await q(`DELETE FROM chant_receipts.receipts WHERE starts_with(address, '${NAME}/')`);
218
+ };
219
+
220
+ beforeAll(async () => {
221
+ if (givenPostgres) endpoint = { url: givenPostgres, password: process.env.CHANT_SQL_RECEIPTS_POSTGRES_PASSWORD };
222
+ else {
223
+ server = await startTestPostgres();
224
+ endpoint = server.endpoint();
225
+ }
226
+ admin = await connectPostgres(endpoint);
227
+ const [s] = await q<{ n: number }>("SELECT count(*)::int AS n FROM pg_catalog.pg_namespace WHERE nspname = 'chant_receipts'");
228
+ hadReceiptsSchema = (s?.n ?? 0) > 0;
229
+ await q(`DROP SCHEMA IF EXISTS ${NAME} CASCADE`);
230
+ await forget();
231
+ await q(`CREATE SCHEMA ${NAME}`);
232
+ await q(`CREATE TABLE ${NAME}.log (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, batch text NOT NULL)`);
233
+ await q(`CREATE TABLE ${NAME}.stop (x int)`);
234
+ await q(`INSERT INTO ${NAME}.stop VALUES (1)`);
235
+ p = await project(
236
+ {
237
+ "chant.config.ts": config({
238
+ dialect: "postgres",
239
+ profiles: { [ENV]: { url: endpoint.url, password: { env: "ADMIN_PASSWORD" }, schemas: [NAME] } },
240
+ }),
241
+ "ops/fill.op.ts": opFile(
242
+ `DO $$ BEGIN IF EXISTS (SELECT 1 FROM ${NAME}.stop) THEN RAISE EXCEPTION 'stopped'; END IF; END $$`,
243
+ (b) => `INSERT INTO ${NAME}.log (batch) VALUES ('${b}')`,
244
+ ),
245
+ },
246
+ { ADMIN_PASSWORD: endpoint.password ?? "" },
247
+ );
248
+ }, 600_000);
249
+
250
+ afterAll(async () => {
251
+ if (admin) {
252
+ await q(`DROP SCHEMA IF EXISTS ${NAME} CASCADE`).catch(() => undefined);
253
+ await forget().catch(() => undefined);
254
+ if (!hadReceiptsSchema) await q("DROP SCHEMA IF EXISTS chant_receipts CASCADE").catch(() => undefined);
255
+ await admin.end();
256
+ }
257
+ await server?.stop();
258
+ if (p) rmSync(p.dir, { recursive: true, force: true });
259
+ });
260
+
261
+ test("a failed run keeps the finished batch's receipt; the rerun skips it", async () => {
262
+ await failThenResume(p, {
263
+ log: async () => (await q<{ batch: string }>(`SELECT batch FROM ${NAME}.log ORDER BY id`)).map((r) => r.batch),
264
+ receipts: async () =>
265
+ (await q<{ address: string }>(`SELECT address FROM chant_receipts.receipts WHERE starts_with(address, '${NAME}/') ORDER BY address`)).map((r) => r.address),
266
+ unstop: async () => {
267
+ await q(`DELETE FROM ${NAME}.stop`);
268
+ },
269
+ });
270
+ // The schema and the table are chant's: their comments carry the receipts key.
271
+ const [c] = await q<{ schema: string; tbl: string }>(
272
+ "SELECT pg_catalog.obj_description('chant_receipts'::regnamespace, 'pg_namespace') AS schema, pg_catalog.obj_description('chant_receipts.receipts'::regclass, 'pg_class') AS tbl",
273
+ );
274
+ expect(c?.schema).toContain("receipts=effects");
275
+ expect(c?.tbl).toContain("receipts=effects");
276
+ }, 600_000);
277
+ });
@@ -22,7 +22,7 @@
22
22
  * column's sequence, since the column prints as `serial`;
23
23
  * - an index that backs a constraint (the constraint creates it), and an
24
24
  * index partition (the partitioned index creates it);
25
- * - chant's own receipts tables (`../../core/ownership.ts`), and the working
25
+ * - chant's own receipts tables and schema (`../../core/ownership.ts`), and the working
26
26
  * columns, constraints and indexes of an expand-and-contract migration in
27
27
  * progress (`../migrate/names.ts`, `../migrate/carry.ts`), so a table reads
28
28
  * as it was before the migration until its switch, and as declared after it.
@@ -199,6 +199,8 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
199
199
  );
200
200
  for (const r of rows) {
201
201
  const name = String(r.name);
202
+ // chant's own receipts schema (`../../receipts.ts`), like the receipts tables.
203
+ if (hasChantTrailerKey(str(r.comment), [RECEIPTS_TRAILER_KEY])) continue;
202
204
  out.push({
203
205
  type: POSTGRES_ENTITY_TYPES.schema,
204
206
  name,
@@ -102,12 +102,13 @@ describe("PostgresMigrationOp", () => {
102
102
  }
103
103
  });
104
104
 
105
- test("every migration step is an activity the sql lexicon exports, and none is a receipt activity", () => {
105
+ test("every migration step is an activity the sql lexicon exports, and the receipt activities are only fallbacks", () => {
106
106
  const exported = new Set(Object.entries(activitiesModule).filter(([, v]) => typeof v === "function").map(([k]) => k));
107
107
  const steps = propsOf(BASE).phases.flatMap((p) => p.steps).filter((s) => s.kind === "activity" && (s as { fn: string }).fn.startsWith("postgresMigration"));
108
108
  expect(steps).toHaveLength(9);
109
109
  for (const s of steps) expect(exported.has((s as { fn: string }).fn)).toBe(true);
110
- expect(exported.has("receiptRead")).toBe(false);
110
+ // The receipt activities are exported for effect() steps only as fallbacks (#3657), so they never take over another lexicon's receipt row.
111
+ expect((activitiesModule as { ACTIVITY_FALLBACKS?: readonly string[] }).ACTIVITY_FALLBACKS).toEqual(["receiptRead", "receiptWrite", "receiptStaleness"]);
111
112
  });
112
113
  });
113
114
 
@@ -27,9 +27,9 @@
27
27
  * stale; onFailure and the contract also delete the migration's receipts.
28
28
  *
29
29
  * The store implements the shared core's `SqlReceiptStore`
30
- * (`../../core/receipts.ts`) over the migration's own connection. It is not
31
- * exported as the `receiptRead` / `receiptWrite` activities, whose names are
32
- * global to a run and belong to an aws or k8s project's receipt row.
30
+ * (`../../core/receipts.ts`) over the migration's own connection, which the
31
+ * backfill reaches directly. A sql project's `effect()` receipts use the same
32
+ * store over `chant_receipts.receipts` (`../../receipts.ts`).
33
33
  */
34
34
 
35
35
  import { OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
@@ -48,10 +48,10 @@ export { receiptAddress } from "../../core/receipts";
48
48
 
49
49
  /** A receipts table chant did not make: a user's table under the same name. */
50
50
  export class ReceiptsTableConflict extends Error {
51
- constructor(schema: string) {
51
+ constructor(schema: string, table: string = POSTGRES_RECEIPTS_TABLE) {
52
52
  super(
53
- `${schema}.${POSTGRES_RECEIPTS_TABLE} exists and is not chant's receipts table (its comment carries no receipts marker). ` +
54
- `The migration keeps its receipts under that name, so it stops rather than write into it; rename the table.`,
53
+ `${schema}.${table} exists and is not chant's receipts table (its comment carries no receipts marker). ` +
54
+ `chant keeps its receipts under that name, so it stops rather than write into it; rename the table.`,
55
55
  );
56
56
  this.name = "ReceiptsTableConflict";
57
57
  }
@@ -70,10 +70,18 @@ export interface PostgresReceiptStore extends SqlReceiptStore {
70
70
  * A receipt store in `<schema>.__chant_receipts`, over `client`. `write` runs
71
71
  * on the client as it is, so a write inside the caller's transaction commits
72
72
  * or rolls back with it. `identity` is the project's ownership stack and env,
73
- * which every address starts with.
73
+ * which every address starts with. `table` names the table in `schema`,
74
+ * `__chant_receipts` by default (`../../receipts.ts` keeps `effect()`
75
+ * receipts in `chant_receipts.receipts`).
74
76
  */
75
- export function postgresReceiptStore(client: PostgresClient, schema: string, identity: { stack?: string; env?: string }, opts: { runId?: string } = {}): PostgresReceiptStore {
76
- const table = `${quoteIdent(schema)}.${quoteIdent(POSTGRES_RECEIPTS_TABLE)}`;
77
+ export function postgresReceiptStore(
78
+ client: PostgresClient,
79
+ schema: string,
80
+ identity: { stack?: string; env?: string },
81
+ opts: { runId?: string; table?: string } = {},
82
+ ): PostgresReceiptStore {
83
+ const name = opts.table ?? POSTGRES_RECEIPTS_TABLE;
84
+ const table = `${quoteIdent(schema)}.${quoteIdent(name)}`;
77
85
  let ensured = false;
78
86
  const comment = async (): Promise<string | null | undefined> => {
79
87
  const [row] = await client.query<{ present: boolean; comment: string | null }>(
@@ -87,7 +95,7 @@ export function postgresReceiptStore(client: PostgresClient, schema: string, ide
87
95
  if (ensured) return true;
88
96
  const c = await comment();
89
97
  if (c === undefined) return false;
90
- if (!hasChantTrailerKey(c ?? undefined, [RECEIPTS_TRAILER_KEY])) throw new ReceiptsTableConflict(schema);
98
+ if (!hasChantTrailerKey(c ?? undefined, [RECEIPTS_TRAILER_KEY])) throw new ReceiptsTableConflict(schema, name);
91
99
  return true;
92
100
  };
93
101
  const address = (receipt: EffectReceiptRef) => receiptAddress(identity, receipt.effect);