@everystack/cli 0.4.82 → 0.4.83
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/CHANGELOG.md +34 -0
- package/package.json +9 -1
- package/src/cli/authz-compile.ts +0 -0
- package/src/cli/authz-contract.ts +5 -3
- package/src/cli/authz-reconcile.ts +18 -15
- package/src/cli/aws.ts +17 -1
- package/src/cli/commands/db-apply.ts +445 -60
- package/src/cli/commands/db-check.ts +39 -20
- package/src/cli/commands/db-plan.ts +61 -27
- package/src/cli/commands/db-pull.ts +67 -11
- package/src/cli/commands/db-reconcile.ts +22 -1
- package/src/cli/commands/db.ts +137 -19
- package/src/cli/db-source.ts +8 -1
- package/src/cli/derived-catchup.ts +154 -0
- package/src/cli/derived-render.ts +531 -245
- package/src/cli/derived-source.ts +25 -90
- package/src/cli/edge-plan.ts +76 -14
- package/src/cli/index.ts +7 -7
- package/src/cli/model-wire-lane.ts +143 -0
- package/src/cli/output.ts +12 -0
- package/src/cli/plain-output.ts +521 -0
- package/src/cli/schema-diff.ts +6 -0
- package/src/cli/schema-fingerprint-v11.ts +234 -0
- package/src/cli/schema-fingerprint.ts +20 -6
- package/src/cli/sql-statement.ts +98 -0
- package/src/cli/state-apply.ts +46 -24
- package/src/wire.ts +30 -0
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
import fs from 'node:fs/promises';
|
|
33
33
|
import { type QueryRunner } from '../authz-contract';
|
|
34
|
-
import { currentGitRef, worktreeDirty } from '../state-apply';
|
|
35
|
-
import { planHash, PLAN_VERSION, APPLY_CONTRACT_VERSION, type EdgePlan } from '../edge-plan';
|
|
34
|
+
import { currentGitRef, worktreeDirty, classifyGeneratedStatements, classifyDestructive } from '../state-apply';
|
|
35
|
+
import { planHash, PLAN_VERSION, APPLY_CONTRACT_VERSION, declaredGranteesOf, type EdgePlan } from '../edge-plan';
|
|
36
36
|
import {
|
|
37
37
|
proveDescent,
|
|
38
38
|
decideDescentApply,
|
|
@@ -72,7 +72,10 @@ import { estimateOpsRuntimeFit } from '../ops-fit';
|
|
|
72
72
|
import { resolveOperatorUrlViaStage } from '../direct-venue';
|
|
73
73
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease';
|
|
74
74
|
import { readStageStateTwice } from '../stage-read-consistency';
|
|
75
|
-
import {
|
|
75
|
+
import { renderApplyReceipt, renderCatchUpFailure } from '../plain-output';
|
|
76
|
+
import { catchUpDerived, type CatchUpVenue } from '../derived-catchup';
|
|
77
|
+
import { buildReconcileReport } from './db-reconcile';
|
|
78
|
+
import { step, success, fail, info, warn, line as printLine } from '../output';
|
|
76
79
|
|
|
77
80
|
// The apply core lives in apply-execute.ts (shared with the ops Lambda's
|
|
78
81
|
// db:apply action). Re-exported so the existing integration suites — and any
|
|
@@ -94,14 +97,58 @@ export type { ApplyPlanOptions, ApplyPlanResult } from '../apply-execute';
|
|
|
94
97
|
* nothing and exited 0). formatApplyRefusal owns the string; this owns the
|
|
95
98
|
* process.exit(1).
|
|
96
99
|
*/
|
|
97
|
-
export function reportApplyResult(
|
|
100
|
+
export function reportApplyResult(
|
|
101
|
+
result: ApplyPlanResult,
|
|
102
|
+
plan: EdgePlan,
|
|
103
|
+
opts: {
|
|
104
|
+
verbose?: boolean;
|
|
105
|
+
catchUp?: readonly string[];
|
|
106
|
+
/**
|
|
107
|
+
* The grantees the checkout's models declare — what makes a revoke against an
|
|
108
|
+
* UNdeclared role recognisable as a strip. Absent means the models could not be read,
|
|
109
|
+
* and a strip then reads as an ordinary `- revoke …` line: under-marking a receipt is
|
|
110
|
+
* the lesser evil against guessing, and the gate that actually decides already ran.
|
|
111
|
+
*/
|
|
112
|
+
declaredGrantees?: ReadonlySet<string>;
|
|
113
|
+
} = {},
|
|
114
|
+
): void {
|
|
115
|
+
// What the function/view catch-up ran on the same target. It joins the receipt's own change
|
|
116
|
+
// list — one command, one list, one count — so a caught-up view is never a second report the
|
|
117
|
+
// reader has to reconcile with the first.
|
|
118
|
+
const catchUp = opts.catchUp ?? [];
|
|
98
119
|
switch (result.status) {
|
|
99
120
|
case 'already-applied':
|
|
100
|
-
success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
|
|
121
|
+
if (opts.verbose) success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
|
|
122
|
+
else {
|
|
123
|
+
const lines = renderApplyReceipt({ executable: [], destructive: [], alreadyApplied: true, catchUp });
|
|
124
|
+
for (const text of lines.slice(0, -1)) printLine(text);
|
|
125
|
+
success(lines[lines.length - 1]);
|
|
126
|
+
}
|
|
101
127
|
return;
|
|
102
|
-
case 'applied':
|
|
103
|
-
|
|
128
|
+
case 'applied': {
|
|
129
|
+
if (opts.verbose) {
|
|
130
|
+
success(`Applied and verified — the target is at ${result.liveFingerprint.slice(0, 12)}, exactly as the plan predicted. Recorded in everystack.schema_log (plan_ref ${planHash(plan).slice(0, 12)}).`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// The receipt: the plan's own change list back, one line each, as what just happened.
|
|
134
|
+
//
|
|
135
|
+
// The ⚠ subset is the SAME three kinds db:plan marked and the --confirm gate counted —
|
|
136
|
+
// drops, narrowing type changes, and STRIPS. A strip is a revoke against a grantee no
|
|
137
|
+
// model declares, so recognising one needs the declared side; the caller reads it off
|
|
138
|
+
// the models it already loads in this same command. Without it the classifier is asked
|
|
139
|
+
// the same question with less to answer from, and a strip prints as a plain
|
|
140
|
+
// `- revoke <role>: …`. The one direction this must never disagree in is the other one:
|
|
141
|
+
// a statement that carried the full destructive ceremony on the way in must not read as
|
|
142
|
+
// ordinary churn on the way out.
|
|
143
|
+
const executable = classifyGeneratedStatements(plan.statements ?? []).executable;
|
|
144
|
+
const { drops, narrowings, strips } = classifyDestructive(executable, {
|
|
145
|
+
...(opts.declaredGrantees ? { declaredGrantees: opts.declaredGrantees } : {}),
|
|
146
|
+
});
|
|
147
|
+
const lines = renderApplyReceipt({ executable, destructive: [...drops, ...narrowings, ...strips], catchUp });
|
|
148
|
+
for (const text of lines.slice(0, -1)) printLine(text);
|
|
149
|
+
success(lines[lines.length - 1]);
|
|
104
150
|
return;
|
|
151
|
+
}
|
|
105
152
|
default:
|
|
106
153
|
// Every remaining status is a refusal or a verify fault. TS narrows result.status here to
|
|
107
154
|
// exactly ApplyRefusalStatus, so a future status that is neither success nor a known refusal
|
|
@@ -112,6 +159,145 @@ export function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void
|
|
|
112
159
|
}
|
|
113
160
|
}
|
|
114
161
|
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// The catch-up: the second half of the job, which db:apply no longer leaves to a human.
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The command that retries ONLY the functions and views.
|
|
168
|
+
*
|
|
169
|
+
* It is db:apply with no plan, which is precisely the catch-up lane — so rerunning it cannot
|
|
170
|
+
* re-run the table/column changes that already committed. Same venue, same models, nothing
|
|
171
|
+
* else. A bare --database-url is given as a placeholder: the real one is a credential, and a
|
|
172
|
+
* credential does not belong in an error message.
|
|
173
|
+
*/
|
|
174
|
+
export function catchUpRerunCommand(flags: Record<string, string>): string {
|
|
175
|
+
const parts = ['db:apply'];
|
|
176
|
+
if (flags.stage) {
|
|
177
|
+
parts.push(`--stage ${flags.stage}`);
|
|
178
|
+
if (flags.direct === 'true') parts.push('--direct');
|
|
179
|
+
} else {
|
|
180
|
+
parts.push('--database-url <url>');
|
|
181
|
+
}
|
|
182
|
+
if (flags.models) parts.push(`--models ${flags.models}`);
|
|
183
|
+
return parts.join(' ');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** What the catch-up did, or what to say about the fact that it did not. */
|
|
187
|
+
type CatchUpReport = { ok: true; statements: string[] } | { ok: false; lines: string[] };
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Run the catch-up and translate its outcome into output — without exiting.
|
|
191
|
+
*
|
|
192
|
+
* The exit is the caller's, deliberately: the connection has to be closed first, and a failure
|
|
193
|
+
* here must NEVER be re-wrapped as "Apply failed and rolled back". The plan DID land; saying
|
|
194
|
+
* otherwise sends the operator hunting for a rollback that never happened.
|
|
195
|
+
*
|
|
196
|
+
* `applied` is how many table/column changes are already committed — the number the one-line
|
|
197
|
+
* failure report names as safe.
|
|
198
|
+
*
|
|
199
|
+
* `onRefusal` is passed STRAIGHT THROUGH to the catch-up, because it is a property of the
|
|
200
|
+
* caller, not of the catch-up: it says whether anything has been written yet. The two callers
|
|
201
|
+
* below answer it differently and must — see `catchUpAfterApply` and `catchUpOnly`.
|
|
202
|
+
*/
|
|
203
|
+
async function runCatchUp(
|
|
204
|
+
venue: CatchUpVenue,
|
|
205
|
+
flags: Record<string, string>,
|
|
206
|
+
applied: number,
|
|
207
|
+
onRefusal: 'exit' | 'throw',
|
|
208
|
+
): Promise<CatchUpReport> {
|
|
209
|
+
const verbose = flags.verbose === 'true';
|
|
210
|
+
try {
|
|
211
|
+
const outcome = await catchUpDerived(venue, {
|
|
212
|
+
...(flags.models ? { models: flags.models } : {}),
|
|
213
|
+
onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
|
|
214
|
+
onRefusal,
|
|
215
|
+
});
|
|
216
|
+
if (verbose && outcome.run) for (const text of buildReconcileReport(outcome.run.plan)) info(text);
|
|
217
|
+
return { ok: true, statements: outcome.statements };
|
|
218
|
+
} catch (err: any) {
|
|
219
|
+
const cause = String(err?.message ?? err);
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
// The cause is printed ONCE, at every verbosity: its headline on the report line, and
|
|
223
|
+
// the rest verbatim underneath. --verbose used to print it a second time, unfolded,
|
|
224
|
+
// above the report — the same text twice, in two different shapes.
|
|
225
|
+
lines: renderCatchUpFailure({ applied, rerun: catchUpRerunCommand(flags), reason: cause }),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The chain: a plan that LANDED is followed by the catch-up, on the same target, always —
|
|
232
|
+
* unless --state-only asked for the tables alone. A refusal or a verify fault chains nothing
|
|
233
|
+
* (there is no applied state to build on); reportApplyResult renders those and exits.
|
|
234
|
+
*
|
|
235
|
+
* THE POST-WRITE CALLER, and the only reason `'throw'` exists: the table and column changes are
|
|
236
|
+
* committed by the time this runs. Nothing downstream may end the process on its own, because
|
|
237
|
+
* the operator is owed one sentence first — your changes are safe, here is what to rerun. So a
|
|
238
|
+
* wire-contract refusal comes back as an exception and is rendered by `runCatchUp` like any
|
|
239
|
+
* other way the catch-up can fail.
|
|
240
|
+
*/
|
|
241
|
+
async function catchUpAfterApply(
|
|
242
|
+
result: ApplyPlanResult,
|
|
243
|
+
plan: EdgePlan,
|
|
244
|
+
venue: CatchUpVenue,
|
|
245
|
+
flags: Record<string, string>,
|
|
246
|
+
): Promise<CatchUpReport> {
|
|
247
|
+
if (result.status !== 'applied' && result.status !== 'already-applied') return { ok: true, statements: [] };
|
|
248
|
+
if (flags['state-only'] === 'true') return { ok: true, statements: [] };
|
|
249
|
+
const applied = result.status === 'applied'
|
|
250
|
+
? classifyGeneratedStatements(plan.statements ?? []).executable.length
|
|
251
|
+
: 0;
|
|
252
|
+
return runCatchUp(venue, flags, applied, 'throw');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The grantees the ⚠ lines are marked against — the receipt's half of the strip classification.
|
|
257
|
+
*
|
|
258
|
+
* The plan carries its own set, and that is the one that counts: db:apply can run long after
|
|
259
|
+
* db:plan minted it, across a rebase or a hotfix to the models, and the receipt must describe the
|
|
260
|
+
* plan that was verified rather than the tree the operator happens to be standing in. So the
|
|
261
|
+
* precedence is the plan first, and when the plan has it nothing is read from disk at all.
|
|
262
|
+
*
|
|
263
|
+
* The checkout is the FALLBACK, for a plan minted before the plan carried the set. It reads the
|
|
264
|
+
* same barrel `db:plan` reads, through the same helper, so the ⚠ lines printed after an apply
|
|
265
|
+
* cannot drift from the ⚠ lines printed before it. Best-effort ON PURPOSE: this runs to print a
|
|
266
|
+
* receipt for work that has already committed, so an unreadable models tree must cost the operator
|
|
267
|
+
* a mark on one line, never the receipt itself. Nothing is decided here — every gate that acts on
|
|
268
|
+
* a strip ran long before.
|
|
269
|
+
*
|
|
270
|
+
* Best-effort is not silent, though: a receipt that quietly stopped marking its destructive
|
|
271
|
+
* lines reads exactly like a receipt with nothing destructive in it. Say so.
|
|
272
|
+
*/
|
|
273
|
+
async function declaredGranteesForReceipt(
|
|
274
|
+
plan: EdgePlan,
|
|
275
|
+
flags: Record<string, string>,
|
|
276
|
+
): Promise<ReadonlySet<string> | undefined> {
|
|
277
|
+
if (plan.declaredGrantees) return new Set(plan.declaredGrantees);
|
|
278
|
+
try {
|
|
279
|
+
return declaredGranteesOf(await loadModels(resolveModelsPath(flags.models)));
|
|
280
|
+
} catch (err: any) {
|
|
281
|
+
warn(`the models could not be read, so the receipt below cannot mark a revoke against an undeclared role as DESTRUCTIVE (${err?.message ?? err}). Everything listed still applied; only the marking is degraded.`);
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Print a failed catch-up and exit: the report line, then the cause as it was written.
|
|
288
|
+
*
|
|
289
|
+
* Only the FIRST line is the failure. The block under it is the cause quoted verbatim, so it
|
|
290
|
+
* goes out plainly — marking every line of a copy-pasteable SQL statement with our own failure
|
|
291
|
+
* symbol would both misread and break the copy.
|
|
292
|
+
*/
|
|
293
|
+
function exitOnCatchUpFailure(report: CatchUpReport): asserts report is { ok: true; statements: string[] } {
|
|
294
|
+
if (report.ok) return;
|
|
295
|
+
const [head, ...cause] = report.lines;
|
|
296
|
+
fail(head);
|
|
297
|
+
for (const text of cause) printLine(text);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
|
|
115
301
|
/**
|
|
116
302
|
* Say what descent found, on BOTH lanes identically. Exported so a test can pin the one line
|
|
117
303
|
* that matters most: proven drift is announced as the ORIGIN, loudly, before anything writes.
|
|
@@ -219,7 +405,8 @@ async function applyPlanViaStage(
|
|
|
219
405
|
const fn = opsFunction(config);
|
|
220
406
|
const attestLineageLost = flags['attest-lineage-lost']; // validated (not bare 'true') in dbApplyCommand
|
|
221
407
|
|
|
222
|
-
|
|
408
|
+
// The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
|
|
409
|
+
if (flags.verbose === 'true') info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
|
|
223
410
|
|
|
224
411
|
// THE STAGE LANE'S READ GUARD, before anything is dispatched. Each introspection is
|
|
225
412
|
// one session, but a full read is two of them (state, then authz) and they can be
|
|
@@ -260,6 +447,10 @@ async function applyPlanViaStage(
|
|
|
260
447
|
process.exit(1);
|
|
261
448
|
}
|
|
262
449
|
|
|
450
|
+
// Assigned inside the try; the catch-up and the report happen AFTER it, so a catch-up failure
|
|
451
|
+
// can never be re-wrapped as "Apply failed" for a plan that actually landed.
|
|
452
|
+
let landedResult: ApplyPlanResult | undefined;
|
|
453
|
+
|
|
263
454
|
try {
|
|
264
455
|
// Descent — the fast-forward rule needs the git checkout, so the CLI
|
|
265
456
|
// decides it (the Lambda has no checkout). It runs against the state the
|
|
@@ -346,11 +537,21 @@ async function applyPlanViaStage(
|
|
|
346
537
|
if (result?.snapshotId) {
|
|
347
538
|
info(`snapshot on record: ${result.snapshotId} — restore with db:restore --from ${result.snapshotId} --confirm.`);
|
|
348
539
|
}
|
|
349
|
-
|
|
540
|
+
landedResult = result as ApplyPlanResult;
|
|
350
541
|
} catch (err: any) {
|
|
351
542
|
fail(`Apply failed: ${err.message}`);
|
|
352
543
|
process.exit(1);
|
|
353
544
|
}
|
|
545
|
+
|
|
546
|
+
// Then the functions, views and triggers — on the same stage, credential-free, always.
|
|
547
|
+
const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags);
|
|
548
|
+
exitOnCatchUpFailure(catchUp);
|
|
549
|
+
const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
|
|
550
|
+
reportApplyResult(landedResult!, plan, {
|
|
551
|
+
verbose: flags.verbose === 'true',
|
|
552
|
+
catchUp: catchUp.statements,
|
|
553
|
+
...(declaredGrantees ? { declaredGrantees } : {}),
|
|
554
|
+
});
|
|
354
555
|
}
|
|
355
556
|
|
|
356
557
|
/**
|
|
@@ -383,11 +584,138 @@ async function escalateStageToDirect(
|
|
|
383
584
|
return { kind: 'url', url: op.url, from: 'operator' };
|
|
384
585
|
}
|
|
385
586
|
|
|
587
|
+
/**
|
|
588
|
+
* `db:apply` with NO plan: catch the functions, views and triggers up, and nothing else.
|
|
589
|
+
*
|
|
590
|
+
* This is the lane that replaces running `db:reconcile --apply` by hand — the standalone second
|
|
591
|
+
* command whose being forgotten shipped half a database once, and drifted two stages apart
|
|
592
|
+
* once. It writes no table and no column, so it needs none of the plan flow's ceremony: there
|
|
593
|
+
* is no edge to lock a fingerprint against and no merged work a fast-forward rule could protect.
|
|
594
|
+
*
|
|
595
|
+
* THE PRE-WRITE CALLER, which is why it takes the `'exit'` default: nothing has been written
|
|
596
|
+
* when a wire-contract refusal lands here, so the refusal's own message is the correct and
|
|
597
|
+
* complete one — the stage and the CLI disagree, NOTHING WAS SENT, and the --direct venue
|
|
598
|
+
* crosses no wire. Borrowing the post-write wording here would tell the operator their changes
|
|
599
|
+
* are safe (there were none) and hand them a rerun of the command that just refused.
|
|
600
|
+
*/
|
|
601
|
+
async function catchUpOnly(flags: Record<string, string>): Promise<void> {
|
|
602
|
+
let dbSource: DbSource;
|
|
603
|
+
try {
|
|
604
|
+
dbSource = resolveDbSource(flags);
|
|
605
|
+
} catch (err: any) {
|
|
606
|
+
fail(err.message);
|
|
607
|
+
process.exit(1);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// The credential-free lane: the whole catch-up runs in the stage's ops Lambda.
|
|
611
|
+
if (dbSource.kind === 'stage' && flags.direct !== 'true') {
|
|
612
|
+
const report = await runCatchUp({ kind: 'stage', stage: flags.stage }, flags, 0, 'exit');
|
|
613
|
+
exitOnCatchUpFailure(report);
|
|
614
|
+
reportCatchUpOnly(report.statements);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// --stage --direct: resolve the operator connection FROM the stage (held in memory only), then
|
|
619
|
+
// run CLI-side on an unbounded clock — a large rebuild outlives the ops Lambda's 900s.
|
|
620
|
+
if (dbSource.kind === 'stage') {
|
|
621
|
+
try {
|
|
622
|
+
step('Resolving the operator connection from the stage (--direct)...');
|
|
623
|
+
const op = await resolveOperatorUrlViaStage(flags.stage);
|
|
624
|
+
info(`operator credential resolved (${op.source}) — executing CLI-side, unbounded clock.`);
|
|
625
|
+
dbSource = { kind: 'url', url: op.url, from: 'operator' };
|
|
626
|
+
} catch (err: any) {
|
|
627
|
+
fail(err.message);
|
|
628
|
+
process.exit(1);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
step(connectingVia(dbSource));
|
|
633
|
+
const { runner, session, end } = await createUrlRunner(dbSource.url);
|
|
634
|
+
let report: CatchUpReport;
|
|
635
|
+
try {
|
|
636
|
+
// The same lease every other write verb takes: one operator mutates a database at a time.
|
|
637
|
+
report = await withMutationLease(
|
|
638
|
+
runner,
|
|
639
|
+
{ verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
|
|
640
|
+
() => runCatchUp({ kind: 'direct', runner, session }, flags, 0, 'exit'),
|
|
641
|
+
);
|
|
642
|
+
} catch (err: any) {
|
|
643
|
+
if (err instanceof MutationLeaseError) {
|
|
644
|
+
fail(err.message);
|
|
645
|
+
process.exit(1);
|
|
646
|
+
}
|
|
647
|
+
fail(`Apply failed: ${err.message}`);
|
|
648
|
+
process.exit(1);
|
|
649
|
+
} finally {
|
|
650
|
+
await end?.();
|
|
651
|
+
}
|
|
652
|
+
exitOnCatchUpFailure(report);
|
|
653
|
+
reportCatchUpOnly(report.statements);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** The receipt for a catch-up-only run: the same change lines, the same count, no plan half. */
|
|
657
|
+
function reportCatchUpOnly(statements: readonly string[]): void {
|
|
658
|
+
const lines = renderApplyReceipt({
|
|
659
|
+
executable: [],
|
|
660
|
+
destructive: [],
|
|
661
|
+
alreadyApplied: statements.length === 0,
|
|
662
|
+
catchUp: statements,
|
|
663
|
+
});
|
|
664
|
+
for (const text of lines.slice(0, -1)) printLine(text);
|
|
665
|
+
success(lines[lines.length - 1]);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Null when the value is an array of strings, otherwise the parenthetical the refusal carries: it
|
|
670
|
+
* names what was found, so a hand-edited file can be corrected without guessing which of its
|
|
671
|
+
* entries the reader means. Whether the field may be ABSENT is the caller's question, not this
|
|
672
|
+
* one's — the two plan fields checked below answer it differently.
|
|
673
|
+
*/
|
|
674
|
+
function malformedStringArray(value: unknown): string | null {
|
|
675
|
+
if (!Array.isArray(value)) return `the field is a ${typeof value}`;
|
|
676
|
+
const at = value.findIndex((entry) => typeof entry !== 'string');
|
|
677
|
+
if (at === -1) return null;
|
|
678
|
+
return `entry ${at} is ${value[at] === null ? 'null' : `a ${typeof value[at]}`}`;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* What is wrong with a loaded plan's `statements`, or null when nothing is. The field is REQUIRED —
|
|
683
|
+
* a plan that does not carry one is not a plan — so missing and null are malformed too.
|
|
684
|
+
*/
|
|
685
|
+
function malformedStatementsField(value: unknown): string | null {
|
|
686
|
+
if (value === undefined) return 'the field is missing';
|
|
687
|
+
if (value === null) return 'the field is null';
|
|
688
|
+
return malformedStringArray(value);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* What is wrong with a loaded plan's `declaredGrantees`, or null when nothing is.
|
|
693
|
+
*
|
|
694
|
+
* Unlike `statements` this field is OPTIONAL, and that difference is the whole check: a plan minted
|
|
695
|
+
* before the field existed simply does not carry one, and it must keep applying exactly as it does
|
|
696
|
+
* today — the receipt falls back to the checkout's models for its strip marking. So absent is
|
|
697
|
+
* VALID, and only a present-but-wrong value is malformed.
|
|
698
|
+
*
|
|
699
|
+
* It is refused here, next to `statements`, for the same reason: the field is dereferenced by
|
|
700
|
+
* `declaredGranteesForReceipt`, which runs to print the receipt AFTER the write has committed and
|
|
701
|
+
* schema_log has recorded it. A wrong value there is not a bad apply — it is a good apply that
|
|
702
|
+
* ends in an uncaught TypeError, which reads to the operator like the deploy broke.
|
|
703
|
+
*/
|
|
704
|
+
function malformedDeclaredGranteesField(value: unknown): string | null {
|
|
705
|
+
if (value === undefined || value === null) return null;
|
|
706
|
+
return malformedStringArray(value);
|
|
707
|
+
}
|
|
708
|
+
|
|
386
709
|
export async function dbApplyCommand(flags: Record<string, string>): Promise<void> {
|
|
387
710
|
const planPath = flags.plan;
|
|
388
711
|
if (!planPath) {
|
|
389
|
-
|
|
390
|
-
|
|
712
|
+
// --state-only says "skip the functions and views". With no plan, that is the whole job.
|
|
713
|
+
if (flags['state-only'] === 'true') {
|
|
714
|
+
fail('--state-only skips the functions and views, and without --plan there is nothing else to do. Pass --plan <file.plan.json> (mint one with db:plan), or drop --state-only to catch the functions and views up.');
|
|
715
|
+
process.exit(1);
|
|
716
|
+
}
|
|
717
|
+
await catchUpOnly(flags);
|
|
718
|
+
return;
|
|
391
719
|
}
|
|
392
720
|
|
|
393
721
|
let plan: EdgePlan;
|
|
@@ -401,6 +729,26 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
401
729
|
fail(`Plan version ${plan.v} is not this CLI's ${PLAN_VERSION} — re-mint with this version's db:plan.`);
|
|
402
730
|
process.exit(1);
|
|
403
731
|
}
|
|
732
|
+
// A plan is minted by db:plan, but it is still a FILE — a hand edit or an old-format artifact
|
|
733
|
+
// can hand this command a `statements` that is not an array of SQL strings. Every lane
|
|
734
|
+
// dereferences that field (the ops-fit estimate before dispatch, the receipt's classification
|
|
735
|
+
// after the write), so a malformed one used to surface as an uncaught TypeError — on the stage
|
|
736
|
+
// lane, from inside applyPlanViaStage, AFTER the tables had already changed. Refuse it here,
|
|
737
|
+
// before any read, any dispatch and any credential.
|
|
738
|
+
const malformed = malformedStatementsField(plan.statements);
|
|
739
|
+
if (malformed) {
|
|
740
|
+
fail(`The plan at ${planPath} is malformed: "statements" must be an array of SQL strings (${malformed}). A plan is minted, not written — re-mint it with db:plan.`);
|
|
741
|
+
process.exit(1);
|
|
742
|
+
}
|
|
743
|
+
// Its sibling, and the same story: `declaredGrantees` is dereferenced to mark the receipt's ⚠
|
|
744
|
+
// lines, AFTER the write has committed. Absent is fine — that is a plan minted before the field
|
|
745
|
+
// existed, and the receipt reads the checkout's models instead — so only a present-but-wrong
|
|
746
|
+
// value is refused, and it is refused in the same breath as `statements`, before anything runs.
|
|
747
|
+
const malformedGrantees = malformedDeclaredGranteesField(plan.declaredGrantees);
|
|
748
|
+
if (malformedGrantees) {
|
|
749
|
+
fail(`The plan at ${planPath} is malformed: "declaredGrantees" must be an array of role names, or absent (${malformedGrantees}). A plan is minted, not written — re-mint it with db:plan.`);
|
|
750
|
+
process.exit(1);
|
|
751
|
+
}
|
|
404
752
|
|
|
405
753
|
let dbSource: DbSource;
|
|
406
754
|
try {
|
|
@@ -595,8 +943,11 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
595
943
|
step(connectingVia(dbSource));
|
|
596
944
|
const { runner, session, end } = await createUrlRunner(dbSource.url);
|
|
597
945
|
|
|
946
|
+
let landed: ApplyPlanResult | undefined;
|
|
947
|
+
let catchUp: CatchUpReport = { ok: true, statements: [] };
|
|
598
948
|
try {
|
|
599
|
-
|
|
949
|
+
// The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
|
|
950
|
+
if (flags.verbose === 'true') info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
|
|
600
951
|
// Set inside checkDescent when --attest-lineage-lost is honoured; read at write time so the
|
|
601
952
|
// waiver lands in the schema_log outcome (the descent decision runs before the apply writes).
|
|
602
953
|
let descentAttested: string | undefined;
|
|
@@ -604,60 +955,86 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
604
955
|
// The mutation lease: one operator mutates a database at a time. Acquired on this apply's
|
|
605
956
|
// own session (the max:1 createUrlRunner) before any write; a second operator is refused
|
|
606
957
|
// by name. Self-releases on disconnect.
|
|
607
|
-
|
|
958
|
+
//
|
|
959
|
+
// ONE lease covers the WHOLE chain — the plan AND the catch-up that follows it. The
|
|
960
|
+
// catch-up creates, replaces and drops views, functions and triggers on this same
|
|
961
|
+
// connection, so it is a mutation in exactly the sense the lease exists for; releasing
|
|
962
|
+
// between the two halves would let a second operator start writing while this one is
|
|
963
|
+
// still replacing views. `executeReconcile` takes no lease of its own (every lease in
|
|
964
|
+
// this tree is taken by a command, never by the reconcile core) and opens no second
|
|
965
|
+
// connection, so widening the body cannot self-deadlock.
|
|
966
|
+
//
|
|
967
|
+
// WHAT THIS COVERS: the DIRECT lane, and every other DIRECT call site of the same
|
|
968
|
+
// catch-up — catchUpOnly below, and db:reconcile --apply. It does NOT cover the --stage
|
|
969
|
+
// (ops Lambda) lane, which holds no mutation lease at all, for its plan or for its
|
|
970
|
+
// catch-up: both halves are separate invokes on separate connections in separate
|
|
971
|
+
// containers, and neither ops action takes a lease. That is a server-side gap, tracked as
|
|
972
|
+
// m5-air-local.me1l0g.0mu398eqb.63cpjf, and there is nothing the CLI can widen to close it.
|
|
973
|
+
const leased = await withMutationLease(
|
|
608
974
|
runner,
|
|
609
975
|
{ verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
|
|
610
|
-
() =>
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
976
|
+
async () => {
|
|
977
|
+
const result = await executeApplyPlan(runner, session, plan, {
|
|
978
|
+
actor: process.env.USER ?? null,
|
|
979
|
+
gitRef: currentGitRef() ?? plan.gitRef,
|
|
980
|
+
...(verifyAuthority ? { verifyAuthority } : {}),
|
|
981
|
+
// The rollback point must capture the state this plan STARTS from. A logical backup stamps
|
|
982
|
+
// the live fingerprint at dump time; if it does not equal plan.from, restoring it would not
|
|
983
|
+
// return the database to where the plan began — so refuse (the escape is re-plan, not a
|
|
984
|
+
// flag). A physical snapshot carries no stamp: it is fresh and the concurrency lock below
|
|
985
|
+
// proves live == plan.from, so temporal ordering is its coverage. `none` opted out.
|
|
986
|
+
...(rollbackPoint?.stampedFingerprint
|
|
987
|
+
? {
|
|
988
|
+
verifySnapshot: async ({ planFrom }: { planFrom: string }) =>
|
|
989
|
+
rollbackPoint!.stampedFingerprint === planFrom
|
|
990
|
+
? { ok: true as const }
|
|
991
|
+
: {
|
|
992
|
+
ok: false as const,
|
|
993
|
+
reason: snapshotMovedRefusal(rollbackPoint!.ref!, rollbackPoint!.stampedFingerprint!, planFrom),
|
|
994
|
+
},
|
|
995
|
+
}
|
|
996
|
+
: {}),
|
|
997
|
+
// Re-verified here, not just at mint: a plan is an artifact with a lifetime, and a
|
|
998
|
+
// foreign role granted between minting and applying would otherwise ride through.
|
|
999
|
+
verifyAuthzBaseline: async ({ contract }) => {
|
|
1000
|
+
const stageName = flags.stage || 'local';
|
|
1001
|
+
const models = await loadModels(resolveModelsPath(flags.models));
|
|
1002
|
+
const governedRoles = (await loadDeclaredDerived(flags.models))?.governedRoles ?? [];
|
|
1003
|
+
const declaredAuthz = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
|
|
1004
|
+
const exemptions = ungovernedGrants(contract, governedRoleSet(declaredAuthz, governedRoles));
|
|
1005
|
+
const violations = checkAgainstBaseline(await readBaselineFile(), stageName, exemptions);
|
|
1006
|
+
return violations.length === 0
|
|
1007
|
+
? { ok: true as const }
|
|
1008
|
+
: { ok: false as const, reason: baselineRefusal(stageName, violations) };
|
|
1009
|
+
},
|
|
1010
|
+
dirty: worktreeDirty(resolveModelsPath(flags.models)),
|
|
1011
|
+
descentNote: () => descentAttested,
|
|
1012
|
+
checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[] }) => {
|
|
1013
|
+
step('Descent: proving the fast-forward rule against the schema_log ledger...');
|
|
1014
|
+
const result = await proveDescentForApply({
|
|
1015
|
+
modelsPath: resolveModelsPath(flags.models),
|
|
1016
|
+
live,
|
|
1017
|
+
ledger: live.ledger,
|
|
1018
|
+
destructive: plan.destructive > 0,
|
|
1019
|
+
...(attestLineageLost ? { attestation: attestLineageLost } : {}),
|
|
1020
|
+
});
|
|
1021
|
+
descentAttested = result.descentAttested;
|
|
1022
|
+
return result.descentVerdict;
|
|
1023
|
+
},
|
|
653
1024
|
});
|
|
654
|
-
|
|
655
|
-
|
|
1025
|
+
|
|
1026
|
+
// The catch-up rides the SAME connection and the SAME lease, right after the apply
|
|
1027
|
+
// committed — so the functions and views can never be a release behind the tables
|
|
1028
|
+
// they read, and no second operator can slip in between the two halves. Its failure
|
|
1029
|
+
// is carried out as a report, never thrown: the plan landed, and "rolled back" would
|
|
1030
|
+
// be a lie.
|
|
1031
|
+
const report = await catchUpAfterApply(result, plan, { kind: 'direct', runner, session }, flags);
|
|
1032
|
+
return { result, catchUp: report };
|
|
656
1033
|
},
|
|
657
|
-
}),
|
|
658
1034
|
);
|
|
659
1035
|
|
|
660
|
-
|
|
1036
|
+
catchUp = leased.catchUp;
|
|
1037
|
+
landed = leased.result;
|
|
661
1038
|
} catch (err: any) {
|
|
662
1039
|
if (err instanceof MutationLeaseError) {
|
|
663
1040
|
// Nothing was applied — the lease refused before any write. Not a rollback.
|
|
@@ -669,4 +1046,12 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
669
1046
|
} finally {
|
|
670
1047
|
await end?.();
|
|
671
1048
|
}
|
|
1049
|
+
|
|
1050
|
+
exitOnCatchUpFailure(catchUp);
|
|
1051
|
+
const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
|
|
1052
|
+
reportApplyResult(landed!, plan, {
|
|
1053
|
+
verbose: flags.verbose === 'true',
|
|
1054
|
+
catchUp: catchUp.statements,
|
|
1055
|
+
...(declaredGrantees ? { declaredGrantees } : {}),
|
|
1056
|
+
});
|
|
672
1057
|
}
|