@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.
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +77 -1
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +6 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts +100 -0
- package/dist/clickhouse/topology.d.ts.map +1 -0
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/postgres-classifier-page.d.ts.map +1 -1
- package/dist/core/statements.d.ts +104 -0
- package/dist/core/statements.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/migration-statements.d.ts +55 -0
- package/dist/migration-statements.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +91 -1
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/apply.ts +25 -53
- package/src/clickhouse/apply/statements.ts +149 -31
- package/src/clickhouse/plan/commands.ts +30 -6
- package/src/clickhouse/topology.e2e.test.ts +100 -0
- package/src/clickhouse/topology.test.ts +276 -0
- package/src/clickhouse/topology.ts +451 -0
- package/src/codegen/classifier-page.ts +17 -0
- package/src/codegen/postgres-classifier-page.ts +17 -0
- package/src/core/statements.ts +127 -0
- package/src/index.ts +15 -0
- package/src/migration-statements.test.ts +265 -0
- package/src/migration-statements.ts +252 -0
- package/src/op/__snapshots__/watch-pipeline.test.ts.snap +93 -0
- package/src/op/watch-drift.e2e.test.ts +302 -0
- package/src/op/watch-pipeline.test.ts +113 -0
- package/src/postgres/apply/apply.ts +38 -96
- 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,
|
|
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
|
|
281
|
-
|
|
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,
|
|
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
|
|
291
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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 =
|
|
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();
|