@intentius/chant-lexicon-sql 0.124.54 → 0.124.55
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,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sql lexicon's capability plugin (#4569): `sql-migrate` and
|
|
3
|
+
* `sql-apply`, so op waves (`step: { kind, ...input }`, #4545) and
|
|
4
|
+
* `chant components pr-plan` plan and apply a SQL member through chant, as
|
|
5
|
+
* they do a Terraform root (`terraform-apply`). A project that lists `sql`
|
|
6
|
+
* among its lexicons gets both registered when its registry is built
|
|
7
|
+
* (`loadCapabilityPluginsFromLexicons`).
|
|
8
|
+
*
|
|
9
|
+
* ## sql-migrate: the versioned path
|
|
10
|
+
*
|
|
11
|
+
* The versioned migration engine stays in sql-yodeler (ruling A on
|
|
12
|
+
* sql-yodeler#285), and this capability runs it through the fixed argv list
|
|
13
|
+
* in `./yodel-run.ts`:
|
|
14
|
+
*
|
|
15
|
+
* ```json
|
|
16
|
+
* { "step": { "kind": "sql-migrate", "environment": "{target}", "spec": "yodel-waves.json" } }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* - `plan` runs `yodel plan <env> --part <file>` for the change-set part,
|
|
20
|
+
* one entry per object the pending migrations touch, with real counts.
|
|
21
|
+
* With `spec` (an op wave) it also runs `yodel apply <env> --wave-plan
|
|
22
|
+
* <file> --spec <spec>`, and the wave plan's digest is the member's: the
|
|
23
|
+
* value the wave's gate binds and the engine checks again under its lock.
|
|
24
|
+
* The wave plan's destructive steps come back as
|
|
25
|
+
* {@link CapabilityPlan.destructive}, each the lint rule that classes it,
|
|
26
|
+
* so `on-destructive` waits on what the engine calls destructive. Without
|
|
27
|
+
* `spec` (a pull request's plan) the digest is the migrations' own, the
|
|
28
|
+
* one `yodel approve` binds. A plan the engine would refuse fails the
|
|
29
|
+
* plan, naming the refusal.
|
|
30
|
+
* - `run` runs `yodel apply <env> --wave-apply <file> --spec <spec>` with
|
|
31
|
+
* the plan the wave decided, which `DeployContext.plans` hands back. The
|
|
32
|
+
* engine plans again under its lock and refuses a plan that moved; that
|
|
33
|
+
* refusal is a moved plan here ({@link SqlPlanMovedError}), and the wave
|
|
34
|
+
* exits 4. The engine also checks chant's decision for the wave and the
|
|
35
|
+
* gate ledger's approval itself (ruling A on sql-yodeler#300), so an
|
|
36
|
+
* apply that chant let through and the ledger does not hold still refuses.
|
|
37
|
+
* `run` applies only a plan an op wave made with `spec`: an apply happens
|
|
38
|
+
* on the merged branch, never before the merge (ruling E on
|
|
39
|
+
* sql-yodeler#285).
|
|
40
|
+
*
|
|
41
|
+
* yodel's exit status reads through `yodelExitOf` (`./exit-codes.ts`,
|
|
42
|
+
* #4570). A yodel before sql-yodeler#288 exits by its own table; set
|
|
43
|
+
* `legacyExit: true` for one, and its moved plan is read from the line it
|
|
44
|
+
* prints.
|
|
45
|
+
*
|
|
46
|
+
* Both kinds take the `target` lock grain, the environment, and refuse
|
|
47
|
+
* `resource` by name (`./lock-grain.ts`, #4571).
|
|
48
|
+
*
|
|
49
|
+
* The planning commands run without the writer's variables; only the apply
|
|
50
|
+
* gets them (`readerEnv` in `./yodel-run.ts`).
|
|
51
|
+
*
|
|
52
|
+
* ## sql-apply: the declarative path
|
|
53
|
+
*
|
|
54
|
+
* `sql-apply` plans a build (`chant build -o dist/schema.json`) against the
|
|
55
|
+
* environment's server, as `chant sql plan` does, and applies it with the
|
|
56
|
+
* lexicon's applier, `clickhouseApply` or `postgresApply`, by the build's
|
|
57
|
+
* dialect:
|
|
58
|
+
*
|
|
59
|
+
* ```json
|
|
60
|
+
* { "kind": "sql-apply", "environment": "prod", "build": "dist/schema.json" }
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* The part has one entry per object the classified changes touch. A change
|
|
64
|
+
* the applier refuses (a ClickHouse rebuild, a Postgres expand and contract),
|
|
65
|
+
* an object the plan cannot read, or a named collection the server lacks
|
|
66
|
+
* fails the plan, naming the migration Op to run instead. `run` plans again
|
|
67
|
+
* and applies only when the digest is still the one decided; otherwise it
|
|
68
|
+
* refuses as moved and sends nothing.
|
|
69
|
+
*/
|
|
70
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
71
|
+
import { dirname, join } from "node:path";
|
|
72
|
+
import type { Capability, CapabilityPlan, DeployContext } from "@intentius/chant/components/capability";
|
|
73
|
+
import { ownPackageVersion, type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
|
|
74
|
+
import { readChangeSetPartFile, type ChangeSetHole, type ChangeSetPart } from "@intentius/chant/change-set";
|
|
75
|
+
import { CHANT_EXIT, type ChantExitCode } from "@intentius/chant/exit-codes";
|
|
76
|
+
import { sqlDeclarativePlanPart, type SqlDeclarativeChange } from "./change-set";
|
|
77
|
+
import { yodelExitOf } from "./exit-codes";
|
|
78
|
+
import { sqlDefaultLockGrain, sqlLockGrainRefusal } from "./lock-grain";
|
|
79
|
+
import {
|
|
80
|
+
defaultYodelRunner,
|
|
81
|
+
readerEnv,
|
|
82
|
+
runAllowedYodel,
|
|
83
|
+
yodelPlanArgs,
|
|
84
|
+
yodelWaveApplyArgs,
|
|
85
|
+
yodelWavePlanArgs,
|
|
86
|
+
type YodelCommonOptions,
|
|
87
|
+
type YodelRun,
|
|
88
|
+
type YodelRunner,
|
|
89
|
+
} from "./yodel-run";
|
|
90
|
+
|
|
91
|
+
/** A plan that moved since it was decided: nothing was applied (core's `isPlanMovedError`). */
|
|
92
|
+
export class SqlPlanMovedError extends Error {
|
|
93
|
+
readonly planMoved = true;
|
|
94
|
+
constructor(
|
|
95
|
+
readonly member: string,
|
|
96
|
+
message: string,
|
|
97
|
+
) {
|
|
98
|
+
super(message);
|
|
99
|
+
this.name = "SqlPlanMovedError";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A yodel run that did not succeed, with the code chant's table gives it. */
|
|
104
|
+
export class YodelRunError extends Error {
|
|
105
|
+
constructor(
|
|
106
|
+
message: string,
|
|
107
|
+
/** The table's code for the run (`CHANT_EXIT`). Named so core never reads it as an exit code. */
|
|
108
|
+
readonly chantExit: ChantExitCode,
|
|
109
|
+
readonly yodelExit: number | null,
|
|
110
|
+
) {
|
|
111
|
+
super(message);
|
|
112
|
+
this.name = "YodelRunError";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── sql-migrate ───────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/** The input of a `sql-migrate` step. */
|
|
119
|
+
export interface SqlMigrateStepInput {
|
|
120
|
+
/** The yodel environment (`{target}` in an op wave), a tenant (`<env>/<tenant>`) or a slot of a tenant set. */
|
|
121
|
+
environment: string;
|
|
122
|
+
/** The yodel project directory, relative to the job's working directory. Default: the working directory. */
|
|
123
|
+
dir?: string;
|
|
124
|
+
/** The migrations Op, when more than one applies the environment. */
|
|
125
|
+
op?: string;
|
|
126
|
+
allowOutOfOrder?: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* The op-waves spec file the wave runs (`yodel-waves.json`), which the
|
|
129
|
+
* engine reads chant's decision for the wave by. Set: plan the wave plan
|
|
130
|
+
* and allow `run`. Absent: plan the migrations only, as a pull request's
|
|
131
|
+
* plan does; `run` refuses.
|
|
132
|
+
*/
|
|
133
|
+
spec?: string;
|
|
134
|
+
/** The yodel predates sql-yodeler#288 and exits by its own table. */
|
|
135
|
+
legacyExit?: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** What `sql-migrate`'s plan hands its run. */
|
|
139
|
+
export interface SqlMigrateArtifact {
|
|
140
|
+
kind: "sql-migrate";
|
|
141
|
+
environment: string;
|
|
142
|
+
planDigest: string;
|
|
143
|
+
/** The wave plan file `--wave-plan` wrote, relative to the working directory. */
|
|
144
|
+
wavePlan?: string;
|
|
145
|
+
spec?: string;
|
|
146
|
+
/** The migrations' own digest, as `yodel apply --digest` prints it. */
|
|
147
|
+
migrations?: string;
|
|
148
|
+
pending: string[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface SqlMigrateStepOutput {
|
|
152
|
+
environment: string;
|
|
153
|
+
planDigest: string;
|
|
154
|
+
/** What yodel printed on standard output: `Applied: ...`, `Nothing to apply.` or the stand-down line. */
|
|
155
|
+
summary: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The parts of the wave plan file `yodel apply --wave-plan` writes that the capability reads. */
|
|
159
|
+
interface YodelWavePlanFile {
|
|
160
|
+
planDigest: string;
|
|
161
|
+
migrations?: string;
|
|
162
|
+
environment?: string;
|
|
163
|
+
empty?: boolean;
|
|
164
|
+
pending?: string[];
|
|
165
|
+
destructiveSteps?: Array<{ migration?: string; step?: number; why?: string }>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** What `sql-migrate` is built with. Tests pass a fake runner. */
|
|
169
|
+
export interface SqlMigrateDeps {
|
|
170
|
+
run?: YodelRunner;
|
|
171
|
+
/** The job's working directory. Default `process.cwd()`. */
|
|
172
|
+
cwd?: () => string;
|
|
173
|
+
/** The job's environment. Default `process.env`. */
|
|
174
|
+
env?: () => NodeJS.ProcessEnv;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Where the capability keeps a run's yodel files, relative to the working directory. */
|
|
178
|
+
export function sqlMigrateFiles(environment: string): { part: string; wavePlan: string } {
|
|
179
|
+
const key = environment.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
180
|
+
return { part: `.chant/sql/yodel/${key}.part.json`, wavePlan: `.chant/sql/yodel/${key}.wave-plan.json` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function requireEnvironment(input: SqlMigrateStepInput): void {
|
|
184
|
+
if (typeof input?.environment !== "string" || input.environment.trim() === "" || input.environment.startsWith("-")) {
|
|
185
|
+
throw new Error("sql-migrate: the step needs `environment`, the yodel environment to migrate (\"{target}\" in an op wave)");
|
|
186
|
+
}
|
|
187
|
+
if (input.spec !== undefined && (typeof input.spec !== "string" || input.spec === "" || input.spec.startsWith("-"))) {
|
|
188
|
+
throw new Error("sql-migrate: `spec` must name the op-waves spec file, such as \"yodel-waves.json\"");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const commonOf = (input: SqlMigrateStepInput): YodelCommonOptions => ({
|
|
193
|
+
...(input.dir ? { dir: input.dir } : {}),
|
|
194
|
+
...(input.op ? { op: input.op } : {}),
|
|
195
|
+
...(input.allowOutOfOrder ? { allowOutOfOrder: true } : {}),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/** The last few lines a failed run printed, for a message. */
|
|
199
|
+
function tail(run: YodelRun): string {
|
|
200
|
+
const text = (run.stderr || run.stdout).trim();
|
|
201
|
+
return text.split("\n").slice(-4).join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A failed yodel run as an error, its exit read through chant's table. */
|
|
205
|
+
function runError(what: string, run: YodelRun, command: string, legacy: boolean): YodelRunError {
|
|
206
|
+
if (run.missingBinary) return new YodelRunError(`${what}: the yodel binary was not found (the project's node_modules/.bin/yodel, or yodel on PATH); install @intentius/sql-yodeler`, CHANT_EXIT.failed, null);
|
|
207
|
+
const chantExit = yodelExitOf({ code: run.code, command, legacy });
|
|
208
|
+
return new YodelRunError(`${what} exited ${run.code === null ? "without a status" : String(run.code)}: ${tail(run)}`, chantExit, run.code);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function readJson(path: string): unknown {
|
|
212
|
+
return JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The destructive classes of a wave plan: each step's rules, as the engine's lint names them, sorted. */
|
|
216
|
+
export function wavePlanDestructive(plan: Pick<YodelWavePlanFile, "destructiveSteps">): string[] {
|
|
217
|
+
const out = new Set<string>();
|
|
218
|
+
for (const s of plan.destructiveSteps ?? []) for (const why of (s.why ?? "").split(",")) if (why.trim()) out.add(why.trim());
|
|
219
|
+
return [...out].sort();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isSqlMigrateArtifact(value: unknown): value is SqlMigrateArtifact {
|
|
223
|
+
const v = value as Partial<SqlMigrateArtifact> | undefined;
|
|
224
|
+
return v?.kind === "sql-migrate" && typeof v.environment === "string" && typeof v.planDigest === "string";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A legacy yodel's moved wave plan, read from the line it prints (it exits 1, as for any error). */
|
|
228
|
+
const LEGACY_MOVED = /wave plan moved since the wave was decided/;
|
|
229
|
+
|
|
230
|
+
/** The `sql-migrate` capability, with its runner and environment injectable. */
|
|
231
|
+
export function createSqlMigrateCapability(deps: SqlMigrateDeps = {}): Capability<SqlMigrateStepInput, SqlMigrateStepOutput> {
|
|
232
|
+
const run = deps.run ?? defaultYodelRunner;
|
|
233
|
+
const cwdOf = deps.cwd ?? (() => process.cwd());
|
|
234
|
+
const envOf = deps.env ?? (() => process.env);
|
|
235
|
+
return {
|
|
236
|
+
kind: "sql-migrate",
|
|
237
|
+
// A migration has no undo short of a revert, which is a change of its own (`yodel revert`).
|
|
238
|
+
rollbackPolicy: "needs-opt-out",
|
|
239
|
+
targets: { refuse: "sql-migrate applies an environment's pending migrations in chain order, so it cannot narrow to some objects" },
|
|
240
|
+
// #4571: the environment's history is the grain; resource is refused by name (./lock-grain.ts).
|
|
241
|
+
defaultLockGrain: () => sqlDefaultLockGrain(),
|
|
242
|
+
lockGrainRefusal: (_input, grain) => sqlLockGrainRefusal("sql-migrate", grain),
|
|
243
|
+
async plan(_ctx: DeployContext, input: SqlMigrateStepInput): Promise<CapabilityPlan> {
|
|
244
|
+
requireEnvironment(input);
|
|
245
|
+
const cwd = cwdOf();
|
|
246
|
+
const env = input.environment;
|
|
247
|
+
const files = sqlMigrateFiles(env);
|
|
248
|
+
const legacy = input.legacyExit === true;
|
|
249
|
+
const reader = readerEnv(envOf());
|
|
250
|
+
const opts = { cwd, env: reader, ...(input.dir ? { dir: input.dir } : {}) };
|
|
251
|
+
mkdirSync(dirname(join(cwd, files.part)), { recursive: true });
|
|
252
|
+
// A file an earlier plan left must not pass for this one's.
|
|
253
|
+
rmSync(join(cwd, files.part), { force: true });
|
|
254
|
+
rmSync(join(cwd, files.wavePlan), { force: true });
|
|
255
|
+
|
|
256
|
+
// The part: real counts, one entry per object the pending migrations touch.
|
|
257
|
+
const planned = await runAllowedYodel(run, yodelPlanArgs(env, files.part, commonOf(input)), opts);
|
|
258
|
+
if (!existsSync(join(cwd, files.part))) throw runError(`sql-migrate: yodel plan ${env}`, planned, "plan", legacy);
|
|
259
|
+
const part: ChangeSetPart = readChangeSetPartFile(readJson(join(cwd, files.part)), "sql", env, "sql");
|
|
260
|
+
if (part.member.status === "failed") {
|
|
261
|
+
// A refusal no approval fixes (a checksum, a fork, the directory): `failed` on chant's table (#4570).
|
|
262
|
+
throw new YodelRunError(`sql-migrate: yodel would refuse to apply ${env}: ${part.member.error ?? tail(planned)}`, CHANT_EXIT.failed, planned.code);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 2: a pending step the engine does not run; the part still says which, and the wave plan refuses it.
|
|
266
|
+
if (planned.code !== 0 && planned.code !== 2) throw runError(`sql-migrate: yodel plan ${env}`, planned, "plan", legacy);
|
|
267
|
+
|
|
268
|
+
if (input.spec === undefined) {
|
|
269
|
+
// A pull request's plan: the migrations' digest, the one `yodel approve` binds.
|
|
270
|
+
const planDigest = part.member.planDigest!;
|
|
271
|
+
const artifact: SqlMigrateArtifact = { kind: "sql-migrate", environment: env, planDigest, migrations: planDigest, pending: [] };
|
|
272
|
+
return { member: env, planDigest, artifact, changeSet: part };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!existsSync(join(cwd, input.spec))) throw new Error(`sql-migrate: the op-waves spec ${input.spec} is not in ${cwd}; the engine reads chant's decision for the wave by it`);
|
|
276
|
+
const waved = await runAllowedYodel(run, yodelWavePlanArgs(env, files.wavePlan, input.spec, commonOf(input)), opts);
|
|
277
|
+
if (waved.code !== 0 || !existsSync(join(cwd, files.wavePlan))) throw runError(`sql-migrate: yodel apply ${env} --wave-plan`, waved, "apply", legacy);
|
|
278
|
+
const wave = readJson(join(cwd, files.wavePlan)) as YodelWavePlanFile;
|
|
279
|
+
if (typeof wave.planDigest !== "string" || wave.planDigest === "") throw new Error(`sql-migrate: ${files.wavePlan} has no planDigest; it is not a plan yodel apply --wave-plan wrote`);
|
|
280
|
+
const destructive = wavePlanDestructive(wave);
|
|
281
|
+
const artifact: SqlMigrateArtifact = {
|
|
282
|
+
kind: "sql-migrate",
|
|
283
|
+
environment: env,
|
|
284
|
+
planDigest: wave.planDigest,
|
|
285
|
+
wavePlan: files.wavePlan,
|
|
286
|
+
spec: input.spec,
|
|
287
|
+
...(wave.migrations ? { migrations: wave.migrations } : {}),
|
|
288
|
+
pending: wave.pending ?? [],
|
|
289
|
+
};
|
|
290
|
+
// The member's digest is the wave plan's, so a gate on the member alone and the wave bind one value.
|
|
291
|
+
const changeSet: ChangeSetPart = { ...part, member: { ...part.member, planDigest: wave.planDigest } };
|
|
292
|
+
return { member: env, planDigest: wave.planDigest, artifact, changeSet, ...(destructive.length > 0 ? { destructive } : {}) };
|
|
293
|
+
},
|
|
294
|
+
async run(ctx: DeployContext, input: SqlMigrateStepInput): Promise<SqlMigrateStepOutput> {
|
|
295
|
+
requireEnvironment(input);
|
|
296
|
+
const env = input.environment;
|
|
297
|
+
const decided = ctx.plans?.[env];
|
|
298
|
+
if (!isSqlMigrateArtifact(decided) || decided.wavePlan === undefined || decided.spec === undefined) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`sql-migrate: ${env} applies only the plan an op wave decided, planned with \`spec\`; migrations apply on the merged branch, never before the merge (sql-yodeler#285, ruling E). Nothing was applied`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const cwd = cwdOf();
|
|
304
|
+
const legacy = input.legacyExit === true;
|
|
305
|
+
const file = join(cwd, decided.wavePlan);
|
|
306
|
+
if (!existsSync(file)) throw new Error(`sql-migrate: the wave plan of ${env} is gone from ${decided.wavePlan}; nothing was applied. Run the wave again`);
|
|
307
|
+
const onDisk = readJson(file) as Partial<YodelWavePlanFile>;
|
|
308
|
+
if (onDisk.planDigest !== decided.planDigest) {
|
|
309
|
+
throw new SqlPlanMovedError(env, `sql-migrate: ${decided.wavePlan} holds plan ${String(onDisk.planDigest)}, and the wave decided ${decided.planDigest}; nothing was applied`);
|
|
310
|
+
}
|
|
311
|
+
// The apply alone runs with the job's environment, so the writer reaches it and nothing else.
|
|
312
|
+
const applied = await runAllowedYodel(run, yodelWaveApplyArgs(env, decided.wavePlan, decided.spec, commonOf(input)), { cwd, env: envOf(), ...(input.dir ? { dir: input.dir } : {}) });
|
|
313
|
+
if (applied.code === 0) return { environment: env, planDigest: decided.planDigest, summary: applied.stdout.trim() };
|
|
314
|
+
const error = runError(`sql-migrate: yodel apply ${env} --wave-apply`, applied, "apply", legacy);
|
|
315
|
+
if (error.chantExit === CHANT_EXIT.moved || (legacy && LEGACY_MOVED.test(`${applied.stderr}\n${applied.stdout}`))) throw new SqlPlanMovedError(env, error.message);
|
|
316
|
+
throw error;
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export const sqlMigrateCapability = createSqlMigrateCapability();
|
|
322
|
+
|
|
323
|
+
// ── sql-apply ─────────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/** The input of a `sql-apply` step. */
|
|
326
|
+
export interface SqlApplyStepInput {
|
|
327
|
+
/** The chant environment, which selects `sql.profiles.<environment>`. */
|
|
328
|
+
environment: string;
|
|
329
|
+
/** The build's output (`chant build src --lexicon sql -o dist/schema.json`). */
|
|
330
|
+
build: string;
|
|
331
|
+
/** Drop this project's objects the build no longer declares. Destructive, so off by default. */
|
|
332
|
+
prune?: boolean;
|
|
333
|
+
stack?: string;
|
|
334
|
+
ownershipEnv?: string;
|
|
335
|
+
/** Postgres: `lock_timeout` per statement, in ms. */
|
|
336
|
+
lockTimeoutMs?: number;
|
|
337
|
+
/** Postgres: `statement_timeout` for a catalog-only statement, in ms. */
|
|
338
|
+
statementTimeoutMs?: number;
|
|
339
|
+
/** Postgres: `statement_timeout` for a statement that reads or rewrites rows, in ms. */
|
|
340
|
+
scanTimeoutMs?: number;
|
|
341
|
+
/** ClickHouse: how long to wait for one table's background rewrite, in ms. */
|
|
342
|
+
mutationTimeoutMs?: number;
|
|
343
|
+
/** Project directory for `chant.config.ts`. Default: the working directory. */
|
|
344
|
+
cwd?: string;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** A declarative plan, as `sql-apply` reads it from the dialect's planner. */
|
|
348
|
+
export interface SqlDeclarativePlan {
|
|
349
|
+
dialect: "clickhouse" | "postgres";
|
|
350
|
+
changes: SqlDeclarativeChange[];
|
|
351
|
+
/** Changes the applier will not make in place, each with the Op to run instead when the planner names one. */
|
|
352
|
+
refused: Array<{ object: string; field: string; op?: string }>;
|
|
353
|
+
holes: ChangeSetHole[];
|
|
354
|
+
/** Why the plan cannot be applied besides (a named collection missing on the server). */
|
|
355
|
+
problems: string[];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface SqlApplyArtifact {
|
|
359
|
+
kind: "sql-apply";
|
|
360
|
+
environment: string;
|
|
361
|
+
dialect: "clickhouse" | "postgres";
|
|
362
|
+
planDigest: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface SqlApplyStepOutput {
|
|
366
|
+
environment: string;
|
|
367
|
+
dialect: "clickhouse" | "postgres";
|
|
368
|
+
planDigest: string;
|
|
369
|
+
applied: string[];
|
|
370
|
+
pruned: string[];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** What `sql-apply` is built with. Tests pass fakes for the planner and the applier. */
|
|
374
|
+
export interface SqlApplyDeps {
|
|
375
|
+
plan?: (input: SqlApplyStepInput, dialect: "clickhouse" | "postgres") => Promise<SqlDeclarativePlan>;
|
|
376
|
+
apply?: (input: SqlApplyStepInput, dialect: "clickhouse" | "postgres") => Promise<{ applied: string[]; pruned: string[]; notAttempted: Array<{ name: string; reason: string }> }>;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The dialect a build output names: `postgres`, else `clickhouse`. */
|
|
380
|
+
export function buildDialect(path: string): "clickhouse" | "postgres" {
|
|
381
|
+
let raw: { dialect?: unknown; sql?: { dialect?: unknown } };
|
|
382
|
+
try {
|
|
383
|
+
raw = JSON.parse(readFileSync(path, "utf-8")) as typeof raw;
|
|
384
|
+
} catch (err) {
|
|
385
|
+
throw new Error(`sql-apply: the build ${path} cannot be read: ${err instanceof Error ? err.message : String(err)}. Run chant build first`);
|
|
386
|
+
}
|
|
387
|
+
const d = raw.sql && typeof raw.sql === "object" ? raw.sql.dialect : raw.dialect;
|
|
388
|
+
return d === "postgres" ? "postgres" : "clickhouse";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const pick = <T extends object>(o: T): Partial<T> => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>;
|
|
392
|
+
|
|
393
|
+
async function defaultPlan(input: SqlApplyStepInput, dialect: "clickhouse" | "postgres"): Promise<SqlDeclarativePlan> {
|
|
394
|
+
const cwd = input.cwd ? { cwd: input.cwd } : {};
|
|
395
|
+
if (dialect === "postgres") {
|
|
396
|
+
const { planPgAgainstServer } = await import("./postgres/plan/commands");
|
|
397
|
+
const diff = await planPgAgainstServer(input.environment, input.build, cwd);
|
|
398
|
+
const { HANDED_OFF } = await import("./postgres/migrate/handoff");
|
|
399
|
+
return {
|
|
400
|
+
dialect,
|
|
401
|
+
changes: diff.changes,
|
|
402
|
+
// A column rename or a type change across kinds is a PostgresMigrationOp's; the other expand and contract changes are made by hand.
|
|
403
|
+
refused: diff.refused.map((c) => ({ object: c.object, field: c.field, ...(HANDED_OFF.has(c.rule) ? { op: "PostgresMigrationOp" } : {}) })),
|
|
404
|
+
holes: [],
|
|
405
|
+
problems: [],
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const { planAgainstServer } = await import("./clickhouse/plan/commands");
|
|
409
|
+
const { namedCollectionsFail } = await import("./clickhouse/live/named-collections");
|
|
410
|
+
const diff = await planAgainstServer(input.environment, input.build, cwd);
|
|
411
|
+
const missing = namedCollectionsFail(diff.namedCollections) ? diff.namedCollections!.missing : [];
|
|
412
|
+
return {
|
|
413
|
+
dialect,
|
|
414
|
+
changes: diff.changes,
|
|
415
|
+
refused: diff.rebuilds.map((c) => ({ object: c.object, field: c.field, op: "ClickHouseRebuildOp" })),
|
|
416
|
+
holes: (diff.unreadable ?? []).map((u) => ({ address: u.object, type: u.type, reason: u.reason })),
|
|
417
|
+
problems: missing.length > 0 ? [`the build references named collection(s) the server does not have: ${missing.join(", ")}`] : [],
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function defaultApply(input: SqlApplyStepInput, dialect: "clickhouse" | "postgres"): Promise<{ applied: string[]; pruned: string[]; notAttempted: Array<{ name: string; reason: string }> }> {
|
|
422
|
+
const args = pick({ buildPath: input.build, environment: input.environment, prune: input.prune, stack: input.stack, ownershipEnv: input.ownershipEnv, cwd: input.cwd });
|
|
423
|
+
const outcome =
|
|
424
|
+
dialect === "postgres"
|
|
425
|
+
? await (await import("./op/activities/postgres-apply")).postgresApply({
|
|
426
|
+
...args,
|
|
427
|
+
buildPath: input.build,
|
|
428
|
+
...pick({ lockTimeoutMs: input.lockTimeoutMs, statementTimeoutMs: input.statementTimeoutMs, scanTimeoutMs: input.scanTimeoutMs }),
|
|
429
|
+
})
|
|
430
|
+
: await (await import("./op/activities/clickhouse-apply")).clickhouseApply({ ...args, buildPath: input.build, ...pick({ mutationTimeoutMs: input.mutationTimeoutMs }) });
|
|
431
|
+
return {
|
|
432
|
+
applied: outcome.applied.map((a) => `${a.kind}/${a.name}`),
|
|
433
|
+
pruned: outcome.pruned.map((p) => `${p.kind}/${p.name}`),
|
|
434
|
+
notAttempted: outcome.notAttempted.map((n) => ({ name: `${n.kind}/${n.name}`, reason: `${n.reason}${n.detail ? `: ${n.detail}` : ""}` })),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function requireApplyInput(input: SqlApplyStepInput): void {
|
|
439
|
+
if (typeof input?.environment !== "string" || input.environment.trim() === "") throw new Error("sql-apply: the step needs `environment`, which selects sql.profiles.<environment>");
|
|
440
|
+
if (typeof input.build !== "string" || input.build.trim() === "") throw new Error("sql-apply: the step needs `build`, the chant build output to apply (dist/schema.json)");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Plan `input` and say why it cannot be applied, if it cannot. */
|
|
444
|
+
async function planDeclarative(plan: NonNullable<SqlApplyDeps["plan"]>, input: SqlApplyStepInput): Promise<{ part: ChangeSetPart; dialect: "clickhouse" | "postgres" }> {
|
|
445
|
+
const dialect = buildDialect(input.build);
|
|
446
|
+
const planned = await plan(input, dialect);
|
|
447
|
+
const why: string[] = [...planned.problems];
|
|
448
|
+
if (planned.refused.length > 0) {
|
|
449
|
+
why.push(`the applier does not make ${planned.refused.map((r) => `${r.object} (${r.field}${r.op ? `, run a ${r.op}` : ""})`).join(", ")} in place`);
|
|
450
|
+
}
|
|
451
|
+
if (planned.holes.length > 0) why.push(`the plan cannot read ${planned.holes.map((h) => `${h.address}: ${h.reason}`).join("; ")}`);
|
|
452
|
+
if (why.length > 0) throw new Error(`sql-apply: ${input.environment} cannot apply this build: ${why.join("; and ")}`);
|
|
453
|
+
const part = sqlDeclarativePlanPart({ member: input.environment, dialect, environment: input.environment, changes: planned.changes });
|
|
454
|
+
return { part, dialect };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The `sql-apply` capability, with its planner and applier injectable. */
|
|
458
|
+
export function createSqlApplyCapability(deps: SqlApplyDeps = {}): Capability<SqlApplyStepInput, SqlApplyStepOutput> {
|
|
459
|
+
const plan = deps.plan ?? defaultPlan;
|
|
460
|
+
const apply = deps.apply ?? defaultApply;
|
|
461
|
+
return {
|
|
462
|
+
kind: "sql-apply",
|
|
463
|
+
// An apply has no undo short of applying the previous build, which is a change of its own.
|
|
464
|
+
rollbackPolicy: "needs-opt-out",
|
|
465
|
+
targets: { refuse: "sql-apply applies a whole build, since its objects depend on each other in creation order" },
|
|
466
|
+
// #4571: the environment is the grain, as for sql-migrate.
|
|
467
|
+
defaultLockGrain: () => sqlDefaultLockGrain(),
|
|
468
|
+
lockGrainRefusal: (_input, grain) => sqlLockGrainRefusal("sql-apply", grain),
|
|
469
|
+
async plan(_ctx: DeployContext, input: SqlApplyStepInput): Promise<CapabilityPlan> {
|
|
470
|
+
requireApplyInput(input);
|
|
471
|
+
const { part, dialect } = await planDeclarative(plan, input);
|
|
472
|
+
const planDigest = part.member.planDigest!;
|
|
473
|
+
const artifact: SqlApplyArtifact = { kind: "sql-apply", environment: input.environment, dialect, planDigest };
|
|
474
|
+
return { member: input.environment, planDigest, artifact, changeSet: part };
|
|
475
|
+
},
|
|
476
|
+
async run(ctx: DeployContext, input: SqlApplyStepInput): Promise<SqlApplyStepOutput> {
|
|
477
|
+
requireApplyInput(input);
|
|
478
|
+
const decided = ctx.plans?.[input.environment] as Partial<SqlApplyArtifact> | undefined;
|
|
479
|
+
if (decided?.kind !== "sql-apply" || typeof decided.planDigest !== "string") {
|
|
480
|
+
throw new Error(`sql-apply: ${input.environment} applies only a plan that was planned and decided first (DeployContext.plans); nothing was applied`);
|
|
481
|
+
}
|
|
482
|
+
// Planned again just before the apply: a server or build that moved since the decision applies nothing.
|
|
483
|
+
const { part, dialect } = await planDeclarative(plan, input);
|
|
484
|
+
if (part.member.planDigest !== decided.planDigest) {
|
|
485
|
+
throw new SqlPlanMovedError(input.environment, `sql-apply: ${input.environment}'s plan moved since it was decided (decided ${decided.planDigest}, now ${String(part.member.planDigest)}); nothing was applied`);
|
|
486
|
+
}
|
|
487
|
+
const outcome = await apply(input, dialect);
|
|
488
|
+
if (outcome.notAttempted.length > 0 && outcome.applied.length === 0 && outcome.pruned.length === 0 && part.entries.length > 0) {
|
|
489
|
+
throw new Error(`sql-apply: nothing was applied to ${input.environment}: ${outcome.notAttempted.map((n) => `${n.name} (${n.reason})`).join(", ")}`);
|
|
490
|
+
}
|
|
491
|
+
return { environment: input.environment, dialect, planDigest: decided.planDigest, applied: outcome.applied, pruned: outcome.pruned };
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export const sqlApplyCapability = createSqlApplyCapability();
|
|
497
|
+
|
|
498
|
+
// ── the plugin ────────────────────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
export const SQL_VERB_FAMILIES = {
|
|
501
|
+
apply: ["sql-apply", "sql-migrate"],
|
|
502
|
+
} as const;
|
|
503
|
+
|
|
504
|
+
export const sqlCapabilityPlugin: CapabilityPlugin = {
|
|
505
|
+
name: "sql",
|
|
506
|
+
get version(): string {
|
|
507
|
+
return ownPackageVersion(import.meta.url);
|
|
508
|
+
},
|
|
509
|
+
capabilities(): Array<Capability<never, unknown>> {
|
|
510
|
+
return [sqlMigrateCapability as Capability<never, unknown>, sqlApplyCapability as Capability<never, unknown>];
|
|
511
|
+
},
|
|
512
|
+
families(): Record<string, readonly string[]> {
|
|
513
|
+
return SQL_VERB_FAMILIES;
|
|
514
|
+
},
|
|
515
|
+
};
|
package/src/change-set.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* - the plan step of the lexicon's own migration Ops, `ClickHouseRebuildOp`
|
|
12
12
|
* and `PostgresMigrationOp`. {@link clickhouseRebuildPart} and
|
|
13
13
|
* {@link postgresMigrationPart}.
|
|
14
|
+
* - a declarative plan's classified changes, as the `sql-apply` capability
|
|
15
|
+
* plans them (#4569). {@link sqlDeclarativePlanPart}.
|
|
14
16
|
*
|
|
15
17
|
* Each returns a part with planner `sql`, so it composes with every other
|
|
16
18
|
* member's in `composeChangeSet`, and a gate that binds the document gets its
|
|
@@ -353,3 +355,75 @@ export function postgresMigrationPart(input: SqlOpPlanPartInput): ChangeSetPart
|
|
|
353
355
|
const table = migration.split(".").slice(0, -1).join(".") || migration;
|
|
354
356
|
return opPart(input, "PostgresMigrationOp", "migrate", table, "Postgres::Table");
|
|
355
357
|
}
|
|
358
|
+
|
|
359
|
+
// ── a declarative plan ────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* One classified change of a declarative plan (`chant sql plan`): a build
|
|
363
|
+
* against the environment's server, as the dialects' classifiers write it.
|
|
364
|
+
*/
|
|
365
|
+
export interface SqlDeclarativeChange {
|
|
366
|
+
/** The object, as the plan labels it. */
|
|
367
|
+
object: string;
|
|
368
|
+
field: string;
|
|
369
|
+
rule: string;
|
|
370
|
+
class: string;
|
|
371
|
+
before?: string;
|
|
372
|
+
after?: string;
|
|
373
|
+
destructive?: boolean;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface SqlDeclarativePlanPartInput {
|
|
377
|
+
member: string;
|
|
378
|
+
dialect: "clickhouse" | "postgres";
|
|
379
|
+
/** The environment it plans into: the member's and every entry's `scope`. */
|
|
380
|
+
environment: string;
|
|
381
|
+
changes: readonly SqlDeclarativeChange[];
|
|
382
|
+
/** What the plan could not read (#3653), as holes. */
|
|
383
|
+
holes?: ChangeSetHole[];
|
|
384
|
+
/** Default `"sql"`. */
|
|
385
|
+
lexicon?: string;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The entry type a declarative change gets: the classified change names no object kind. */
|
|
389
|
+
const DECLARATIVE_TYPE = { clickhouse: "ClickHouse::Object", postgres: "Postgres::Object" } as const;
|
|
390
|
+
|
|
391
|
+
/** The digest of a declarative plan: the dialect, the environment and every change, sorted. */
|
|
392
|
+
export function sqlDeclarativePlanDigest(input: Pick<SqlDeclarativePlanPartInput, "dialect" | "environment" | "changes">): string {
|
|
393
|
+
const changes = input.changes
|
|
394
|
+
.map((c) => ({ object: c.object, field: c.field, rule: c.rule, class: c.class, before: c.before ?? null, after: c.after ?? null, destructive: c.destructive === true }))
|
|
395
|
+
.sort((a, b) => (a.object === b.object ? (a.field < b.field ? -1 : a.field > b.field ? 1 : 0) : a.object < b.object ? -1 : 1));
|
|
396
|
+
return computePlanDigest("sql-declarative-plan", { dialect: input.dialect, environment: input.environment, changes });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* A declarative plan's part (#4569): one entry per object the changes touch,
|
|
401
|
+
* the action and disruption from their classes as for a versioned plan, the
|
|
402
|
+
* fields as attributes. Its digest is {@link sqlDeclarativePlanDigest}.
|
|
403
|
+
*/
|
|
404
|
+
export function sqlDeclarativePlanPart(input: SqlDeclarativePlanPartInput): ChangeSetPart {
|
|
405
|
+
const lexicon = input.lexicon ?? SQL_CHANGE_SET_LEXICON;
|
|
406
|
+
const byAddress = new Map<string, ObjectChanges>();
|
|
407
|
+
for (const c of input.changes) {
|
|
408
|
+
let o = byAddress.get(c.object);
|
|
409
|
+
if (!o) {
|
|
410
|
+
o = { address: c.object, type: DECLARATIVE_TYPE[input.dialect], classes: [], actions: [], destructive: false, ops: [], manual: [], sql: [], fields: new Map() };
|
|
411
|
+
byAddress.set(c.object, o);
|
|
412
|
+
}
|
|
413
|
+
o.classes.push(c.class);
|
|
414
|
+
if (c.destructive) o.destructive = true;
|
|
415
|
+
o.fields.set(c.field, { rule: c.rule, class: c.class, field: c.field, ...(c.before !== undefined ? { before: c.before } : {}), ...(c.after !== undefined ? { after: c.after } : {}), ...(c.destructive ? { destructive: true } : {}) });
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
member: {
|
|
419
|
+
member: input.member,
|
|
420
|
+
lexicon,
|
|
421
|
+
planner: "sql",
|
|
422
|
+
scope: input.environment,
|
|
423
|
+
status: "planned",
|
|
424
|
+
planDigest: sqlDeclarativePlanDigest(input),
|
|
425
|
+
holes: input.holes ?? [],
|
|
426
|
+
},
|
|
427
|
+
entries: entriesOf(input.member, lexicon, input.environment, [...byAddress.values()]),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** #4572: the sql lexicon's CI setup lines and replay services. */
|
|
2
|
+
|
|
3
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { declaresReplay, NODE_CHECK_LINE, PACKAGES_INSTALL_LINE, replayService, sqlCiServices, sqlCiSetup, sqlDialectsOf } from "./ci-setup";
|
|
8
|
+
import { sqlPlugin } from "./plugin";
|
|
9
|
+
import { clickhouseImage } from "./spec/pin";
|
|
10
|
+
import { POSTGRES_LATEST_MAJOR, postgresImage } from "./spec/postgres-pin";
|
|
11
|
+
|
|
12
|
+
function project(config?: string): string {
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), "sql-ci-setup-"));
|
|
14
|
+
if (config !== undefined) writeFileSync(join(dir, "chant.config.ts"), config);
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("sqlDialectsOf", () => {
|
|
19
|
+
test("ClickHouse by default, the named dialect, or each of a list", () => {
|
|
20
|
+
expect(sqlDialectsOf("export default { sql: {} }")).toEqual(["clickhouse"]);
|
|
21
|
+
expect(sqlDialectsOf('export default { sql: { dialect: "postgres" } }')).toEqual(["postgres"]);
|
|
22
|
+
expect(sqlDialectsOf("export default { sql: { dialect: ['postgres', 'clickhouse'] } }")).toEqual(["clickhouse", "postgres"]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("declaresReplay", () => {
|
|
27
|
+
test("a replay profile, quoted or not", () => {
|
|
28
|
+
expect(declaresReplay("profiles: { replay: { url: 'x' } }")).toBe(true);
|
|
29
|
+
expect(declaresReplay('profiles: { "replay": { url: "x" } }')).toBe(true);
|
|
30
|
+
expect(declaresReplay("profiles: { prod: { url: 'x' } }")).toBe(false);
|
|
31
|
+
expect(declaresReplay("profiles: { noreplay: { url: 'x' } }")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("sqlCiSetup", () => {
|
|
36
|
+
test("the Node check and the guarded install, for a project with a config", () => {
|
|
37
|
+
expect(sqlCiSetup({ dir: project("export default { sql: {} }") })).toEqual([NODE_CHECK_LINE, PACKAGES_INSTALL_LINE]);
|
|
38
|
+
expect(sqlCiSetup({ dir: project() })).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the install skips when the lexicon is installed, and puts the bins on PATH on every forge", () => {
|
|
42
|
+
expect(PACKAGES_INSTALL_LINE).toContain("[ -d node_modules/@intentius/chant-lexicon-sql ] || npm ci");
|
|
43
|
+
expect(PACKAGES_INSTALL_LINE).toContain('export PATH="$PWD/node_modules/.bin:$PATH"');
|
|
44
|
+
expect(PACKAGES_INSTALL_LINE).toContain('>> "$GITHUB_PATH"');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("the plugin gives them", () => {
|
|
48
|
+
const dir = project("export default { sql: {} }");
|
|
49
|
+
expect(sqlPlugin.ciSetup?.({ dir })).toEqual(sqlCiSetup({ dir }));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("sqlCiServices", () => {
|
|
54
|
+
test("the replay server per dialect at the emulator's pins, only with a replay profile", () => {
|
|
55
|
+
const dir = project('export default { sql: { dialect: "postgres", profiles: { replay: { url: "postgres://replay-postgres:5432/postgres" } } } }');
|
|
56
|
+
expect(sqlCiServices({ dir })).toEqual([replayService("postgres")]);
|
|
57
|
+
expect(replayService("postgres").image).toBe(postgresImage(POSTGRES_LATEST_MAJOR));
|
|
58
|
+
expect(replayService("clickhouse").image).toBe(clickhouseImage());
|
|
59
|
+
expect(sqlCiServices({ dir: project("export default { sql: { profiles: { prod: {} } } }") })).toEqual([]);
|
|
60
|
+
expect(sqlPlugin.ciServices?.({ dir })).toEqual([replayService("postgres")]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("the job reaches each server by its name, with the variables sql-yodeler's replay profile reads", () => {
|
|
64
|
+
expect(replayService("clickhouse").jobEnv).toEqual({ REPLAY_CLICKHOUSE_URL: "http://replay-clickhouse:8123" });
|
|
65
|
+
expect(replayService("postgres").jobEnv).toMatchObject({ REPLAY_POSTGRES_URL: "postgres://replay-postgres:5432/postgres", REPLAY_POSTGRES_USER: "postgres" });
|
|
66
|
+
});
|
|
67
|
+
});
|