@intentius/chant-lexicon-sql 0.124.54 → 0.124.56
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/capability-plugin.d.ts +160 -0
- package/dist/capability-plugin.d.ts.map +1 -0
- package/dist/capability-plugin.js +370 -0
- package/dist/change-set.d.ts +35 -0
- package/dist/change-set.d.ts.map +1 -1
- package/dist/change-set.js +43 -0
- package/dist/ci-setup.d.ts +34 -0
- package/dist/ci-setup.d.ts.map +1 -0
- package/dist/ci-setup.js +106 -0
- package/dist/clickhouse/access.d.ts +20 -0
- package/dist/clickhouse/access.d.ts.map +1 -1
- package/dist/clickhouse/access.js +42 -1
- package/dist/clickhouse/live/describe-resources.d.ts +3 -1
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.js +31 -3
- package/dist/clickhouse/plan/deep.d.ts +42 -11
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.js +104 -33
- package/dist/exit-codes.d.ts +69 -0
- package/dist/exit-codes.d.ts.map +1 -0
- package/dist/exit-codes.js +105 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/integrity.json +2 -2
- package/dist/lock-grain.d.ts +27 -0
- package/dist/lock-grain.d.ts.map +1 -0
- package/dist/lock-grain.js +13 -0
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +8 -0
- package/dist/postgres/testing/migration.d.ts +2 -0
- package/dist/postgres/testing/migration.d.ts.map +1 -1
- package/dist/token-source.d.ts +28 -0
- package/dist/token-source.d.ts.map +1 -1
- package/dist/token-source.js +70 -1
- package/dist/yodel-run.d.ts +58 -0
- package/dist/yodel-run.d.ts.map +1 -0
- package/dist/yodel-run.js +145 -0
- package/package.json +2 -2
- package/src/capability-plugin.test.ts +317 -0
- package/src/capability-plugin.ts +515 -0
- package/src/change-set.ts +74 -0
- package/src/ci-setup.test.ts +67 -0
- package/src/ci-setup.ts +116 -0
- package/src/clickhouse/access.ts +49 -2
- package/src/clickhouse/live/access.e2e.test.ts +8 -7
- package/src/clickhouse/live/describe-resources.ts +27 -3
- package/src/clickhouse/plan/deep-grants-drift.test.ts +151 -0
- package/src/clickhouse/plan/deep-grants.test.ts +10 -5
- package/src/clickhouse/plan/deep.ts +103 -32
- package/src/exit-codes.test.ts +46 -0
- package/src/exit-codes.ts +140 -0
- package/src/index.ts +28 -0
- package/src/lock-grain.test.ts +57 -0
- package/src/lock-grain.ts +37 -0
- package/src/plugin.ts +9 -0
- package/src/testdata/fake-yodel.mjs +58 -0
- package/src/token-source-traded.test.ts +75 -0
- package/src/token-source.ts +74 -1
- package/src/yodel-run.ts +175 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running sql-yodeler's versioned migration engine (`yodel`) for the sql
|
|
3
|
+
* lexicon's `sql-migrate` capability (#4569). The ruling on
|
|
4
|
+
* sql-yodeler#285 (A) keeps the engine in sql-yodeler and has chant run it
|
|
5
|
+
* through a fixed argv list, the way the pulumi lexicon runs plunge
|
|
6
|
+
* (`lexicons/pulumi/src/run.ts`). {@link YODEL_COMMANDS} is that list, and
|
|
7
|
+
* {@link runAllowedYodel} refuses any argv off it before it reaches the
|
|
8
|
+
* runner, so no later caller can make this module run another yodel verb.
|
|
9
|
+
*
|
|
10
|
+
* The three commands, each the engine's own entry point:
|
|
11
|
+
*
|
|
12
|
+
* - `yodel plan <env> --part <file>`: the pending migrations as a chant
|
|
13
|
+
* change-set part (sql-yodeler#237, built by this lexicon's
|
|
14
|
+
* `sqlVersionedPlanPart`), one entry per object with its change classes.
|
|
15
|
+
* Reads only.
|
|
16
|
+
* - `yodel apply <env> --wave-plan <file> --spec <spec>`: the wave plan the
|
|
17
|
+
* op wave's gate binds. Its digest covers the migrations' digest, the
|
|
18
|
+
* environment's gate policy at the base commit, whether a pending step is
|
|
19
|
+
* destructive, and the promotion evidence. Reads only.
|
|
20
|
+
* - `yodel apply <env> --wave-apply <file> --spec <spec>`: applies that
|
|
21
|
+
* plan under the engine's lock. The engine plans again under the lock and
|
|
22
|
+
* refuses a plan that moved, and it checks chant's decision for the wave
|
|
23
|
+
* and the gate ledger's approval itself (ruling A on sql-yodeler#300).
|
|
24
|
+
*
|
|
25
|
+
* Each may carry, in this order and no other, `--dir <project>`, `--op
|
|
26
|
+
* <name>` and `--allow-out-of-order`. A value must not start with a dash.
|
|
27
|
+
*
|
|
28
|
+
* ## Credentials
|
|
29
|
+
*
|
|
30
|
+
* The two planning commands run with the writer taken out of their
|
|
31
|
+
* environment ({@link readerEnv}): no `YODEL_ROLE` or `YODEL_CREDENTIALS`,
|
|
32
|
+
* which pick the writer, and no `<ENV>_<DIALECT>_WRITER_USER` or
|
|
33
|
+
* `..._WRITER_PASSWORD`, the writer's default variables. Only the apply runs
|
|
34
|
+
* with the job's environment as given, so the writer reaches the apply
|
|
35
|
+
* alone even when one job plans and applies.
|
|
36
|
+
*
|
|
37
|
+
* The runner is injectable, so the tests run no binary.
|
|
38
|
+
*/
|
|
39
|
+
import { execFile } from "node:child_process";
|
|
40
|
+
import { existsSync } from "node:fs";
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
/**
|
|
43
|
+
* The only yodel commands chant runs (ruling A on sql-yodeler#285). Each
|
|
44
|
+
* may be followed by `--dir <project>`, `--op <name>` and
|
|
45
|
+
* `--allow-out-of-order`, in that order ({@link YODEL_OPTIONAL_FLAGS}).
|
|
46
|
+
*/
|
|
47
|
+
export const YODEL_COMMANDS = [
|
|
48
|
+
"yodel plan <env> --part <file>",
|
|
49
|
+
"yodel apply <env> --wave-plan <file> --spec <spec>",
|
|
50
|
+
"yodel apply <env> --wave-apply <file> --spec <spec>",
|
|
51
|
+
];
|
|
52
|
+
/** The flags any of {@link YODEL_COMMANDS} may end with, in this order. */
|
|
53
|
+
export const YODEL_OPTIONAL_FLAGS = ["--dir <project>", "--op <name>", "--allow-out-of-order"];
|
|
54
|
+
function common(opts) {
|
|
55
|
+
return [...(opts.dir ? ["--dir", opts.dir] : []), ...(opts.op ? ["--op", opts.op] : []), ...(opts.allowOutOfOrder ? ["--allow-out-of-order"] : [])];
|
|
56
|
+
}
|
|
57
|
+
/** `yodel plan <env> --part <file>`, without the binary. */
|
|
58
|
+
export function yodelPlanArgs(env, part, opts = {}) {
|
|
59
|
+
return ["plan", env, "--part", part, ...common(opts)];
|
|
60
|
+
}
|
|
61
|
+
/** `yodel apply <env> --wave-plan <file> --spec <spec>`, without the binary. */
|
|
62
|
+
export function yodelWavePlanArgs(env, file, spec, opts = {}) {
|
|
63
|
+
return ["apply", env, "--wave-plan", file, "--spec", spec, ...common(opts)];
|
|
64
|
+
}
|
|
65
|
+
/** `yodel apply <env> --wave-apply <file> --spec <spec>`, without the binary. */
|
|
66
|
+
export function yodelWaveApplyArgs(env, file, spec, opts = {}) {
|
|
67
|
+
return ["apply", env, "--wave-apply", file, "--spec", spec, ...common(opts)];
|
|
68
|
+
}
|
|
69
|
+
/** A value after a flag: present, and not itself a flag. */
|
|
70
|
+
const value = (v) => typeof v === "string" && v.length > 0 && !v.startsWith("-");
|
|
71
|
+
/** The optional tail: `--dir <d>`, `--op <n>`, `--allow-out-of-order`, each at most once, in that order. */
|
|
72
|
+
function allowedTail(rest) {
|
|
73
|
+
let i = 0;
|
|
74
|
+
if (rest[i] === "--dir") {
|
|
75
|
+
if (!value(rest[i + 1]))
|
|
76
|
+
return false;
|
|
77
|
+
i += 2;
|
|
78
|
+
}
|
|
79
|
+
if (rest[i] === "--op") {
|
|
80
|
+
if (!value(rest[i + 1]))
|
|
81
|
+
return false;
|
|
82
|
+
i += 2;
|
|
83
|
+
}
|
|
84
|
+
if (rest[i] === "--allow-out-of-order")
|
|
85
|
+
i += 1;
|
|
86
|
+
return i === rest.length;
|
|
87
|
+
}
|
|
88
|
+
/** Whether `args` is one of {@link YODEL_COMMANDS} with an allowed tail. */
|
|
89
|
+
export function allowedYodelArgv(args) {
|
|
90
|
+
if (!value(args[1]))
|
|
91
|
+
return false;
|
|
92
|
+
if (args[0] === "plan")
|
|
93
|
+
return args[2] === "--part" && value(args[3]) && allowedTail(args.slice(4));
|
|
94
|
+
if (args[0] === "apply") {
|
|
95
|
+
return (args[2] === "--wave-plan" || args[2] === "--wave-apply") && value(args[3]) && args[4] === "--spec" && value(args[5]) && allowedTail(args.slice(6));
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
/** Whether `args` is the apply, the one command that writes. */
|
|
100
|
+
export function isYodelApplyArgv(args) {
|
|
101
|
+
return args[0] === "apply" && args[2] === "--wave-apply";
|
|
102
|
+
}
|
|
103
|
+
/** Run `args` through `run`, refusing any argv off {@link YODEL_COMMANDS}. */
|
|
104
|
+
export async function runAllowedYodel(run, args, opts) {
|
|
105
|
+
if (!allowedYodelArgv(args)) {
|
|
106
|
+
throw new Error(`chant runs only ${YODEL_COMMANDS.join(", ")} (each optionally with ${YODEL_OPTIONAL_FLAGS.join(", ")}) for a SQL member, not yodel ${args.join(" ")}`);
|
|
107
|
+
}
|
|
108
|
+
return run(args, opts);
|
|
109
|
+
}
|
|
110
|
+
/** The variables that pick the writer (sql-yodeler `credentials.ts`). */
|
|
111
|
+
export const YODEL_ROLE_VARIABLES = ["YODEL_ROLE", "YODEL_CREDENTIALS"];
|
|
112
|
+
/** A writer's default user and password variables, `<ENV>_<DIALECT>_WRITER_USER` and `..._WRITER_PASSWORD`. */
|
|
113
|
+
const WRITER_VARIABLE = /^[A-Z0-9_]+_(CLICKHOUSE|POSTGRES)_WRITER_(USER|PASSWORD)$/;
|
|
114
|
+
/** `env` with the writer taken out: what the planning commands run with. */
|
|
115
|
+
export function readerEnv(env = process.env) {
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const [k, v] of Object.entries(env)) {
|
|
118
|
+
if (YODEL_ROLE_VARIABLES.includes(k) || WRITER_VARIABLE.test(k))
|
|
119
|
+
continue;
|
|
120
|
+
out[k] = v;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The yodel binary: the project's `node_modules/.bin/yodel`, else the
|
|
126
|
+
* working directory's, else `yodel` on PATH. Always a file named `yodel`.
|
|
127
|
+
*/
|
|
128
|
+
export function yodelBinary(cwd, dir) {
|
|
129
|
+
for (const base of [...(dir ? [join(cwd, dir)] : []), cwd]) {
|
|
130
|
+
const bin = join(base, "node_modules", ".bin", "yodel");
|
|
131
|
+
if (existsSync(bin))
|
|
132
|
+
return bin;
|
|
133
|
+
}
|
|
134
|
+
return "yodel";
|
|
135
|
+
}
|
|
136
|
+
export const defaultYodelRunner = (args, opts) => new Promise((resolve) => {
|
|
137
|
+
execFile(yodelBinary(opts.cwd, opts.dir), args, { cwd: opts.cwd, env: opts.env, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
138
|
+
if (!err)
|
|
139
|
+
return resolve({ code: 0, stdout, stderr });
|
|
140
|
+
const e = err;
|
|
141
|
+
if (e.code === "ENOENT")
|
|
142
|
+
return resolve({ code: null, stdout, stderr: stderr || e.message, missingBinary: true });
|
|
143
|
+
resolve({ code: typeof e.code === "number" ? e.code : null, stdout, stderr: stderr || e.message });
|
|
144
|
+
});
|
|
145
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-sql",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.56",
|
|
4
4
|
"description": "SQL lexicon for chant — database schema as typed declarations, one dialect at a time; ClickHouse first, with types read from a pinned server's catalog",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -72,6 +72,6 @@
|
|
|
72
72
|
"typescript": "^5.9.3"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
|
-
"@intentius/chant": "^0.124.
|
|
75
|
+
"@intentius/chant": "^0.124.56"
|
|
76
76
|
}
|
|
77
77
|
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sql lexicon's capabilities (#4569): `sql-migrate` runs sql-yodeler's
|
|
3
|
+
* engine through the fixed argv list (ruling A on sql-yodeler#285), and
|
|
4
|
+
* `sql-apply` plans and applies a build with the lexicon's appliers. The
|
|
5
|
+
* runner, planner and applier are fakes; the last block runs
|
|
6
|
+
* `testdata/fake-yodel.mjs` as the project's `node_modules/.bin/yodel`, so no
|
|
7
|
+
* real yodel and no server is involved.
|
|
8
|
+
*/
|
|
9
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { describe, expect, test } from "vitest";
|
|
13
|
+
import { CapabilityRegistry, isPlanMovedError } from "@intentius/chant/components/capability";
|
|
14
|
+
import { isCapabilityPlugin } from "@intentius/chant/components/capability-plugin";
|
|
15
|
+
import { CHANT_EXIT } from "@intentius/chant/exit-codes";
|
|
16
|
+
import { planOpWaveStep, applyOpWaveStep } from "@intentius/chant/op/op-waves-capability";
|
|
17
|
+
import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
18
|
+
import {
|
|
19
|
+
createSqlApplyCapability,
|
|
20
|
+
createSqlMigrateCapability,
|
|
21
|
+
sqlCapabilityPlugin,
|
|
22
|
+
sqlMigrateFiles,
|
|
23
|
+
wavePlanDestructive,
|
|
24
|
+
YodelRunError,
|
|
25
|
+
type SqlDeclarativePlan,
|
|
26
|
+
} from "./capability-plugin";
|
|
27
|
+
import { allowedYodelArgv, defaultYodelRunner, readerEnv, runAllowedYodel, YODEL_COMMANDS, type YodelRunner } from "./yodel-run";
|
|
28
|
+
|
|
29
|
+
const scratch = () => mkdtempSync(join(tmpdir(), "chant-sql-capability-"));
|
|
30
|
+
|
|
31
|
+
function writeJson(path: string, doc: unknown): void {
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
writeFileSync(path, `${JSON.stringify(doc)}\n`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const part = (env: string, digest = "jcs1-sha256:migrations", status: "planned" | "failed" = "planned") => ({
|
|
37
|
+
member: { member: "app", lexicon: "sql", planner: "sql", scope: env, status, ...(status === "failed" ? { error: "0001_init's checksum does not match the history" } : {}), planDigest: status === "failed" ? null : digest, holes: [] },
|
|
38
|
+
entries: [{ member: "app", lexicon: "sql", planner: "sql", address: "app.users", type: "Postgres::Table", action: "update", disruption: "rolling", scope: env, attributes: [] }],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** A runner that writes what yodel would, and records each call with its environment. */
|
|
42
|
+
function fakeRunner(cwd: string, answers: { planStatus?: "planned" | "failed"; waveDigest?: string; applyCode?: number; applyStderr?: string } = {}) {
|
|
43
|
+
const calls: Array<{ args: string[]; env: NodeJS.ProcessEnv }> = [];
|
|
44
|
+
const run: YodelRunner = async (args, opts) => {
|
|
45
|
+
calls.push({ args, env: opts.env });
|
|
46
|
+
if (args[0] === "plan") {
|
|
47
|
+
writeJson(join(cwd, args[3]!), part(args[1]!, undefined, answers.planStatus));
|
|
48
|
+
return { code: answers.planStatus === "failed" ? 4 : 0, stdout: "", stderr: "" };
|
|
49
|
+
}
|
|
50
|
+
if (args[2] === "--wave-plan") {
|
|
51
|
+
writeJson(join(cwd, args[3]!), {
|
|
52
|
+
planDigest: answers.waveDigest ?? "jcs1-sha256:wave",
|
|
53
|
+
migrations: "jcs1-sha256:migrations",
|
|
54
|
+
environment: args[1],
|
|
55
|
+
empty: false,
|
|
56
|
+
pending: ["0002_users_email"],
|
|
57
|
+
destructiveSteps: [{ migration: "0002_users_email", step: 0, why: "pg-rewrite, pg-lock" }],
|
|
58
|
+
});
|
|
59
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
60
|
+
}
|
|
61
|
+
const code = answers.applyCode ?? 0;
|
|
62
|
+
return { code, stdout: code === 0 ? "Applied: 0002_users_email\n" : "", stderr: answers.applyStderr ?? "" };
|
|
63
|
+
};
|
|
64
|
+
return { run, calls };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const jobEnv = { PATH: "/usr/bin", YODEL_ROLE: "writer:dev", DEV_POSTGRES_WRITER_USER: "w", DEV_POSTGRES_WRITER_PASSWORD: "secret", DEV_POSTGRES_READER_USER: "r", DEV_POSTGRES_READER_PASSWORD: "read" };
|
|
68
|
+
|
|
69
|
+
describe("the yodel argv list (ruling A on sql-yodeler#285)", () => {
|
|
70
|
+
test("the three commands, with the optional tail in order, are allowed", () => {
|
|
71
|
+
expect(YODEL_COMMANDS).toEqual(["yodel plan <env> --part <file>", "yodel apply <env> --wave-plan <file> --spec <spec>", "yodel apply <env> --wave-apply <file> --spec <spec>"]);
|
|
72
|
+
expect(allowedYodelArgv(["plan", "dev", "--part", "p.json"])).toBe(true);
|
|
73
|
+
expect(allowedYodelArgv(["apply", "prod/@1of4", "--wave-plan", "w.json", "--spec", "yodel-waves.json", "--dir", "db", "--op", "migrate-prod", "--allow-out-of-order"])).toBe(true);
|
|
74
|
+
expect(allowedYodelArgv(["apply", "dev", "--wave-apply", "w.json", "--spec", "yodel-waves.json"])).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("anything else is refused before it runs", async () => {
|
|
78
|
+
for (const args of [
|
|
79
|
+
["apply", "dev", "--execute"],
|
|
80
|
+
["apply", "dev"],
|
|
81
|
+
["plan", "dev", "--part", "p.json", "--comment"],
|
|
82
|
+
["plan", "dev", "--part", "--comment"],
|
|
83
|
+
["plan", "--json", "--part", "p.json"],
|
|
84
|
+
["apply", "dev", "--wave-apply", "w.json", "--spec", "s.json", "--op", "x", "--dir", "db"],
|
|
85
|
+
["apply", "dev", "--wave-apply", "w.json"],
|
|
86
|
+
["revert", "dev", "0001"],
|
|
87
|
+
["approve", "dev"],
|
|
88
|
+
]) {
|
|
89
|
+
expect(allowedYodelArgv(args)).toBe(false);
|
|
90
|
+
}
|
|
91
|
+
let ran = false;
|
|
92
|
+
await expect(runAllowedYodel(async () => ((ran = true), { code: 0, stdout: "", stderr: "" }), ["apply", "dev", "--execute"], { cwd: ".", env: {} })).rejects.toThrow(/chant runs only/);
|
|
93
|
+
expect(ran).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the planning environment has no writer", () => {
|
|
97
|
+
const env = readerEnv({ ...jobEnv, YODEL_CREDENTIALS: "writer", PROD_CLICKHOUSE_WRITER_PASSWORD: "x" });
|
|
98
|
+
expect(env).toEqual({ PATH: "/usr/bin", DEV_POSTGRES_READER_USER: "r", DEV_POSTGRES_READER_PASSWORD: "read" });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("sql-migrate", () => {
|
|
103
|
+
test("a pull request's plan: the part with real counts, under the migrations' digest", async () => {
|
|
104
|
+
const cwd = scratch();
|
|
105
|
+
const { run, calls } = fakeRunner(cwd);
|
|
106
|
+
const cap = createSqlMigrateCapability({ run, cwd: () => cwd, env: () => jobEnv });
|
|
107
|
+
const planned = await cap.plan!({ env: "dev", component: "db" }, { environment: "dev" });
|
|
108
|
+
expect(calls.map((c) => c.args)).toEqual([["plan", "dev", "--part", sqlMigrateFiles("dev").part]]);
|
|
109
|
+
expect(planned.member).toBe("dev");
|
|
110
|
+
expect(planned.planDigest).toBe("jcs1-sha256:migrations");
|
|
111
|
+
expect(planned.changeSet?.member.member).toBe("dev");
|
|
112
|
+
expect(planned.changeSet?.entries.map((e) => [e.member, e.address, e.action])).toEqual([["dev", "app.users", "update"]]);
|
|
113
|
+
expect(planned.destructive).toBeUndefined();
|
|
114
|
+
expect(calls[0]!.env.YODEL_ROLE).toBeUndefined();
|
|
115
|
+
expect(calls[0]!.env.DEV_POSTGRES_WRITER_PASSWORD).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("a pull request's plan does not apply: run refuses without a wave's plan (ruling E)", async () => {
|
|
119
|
+
const cwd = scratch();
|
|
120
|
+
const { run, calls } = fakeRunner(cwd);
|
|
121
|
+
const cap = createSqlMigrateCapability({ run, cwd: () => cwd, env: () => jobEnv });
|
|
122
|
+
const planned = await cap.plan!({ env: "dev", component: "db" }, { environment: "dev" });
|
|
123
|
+
await expect(cap.run({ env: "dev", component: "db", plans: { dev: planned.artifact } }, { environment: "dev" })).rejects.toThrow(/merged branch/);
|
|
124
|
+
await expect(cap.run({ env: "dev", component: "db" }, { environment: "dev" })).rejects.toThrow(/only the plan an op wave decided/);
|
|
125
|
+
expect(calls.filter((c) => c.args[2] === "--wave-apply")).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a plan the engine would refuse fails, naming the refusal", async () => {
|
|
129
|
+
const cwd = scratch();
|
|
130
|
+
const { run } = fakeRunner(cwd, { planStatus: "failed" });
|
|
131
|
+
const cap = createSqlMigrateCapability({ run, cwd: () => cwd, env: () => jobEnv });
|
|
132
|
+
const err = await cap.plan!({ env: "dev", component: "db" }, { environment: "dev" }).catch((e: unknown) => e);
|
|
133
|
+
expect(err).toBeInstanceOf(YodelRunError);
|
|
134
|
+
expect((err as Error).message).toMatch(/checksum does not match/);
|
|
135
|
+
expect((err as YodelRunError).chantExit).toBe(CHANT_EXIT.failed);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("in an op wave: the wave plan's digest, the engine's destructive classes, then the apply with the writer", async () => {
|
|
139
|
+
const cwd = scratch();
|
|
140
|
+
writeJson(join(cwd, "yodel-waves.json"), { name: "yodel-apply", waves: [{ name: "dev", runs: [{ target: "dev" }] }] });
|
|
141
|
+
const { run, calls } = fakeRunner(cwd);
|
|
142
|
+
const cap = createSqlMigrateCapability({ run, cwd: () => cwd, env: () => jobEnv });
|
|
143
|
+
const input = { environment: "dev", spec: "yodel-waves.json", dir: "db" };
|
|
144
|
+
const planned = await cap.plan!({ env: "dev", component: "dev" }, input);
|
|
145
|
+
expect(planned.planDigest).toBe("jcs1-sha256:wave");
|
|
146
|
+
expect(planned.changeSet?.member.planDigest).toBe("jcs1-sha256:wave");
|
|
147
|
+
expect(planned.destructive).toEqual(["pg-lock", "pg-rewrite"]);
|
|
148
|
+
const out = await cap.run({ env: "dev", component: "dev", plans: { dev: planned.artifact } }, input);
|
|
149
|
+
expect(out.summary).toBe("Applied: 0002_users_email");
|
|
150
|
+
const files = sqlMigrateFiles("dev");
|
|
151
|
+
expect(calls.map((c) => c.args)).toEqual([
|
|
152
|
+
["plan", "dev", "--part", files.part, "--dir", "db"],
|
|
153
|
+
["apply", "dev", "--wave-plan", files.wavePlan, "--spec", "yodel-waves.json", "--dir", "db"],
|
|
154
|
+
["apply", "dev", "--wave-apply", files.wavePlan, "--spec", "yodel-waves.json", "--dir", "db"],
|
|
155
|
+
]);
|
|
156
|
+
// The writer reaches the apply alone.
|
|
157
|
+
expect(calls.map((c) => c.env.DEV_POSTGRES_WRITER_PASSWORD ?? null)).toEqual([null, null, "secret"]);
|
|
158
|
+
expect(calls.map((c) => c.env.YODEL_ROLE ?? null)).toEqual([null, null, "writer:dev"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("an apply the engine refuses as moved is a moved plan; a held lock is not", async () => {
|
|
162
|
+
const cwd = scratch();
|
|
163
|
+
writeJson(join(cwd, "yodel-waves.json"), { name: "yodel-apply", waves: [{ name: "dev", runs: [{ target: "dev" }] }] });
|
|
164
|
+
const input = { environment: "dev", spec: "yodel-waves.json" };
|
|
165
|
+
for (const [answers, moved, chantExit] of [
|
|
166
|
+
[{ applyCode: 4 }, true, CHANT_EXIT.moved],
|
|
167
|
+
[{ applyCode: 1, applyStderr: "refused: dev's wave plan moved since the wave was decided" }, false, CHANT_EXIT.failed],
|
|
168
|
+
[{ applyCode: 5 }, false, CHANT_EXIT.held],
|
|
169
|
+
] as const) {
|
|
170
|
+
const { run } = fakeRunner(cwd, answers);
|
|
171
|
+
const cap = createSqlMigrateCapability({ run, cwd: () => cwd, env: () => jobEnv });
|
|
172
|
+
const planned = await cap.plan!({ env: "dev", component: "dev" }, input);
|
|
173
|
+
const err = await cap.run({ env: "dev", component: "dev", plans: { dev: planned.artifact } }, input).catch((e: unknown) => e);
|
|
174
|
+
expect(isPlanMovedError(err)).toBe(moved);
|
|
175
|
+
if (!moved) expect((err as YodelRunError).chantExit).toBe(chantExit);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("a legacy yodel's moved plan is read from its line", async () => {
|
|
180
|
+
const cwd = scratch();
|
|
181
|
+
writeJson(join(cwd, "yodel-waves.json"), { name: "yodel-apply", waves: [{ name: "dev", runs: [{ target: "dev" }] }] });
|
|
182
|
+
const input = { environment: "dev", spec: "yodel-waves.json", legacyExit: true };
|
|
183
|
+
const { run } = fakeRunner(cwd, { applyCode: 1, applyStderr: "refused: dev's wave plan moved since the wave was decided (decided a, now b)" });
|
|
184
|
+
const cap = createSqlMigrateCapability({ run, cwd: () => cwd, env: () => jobEnv });
|
|
185
|
+
const planned = await cap.plan!({ env: "dev", component: "dev" }, input);
|
|
186
|
+
const err = await cap.run({ env: "dev", component: "dev", plans: { dev: planned.artifact } }, input).catch((e: unknown) => e);
|
|
187
|
+
expect(isPlanMovedError(err)).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a wave plan file rewritten since the decision refuses as moved, and yodel never runs", async () => {
|
|
191
|
+
const cwd = scratch();
|
|
192
|
+
writeJson(join(cwd, "yodel-waves.json"), { name: "yodel-apply", waves: [{ name: "dev", runs: [{ target: "dev" }] }] });
|
|
193
|
+
const input = { environment: "dev", spec: "yodel-waves.json" };
|
|
194
|
+
const { run, calls } = fakeRunner(cwd);
|
|
195
|
+
const cap = createSqlMigrateCapability({ run, cwd: () => cwd, env: () => jobEnv });
|
|
196
|
+
const planned = await cap.plan!({ env: "dev", component: "dev" }, input);
|
|
197
|
+
writeJson(join(cwd, sqlMigrateFiles("dev").wavePlan), { planDigest: "jcs1-sha256:other", migrations: "x" });
|
|
198
|
+
const err = await cap.run({ env: "dev", component: "dev", plans: { dev: planned.artifact } }, input).catch((e: unknown) => e);
|
|
199
|
+
expect(isPlanMovedError(err)).toBe(true);
|
|
200
|
+
expect(calls.some((c) => c.args[2] === "--wave-apply")).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("an op wave's step plans through it, destructive by the engine's classes, and applies with no callback", async () => {
|
|
204
|
+
const cwd = scratch();
|
|
205
|
+
writeJson(join(cwd, "yodel-waves.json"), { name: "yodel-apply", waves: [{ name: "dev", runs: [{ target: "dev" }] }] });
|
|
206
|
+
const { run, calls } = fakeRunner(cwd);
|
|
207
|
+
const registry = new CapabilityRegistry().register(createSqlMigrateCapability({ run, cwd: () => cwd, env: () => jobEnv }));
|
|
208
|
+
const spec = { name: "yodel-apply", op: "migrate-dev", step: { kind: "sql-migrate", environment: "{target}", spec: "yodel-waves.json" }, waves: [{ name: "dev", runs: [{ target: "dev" }] }] } as unknown as OpWavesSpec;
|
|
209
|
+
const wave = spec.waves[0]!;
|
|
210
|
+
const runOf = wave.runs![0]!;
|
|
211
|
+
const plan = await planOpWaveStep({ spec, wave, cwd, registry, planPath: ".chant/op-waves/yodel-apply/wave-1/dev.plan.json" }, runOf);
|
|
212
|
+
expect(plan.planDigest).toBe("jcs1-sha256:wave");
|
|
213
|
+
expect(plan.destructive).toBe(true);
|
|
214
|
+
expect(plan.empty).toBe(false);
|
|
215
|
+
const applied = await applyOpWaveStep({ spec, wave, registry }, runOf, plan);
|
|
216
|
+
expect(applied.outcome).toBe("applied");
|
|
217
|
+
expect(calls.map((c) => c.args[2] ?? c.args[0])).toEqual(["--part", "--wave-plan", "--wave-apply"]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("wavePlanDestructive splits and sorts each step's rules", () => {
|
|
221
|
+
expect(wavePlanDestructive({ destructiveSteps: [{ why: "op-step" }, { why: "destructive, pg-lock" }, { why: "pg-lock" }] })).toEqual(["destructive", "op-step", "pg-lock"]);
|
|
222
|
+
expect(wavePlanDestructive({})).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("sql-migrate against a fake yodel binary", () => {
|
|
227
|
+
test("the project's node_modules/.bin/yodel runs, and only the apply holds the writer", async () => {
|
|
228
|
+
const cwd = scratch();
|
|
229
|
+
const bin = join(cwd, "node_modules", ".bin", "yodel");
|
|
230
|
+
mkdirSync(dirname(bin), { recursive: true });
|
|
231
|
+
copyFileSync(join(import.meta.dirname, "testdata", "fake-yodel.mjs"), bin);
|
|
232
|
+
chmodSync(bin, 0o755);
|
|
233
|
+
writeJson(join(cwd, "yodel-waves.json"), { name: "yodel-apply", waves: [{ name: "dev", runs: [{ target: "dev" }] }] });
|
|
234
|
+
const log = join(cwd, "calls.jsonl");
|
|
235
|
+
const cap = createSqlMigrateCapability({ run: defaultYodelRunner, cwd: () => cwd, env: () => ({ ...process.env, ...jobEnv, PATH: process.env.PATH, FAKE_YODEL_LOG: log }) });
|
|
236
|
+
const input = { environment: "dev", spec: "yodel-waves.json" };
|
|
237
|
+
const planned = await cap.plan!({ env: "dev", component: "dev" }, input);
|
|
238
|
+
expect(planned.changeSet?.entries.map((e) => e.action).sort()).toEqual(["create", "update"]);
|
|
239
|
+
const out = await cap.run({ env: "dev", component: "dev", plans: { dev: planned.artifact } }, input);
|
|
240
|
+
expect(out.summary).toBe("Applied: 0002_users_email");
|
|
241
|
+
expect(existsSync(log)).toBe(true);
|
|
242
|
+
const lines = readFileSync(log, "utf-8").trim().split("\n").map((l) => JSON.parse(l) as { args: string[]; role: string | null; writer: string | null });
|
|
243
|
+
expect(lines.map((l) => [l.args[0], l.args[2], l.role, l.writer])).toEqual([
|
|
244
|
+
["plan", "--part", null, null],
|
|
245
|
+
["apply", "--wave-plan", null, null],
|
|
246
|
+
["apply", "--wave-apply", "writer:dev", "secret"],
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("sql-apply", () => {
|
|
252
|
+
const build = (dialect: "clickhouse" | "postgres") => {
|
|
253
|
+
const cwd = scratch();
|
|
254
|
+
const path = join(cwd, "schema.json");
|
|
255
|
+
writeJson(path, { dialect });
|
|
256
|
+
return path;
|
|
257
|
+
};
|
|
258
|
+
const plan = (over: Partial<SqlDeclarativePlan> = {}): SqlDeclarativePlan => ({
|
|
259
|
+
dialect: "postgres",
|
|
260
|
+
changes: [
|
|
261
|
+
{ object: "app.users", field: "columns.email", rule: "SQLPG201", class: "metadata", after: "text" },
|
|
262
|
+
{ object: "app.users", field: "columns.name", rule: "SQLPG240", class: "rewrite" },
|
|
263
|
+
{ object: "app.audit", field: "name", rule: "SQLPG100", class: "create" },
|
|
264
|
+
],
|
|
265
|
+
refused: [],
|
|
266
|
+
holes: [],
|
|
267
|
+
problems: [],
|
|
268
|
+
...over,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("plans the build against the server as one entry per object, and applies the decided plan", async () => {
|
|
272
|
+
const path = build("postgres");
|
|
273
|
+
const applied: string[] = [];
|
|
274
|
+
const cap = createSqlApplyCapability({ plan: async () => plan(), apply: async (input, dialect) => (applied.push(`${dialect}:${input.environment}`), { applied: ["table/app.users"], pruned: [], notAttempted: [] }) });
|
|
275
|
+
const planned = await cap.plan!({ env: "prod", component: "db" }, { environment: "prod", build: path });
|
|
276
|
+
expect(planned.changeSet?.entries.map((e) => [e.address, e.action, e.disruption])).toEqual([
|
|
277
|
+
["app.users", "update", "rolling"],
|
|
278
|
+
["app.audit", "create", undefined],
|
|
279
|
+
]);
|
|
280
|
+
const out = await cap.run({ env: "prod", component: "db", plans: { prod: planned.artifact } }, { environment: "prod", build: path });
|
|
281
|
+
expect(out.applied).toEqual(["table/app.users"]);
|
|
282
|
+
expect(applied).toEqual(["postgres:prod"]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a server that moved since the decision refuses as moved and sends nothing", async () => {
|
|
286
|
+
const path = build("postgres");
|
|
287
|
+
let n = 0;
|
|
288
|
+
let sent = false;
|
|
289
|
+
const cap = createSqlApplyCapability({
|
|
290
|
+
plan: async () => (n++ === 0 ? plan() : plan({ changes: [{ object: "app.users", field: "columns.email", rule: "SQLPG201", class: "drop", destructive: true }] })),
|
|
291
|
+
apply: async () => ((sent = true), { applied: [], pruned: [], notAttempted: [] }),
|
|
292
|
+
});
|
|
293
|
+
const planned = await cap.plan!({ env: "prod", component: "db" }, { environment: "prod", build: path });
|
|
294
|
+
const err = await cap.run({ env: "prod", component: "db", plans: { prod: planned.artifact } }, { environment: "prod", build: path }).catch((e: unknown) => e);
|
|
295
|
+
expect(isPlanMovedError(err)).toBe(true);
|
|
296
|
+
expect(sent).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("a rebuild the applier refuses fails the plan, naming the Op", async () => {
|
|
300
|
+
const path = build("clickhouse");
|
|
301
|
+
const cap = createSqlApplyCapability({ plan: async () => plan({ dialect: "clickhouse", refused: [{ object: "db.events", field: "orderBy", op: "ClickHouseRebuildOp" }] }) });
|
|
302
|
+
await expect(cap.plan!({ env: "prod", component: "db" }, { environment: "prod", build: path })).rejects.toThrow(/ClickHouseRebuildOp/);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("run refuses with no decided plan", async () => {
|
|
306
|
+
const path = build("postgres");
|
|
307
|
+
const cap = createSqlApplyCapability({ plan: async () => plan() });
|
|
308
|
+
await expect(cap.run({ env: "prod", component: "db" }, { environment: "prod", build: path })).rejects.toThrow(/planned and decided first/);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("the plugin", () => {
|
|
313
|
+
test("is a capability plugin with both kinds", () => {
|
|
314
|
+
expect(isCapabilityPlugin(sqlCapabilityPlugin)).toBe(true);
|
|
315
|
+
expect(sqlCapabilityPlugin.capabilities().map((c) => c.kind)).toEqual(["sql-migrate", "sql-apply"]);
|
|
316
|
+
});
|
|
317
|
+
});
|