@intentius/chant-lexicon-sql 0.112.1 → 0.113.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 (38) hide show
  1. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  2. package/dist/clickhouse/apply/statements.d.ts +77 -1
  3. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  4. package/dist/clickhouse/plan/commands.d.ts +6 -1
  5. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  6. package/dist/clickhouse/topology.d.ts +100 -0
  7. package/dist/clickhouse/topology.d.ts.map +1 -0
  8. package/dist/codegen/classifier-page.d.ts.map +1 -1
  9. package/dist/codegen/postgres-classifier-page.d.ts.map +1 -1
  10. package/dist/core/statements.d.ts +104 -0
  11. package/dist/core/statements.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/integrity.json +2 -2
  15. package/dist/manifest.json +1 -1
  16. package/dist/migration-statements.d.ts +55 -0
  17. package/dist/migration-statements.d.ts.map +1 -0
  18. package/dist/postgres/apply/apply.d.ts.map +1 -1
  19. package/dist/postgres/apply/statements.d.ts +91 -1
  20. package/dist/postgres/apply/statements.d.ts.map +1 -1
  21. package/package.json +2 -2
  22. package/src/clickhouse/apply/apply.ts +25 -53
  23. package/src/clickhouse/apply/statements.ts +149 -31
  24. package/src/clickhouse/plan/commands.ts +30 -6
  25. package/src/clickhouse/topology.e2e.test.ts +100 -0
  26. package/src/clickhouse/topology.test.ts +276 -0
  27. package/src/clickhouse/topology.ts +451 -0
  28. package/src/codegen/classifier-page.ts +17 -0
  29. package/src/codegen/postgres-classifier-page.ts +17 -0
  30. package/src/core/statements.ts +127 -0
  31. package/src/index.ts +15 -0
  32. package/src/migration-statements.test.ts +265 -0
  33. package/src/migration-statements.ts +252 -0
  34. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +93 -0
  35. package/src/op/watch-drift.e2e.test.ts +302 -0
  36. package/src/op/watch-pipeline.test.ts +113 -0
  37. package/src/postgres/apply/apply.ts +38 -96
  38. package/src/postgres/apply/statements.ts +145 -2
