@intentius/chant-lexicon-sql 0.114.1 → 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.
- package/dist/clickhouse/apply/mutations.d.ts +7 -1
- package/dist/clickhouse/apply/mutations.d.ts.map +1 -1
- package/dist/clickhouse/container.d.ts +22 -7
- package/dist/clickhouse/container.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts +5 -0
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +11 -5
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/op.d.ts +29 -0
- package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/partitions.d.ts +8 -0
- package/dist/clickhouse/rebuild/partitions.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/receipts.d.ts +4 -6
- package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/shards.d.ts +59 -0
- package/dist/clickhouse/rebuild/shards.d.ts.map +1 -0
- package/dist/clickhouse/rebuild/steps.d.ts +3 -0
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/verify.d.ts +4 -0
- package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/core/receipts.d.ts +5 -3
- package/dist/core/receipts.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +13 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/sql-exec.d.ts +41 -0
- package/dist/op/activities/sql-exec.d.ts.map +1 -0
- package/dist/op/activity-contracts.d.ts +20 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +1 -1
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/migrate/receipts.d.ts +8 -5
- package/dist/postgres/migrate/receipts.d.ts.map +1 -1
- package/dist/receipts.d.ts +102 -0
- package/dist/receipts.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/clickhouse/apply/mutations.ts +9 -3
- package/src/clickhouse/container.ts +39 -11
- package/src/clickhouse/plan/cluster-drift.e2e.test.ts +117 -0
- package/src/clickhouse/plan/deep.ts +102 -3
- package/src/clickhouse/plan/normalize.ts +10 -0
- package/src/clickhouse/plan/plan.test.ts +10 -0
- package/src/clickhouse/rebuild/backfill.ts +85 -42
- package/src/clickhouse/rebuild/op.ts +80 -35
- package/src/clickhouse/rebuild/partitions.ts +11 -0
- package/src/clickhouse/rebuild/rebuild-sharded.e2e.test.ts +161 -0
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +110 -4
- package/src/clickhouse/rebuild/rebuild.test.ts +56 -4
- package/src/clickhouse/rebuild/receipts.ts +4 -6
- package/src/clickhouse/rebuild/shards.ts +104 -0
- package/src/clickhouse/rebuild/steps.ts +4 -0
- package/src/clickhouse/rebuild/verify.ts +14 -5
- package/src/config.ts +7 -0
- package/src/core/receipts.ts +5 -3
- package/src/op/activities/clickhouse-rebuild.ts +32 -7
- package/src/op/activities/index.ts +21 -4
- package/src/op/activities/sql-exec.ts +84 -0
- package/src/op/activity-contracts.ts +15 -0
- package/src/op/effect-receipts.e2e.test.ts +277 -0
- package/src/postgres/live/catalog.ts +3 -1
- package/src/postgres/migrate/migrate.test.ts +3 -2
- package/src/postgres/migrate/receipts.ts +18 -10
- package/src/receipts.test.ts +91 -0
- package/src/receipts.ts +259 -0
- 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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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}.${
|
|
54
|
-
`
|
|
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(
|
|
76
|
-
|
|
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);
|