@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
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The Postgres deep reader's columns (INTENTIUS/sql-yodeler#47): matched by
3
+ * name, not by position, because Postgres keeps a table's columns in the
4
+ * order they were added and a column renamed by expand and contract is last
5
+ * on the server.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import * as pg from "../entities";
10
+ import { POSTGRES_ENTITY_TYPES } from "../entity-types";
11
+ import type { LivePgObject } from "../live/catalog";
12
+ import { inDeclaredVocabulary, liveProps } from "./deep";
13
+ import { canonicalPgObject } from "./normalize";
14
+
15
+ type Props = Record<string, unknown>;
16
+
17
+ const declared = (pg.table`
18
+ CREATE TABLE shop.orders (
19
+ id bigint GENERATED ALWAYS AS IDENTITY,
20
+ email text NOT NULL, -- previously: customer_email
21
+ status text DEFAULT 'placed'::text NOT NULL,
22
+ amount numeric(12,2) NOT NULL,
23
+ refunded_at timestamp with time zone,
24
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
25
+ )` as unknown as { props: Props }).props;
26
+
27
+ /** What the deep reader makes of the server's definition of the table. */
28
+ function observe(statement: string): Props {
29
+ const o = { type: POSTGRES_ENTITY_TYPES.table, schema: "shop", name: "orders", oid: "1", statement } as unknown as LivePgObject;
30
+ const live = liveProps(o);
31
+ return inDeclaredVocabulary(declared, live, canonicalPgObject(POSTGRES_ENTITY_TYPES.table, declared), canonicalPgObject(POSTGRES_ENTITY_TYPES.table, live));
32
+ }
33
+
34
+ const names = (p: Props) => (p.columns as Array<{ name: string }>).map((c) => c.name);
35
+
36
+ describe("Postgres deep observation of a table's columns", () => {
37
+ test("a column the server holds last and the declaration earlier is not drift", () => {
38
+ const props = observe(`CREATE TABLE shop.orders (
39
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
40
+ status text DEFAULT 'placed'::text NOT NULL,
41
+ amount numeric(12,2) NOT NULL,
42
+ refunded_at timestamp with time zone,
43
+ email text NOT NULL,
44
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
45
+ )`);
46
+ expect(props.columns).toEqual(declared.columns);
47
+ });
48
+
49
+ test("a column that differs is reported at its declared place, and an undeclared one comes after the declared ones", () => {
50
+ const props = observe(`CREATE TABLE shop.orders (
51
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
52
+ note text,
53
+ status text DEFAULT 'placed'::text NOT NULL,
54
+ amount numeric(12,2) NOT NULL,
55
+ refunded_at timestamp with time zone,
56
+ email character varying(200) NOT NULL,
57
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
58
+ )`);
59
+ expect(names(props)).toEqual(["id", "email", "status", "amount", "refunded_at", "note"]);
60
+ const columns = props.columns as Props[];
61
+ const declaredColumns = declared.columns as Props[];
62
+ expect(columns[1]).not.toEqual(declaredColumns[1]);
63
+ expect(columns.slice(2, 5)).toEqual(declaredColumns.slice(2, 5));
64
+ });
65
+
66
+ test("a declared column the server does not have leaves the others matched by name", () => {
67
+ const props = observe(`CREATE TABLE shop.orders (
68
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
69
+ status text DEFAULT 'placed'::text NOT NULL,
70
+ amount numeric(12,2) NOT NULL,
71
+ email text NOT NULL,
72
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
73
+ )`);
74
+ expect(names(props)).toEqual(["id", "email", "status", "amount"]);
75
+ });
76
+ });
@@ -108,11 +108,22 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalP
108
108
  if (d.kind === "table") {
109
109
  const declaredColumns = (declared.columns as unknown[] | undefined) ?? [];
110
110
  const liveColumns = (live.columns as unknown[] | undefined) ?? [];
111
- out.columns = liveColumns.map((c, i) => {
111
+ // Columns are matched by name and listed in the declaration's order, the
112
+ // live columns the declaration does not name after them. Postgres keeps a
113
+ // table's columns in the order they were added, and no ALTER moves one: a
114
+ // column renamed by expand and contract is last on the server and where
115
+ // the old one was in the declaration (INTENTIUS/sql-yodeler#47). The plan
116
+ // compares columns by name too, so an order the server cannot be brought
117
+ // to is not drift.
118
+ const read = liveColumns.map((c, i) => {
112
119
  const lc = l.columns[i];
113
120
  const dc = lc ? d.columns.find((x) => x.name === lc.name) : undefined;
114
- return lc && dc && columnShape(lc) === columnShape(dc) ? declaredColumns[dc.position] : c;
121
+ return { at: dc?.position, value: lc && dc && columnShape(lc) === columnShape(dc) ? declaredColumns[dc.position] : c };
115
122
  });
123
+ out.columns = [
124
+ ...read.filter((c) => c.at !== undefined).sort((a, b) => a.at! - b.at!),
125
+ ...read.filter((c) => c.at === undefined),
126
+ ].map((c) => c.value);
116
127
  }
117
128
  if (d.kind === "table" || d.kind === "domain") {
118
129
  // A constraint the declaration says the same as the server is the declaration's (its name included or left out).
@@ -91,6 +91,32 @@ describe.skipIf(!enabled)("planning the getting-started example against a server
91
91
  expect((await planPgAgainstServer("e2e", file, profile("star"))).changes.map((c) => `${c.field}: ${c.before} -> ${c.after}`)).toEqual([]);
92
92
  }, 600_000);
93
93
 
94
+ test("a column renamed by expand and contract, last on the server, plans and reads back with no drift (sql-yodeler#47)", async () => {
95
+ const admin = await server!.connect();
96
+ await admin.query("CREATE DATABASE renamed");
97
+ await admin.end();
98
+ const orders = pg.table`CREATE TABLE public.orders (
99
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
100
+ email text NOT NULL, -- previously: customer_email
101
+ status text DEFAULT 'placed'::text NOT NULL,
102
+ refunded_at timestamp with time zone
103
+ )`;
104
+ const out = sqlSerializer.serialize(new Map<string, unknown>([["orders", orders]]) as never) as SerializerResult;
105
+ const file = join(dir, "renamed.json");
106
+ writeFileSync(file, out.primary);
107
+ const client = await server!.connect("renamed");
108
+ await client.query(
109
+ "CREATE TABLE public.orders (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, customer_email text NOT NULL, status text DEFAULT 'placed'::text NOT NULL, refunded_at timestamp with time zone);" +
110
+ "ALTER TABLE public.orders ADD COLUMN email text; UPDATE public.orders SET email = customer_email; ALTER TABLE public.orders ALTER COLUMN email SET NOT NULL; ALTER TABLE public.orders DROP COLUMN customer_email",
111
+ );
112
+ expect((await planPgAgainstServer("e2e", file, profile("renamed"))).changes).toEqual([]);
113
+
114
+ const props = (orders as unknown as { props: Record<string, unknown> }).props;
115
+ const deep = await observeResourcesDeep({ environment: "e2e", entityNames: ["orders"], entities: new Map([["orders", { entityType: "Postgres::Table", props }]]), ...profile("renamed") });
116
+ expect(deep.resources.orders!.properties.columns).toEqual(props.columns);
117
+ await client.end();
118
+ }, 600_000);
119
+
94
120
  test("a serial table plans with no changes after apply; serial to bigserial is integer to bigint, unqualified", async () => {
95
121
  const admin = await server!.connect();
96
122
  await admin.query("CREATE DATABASE serials");
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { environmentOf, receiptDialect, sqlReceiptStore } from "./receipts";
3
+ import type { PostgresClient } from "./postgres/live/client";
4
+
5
+ const ref = (effect: string) => ({ name: effect, effect, flavor: "existence" as const, inputs: {} });
6
+
7
+ /** A Postgres client that records each statement and answers the store's catalog reads. */
8
+ function fakePostgres(state: { schema?: boolean; table?: string | null } = {}) {
9
+ const statements: string[] = [];
10
+ let schema = state.schema ?? false;
11
+ let table: string | null | undefined = state.table;
12
+ const rows = new Map<string, string>();
13
+ const client: PostgresClient = {
14
+ async query<T>(sql: string, params: readonly unknown[] = []): Promise<T[]> {
15
+ statements.push(sql);
16
+ if (sql.includes("pg_namespace WHERE nspname")) return [{ n: schema ? 1 : 0 }] as T[];
17
+ if (sql.startsWith("CREATE SCHEMA")) schema = true;
18
+ if (sql.includes("to_regclass($1) IS NOT NULL AS present")) return [{ present: table !== undefined, comment: table ?? null }] as T[];
19
+ if (sql.startsWith("CREATE TABLE")) table = null;
20
+ if (sql.startsWith("COMMENT ON TABLE")) table = sql.slice(sql.indexOf("IS '") + 4, -1);
21
+ if (sql.startsWith("INSERT INTO")) rows.set(String(params[0]), String(params[2]));
22
+ if (sql.startsWith("SELECT expectation")) {
23
+ const v = rows.get(String(params[0]));
24
+ return (v === undefined ? [] : [{ expectation: v }]) as T[];
25
+ }
26
+ return [] as T[];
27
+ },
28
+ end: async () => undefined,
29
+ };
30
+ return { client, statements, rows };
31
+ }
32
+
33
+ describe("receiptDialect (#3657)", () => {
34
+ test("the environment's profile decides, by its URL's scheme", () => {
35
+ const config = { sql: { profiles: { pg: { url: "postgres://db:5432/app" }, ch: { url: "http://ch:8123" } } } };
36
+ expect(receiptDialect(config, "pg", {})).toBe("postgres");
37
+ expect(receiptDialect(config, "ch", {})).toBe("clickhouse");
38
+ });
39
+
40
+ test("with no profile, sql.dialect, then whichever server variable is set", () => {
41
+ expect(receiptDialect({ sql: { dialect: "postgres" } }, "dev", {})).toBe("postgres");
42
+ expect(receiptDialect({ sql: { dialect: ["clickhouse"] } }, undefined, { POSTGRES_URL: "postgres://x/y" })).toBe("clickhouse");
43
+ expect(receiptDialect(undefined, undefined, { POSTGRES_URL: "postgres://x/y" })).toBe("postgres");
44
+ expect(receiptDialect(undefined, undefined, {})).toBe("clickhouse");
45
+ });
46
+ });
47
+
48
+ describe("environmentOf (#3657)", () => {
49
+ test("the option, then CHANT_ENV, then a literal ownership.env", () => {
50
+ expect(environmentOf({ environment: "a" }, { ownership: { stack: "s", env: "c" } }, { CHANT_ENV: "b" })).toBe("a");
51
+ expect(environmentOf({}, { ownership: { stack: "s", env: "c" } }, { CHANT_ENV: "b" })).toBe("b");
52
+ expect(environmentOf({}, { ownership: { stack: "s", env: "c" } }, {})).toBe("c");
53
+ expect(environmentOf({}, undefined, {})).toBeUndefined();
54
+ });
55
+ });
56
+
57
+ describe("sqlReceiptStore over Postgres (#3657)", () => {
58
+ test("keeps receipts in chant_receipts.receipts, a schema and table it marks as chant's", async () => {
59
+ const pg = fakePostgres();
60
+ const store = sqlReceiptStore({ environment: "prod", stack: "shop", postgres: pg.client, runId: "r1" });
61
+ expect(await store.read(ref("fill/b1"))).toBeUndefined();
62
+ await store.write(ref("fill/b1"), "present");
63
+ expect(await store.read(ref("fill/b1"))).toBe("present");
64
+
65
+ expect(pg.statements).toContain("CREATE SCHEMA IF NOT EXISTS chant_receipts");
66
+ expect(pg.statements.find((s) => s.startsWith("COMMENT ON SCHEMA chant_receipts"))).toContain("receipts=effects");
67
+ expect(pg.statements.find((s) => s.startsWith("CREATE TABLE"))).toContain("TABLE IF NOT EXISTS chant_receipts.receipts (");
68
+ expect([...pg.rows.keys()]).toEqual(["shop/prod/fill/b1"]);
69
+ expect(await store.location()).toEqual({ dialect: "postgres", table: "chant_receipts.receipts", source: "the caller's connection", address: "shop/prod/<effect>" });
70
+ });
71
+
72
+ test("a receiptsSchema keeps them in <schema>.__chant_receipts, and creates no schema", async () => {
73
+ const pg = fakePostgres();
74
+ const store = sqlReceiptStore({
75
+ environment: "prod",
76
+ postgres: pg.client,
77
+ config: { ownership: { stack: "shop" }, sql: { profiles: { prod: { url: "postgres://db/app", receiptsSchema: "app" } } } },
78
+ });
79
+ await store.write(ref("fill/b1"), "present");
80
+ expect(pg.statements.some((s) => s.includes("CREATE SCHEMA"))).toBe(false);
81
+ expect(pg.statements.find((s) => s.startsWith("CREATE TABLE"))).toContain("TABLE IF NOT EXISTS app.__chant_receipts (");
82
+ expect((await store.location()).table).toBe("app.__chant_receipts");
83
+ });
84
+
85
+ test("a table of the user's under the receipts name is refused, not written into", async () => {
86
+ const pg = fakePostgres({ schema: true, table: "the user's own receipts" });
87
+ const store = sqlReceiptStore({ environment: "prod", stack: "shop", postgres: pg.client });
88
+ await expect(store.write(ref("fill/b1"), "present")).rejects.toThrow(/chant_receipts\.receipts exists and is not chant's receipts table/);
89
+ expect(pg.rows.size).toBe(0);
90
+ });
91
+ });
@@ -0,0 +1,259 @@
1
+ /**
2
+ * The sql lexicon's `effect()` receipt store (#3657): where the receipts of a
3
+ * project that uses only the sql lexicon are kept, on the database server the
4
+ * environment names.
5
+ *
6
+ * ClickHouse chant_receipts.receipts
7
+ * Postgres chant_receipts.receipts (or <receiptsSchema>.__chant_receipts)
8
+ *
9
+ * The op activities module binds it as `receiptRead`, `receiptWrite` and
10
+ * `receiptStaleness` (`./op/activities/index.ts`), listed as fallbacks: a
11
+ * project that also configures aws or k8s keeps its receipts in that
12
+ * lexicon's receipt row, in whichever order `lexicons` lists them.
13
+ *
14
+ * Which server: the environment is the one `chant run --env` sets
15
+ * (`CHANT_ENV`), else a literal `ownership.env`. Its `sql.profiles.<env>`
16
+ * binds the server, a `postgres://` URL making it Postgres; with no profile,
17
+ * `CLICKHOUSE_URL` or `POSTGRES_URL`, picked by `sql.dialect`. A receipt's
18
+ * address is `<stack>/<env>/<effect>`, from `ownership.stack` and that
19
+ * environment, the same identity the rebuild's and the column migration's
20
+ * receipts carry (`./core/receipts.ts`).
21
+ *
22
+ * Both tables carry chant's ownership trailer with the `receipts` key in their
23
+ * comment (the Postgres schema too), so plan, import and prune leave them out.
24
+ *
25
+ * A caller that runs its own Op over a server it has already bound (a
26
+ * migration runner, say) passes that server instead: a ClickHouse endpoint,
27
+ * or a Postgres connection, whose receipt writes then commit or roll back
28
+ * with whatever transaction the caller has open on it.
29
+ */
30
+
31
+ import type { ChantConfig } from "@intentius/chant/config";
32
+ import { receiptActivities, type ReceiptActivities, type ReceiptActivityOptions } from "@intentius/chant/op/receipt-store";
33
+ import { OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
34
+ import type { SqlReceiptStore } from "./core/receipts";
35
+ import { COMMENT_OWNERSHIP_KEYS, RECEIPTS_TRAILER_KEY } from "./core/ownership";
36
+ import type { ClickHouseEndpoint } from "./clickhouse/http";
37
+ import type { Topology } from "./clickhouse/topology";
38
+ import type { PostgresClient } from "./postgres/live/client";
39
+
40
+ /** The database (ClickHouse) or schema (Postgres) the receipts are kept in. */
41
+ export const SQL_RECEIPTS_SCHEMA = "chant_receipts";
42
+ /** The table in it. */
43
+ export const SQL_RECEIPTS_TABLE = "receipts";
44
+ /** The table's name in a schema of the user's own (`receiptsSchema`), as the column migration names it. */
45
+ export const SQL_RECEIPTS_TABLE_BESIDE = "__chant_receipts";
46
+
47
+ export interface SqlReceiptStoreOptions {
48
+ /** The chant environment. Default: `CHANT_ENV`, then a literal `ownership.env`. */
49
+ environment?: string;
50
+ /** The address's stack segment. Default: `ownership.stack`. */
51
+ stack?: string;
52
+ /** The project directory chant.config.ts is read from. Default: the working directory. */
53
+ cwd?: string;
54
+ /** The project's config, instead of reading chant.config.ts. */
55
+ config?: Pick<ChantConfig, "sql" | "ownership">;
56
+ /** The process environment the bindings and `CHANT_ENV` are read from. Default: `process.env`. */
57
+ env?: Record<string, string | undefined>;
58
+ /** A ClickHouse server already bound: the receipts go there, and no profile is read. */
59
+ clickhouse?: { endpoint: ClickHouseEndpoint; topology?: Topology };
60
+ /**
61
+ * A Postgres connection already open: the receipts go over it, and it is
62
+ * never closed. A write runs inside whatever transaction the caller has
63
+ * open, so it commits or rolls back with it. Call `ensure()` before such a
64
+ * transaction begins, so the receipts table does not roll back with it.
65
+ */
66
+ postgres?: PostgresClient;
67
+ /** Postgres: the schema to keep the receipts in. Default: the profile's `receiptsSchema`, then `chant_receipts`. */
68
+ schema?: string;
69
+ /** Recorded on each receipt written. Default: the current Op run's id. */
70
+ runId?: string;
71
+ }
72
+
73
+ /** The store, and the step a caller with its own transaction takes before one opens. */
74
+ export interface SqlServerReceiptStore extends SqlReceiptStore {
75
+ /** Create the receipts table (and database or schema) if it is not there. */
76
+ ensure(): Promise<void>;
77
+ /** Where the receipts are, as resolved: `chant_receipts.receipts` and the server it is on. */
78
+ location(): Promise<{ dialect: "clickhouse" | "postgres"; table: string; source: string; address: string }>;
79
+ }
80
+
81
+ type Config = Pick<ChantConfig, "sql" | "ownership">;
82
+
83
+ interface Resolved {
84
+ identity: { stack?: string; env?: string };
85
+ open(): Promise<{ store: SqlReceiptStore & { ensure?: () => Promise<void> }; close(): Promise<void> }>;
86
+ dialect: "clickhouse" | "postgres";
87
+ table: string;
88
+ source: string;
89
+ }
90
+
91
+ async function loadConfig(cwd: string): Promise<Config | undefined> {
92
+ try {
93
+ const { loadChantConfig } = await import("@intentius/chant/config");
94
+ return (await loadChantConfig(cwd)).config;
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ }
99
+
100
+ const isPostgresUrl = (url: string): boolean => /^postgres(ql)?:\/\//i.test(url);
101
+
102
+ /** The dialect an environment's receipts are kept in: its profile's URL, else `sql.dialect`, else whichever server variable is set. */
103
+ export function receiptDialect(config: Config | undefined, environment: string | undefined, env: Record<string, string | undefined>): "clickhouse" | "postgres" {
104
+ const profile = environment !== undefined ? config?.sql?.profiles?.[environment] : undefined;
105
+ if (profile) return isPostgresUrl(profile.url) ? "postgres" : "clickhouse";
106
+ const dialects = config?.sql?.dialect === undefined ? [] : Array.isArray(config.sql.dialect) ? config.sql.dialect : [config.sql.dialect];
107
+ if (dialects.length === 1) return dialects[0] === "postgres" ? "postgres" : "clickhouse";
108
+ if (!env.CLICKHOUSE_URL && env.POSTGRES_URL) return "postgres";
109
+ return "clickhouse";
110
+ }
111
+
112
+ /** The run's environment: the option, `CHANT_ENV`, or a literal `ownership.env`. */
113
+ export function environmentOf(options: { environment?: string }, config: Pick<ChantConfig, "ownership"> | undefined, env: Record<string, string | undefined>): string | undefined {
114
+ if (options.environment) return options.environment;
115
+ if (env.CHANT_ENV) return env.CHANT_ENV;
116
+ const literal = config?.ownership?.env;
117
+ return typeof literal === "string" ? literal : undefined;
118
+ }
119
+
120
+ async function currentRunId(): Promise<string | undefined> {
121
+ try {
122
+ const { currentOpRun } = await import("@intentius/chant/op");
123
+ return currentOpRun()?.runId;
124
+ } catch {
125
+ return undefined;
126
+ }
127
+ }
128
+
129
+ const receiptsComment = `chant effect receipts [chant ${COMMENT_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE} ${RECEIPTS_TRAILER_KEY}=effects]`;
130
+
131
+ async function resolve(options: SqlReceiptStoreOptions): Promise<Resolved> {
132
+ const env = options.env ?? process.env;
133
+ const needsConfig = options.config === undefined && (!options.stack || (!options.clickhouse && !options.postgres) || !(options.environment || env.CHANT_ENV));
134
+ const config = options.config ?? (needsConfig ? await loadConfig(options.cwd ?? process.cwd()) : undefined);
135
+ const environment = environmentOf(options, config, env);
136
+ const stack = options.stack ?? (config?.ownership && config.ownership.enabled !== false ? config.ownership.stack : undefined);
137
+ const identity = { ...(stack ? { stack } : {}), ...(environment ? { env: environment } : {}) };
138
+ const runId = options.runId ?? (await currentRunId());
139
+ const dialect = options.postgres ? "postgres" : options.clickhouse ? "clickhouse" : receiptDialect(config, environment, env);
140
+
141
+ if (dialect === "clickhouse") {
142
+ const { clickhouseReceiptStore } = await import("./clickhouse/rebuild/receipts");
143
+ let endpoint: ClickHouseEndpoint;
144
+ let topology: Topology | undefined;
145
+ let source: string;
146
+ if (options.clickhouse) {
147
+ endpoint = options.clickhouse.endpoint;
148
+ topology = options.clickhouse.topology;
149
+ source = endpoint.url;
150
+ } else {
151
+ const { bindClickHouse } = await import("./clickhouse/live/bind");
152
+ const target = await bindClickHouse({ ...(environment !== undefined ? { environment } : {}), config: config ?? {}, env });
153
+ endpoint = target.endpoint;
154
+ topology = target.topology;
155
+ source = target.source;
156
+ }
157
+ // Created for the environment's topology: ON CLUSTER on a cluster, a Replicated database in the replicated one.
158
+ const store = clickhouseReceiptStore(endpoint, identity, { ...(runId ? { runId } : {}), ...(topology ? { topology } : {}) });
159
+ return {
160
+ identity,
161
+ dialect,
162
+ table: `${SQL_RECEIPTS_SCHEMA}.${SQL_RECEIPTS_TABLE}`,
163
+ source,
164
+ open: async () => ({ store, close: async () => undefined }),
165
+ };
166
+ }
167
+
168
+ const { postgresReceiptStore } = await import("./postgres/migrate/receipts");
169
+ const profile = environment !== undefined ? config?.sql?.profiles?.[environment] : undefined;
170
+ const schema = options.schema ?? profile?.receiptsSchema ?? SQL_RECEIPTS_SCHEMA;
171
+ const own = schema === SQL_RECEIPTS_SCHEMA;
172
+ const table = own ? SQL_RECEIPTS_TABLE : SQL_RECEIPTS_TABLE_BESIDE;
173
+ const storeOver = (client: PostgresClient) => {
174
+ const store = postgresReceiptStore(client, schema, identity, { ...(runId ? { runId } : {}), table });
175
+ let ensured = false;
176
+ return {
177
+ ...store,
178
+ async ensure() {
179
+ if (ensured) return;
180
+ // chant's own schema is made here, marked so the catalog leaves it out; a schema of the user's must exist.
181
+ if (own) {
182
+ const [present] = await client.query<{ n: number }>("SELECT count(*)::int AS n FROM pg_catalog.pg_namespace WHERE nspname = $1", [SQL_RECEIPTS_SCHEMA]);
183
+ if (!present?.n) {
184
+ await client.query(`CREATE SCHEMA IF NOT EXISTS ${SQL_RECEIPTS_SCHEMA}`);
185
+ await client.query(`COMMENT ON SCHEMA ${SQL_RECEIPTS_SCHEMA} IS '${receiptsComment.replace(/'/g, "''")}'`);
186
+ }
187
+ }
188
+ await store.ensure();
189
+ ensured = true;
190
+ },
191
+ };
192
+ };
193
+ if (options.postgres) {
194
+ const client = options.postgres;
195
+ const store = storeOver(client);
196
+ return { identity, dialect, table: `${schema}.${table}`, source: "the caller's connection", open: async () => ({ store, close: async () => undefined }) };
197
+ }
198
+ const { resolveBoundTarget } = await import("./postgres/live/bind");
199
+ const { connectPostgres } = await import("./postgres/live/client");
200
+ const target = await resolveBoundTarget({ ...(environment !== undefined ? { environment } : {}), config: config ?? {}, env });
201
+ return {
202
+ identity,
203
+ dialect,
204
+ table: `${schema}.${table}`,
205
+ source: target.source,
206
+ // One connection per read or write: a run's process holds no connection open between steps, and exits when the run ends.
207
+ open: async () => {
208
+ const client = await connectPostgres(target.endpoint, { applicationName: "chant receipts" });
209
+ return { store: storeOver(client), close: () => client.end() };
210
+ },
211
+ };
212
+ }
213
+
214
+ /**
215
+ * The receipt store on the environment's database server. Nothing is read or
216
+ * connected until the first read or write; the environment, the server and
217
+ * the identity are resolved then, once.
218
+ */
219
+ export function sqlReceiptStore(options: SqlReceiptStoreOptions = {}): SqlServerReceiptStore {
220
+ let resolved: Promise<Resolved> | undefined;
221
+ const resolvedOnce = () => (resolved ??= resolve(options));
222
+ const using = async <T>(fn: (store: SqlReceiptStore & { ensure?: () => Promise<void> }, r: Resolved) => Promise<T>): Promise<T> => {
223
+ const r = await resolvedOnce();
224
+ const { store, close } = await r.open();
225
+ try {
226
+ return await fn(store, r);
227
+ } finally {
228
+ await close();
229
+ }
230
+ };
231
+ return {
232
+ read: (receipt) => using((s) => s.read(receipt)),
233
+ readAll: (prefix) => using((s) => s.readAll(prefix)),
234
+ async write(receipt, expectation) {
235
+ await using(async (s) => {
236
+ // Made on the first write when it is not there. A caller writing
237
+ // inside its own transaction calls ensure() before opening it, so a
238
+ // rolled-back batch does not take the table with it.
239
+ await s.ensure?.();
240
+ await s.write(receipt, expectation);
241
+ });
242
+ },
243
+ ensure: () => using(async (s) => s.ensure?.()),
244
+ async location() {
245
+ const r = await resolvedOnce();
246
+ const { receiptAddress } = await import("./core/receipts");
247
+ return { dialect: r.dialect, table: r.table, source: r.source, address: receiptAddress(r.identity, "<effect>") };
248
+ },
249
+ };
250
+ }
251
+
252
+ /**
253
+ * The `receiptRead`, `receiptWrite` and `receiptStaleness` activities over
254
+ * {@link sqlReceiptStore}: what the op activities module exports, and what a
255
+ * caller running an Op of `effect()` steps with its own activity map binds.
256
+ */
257
+ export function sqlReceiptActivities(options: SqlReceiptStoreOptions = {}, activityOptions?: ReceiptActivityOptions): ReceiptActivities {
258
+ return receiptActivities(sqlReceiptStore(options), activityOptions);
259
+ }
@@ -28,6 +28,10 @@ export const { op } = ClickHouseRebuildOp({
28
28
 
29
29
  Options: `name`, `env`, `table`, `dualWrite` (required); `output` (default `dist/schema.json`), `path`, `build` (default true runs `chant build` first), `retain` (default `7d`), `gate` (the swap gate: `gate`, `timeout`, `description`, `approval`), `dropGate`, `writesGate` (app mode), `backfillTimeout` (default `6h`), `mutationTimeout` (default `10m`), `replicaTimeout` (default `2m`, Replicated databases only), `stack`, `ownershipEnv`.
30
30
 
31
+ On a `cluster:<name>` topology with more than one shard, the backfill and the verification work per shard through the profile's server, with a receipt per shard and partition; each shard needs its own `{shard}` macro.
32
+
33
+ Run as one step of a change approved elsewhere: `gates: "outer"` leaves out the swap gate, the drop gate and the Drop phase (verification and the swap's own comparison still run; the old table is kept), and `onFailure: "keep"` leaves out onFailure, so a failed run's next run resumes the backfill from its receipts. Set them rather than editing the Op's phases.
34
+
31
35
  Pick the dual-write mode:
32
36
 
33
37
  - `{ mode: "materialized-view", cutoverColumn: "ts", cutoverDelay?: "1m" }` when the table has a time column rows arrive in order of. Writes keep flowing: a materialized view sends rows at or after the cut-over to the new table, the backfill copies the rows before it.
@@ -51,7 +55,7 @@ The swap gate is bound to a digest of the plan and the verification (row counts
51
55
  ## When something goes wrong
52
56
 
53
57
  - A run stopped during the backfill (Ctrl-C, a killed job) is not a failure. The next run resumes from the receipts in `chant_receipts.receipts` on the same server (in `<db>.__chant_receipts` on a Replicated database), and a partition whose copy was cut off before its receipt is cleared and copied again, so no row is copied twice.
54
- - A failed step (a failed copy after its retries, a verification mismatch, a refused declaration) runs onFailure: the new table `<t>__chant_new` and the dual-write view `<t>__chant_dual` are dropped, and the next run starts from a new table. The original table is untouched until the swap.
58
+ - A failed step (a failed copy after its retries, a verification mismatch, a refused declaration) runs onFailure: the new table `<t>__chant_new` and the dual-write view `<t>__chant_dual` are dropped, and the next run starts from a new table. With `onFailure: "keep"` they stay, and the next run resumes; drop them by hand to start over. The original table is untouched until the swap.
55
59
  - A verification mismatch in materialized-view mode usually means rows arrived with a time before the cut-over later than `cutoverDelay`. Raise the delay and run again.
56
60
  - The Op refuses a database that is neither Atomic nor Replicated (`EXCHANGE TABLES` needs one of them), a table whose change is not a rebuild (use the applier), and an object already under one of its working names that is not its own. In a Replicated database it also refuses a table, or a declaration, whose engine is not `Replicated*MergeTree`: a plain MergeTree keeps different rows on each replica and one replica's copy would lose the others'.
57
61