@@ -0,0 +1,302 @@
1
+ /**
2
+ * A scheduled drift check over a sql environment, run end to end (#3642).
3
+ *
4
+ * For each dialect, a project in a temporary directory declares a schema, an
5
+ * `ApplyOp` that creates it as an admin, and a `WatchOp` with a `schedule`
6
+ * over a second environment whose profile reads the server as a user that can
7
+ * only read. `chant run` runs the watch, as the job `generateOpsPipeline`
8
+ * renders does (`watch-pipeline.test.ts`), and its `Drift` outcome is read off
9
+ * the run:
10
+ *
11
+ * - false on the schema as applied;
12
+ * - false after a table the project does not own is created beside it;
13
+ * - true after an owned object's property is changed out of band;
14
+ * - true after an owned object is dropped.
15
+ *
16
+ * Everything is written to the database (ClickHouse) or schema (Postgres)
17
+ * `yodel_3642`, and a reader user named after it.
18
+ *
19
+ * The servers: `CHANT_SQL_WATCH_CLICKHOUSE_URL` and
20
+ * `CHANT_SQL_WATCH_POSTGRES_URL` (a `postgres://user@host:port/db` URL, with
21
+ * `CHANT_SQL_WATCH_POSTGRES_PASSWORD`) name running ones, such as the servers
22
+ * `chant emulator up --lexicon sql` starts. Without them, each dialect starts
23
+ * a throwaway server at the same pin, and skips cleanly when Docker is not
24
+ * available.
25
+ */
26
+
27
+ import { execFile } from "node:child_process";
28
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { promisify } from "node:util";
32
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
33
+ import { dockerAvailable, startScratchServer, type ScratchServer } from "../clickhouse/container";
34
+ import { clickhouseQuery, type ClickHouseEndpoint } from "../clickhouse/http";
35
+ import { clickhouseImage } from "../spec/pin";
36
+ import { startTestPostgres, type TestPostgres } from "../postgres/testing/server";
37
+ import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../postgres/live/client";
38
+
39
+ const exec = promisify(execFile);
40
+
41
+ const REPO = join(import.meta.dirname, "../../../..");
42
+ const BIN = join(REPO, "node_modules/.bin");
43
+ const NAME = "yodel_3642";
44
+ const READER = `${NAME}_reader`;
45
+ const READER_PASSWORD = "reader-3642";
46
+
47
+ const givenClickHouse = process.env.CHANT_SQL_WATCH_CLICKHOUSE_URL;
48
+ const givenPostgres = process.env.CHANT_SQL_WATCH_POSTGRES_URL;
49
+ const docker = givenClickHouse && givenPostgres ? false : await dockerAvailable();
50
+
51
+ interface Watch {
52
+ drift: boolean;
53
+ output: string;
54
+ }
55
+
56
+ /**
57
+ * A project directory chant can run in: the repository's `node_modules`
58
+ * linked in, so `@intentius/chant` and the sql lexicon resolve, and a git
59
+ * repository, which the watch's snapshot is written to.
60
+ */
61
+ async function project(files: Record<string, string>): Promise<{ dir: string; run(op: string, env?: Record<string, string>): Promise<Watch> }> {
62
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-watch-"));
63
+ symlinkSync(join(REPO, "node_modules"), join(dir, "node_modules"), "dir");
64
+ const all: Record<string, string> = {
65
+ "package.json": JSON.stringify({ name: "sql-watch-e2e", private: true, type: "module", scripts: { build: "chant build src --lexicon sql -o dist/schema.json" } }),
66
+ ".gitignore": "node_modules\ndist\n.chant\n",
67
+ ...files,
68
+ };
69
+ for (const [path, text] of Object.entries(all)) {
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 schema");
77
+ return {
78
+ dir,
79
+ async run(op, env = {}) {
80
+ const result = await exec(join(BIN, "chant"), ["run", op], {
81
+ cwd: dir,
82
+ env: { ...process.env, ...env, PATH: `${BIN}:${process.env.PATH ?? ""}` },
83
+ maxBuffer: 16 * 1024 * 1024,
84
+ }).catch((err: { stdout?: string; stderr?: string; message: string }) => {
85
+ throw new Error(`chant run ${op} failed: ${err.message}\n${err.stdout ?? ""}${err.stderr ?? ""}`);
86
+ });
87
+ const output = `${result.stdout}${result.stderr}`.replace(/\u001b\[[0-9;]*m/g, "");
88
+ const outcome = /\[outcome\] Drift=(true|false)/.exec(output);
89
+ if (op === "schema-watch" && !outcome) throw new Error(`chant run ${op} reported no Drift outcome:\n${output}`);
90
+ return { drift: outcome?.[1] === "true", output };
91
+ },
92
+ };
93
+ }
94
+
95
+ const OPS = (target: string) => ({
96
+ "ops/apply.op.ts": `import { ApplyOp } from "@intentius/chant/op";
97
+ const { op } = ApplyOp({ name: "schema-apply", env: "admin", target: "${target}" });
98
+ export default op;
99
+ `,
100
+ "ops/watch.op.ts": `import { WatchOp } from "@intentius/chant/op";
101
+ const { op } = WatchOp({ name: "schema-watch", env: "watch", schedule: "17 * * * *" });
102
+ export default op;
103
+ `,
104
+ });
105
+
106
+ const config = (sql: Record<string, unknown>) => `import type { ChantConfig } from "@intentius/chant";
107
+ import "@intentius/chant-lexicon-sql";
108
+
109
+ export default {
110
+ lexicons: ["sql"],
111
+ ownership: { stack: "yodel-3642", env: "watch" },
112
+ sql: ${JSON.stringify(sql, null, 2)},
113
+ } satisfies ChantConfig;
114
+ `;
115
+
116
+ const READER_ENV = { WATCH_USER: READER, WATCH_PASSWORD: READER_PASSWORD };
117
+
118
+ // ── ClickHouse ──────────────────────────────────────────────────────────────
119
+
120
+ describe.skipIf(!givenClickHouse && !docker)("a scheduled WatchOp over a ClickHouse environment (#3642)", () => {
121
+ let server: ScratchServer | undefined;
122
+ let admin: ClickHouseEndpoint;
123
+ let p: Awaited<ReturnType<typeof project>>;
124
+ const q = (sql: string) => clickhouseQuery(admin, sql);
125
+ const cleanup = async () => {
126
+ await q(`DROP DATABASE IF EXISTS ${NAME} SYNC`);
127
+ await q(`DROP USER IF EXISTS ${READER}`);
128
+ };
129
+
130
+ beforeAll(async () => {
131
+ if (givenClickHouse) admin = { url: givenClickHouse };
132
+ else {
133
+ server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-watch" });
134
+ admin = server.endpoint;
135
+ }
136
+ await cleanup();
137
+ p = await project({
138
+ ...OPS("clickhouse"),
139
+ "chant.config.ts": config({
140
+ dialect: "clickhouse",
141
+ profiles: {
142
+ admin: { url: admin.url, databases: [NAME] },
143
+ watch: { url: admin.url, user: { env: "WATCH_USER" }, password: { env: "WATCH_PASSWORD" }, databases: [NAME] },
144
+ },
145
+ }),
146
+ "src/schema.ts": `import { database, table, view } from "@intentius/chant-lexicon-sql/clickhouse";
147
+
148
+ export const db = database\`CREATE DATABASE ${NAME} ENGINE = Atomic COMMENT 'drift watch'\`;
149
+
150
+ export const events = table\`
151
+ CREATE TABLE \${db}.events (
152
+ user_id UInt64,
153
+ kind LowCardinality(String),
154
+ ts DateTime
155
+ )
156
+ ENGINE = MergeTree
157
+ ORDER BY (user_id, ts)
158
+ TTL ts + INTERVAL 180 DAY
159
+ COMMENT 'Raw events'\`;
160
+
161
+ export const byKind = view\`
162
+ CREATE VIEW \${db}.by_kind AS
163
+ SELECT \${events.columns.kind} AS kind, count() AS n FROM \${events} GROUP BY kind\`;
164
+ `,
165
+ });
166
+ const applied = await p.run("schema-apply");
167
+ expect(applied.output).toMatch(/Op "schema-apply" completed/);
168
+ // A reader: SELECT on the database and nothing else, and readonly besides.
169
+ await q(`CREATE USER ${READER} IDENTIFIED WITH sha256_password BY '${READER_PASSWORD}' SETTINGS readonly = 1`);
170
+ await q(`GRANT SELECT ON ${NAME}.* TO ${READER}`);
171
+ }, 600_000);
172
+
173
+ afterAll(async () => {
174
+ if (admin) await cleanup().catch(() => undefined);
175
+ await server?.stop();
176
+ if (p) rmSync(p.dir, { recursive: true, force: true });
177
+ });
178
+
179
+ test("reports no drift on the schema as applied", async () => {
180
+ const watch = await p.run("schema-watch", READER_ENV);
181
+ expect(watch.output).toContain("No drift detected");
182
+ expect(watch.drift).toBe(false);
183
+ }, 120_000);
184
+
185
+ test("reports nothing for a table the project does not own", async () => {
186
+ await q(`CREATE TABLE ${NAME}.made_by_hand (id UInt64) ENGINE = MergeTree ORDER BY id COMMENT 'not chant'`);
187
+ const watch = await p.run("schema-watch", READER_ENV);
188
+ expect(watch.output).not.toContain("made_by_hand");
189
+ expect(watch.drift).toBe(false);
190
+ }, 120_000);
191
+
192
+ test("reports drift when an owned table's TTL is changed out of band", async () => {
193
+ await q(`ALTER TABLE ${NAME}.events MODIFY TTL ts + INTERVAL 30 DAY`);
194
+ const watch = await p.run("schema-watch", READER_ENV);
195
+ expect(watch.output).toContain("PROPERTY DRIFT");
196
+ expect(watch.output).toMatch(/- events \(ClickHouse::Table\)\n\s+ttl: ts \+ INTERVAL 180 DAY → ts \+ toIntervalDay\(30\)/);
197
+ expect(watch.drift).toBe(true);
198
+ await q(`ALTER TABLE ${NAME}.events MODIFY TTL ts + INTERVAL 180 DAY`);
199
+ }, 120_000);
200
+
201
+ test("reports drift when an owned view is dropped", async () => {
202
+ await q(`DROP VIEW ${NAME}.by_kind`);
203
+ const watch = await p.run("schema-watch", READER_ENV);
204
+ expect(watch.output).toMatch(/MISSING \(declared, provider reports not in cloud\):\n\s+- byKind /);
205
+ expect(watch.drift).toBe(true);
206
+ }, 120_000);
207
+ });
208
+
209
+ // ── Postgres ────────────────────────────────────────────────────────────────
210
+
211
+ describe.skipIf(!givenPostgres && !docker)("a scheduled WatchOp over a Postgres environment (#3642)", () => {
212
+ let server: TestPostgres | undefined;
213
+ let endpoint: PostgresEndpoint;
214
+ let admin: PostgresClient;
215
+ let p: Awaited<ReturnType<typeof project>>;
216
+ const q = (sql: string) => admin.query(sql);
217
+ const cleanup = async () => {
218
+ await q(`DROP SCHEMA IF EXISTS ${NAME} CASCADE`);
219
+ await q(`DROP ROLE IF EXISTS ${READER}`);
220
+ };
221
+
222
+ beforeAll(async () => {
223
+ if (givenPostgres) endpoint = { url: givenPostgres, password: process.env.CHANT_SQL_WATCH_POSTGRES_PASSWORD };
224
+ else {
225
+ server = await startTestPostgres();
226
+ endpoint = server.endpoint();
227
+ }
228
+ admin = await connectPostgres(endpoint);
229
+ await cleanup();
230
+ const readerUrl = new URL(endpoint.url);
231
+ readerUrl.username = READER;
232
+ p = await project({
233
+ ...OPS("postgres"),
234
+ "chant.config.ts": config({
235
+ dialect: "postgres",
236
+ profiles: {
237
+ admin: { url: endpoint.url, password: { env: "ADMIN_PASSWORD" }, schemas: [NAME] },
238
+ watch: { url: readerUrl.toString(), password: { env: "WATCH_PASSWORD" }, schemas: [NAME] },
239
+ },
240
+ }),
241
+ "src/schema.ts": `import { schema, table, index } from "@intentius/chant-lexicon-sql/postgres";
242
+
243
+ export const app = schema\`CREATE SCHEMA ${NAME};
244
+ COMMENT ON SCHEMA ${NAME} IS 'drift watch'\`;
245
+
246
+ export const orders = table\`
247
+ CREATE TABLE \${app}.orders (
248
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
249
+ status text NOT NULL DEFAULT 'new',
250
+ amount numeric(12, 2) NOT NULL
251
+ );
252
+ COMMENT ON TABLE \${app}.orders IS 'One row per order'\`;
253
+
254
+ export const ordersStatus = index\`CREATE INDEX orders_status_idx ON \${orders} (\${orders.columns.status})\`;
255
+ `,
256
+ });
257
+ const applied = await p.run("schema-apply", { ADMIN_PASSWORD: endpoint.password ?? "" });
258
+ expect(applied.output).toMatch(/Op "schema-apply" completed/);
259
+ // A reader: it may log in and look in the schema, and every transaction it opens is read-only.
260
+ await q(`CREATE ROLE ${READER} LOGIN PASSWORD '${READER_PASSWORD}'`);
261
+ await q(`ALTER ROLE ${READER} SET default_transaction_read_only = on`);
262
+ await q(`GRANT USAGE ON SCHEMA ${NAME} TO ${READER}`);
263
+ }, 600_000);
264
+
265
+ afterAll(async () => {
266
+ if (admin) {
267
+ await cleanup().catch(() => undefined);
268
+ await admin.end();
269
+ }
270
+ await server?.stop();
271
+ if (p) rmSync(p.dir, { recursive: true, force: true });
272
+ });
273
+
274
+ test("reports no drift on the schema as applied", async () => {
275
+ const watch = await p.run("schema-watch", READER_ENV);
276
+ expect(watch.output).toContain("No drift detected");
277
+ expect(watch.drift).toBe(false);
278
+ }, 120_000);
279
+
280
+ test("reports nothing for a table the project does not own", async () => {
281
+ await q(`CREATE TABLE ${NAME}.made_by_hand (id int)`);
282
+ const watch = await p.run("schema-watch", READER_ENV);
283
+ expect(watch.output).not.toContain("made_by_hand");
284
+ expect(watch.drift).toBe(false);
285
+ }, 120_000);
286
+
287
+ test("reports drift when an owned column's default is changed out of band", async () => {
288
+ await q(`ALTER TABLE ${NAME}.orders ALTER COLUMN status SET DEFAULT 'pending'`);
289
+ const watch = await p.run("schema-watch", READER_ENV);
290
+ expect(watch.output).toContain("PROPERTY DRIFT");
291
+ expect(watch.output).toMatch(/- orders \(Postgres::Table\)\n\s+columns\[1\]\.default: 'new' → 'pending'::text/);
292
+ expect(watch.drift).toBe(true);
293
+ await q(`ALTER TABLE ${NAME}.orders ALTER COLUMN status SET DEFAULT 'new'`);
294
+ }, 120_000);
295
+
296
+ test("reports drift when an owned index is dropped", async () => {
297
+ await q(`DROP INDEX ${NAME}.orders_status_idx`);
298
+ const watch = await p.run("schema-watch", READER_ENV);
299
+ expect(watch.output).toMatch(/MISSING \(declared, provider reports not in cloud\):\n\s+- ordersStatus /);
300
+ expect(watch.drift).toBe(true);
301
+ }, 120_000);
302
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The scheduled drift check for a sql environment, rendered to CI (#3642).
3
+ *
4
+ * `examples/drift-watch` declares a `WatchOp` with a `schedule` over its
5
+ * `prod` environment, whose `sql.profiles.prod` reads the server as a reader
6
+ * user. `generateOpsPipeline` renders it for each forge chant targets, through
7
+ * the real github, gitlab and forgejo plugins and real Op discovery. The job
8
+ * holds read-only credentials: the reader's user and password and nothing
9
+ * else, and, where the forge lets a workflow say so, a token that can only
10
+ * read the repository.
11
+ *
12
+ * `watch-drift.e2e.test.ts` runs the same kind of Op against a server.
13
+ */
14
+
15
+ import { join } from "node:path";
16
+ import { describe, expect, test } from "vitest";
17
+ import { generateOpsPipeline } from "@intentius/chant/op";
18
+ import { parseYAML } from "@intentius/chant/yaml";
19
+ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
20
+
21
+ const EXAMPLE = join(import.meta.dirname, "../../examples/drift-watch");
22
+
23
+ /** The reader's credentials, as each forge names a secret. */
24
+ const reader = (secret: (name: string) => string): ScheduledOpSpec => ({
25
+ name: "schema-watch",
26
+ variables: {
27
+ CLICKHOUSE_READER_USER: "chant_reader",
28
+ CLICKHOUSE_READER_PASSWORD: secret("CLICKHOUSE_READER_PASSWORD"),
29
+ },
30
+ });
31
+ const actionsSecret = (name: string) => `\${{ secrets.${name} }}`;
32
+ const gitlabVariable = (name: string) => `$${name}`;
33
+
34
+ /**
35
+ * Install the project, and put its `chant` on the job's PATH: `chant run`
36
+ * runs `chant lifecycle` itself. A GitHub or Forgejo step is a shell of its
37
+ * own, so the path goes through `$GITHUB_PATH`; GitLab's script lines share one.
38
+ */
39
+ const INSTALL = {
40
+ actions: ["npm ci", 'echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"'],
41
+ gitlab: ["npm ci", 'export PATH="$PWD/node_modules/.bin:$PATH"'],
42
+ };
43
+
44
+ async function render(forge: "github" | "gitlab" | "forgejo", spec: ScheduledOpSpec) {
45
+ const beforeScript = forge === "gitlab" ? INSTALL.gitlab : INSTALL.actions;
46
+ const result = await generateOpsPipeline([spec], forge, { beforeScript }, EXAMPLE);
47
+ expect(result.error).toBeUndefined();
48
+ expect(result.success).toBe(true);
49
+ expect(result.files).toHaveLength(1);
50
+ expect(result.jobs).toEqual([
51
+ { jobName: "schema-watch", op: "schema-watch", trigger: { kind: "cron", schedule: "17 * * * *" }, findingMode: "report" },
52
+ ]);
53
+ const file = result.files![0]!;
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- a parsed workflow, read by path
55
+ return { file, doc: parseYAML(file.yaml) as Record<string, any> };
56
+ }
57
+
58
+ const runLine = (steps: Array<Record<string, unknown>>) => steps.find((s) => s.run === "chant run schema-watch");
59
+
60
+ describe("generateOpsPipeline renders the sql drift watch (#3642)", () => {
61
+ test("GitHub: the Op's own cron, a read-only token and the reader's credentials", async () => {
62
+ const { file, doc } = await render("github", reader(actionsSecret));
63
+ expect(file.name).toBe("schema-watch.yml");
64
+ expect(doc.on.schedule).toEqual([{ cron: "17 * * * *" }]);
65
+ expect(doc.permissions).toEqual({ contents: "read" });
66
+ const job = doc.jobs["schema-watch"];
67
+ expect(job.env).toEqual({
68
+ CLICKHOUSE_READER_USER: "chant_reader",
69
+ CLICKHOUSE_READER_PASSWORD: "${{ secrets.CLICKHOUSE_READER_PASSWORD }}",
70
+ });
71
+ expect(job.permissions).toBeUndefined();
72
+ expect(job.steps.map((s: Record<string, unknown>) => s.run).filter(Boolean)).toEqual([...INSTALL.actions, "chant run schema-watch"]);
73
+ expect(runLine(job.steps)?.env).toEqual({ GITHUB_TOKEN: "${{ github.token }}" });
74
+ expect(file.yaml).toMatchSnapshot();
75
+ });
76
+
77
+ test("GitLab: a schedule-only job with the reader's credentials and no token of its own", async () => {
78
+ const { file, doc } = await render("gitlab", reader(gitlabVariable));
79
+ expect(file.yaml).toContain('# schema-watch: cron "17 * * * *", CHANT_SCHEDULED_OP="schema-watch", finding-mode report');
80
+ const job = doc["schema-watch"];
81
+ expect(job.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "schedule" && $CHANT_SCHEDULED_OP == "schema-watch"' }]);
82
+ expect(job.variables).toEqual({
83
+ CLICKHOUSE_READER_USER: "chant_reader",
84
+ CLICKHOUSE_READER_PASSWORD: "$CLICKHOUSE_READER_PASSWORD",
85
+ });
86
+ expect(doc.variables).toBeUndefined();
87
+ expect(job.script).toEqual([...INSTALL.gitlab, "chant run schema-watch"]);
88
+ expect(file.yaml).toMatchSnapshot();
89
+ });
90
+
91
+ test("Forgejo: the Op's own cron and the reader's credentials; the runner's token takes no permissions block", async () => {
92
+ const { file, doc } = await render("forgejo", reader(actionsSecret));
93
+ expect(file.name).toBe("schema-watch.yml");
94
+ expect(doc.on.schedule).toEqual([{ cron: "17 * * * *" }]);
95
+ // Forgejo's runner reads no `permissions:`, so the generator writes none.
96
+ expect(doc.permissions).toBeUndefined();
97
+ const job = doc.jobs["schema-watch"];
98
+ expect(job.env).toEqual({
99
+ CLICKHOUSE_READER_USER: "chant_reader",
100
+ CLICKHOUSE_READER_PASSWORD: "${{ secrets.CLICKHOUSE_READER_PASSWORD }}",
101
+ });
102
+ expect(job.steps.map((s: Record<string, unknown>) => s.run).filter(Boolean)).toEqual([...INSTALL.actions, "chant run schema-watch"]);
103
+ expect(file.yaml).toMatchSnapshot();
104
+ });
105
+
106
+ test("the example's profile reads the server as the user and password the job holds", async () => {
107
+ const { default: config } = await import(join(EXAMPLE, "chant.config.ts"));
108
+ expect(config.sql.profiles.prod).toMatchObject({
109
+ user: { env: "CLICKHOUSE_READER_USER" },
110
+ password: { env: "CLICKHOUSE_READER_PASSWORD" },
111
+ });
112
+ });
113
+ });
@@ -68,7 +68,7 @@
68
68
 
69
69
  import type { OwnershipMarker } from "@intentius/chant/ownership";
70
70
  import type { ApplyRef } from "@intentius/chant/apply";
71
- import { SqlApplyError, changesByObject, dependencyFailedDetail, missingDependencies, readBuildObjects, type SqlApplyOutcome } from "../../core/apply";
71
+ import { SqlApplyError, dependencyFailedDetail, missingDependencies, readBuildObjects, type SqlApplyOutcome } from "../../core/apply";
72
72
  import { carriesMarker, isChantManaged } from "../../core/ownership";
73
73
  import { PostgresQueryError, type PostgresClient } from "../live/client";
74
74
  import type { PostgresTarget } from "../live/bind";
@@ -83,18 +83,7 @@ import type { PostgresEntityType } from "../entity-types";
83
83
  import type { PgChangeClass } from "../plan/rules";
84
84
  import type { CanonicalKind } from "../plan/normalize";
85
85
  import { quoteIdent } from "../keywords";
86
- import {
87
- alterSteps,
88
- commentStatement,
89
- createSteps,
90
- dropStatement,
91
- isDestructive,
92
- isRefused,
93
- refusalDetail,
94
- scansRows,
95
- type DeclaredPgObject,
96
- type PgStep,
97
- } from "./statements";
86
+ import { planPgStatements, scansRows, type DeclaredPgObject, type PgDropStatement, type PgStep } from "./statements";
98
87
 
99
88
  /** The timeouts a statement runs with, in milliseconds; 0 is no limit. */
100
89
  export interface PostgresApplyTimeouts {
@@ -195,19 +184,6 @@ export interface PostgresApplyOptions {
195
184
  serverNormalize?: typeof serverNormalized;
196
185
  }
197
186
 
198
- /** Drop order: what reads from a table before the table, types after the tables using them, a schema last. */
199
- const DROP_ORDER: Record<CanonicalKind, number> = {
200
- materializedView: 0,
201
- view: 1,
202
- index: 2,
203
- table: 3,
204
- sequence: 4,
205
- domain: 5,
206
- enum: 6,
207
- extension: 7,
208
- schema: 8,
209
- };
210
-
211
187
  const SQLSTATE_LOCK_NOT_AVAILABLE = "55P03";
212
188
  const SQLSTATE_QUERY_CANCELED = "57014";
213
189
  const SQLSTATE_DEPENDENT_OBJECTS = "2BP01";
@@ -273,82 +249,52 @@ export async function applyPostgres(
273
249
  ...(opts.readLive ? { readLive: opts.readLive } : {}),
274
250
  ...(opts.serverNormalize ? { serverNormalize: opts.serverNormalize } : {}),
275
251
  });
276
- const byObject = changesByObject(plan.diff.changes);
277
252
  const normalizedByKey = new Map(plan.declared.map((o) => [o.key, o.canonical]));
278
- const liveByKey = new Map(plan.live.map((o) => [o.key, o.canonical]));
279
253
  const liveRawByKey = new Map(plan.liveObjects.map((o) => [qualifiedKey(liveCanonicalAddress(o)), o]));
280
- const matchedLive = new Map<string, string>();
281
- for (const m of matchPgObjects(plan.live, plan.declared)) if (m.kind === "matched") matchedLive.set(m.after.key, m.before.key);
254
+ const objects = declared.map((original) => ({ ...original, canonical: { ...original.canonical, ...normalizedByKey.get(original.key) } }));
255
+ const matchedLive = new Set<string>();
256
+ for (const m of matchPgObjects(plan.live, plan.declared)) if (m.kind === "matched") matchedLive.add(m.after.key);
282
257
 
283
258
  // An extension's own comment stays under the trailer when the declaration sets none.
284
- const extensionComments = await extensionDefaults(client, declared.filter((o) => o.canonical.kind === "extension" && !matchedLive.has(o.key)).map((o) => o.canonical.name));
259
+ const extensionComments = await extensionDefaults(client, objects.filter((o) => o.canonical.kind === "extension" && !matchedLive.has(o.key)).map((o) => o.canonical.name));
285
260
 
286
261
  // ── Each object's statements, or its verdict when it has none to send. ──
262
+ const statements = planPgStatements({
263
+ declared: objects,
264
+ changes: plan.diff.changes,
265
+ current: plan.live,
266
+ major: plan.major ?? POSTGRES_LATEST_MAJOR,
267
+ allowDestructive: opts.prune === true,
268
+ carriesMarker: (_live, key) => carriesMarker(liveRawByKey.get(key)?.comment, opts.marker),
269
+ extensionComment: (name) => extensionComments.get(name),
270
+ ...(opts.marker ? { marker: opts.marker } : {}),
271
+ });
272
+
287
273
  const units: Unit[] = [];
288
274
  const onServer = new Set<string>();
289
275
  const exportNames = new Set(declared.map((o) => o.exportName));
290
- const recreatedRelations = new Set<string>();
291
- for (const original of declared) {
292
- const obj = { ...original, canonical: { ...original.canonical, ...normalizedByKey.get(original.key) } };
276
+ for (const entry of statements.objects) {
277
+ const obj = entry.obj;
293
278
  const ref = { kind: obj.type, name: obj.name };
294
- const liveKey = matchedLive.get(obj.key);
295
- const live = liveKey !== undefined ? liveByKey.get(liveKey) : undefined;
296
- const liveRaw = liveKey !== undefined ? liveRawByKey.get(liveKey) : undefined;
297
- const mine = byObject.get(obj.key) ?? [];
298
-
299
- if (live?.foreign) {
300
- onServer.add(obj.exportName);
301
- outcome.notAttempted.push({ ...ref, reason: "filtered", detail: `${live.foreign} keeps ${obj.name}; it is not chant's to change` });
302
- continue;
303
- }
304
- const refused = mine.filter(isRefused);
305
- if (refused.length > 0) {
306
- if (live) onServer.add(obj.exportName);
307
- outcome.notAttempted.push({ ...ref, reason: "unsupported-kind", detail: refusalDetail(refused, obj.name, obj.canonical) });
308
- continue;
309
- }
310
- const destructive = mine.filter(isDestructive);
311
- if (destructive.length > 0 && !opts.prune) {
312
- onServer.add(obj.exportName);
313
- outcome.notAttempted.push({
314
- ...ref,
315
- reason: "filtered",
316
- detail: `drops ${destructive.map((c) => c.field).join(", ")}, which destroys the data in it; an apply that may delete (prune, ApplyOp delete "owned-only" or "gated") makes it`,
317
- });
318
- continue;
319
- }
320
-
321
- // An index on a materialized view this apply recreates went with the view, so it is created again.
322
- const onRecreated = obj.canonical.kind === "index" && recreatedRelations.has(String(obj.canonical.fields.table));
323
- const created = !live || onRecreated;
324
- let steps: PgStep[];
325
- if (created) {
326
- const base = obj.canonical.kind === "extension" ? extensionComments.get(obj.canonical.name) : undefined;
327
- // An index on a table that exists is built under the scan timeout (SQLPG240, SQLPG241); one on a new table is part of the create.
328
- const how = mine.find((c) => c.rule === "SQLPG200" || c.rule === "SQLPG240" || c.rule === "SQLPG241");
329
- steps = createSteps(obj, opts.marker, { ...(base !== undefined ? { base } : {}), ...(how ? { cls: how.class, rule: how.rule } : {}) });
330
- } else {
331
- const altered = alterSteps(obj, live, mine, { ...(opts.marker ? { marker: opts.marker } : {}), major: plan.major ?? POSTGRES_LATEST_MAJOR });
332
- if (altered.unsupported.length > 0) {
279
+ const liveRaw = entry.live ? liveRawByKey.get(qualifiedKey(entry.live)) : undefined;
280
+ switch (entry.verdict) {
281
+ case "foreign":
282
+ case "withheld":
283
+ case "unsupported":
333
284
  onServer.add(obj.exportName);
334
- outcome.notAttempted.push({
335
- ...ref,
336
- reason: "unsupported-kind",
337
- detail: `${obj.name}: ${altered.unsupported.map((u) => `${u.change.rule} ${u.change.field}: ${u.why}`).join("; ")}. Nothing was sent for it.`,
338
- });
285
+ outcome.notAttempted.push({ ...ref, reason: entry.verdict === "unsupported" ? "unsupported-kind" : "filtered", detail: entry.detail });
286
+ continue;
287
+ case "refused":
288
+ if (entry.live) onServer.add(obj.exportName);
289
+ outcome.notAttempted.push({ ...ref, reason: "unsupported-kind", detail: entry.detail });
339
290
  continue;
340
- }
341
- steps = altered.steps;
342
- if (mine.some((c) => c.rule === "SQLPG252")) recreatedRelations.add(`${obj.canonical.schema}.${obj.canonical.name}`);
343
- const stamp = commentStatement(obj, opts.marker);
344
- if (!steps.some((s) => s.sql === stamp) && !carriesMarker(liveRaw?.comment, opts.marker)) steps.push({ sql: stamp, class: "metadata", transactional: true, rule: "SQLPG216" });
345
291
  }
346
- if (steps.length === 0) {
292
+ if (entry.steps.length === 0) {
347
293
  onServer.add(obj.exportName);
348
294
  outcome.applied.push({ ...ref, action: "unchanged", ...(liveRaw ? { physicalId: liveRaw.oid } : {}), statements: [] });
349
295
  continue;
350
296
  }
351
- units.push({ obj, ref, created: !live, ...(liveRaw ? { physicalId: liveRaw.oid } : {}), steps, ran: [], pending: [], done: 0, state: "planned" });
297
+ units.push({ obj, ref, created: !entry.live, ...(liveRaw ? { physicalId: liveRaw.oid } : {}), steps: entry.steps, ran: [], pending: [], done: 0, state: "planned" });
352
298
  }
353
299
 
354
300
  // ── Run them, grouped into transactions. ──
@@ -482,7 +428,7 @@ export async function applyPostgres(
482
428
  }
483
429
  await commit();
484
430
 
485
- if (opts.prune) await prune(client, plan.diff.changes, liveRawByKey, opts, timeouts, outcome, log);
431
+ if (opts.prune) await prune(client, statements.drops, liveRawByKey, opts, timeouts, outcome, log);
486
432
  if (outcome.failed.length > 0) throw new PostgresApplyError(outcome);
487
433
  return outcome;
488
434
  }
@@ -529,21 +475,17 @@ async function dropInvalidIndex(client: PostgresClient, index: string, log: (lin
529
475
 
530
476
  async function prune(
531
477
  client: PostgresClient,
532
- changes: readonly PgChange[],
478
+ drops: readonly PgDropStatement[],
533
479
  liveRawByKey: Map<string, LivePgObject>,
534
480
  opts: PostgresApplyOptions,
535
481
  timeouts: PostgresApplyTimeouts,
536
482
  outcome: PostgresApplyOutcome,
537
483
  log: (line: string) => void,
538
484
  ): Promise<void> {
539
- const orphans = changes
540
- .filter((c) => (c.rule === "SQLPG270" || c.rule === "SQLPG242") && c.after === undefined)
541
- .map((c) => liveRawByKey.get(c.object))
542
- .filter((o): o is LivePgObject => o !== undefined && !o.foreign)
543
- .map((o) => ({ raw: o, address: liveCanonicalAddress(o) }))
544
- .sort((a, b) => DROP_ORDER[a.address.kind] - DROP_ORDER[b.address.kind]);
545
-
546
- for (const { raw, address } of orphans) {
485
+ for (const drop of drops) {
486
+ const raw = liveRawByKey.get(drop.key);
487
+ if (!raw || raw.foreign) continue;
488
+ const address = liveCanonicalAddress(raw);
547
489
  const ref = { kind: raw.type, name: refName(address) };
548
490
  if (!isChantManaged(raw.comment)) continue; // not chant's: never touched, never reported
549
491
  if (!opts.marker?.stack) {
@@ -555,7 +497,7 @@ async function prune(
555
497
  continue;
556
498
  }
557
499
  if (!carriesMarker(raw.comment, opts.marker)) continue; // another stack's or env's
558
- const s = dropStatement(address.kind, address.schema, address.name);
500
+ const s = drop.step;
559
501
  const t = { lockTimeoutMs: timeouts.lockTimeoutMs, statementTimeoutMs: scansRows(s.class) ? timeouts.scanTimeoutMs : timeouts.statementTimeoutMs };
560
502
  try {
561
503
  opts.signal?.throwIfAborted();