@intentius/chant-lexicon-sql 0.114.1 → 0.116.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 (142) 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 +11 -5
  9. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  10. package/dist/clickhouse/rebuild/op.d.ts +29 -0
  11. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  12. package/dist/clickhouse/rebuild/partitions.d.ts +8 -0
  13. package/dist/clickhouse/rebuild/partitions.d.ts.map +1 -1
  14. package/dist/clickhouse/rebuild/receipts.d.ts +4 -6
  15. package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
  16. package/dist/clickhouse/rebuild/shards.d.ts +59 -0
  17. package/dist/clickhouse/rebuild/shards.d.ts.map +1 -0
  18. package/dist/clickhouse/rebuild/steps.d.ts +3 -0
  19. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  20. package/dist/clickhouse/rebuild/verify.d.ts +4 -0
  21. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  22. package/dist/codegen/registry.d.ts.map +1 -1
  23. package/dist/config.d.ts +14 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/core/receipts.d.ts +5 -3
  26. package/dist/core/receipts.d.ts.map +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/integrity.json +5 -5
  30. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  31. package/dist/lsp/postgres-template.d.ts.map +1 -1
  32. package/dist/manifest.json +1 -1
  33. package/dist/meta.json +15 -0
  34. package/dist/okf/index.md +3 -0
  35. package/dist/okf/types/PostgresFunction.md +9 -0
  36. package/dist/okf/types/PostgresProcedure.md +9 -0
  37. package/dist/okf/types/PostgresTrigger.md +9 -0
  38. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  39. package/dist/op/activities/index.d.ts +13 -1
  40. package/dist/op/activities/index.d.ts.map +1 -1
  41. package/dist/op/activities/sql-exec.d.ts +41 -0
  42. package/dist/op/activities/sql-exec.d.ts.map +1 -0
  43. package/dist/op/activity-contracts.d.ts +20 -0
  44. package/dist/op/activity-contracts.d.ts.map +1 -1
  45. package/dist/plugin.d.ts.map +1 -1
  46. package/dist/postgres/apply/apply.d.ts +6 -1
  47. package/dist/postgres/apply/apply.d.ts.map +1 -1
  48. package/dist/postgres/apply/statements.d.ts +9 -2
  49. package/dist/postgres/apply/statements.d.ts.map +1 -1
  50. package/dist/postgres/entities.d.ts +110 -3
  51. package/dist/postgres/entities.d.ts.map +1 -1
  52. package/dist/postgres/entity-types.d.ts +3 -0
  53. package/dist/postgres/entity-types.d.ts.map +1 -1
  54. package/dist/postgres/import/generator.d.ts.map +1 -1
  55. package/dist/postgres/import/live-export.d.ts.map +1 -1
  56. package/dist/postgres/live/catalog.d.ts +17 -5
  57. package/dist/postgres/live/catalog.d.ts.map +1 -1
  58. package/dist/postgres/live/describe-resources.d.ts +6 -1
  59. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  60. package/dist/postgres/migrate/receipts.d.ts +8 -5
  61. package/dist/postgres/migrate/receipts.d.ts.map +1 -1
  62. package/dist/postgres/parser.d.ts +82 -5
  63. package/dist/postgres/parser.d.ts.map +1 -1
  64. package/dist/postgres/plan/deep.d.ts.map +1 -1
  65. package/dist/postgres/plan/diff.d.ts +1 -1
  66. package/dist/postgres/plan/diff.d.ts.map +1 -1
  67. package/dist/postgres/plan/normalize.d.ts +21 -2
  68. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  69. package/dist/postgres/plan/rules.d.ts +6 -0
  70. package/dist/postgres/plan/rules.d.ts.map +1 -1
  71. package/dist/postgres/plan/schema.d.ts +11 -1
  72. package/dist/postgres/plan/schema.d.ts.map +1 -1
  73. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  74. package/dist/postgres.d.ts +1 -1
  75. package/dist/postgres.d.ts.map +1 -1
  76. package/dist/receipts.d.ts +102 -0
  77. package/dist/receipts.d.ts.map +1 -0
  78. package/dist/rules/postgres-templates.ts +2 -2
  79. package/dist/rules/sqlpg001.ts +3 -0
  80. package/dist/validate.d.ts.map +1 -1
  81. package/package.json +2 -2
  82. package/src/clickhouse/apply/mutations.ts +9 -3
  83. package/src/clickhouse/container.ts +39 -11
  84. package/src/clickhouse/plan/cluster-drift.e2e.test.ts +117 -0
  85. package/src/clickhouse/plan/deep.ts +102 -3
  86. package/src/clickhouse/plan/normalize.ts +10 -0
  87. package/src/clickhouse/plan/plan.test.ts +10 -0
  88. package/src/clickhouse/rebuild/backfill.ts +85 -42
  89. package/src/clickhouse/rebuild/op.ts +80 -35
  90. package/src/clickhouse/rebuild/partitions.ts +11 -0
  91. package/src/clickhouse/rebuild/rebuild-sharded.e2e.test.ts +161 -0
  92. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +110 -4
  93. package/src/clickhouse/rebuild/rebuild.test.ts +56 -4
  94. package/src/clickhouse/rebuild/receipts.ts +4 -6
  95. package/src/clickhouse/rebuild/shards.ts +104 -0
  96. package/src/clickhouse/rebuild/steps.ts +4 -0
  97. package/src/clickhouse/rebuild/verify.ts +14 -5
  98. package/src/codegen/postgres-classifier-page.ts +1 -1
  99. package/src/codegen/registry.ts +3 -0
  100. package/src/config.ts +7 -0
  101. package/src/core/receipts.ts +5 -3
  102. package/src/generated/lexicon-sql.json +15 -0
  103. package/src/index.ts +1 -1
  104. package/src/lint/rules/postgres-templates.ts +2 -2
  105. package/src/lint/rules/sqlpg001.ts +3 -0
  106. package/src/lsp/postgres-template.ts +6 -0
  107. package/src/mcp/postgres.ts +1 -1
  108. package/src/op/activities/clickhouse-rebuild.ts +32 -7
  109. package/src/op/activities/index.ts +21 -4
  110. package/src/op/activities/sql-exec.ts +84 -0
  111. package/src/op/activity-contracts.ts +15 -0
  112. package/src/op/effect-receipts.e2e.test.ts +277 -0
  113. package/src/plugin.ts +3 -0
  114. package/src/postgres/apply/apply.ts +21 -5
  115. package/src/postgres/apply/statements.ts +53 -11
  116. package/src/postgres/entities.ts +332 -9
  117. package/src/postgres/entity-types.ts +3 -0
  118. package/src/postgres/import/generator.ts +5 -2
  119. package/src/postgres/import/live-export.ts +4 -0
  120. package/src/postgres/live/catalog.ts +81 -5
  121. package/src/postgres/live/describe-resources.ts +17 -7
  122. package/src/postgres/live/routines.e2e.test.ts +173 -0
  123. package/src/postgres/migrate/batches.e2e.test.ts +2 -1
  124. package/src/postgres/migrate/migrate.e2e.test.ts +2 -1
  125. package/src/postgres/migrate/migrate.test.ts +3 -2
  126. package/src/postgres/migrate/receipts.ts +18 -10
  127. package/src/postgres/parser.ts +314 -8
  128. package/src/postgres/plan/commands.ts +2 -2
  129. package/src/postgres/plan/deep.ts +57 -17
  130. package/src/postgres/plan/diff.ts +46 -6
  131. package/src/postgres/plan/normalize.ts +158 -4
  132. package/src/postgres/plan/rules.ts +36 -0
  133. package/src/postgres/plan/schema.ts +25 -6
  134. package/src/postgres/plan/server-normalize.ts +24 -0
  135. package/src/postgres/routines.test.ts +150 -0
  136. package/src/postgres.ts +11 -0
  137. package/src/receipts.test.ts +91 -0
  138. package/src/receipts.ts +259 -0
  139. package/src/skill-defs.ts +1 -1
  140. package/src/skills/chant-sql-postgres.md +18 -2
  141. package/src/skills/chant-sql-rebuild.md +5 -1
  142. package/src/validate.ts +3 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * A rebuild across the shards of a cluster (#3663).
3
+ *
4
+ * On a `cluster:<name>` topology the rebuild's DDL runs `ON CLUSTER`, so
5
+ * every server gets the new table, the dual-write view and the swap. The
6
+ * rows do not move that way: each shard holds its own, and a copy run on
7
+ * the profile's server alone copies that server's shard and no other. So
8
+ * with more than one shard, the backfill and the verification work shard by
9
+ * shard, all through the profile's server:
10
+ *
11
+ * - a shard's rows are read with `cluster('<name>', db, t)` and
12
+ * `_shard_num = <n>`, one replica per shard, as the cluster's own
13
+ * configuration reaches it;
14
+ * - they are written back to the same shard with
15
+ * `INSERT INTO FUNCTION cluster('<name>', db, t__chant_new, <slot>)`, a
16
+ * constant sharding key that lands on that shard, inserted in the
17
+ * foreground so the statement returns once the shard has the rows;
18
+ * - a shard's half-copied rows are deleted with an `ALTER ... ON CLUSTER`
19
+ * whose condition holds on that shard's servers only
20
+ * (`getMacro('shard')`, the macro the cluster's Keeper paths already use).
21
+ *
22
+ * Each (shard, partition) is one copy with its own receipt, so a rerun after
23
+ * a failure on one shard skips what every shard already copied.
24
+ *
25
+ * With one shard nothing changes: the replicas of a shard replicate what is
26
+ * written on any of them, which is what the rebuild has always relied on.
27
+ */
28
+
29
+ import { clickhouseQuery } from "../http";
30
+ import type { ClickHouseTarget } from "../live/bind";
31
+ import { ident, sqlString } from "../apply/statements";
32
+ import { RebuildRefusal } from "./observe";
33
+
34
+ export interface Shard {
35
+ /** `_shard_num` / `system.clusters.shard_num`, from 1. */
36
+ num: number;
37
+ /** The shard's `{shard}` macro, the same on each of its replicas. */
38
+ macro: string;
39
+ /** The sharding key that lands on this shard: the sum of the weights of the shards before it. */
40
+ slot: number;
41
+ }
42
+
43
+ export interface Sharding {
44
+ cluster: string;
45
+ shards: Shard[];
46
+ }
47
+
48
+ /** Settings for a copy into one shard: in the foreground, and read from the cluster rather than pushed down to each shard. */
49
+ export const SHARD_INSERT_SETTINGS: Record<string, string> = { distributed_foreground_insert: "1", parallel_distributed_insert_select: "0" };
50
+
51
+ /** Settings for a delete on one shard: its condition is the server's own macro, the same on every replica of the shard. */
52
+ export const SHARD_DELETE_SETTINGS: Record<string, string> = { allow_nondeterministic_mutations: "1", mutations_sync: "2" };
53
+
54
+ /**
55
+ * The shards of the target's cluster, when its topology is `cluster:<name>`
56
+ * and the cluster has more than one. Undefined otherwise: the rebuild works
57
+ * on the profile's server as it always has.
58
+ */
59
+ export async function clusterSharding(target: ClickHouseTarget): Promise<Sharding | undefined> {
60
+ if (target.topology?.kind !== "cluster") return undefined;
61
+ let cluster = target.topology.cluster;
62
+ const macro = /^\{(\w+)\}$/.exec(cluster);
63
+ if (macro) {
64
+ const [row] = await clickhouseQuery<{ c: string }>(target.endpoint, `SELECT getMacro(${sqlString(macro[1]!)}) AS c`);
65
+ cluster = row?.c ?? cluster;
66
+ }
67
+ const weights = await clickhouseQuery<{ shard: number | string; weight: number | string }>(
68
+ target.endpoint,
69
+ `SELECT shard_num AS shard, any(shard_weight) AS weight FROM system.clusters WHERE cluster = ${sqlString(cluster)} GROUP BY shard_num ORDER BY shard_num`,
70
+ );
71
+ if (weights.length <= 1) return undefined;
72
+ const macros = new Map(
73
+ (await clickhouseQuery<{ shard: number | string; macro: string }>(target.endpoint, `SELECT _shard_num AS shard, getMacro('shard') AS macro FROM cluster(${sqlString(cluster)}, system.one)`)).map(
74
+ (r) => [Number(r.shard), r.macro],
75
+ ),
76
+ );
77
+ let slot = 0;
78
+ const shards: Shard[] = [];
79
+ for (const w of weights) {
80
+ const num = Number(w.shard);
81
+ const m = macros.get(num);
82
+ if (m === undefined) throw new RebuildRefusal(`cluster ${cluster}: shard ${num} has no {shard} macro, which a rebuild across shards needs to delete a half-copied partition on that shard alone`);
83
+ shards.push({ num, macro: m, slot });
84
+ slot += Number(w.weight) || 1;
85
+ }
86
+ const distinct = new Set(shards.map((s) => s.macro));
87
+ if (distinct.size !== shards.length) {
88
+ throw new RebuildRefusal(`cluster ${cluster}: two shards share a {shard} macro (${shards.map((s) => `${s.num}=${s.macro}`).join(", ")}); each shard needs its own`);
89
+ }
90
+ return { cluster, shards };
91
+ }
92
+
93
+ /** One shard's rows of `database.table`, as a table expression; filter with {@link onShard}. */
94
+ export const shardTable = (sharding: Sharding, database: string, table: string): string => `cluster(${sqlString(sharding.cluster)}, ${ident(database)}, ${ident(table)})`;
95
+
96
+ /** The condition that keeps one shard's rows of a {@link shardTable}. */
97
+ export const onShard = (shard: Shard): string => `_shard_num = ${shard.num}`;
98
+
99
+ /** Where a copy into one shard of `database.table` writes. */
100
+ export const intoShard = (sharding: Sharding, shard: Shard, database: string, table: string): string =>
101
+ `FUNCTION cluster(${sqlString(sharding.cluster)}, ${ident(database)}, ${ident(table)}, ${shard.slot})`;
102
+
103
+ /** The condition, in an `ALTER ... ON CLUSTER` mutation, that holds on that shard's servers only. */
104
+ export const onShardServers = (shard: Shard): string => `getMacro('shard') = ${sqlString(shard.macro)}`;
@@ -17,6 +17,7 @@ import { waitForMutations } from "../apply/mutations";
17
17
  import { renderFor } from "../topology";
18
18
  import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
19
19
  import { assertSameRows } from "./verify";
20
+ import type { Sharding } from "./shards";
20
21
  import {
21
22
  changesBetween,
22
23
  observeRebuild,
@@ -45,6 +46,8 @@ export interface RebuildRun {
45
46
  signal?: AbortSignal;
46
47
  /** The run's id, recorded on the receipts it writes. */
47
48
  runId?: string;
49
+ /** On a cluster of more than one shard, its shards: the backfill and the verification work shard by shard (`./shards.ts`). */
50
+ sharding?: Sharding;
48
51
  }
49
52
 
50
53
  /** The server's clock, in milliseconds: the cut-over and the retention are the server's times, not this machine's. */
@@ -76,6 +79,7 @@ const q = (run: RebuildRun, statement: string) => {
76
79
 
77
80
  export async function waitOn(run: RebuildRun, database: string, table: string): Promise<void> {
78
81
  const ids = await waitForMutations(run.target.endpoint, database, table, {
82
+ ...(run.sharding ? { source: `clusterAllReplicas(${sqlString(run.sharding.cluster)}, system.mutations)` } : {}),
79
83
  ...(run.mutationTimeoutMs !== undefined ? { timeoutMs: run.mutationTimeoutMs } : {}),
80
84
  ...(run.signal ? { signal: run.signal } : {}),
81
85
  });
@@ -24,6 +24,10 @@
24
24
  * later than the cut-over delay, with a time before the cut-over) stop the
25
25
  * swap too, rather than stay behind in the old table.
26
26
  *
27
+ * On a cluster of more than one shard (#3663) every shard is read, through
28
+ * the profile's server, and each partition is compared per shard
29
+ * (`shard<n>/<partition>`), so a shard the copy missed is a difference.
30
+ *
27
31
  * Any difference fails the run, naming the partitions; the run's onFailure
28
32
  * then drops the new table. On a match the result carries the digest the
29
33
  * swap gate binds: the rebuild's plan and the counts and checksums of the
@@ -39,6 +43,7 @@ import { ident, sqlString } from "../apply/statements";
39
43
  import { rebuildPlanSubject, RebuildRefusal, type RebuildObservation } from "./observe";
40
44
  import { sourcePartitionExpression } from "./partitions";
41
45
  import { cutoverOf, observe, syncReplica, utcLiteral, waitOn, type RebuildRun } from "./steps";
46
+ import { shardTable } from "./shards";
42
47
 
43
48
  export interface PartitionCheck {
44
49
  partition: string;
@@ -120,11 +125,15 @@ async function compareTables(
120
125
  const newBefore = cut && cutover !== undefined ? `${ident(cut.name)} < ${utcLiteral(cutover)}` : "1";
121
126
  const fromOld = sourcePartitionExpression(o.live.partitionKey, o.newTable.partitionKey, o.copied);
122
127
 
123
- const read = async (table: string, partition: string, hash: string, before: string): Promise<SideCounts> => {
128
+ // Across the shards of a cluster (#3663), every shard's rows, each partition counted per shard as `shard<n>/<partition>`.
129
+ const sharding = run.sharding;
130
+ const read = async (table: string, name: string, partition: string, hash: string, before: string): Promise<SideCounts> => {
131
+ const from = sharding ? shardTable(sharding, n.database, name) : table;
132
+ const key = sharding ? `concat('shard', toString(_shard_num), '/', ${partition})` : partition;
124
133
  const rows = await clickhouseQuery<{ p: string; rows: string | number; checksum: string; brows: string | number; bchecksum: string }>(
125
134
  run.target.endpoint,
126
- `SELECT ${partition} AS p, count() AS rows, toString(sum(${hash})) AS checksum, countIf(${before}) AS brows, toString(sumIf(${hash}, ${before})) AS bchecksum ` +
127
- `FROM ${table} GROUP BY p`,
135
+ `SELECT ${key} AS p, count() AS rows, toString(sum(${hash})) AS checksum, countIf(${before}) AS brows, toString(sumIf(${hash}, ${before})) AS bchecksum ` +
136
+ `FROM ${from} GROUP BY p`,
128
137
  );
129
138
  const side: SideCounts = { all: new Map(), before: new Map() };
130
139
  for (const r of rows) {
@@ -141,8 +150,8 @@ async function compareTables(
141
150
  await syncReplica(run, o, n.database, n.name);
142
151
  await syncReplica(run, o, n.database, n.newName);
143
152
  // The old table first: a row the view has yet to commit in the new one is read as missing there, never the other way round.
144
- const before = await read(n.table, "_partition_id", oldHash, oldBefore);
145
- const after = await read(n.newTable, fromOld, newHash, newBefore);
153
+ const before = await read(n.table, n.name, "_partition_id", oldHash, oldBefore);
154
+ const after = await read(n.newTable, n.newName, fromOld, newHash, newBefore);
146
155
  const mismatches = differences(before.all, after.all);
147
156
  if (mismatches.length === 0 || attempt >= attempts) return { old: before, new: after, mismatches };
148
157
  run.log(`-- ${n.key}: ${mismatches.length} partition(s) differ (${mismatches.map((m) => m.partition).slice(0, 5).join(", ")}); reading again (${attempt}/${attempts})`);
@@ -70,7 +70,7 @@ Two rules change class across the supported majors: a STORED generated column's
70
70
 
71
71
  ## Identity and renames
72
72
 
73
- An object is identified by its export name between two builds, and by its schema-qualified name against a server, which has no export names. Tables, views, materialized views, sequences and indexes share one namespace per schema, types and domains another, and schemas and extensions are matched by name.
73
+ An object is identified by its export name between two builds, and by its schema-qualified name against a server, which has no export names. Tables, views, materialized views, sequences and indexes share one namespace per schema, types and domains another, and schemas and extensions are matched by name. Functions and procedures share a namespace of their own and are told apart by their input parameter types, so an overload is another object; a trigger is matched by its name on its table.
74
74
 
75
75
  A rename is declared where it happens. Before a CREATE, \`-- previously: <old name>\` says the object had another name; on a column's line, it says the column did:
76
76
 
@@ -26,6 +26,9 @@ const KINDS: Record<string, string> = {
26
26
  PostgresEnum: POSTGRES_ENTITY_TYPES.enum,
27
27
  PostgresDomain: POSTGRES_ENTITY_TYPES.domain,
28
28
  PostgresExtension: POSTGRES_ENTITY_TYPES.extension,
29
+ PostgresFunction: POSTGRES_ENTITY_TYPES.function,
30
+ PostgresProcedure: POSTGRES_ENTITY_TYPES.procedure,
31
+ PostgresTrigger: POSTGRES_ENTITY_TYPES.trigger,
29
32
  };
30
33
 
31
34
  export function buildRegistry(): string {
package/src/config.ts CHANGED
@@ -115,6 +115,13 @@ export const sqlProfileSchema = z.strictObject({
115
115
  schemas: z.array(z.string()).optional(),
116
116
  /** Postgres: the schema an unqualified declaration is created in. `public` when omitted. */
117
117
  defaultSchema: z.string().optional(),
118
+ /**
119
+ * Postgres: the schema `effect()` receipts are kept in, as
120
+ * `<schema>.__chant_receipts`. When omitted they are in
121
+ * `chant_receipts.receipts`, a schema chant creates; name an existing schema
122
+ * on a server where the role cannot `CREATE SCHEMA`.
123
+ */
124
+ receiptsSchema: z.string().optional(),
118
125
  /**
119
126
  * The managed service this environment's Postgres runs on, when it differs
120
127
  * from `sql.provider`. Import reads the provider's own roles, schemas and
@@ -10,9 +10,11 @@
10
10
  *
11
11
  * A dialect's store implements this interface over its own client and keeps
12
12
  * the receipts in a table whose comment carries the ownership trailer with
13
- * the `receipts` key (`./ownership.ts`), so schema reads leave it out. It is
14
- * not registered as the global `receiptRead` / `receiptWrite` activities,
15
- * which would take over every other lexicon's `effect()` steps in the run.
13
+ * the `receipts` key (`./ownership.ts`), so schema reads leave it out. A
14
+ * migration's store is reached by its backfill directly. The store `effect()`
15
+ * steps use (`../receipts.ts`) is registered as the `receiptRead` /
16
+ * `receiptWrite` activities only as a fallback, so it never takes over
17
+ * another configured lexicon's receipt row.
16
18
  */
17
19
 
18
20
  import type { ReceiptStore } from "@intentius/chant/op/receipt-store";
@@ -63,5 +63,20 @@
63
63
  "resourceType": "Postgres::Extension",
64
64
  "kind": "resource",
65
65
  "lexicon": "sql"
66
+ },
67
+ "PostgresFunction": {
68
+ "resourceType": "Postgres::Function",
69
+ "kind": "resource",
70
+ "lexicon": "sql"
71
+ },
72
+ "PostgresProcedure": {
73
+ "resourceType": "Postgres::Procedure",
74
+ "kind": "resource",
75
+ "lexicon": "sql"
76
+ },
77
+ "PostgresTrigger": {
78
+ "resourceType": "Postgres::Trigger",
79
+ "kind": "resource",
80
+ "lexicon": "sql"
66
81
  }
67
82
  }
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ export { database, table, view, literal } from "./clickhouse/entities";
18
18
  // A tag folds as the function its file imports, so `table` imported from
19
19
  // `@intentius/chant-lexicon-sql/postgres` is Postgres's. Declarations import
20
20
  // them from the dialect subpath.
21
- export { schema, index, sequence, type, domain, extension } from "./postgres/entities";
21
+ export { schema, index, sequence, type, domain, extension, func, procedure, trigger } from "./postgres/entities";
22
22
 
23
23
  // A schema change's statements between two builds, rendered offline for a
24
24
  // migration file (#3644); also at `@intentius/chant-lexicon-sql/migration-statements`.
@@ -17,12 +17,12 @@ export const POSTGRES_TEMPLATE_SOURCES: readonly TemplateSource<PostgresTag>[] =
17
17
  {
18
18
  dialect: "postgres",
19
19
  modules: ["@intentius/chant-lexicon-sql/postgres"],
20
- tags: ["schema", "table", "index", "view", "sequence", "type", "domain", "extension"],
20
+ tags: ["schema", "table", "index", "view", "sequence", "type", "domain", "extension", "func", "procedure", "trigger"],
21
21
  },
22
22
  {
23
23
  dialect: "postgres",
24
24
  modules: ["@intentius/chant-lexicon-sql"],
25
- tags: ["schema", "index", "sequence", "type", "domain", "extension"],
25
+ tags: ["schema", "index", "sequence", "type", "domain", "extension", "func", "procedure", "trigger"],
26
26
  },
27
27
  ];
28
28
 
@@ -13,6 +13,9 @@ const STATEMENT: Record<PostgresTag, string> = {
13
13
  type: "enum",
14
14
  domain: "domain",
15
15
  extension: "extension",
16
+ func: "function",
17
+ procedure: "procedure",
18
+ trigger: "trigger",
16
19
  };
17
20
 
18
21
  const TAG_OF: Record<string, PostgresTag> = Object.fromEntries(Object.entries(STATEMENT).map(([tag, statement]) => [statement, tag as PostgresTag]));
@@ -149,6 +149,12 @@ export function postgresDeclarations(content: string, fileName = "file.ts"): Pos
149
149
  case "extension":
150
150
  sqlName = text(node.name.span);
151
151
  break;
152
+ case "function":
153
+ case "procedure":
154
+ case "trigger":
155
+ sqlName = text(node.name.span);
156
+ kind = node.statement;
157
+ break;
152
158
  default:
153
159
  continue;
154
160
  }
@@ -81,7 +81,7 @@ export function postgresSearch(params: Record<string, unknown>): unknown {
81
81
  };
82
82
  }
83
83
 
84
- const TAGS = { schema: pg.schema, table: pg.table, index: pg.index, view: pg.view, sequence: pg.sequence, type: pg.type, domain: pg.domain, extension: pg.extension } as const;
84
+ const TAGS = { schema: pg.schema, table: pg.table, index: pg.index, view: pg.view, sequence: pg.sequence, type: pg.type, domain: pg.domain, extension: pg.extension, func: pg.func, procedure: pg.procedure, trigger: pg.trigger } as const;
85
85
  export const POSTGRES_TAGS = Object.keys(TAGS);
86
86
 
87
87
  /** Parse one statement with the Postgres tag; an error comes back as SQLPG001 with its line and column. */
@@ -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
+ );