@everystack/cli 0.4.81 → 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.
@@ -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 { step, success, fail, info, warn } from '../output';
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(result: ApplyPlanResult, plan: EdgePlan): void {
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
- 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)}).`);
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
- 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` : ''})`);
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
- reportApplyResult(result as ApplyPlanResult, plan);
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
- fail('db:apply needs --plan <file.plan.json> — mint one with db:plan.');
390
- process.exit(1);
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
- 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` : ''})`);
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
- const result = await withMutationLease(
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
- () => executeApplyPlan(runner, session, plan, {
611
- actor: process.env.USER ?? null,
612
- gitRef: currentGitRef() ?? plan.gitRef,
613
- ...(verifyAuthority ? { verifyAuthority } : {}),
614
- // The rollback point must capture the state this plan STARTS from. A logical backup stamps
615
- // the live fingerprint at dump time; if it does not equal plan.from, restoring it would not
616
- // return the database to where the plan began — so refuse (the escape is re-plan, not a
617
- // flag). A physical snapshot carries no stamp: it is fresh and the concurrency lock below
618
- // proves live == plan.from, so temporal ordering is its coverage. `none` opted out.
619
- ...(rollbackPoint?.stampedFingerprint
620
- ? {
621
- verifySnapshot: async ({ planFrom }: { planFrom: string }) =>
622
- rollbackPoint!.stampedFingerprint === planFrom
623
- ? { ok: true as const }
624
- : {
625
- ok: false as const,
626
- reason: snapshotMovedRefusal(rollbackPoint!.ref!, rollbackPoint!.stampedFingerprint!, planFrom),
627
- },
628
- }
629
- : {}),
630
- // Re-verified here, not just at mint: a plan is an artifact with a lifetime, and a
631
- // foreign role granted between minting and applying would otherwise ride through.
632
- verifyAuthzBaseline: async ({ contract }) => {
633
- const stageName = flags.stage || 'local';
634
- const models = await loadModels(resolveModelsPath(flags.models));
635
- const governedRoles = (await loadDeclaredDerived(flags.models))?.governedRoles ?? [];
636
- const declaredAuthz = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
637
- const exemptions = ungovernedGrants(contract, governedRoleSet(declaredAuthz, governedRoles));
638
- const violations = checkAgainstBaseline(await readBaselineFile(), stageName, exemptions);
639
- return violations.length === 0
640
- ? { ok: true as const }
641
- : { ok: false as const, reason: baselineRefusal(stageName, violations) };
642
- },
643
- dirty: worktreeDirty(resolveModelsPath(flags.models)),
644
- descentNote: () => descentAttested,
645
- checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[] }) => {
646
- step('Descent: proving the fast-forward rule against the schema_log ledger...');
647
- const result = await proveDescentForApply({
648
- modelsPath: resolveModelsPath(flags.models),
649
- live,
650
- ledger: live.ledger,
651
- destructive: plan.destructive > 0,
652
- ...(attestLineageLost ? { attestation: attestLineageLost } : {}),
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
- descentAttested = result.descentAttested;
655
- return result.descentVerdict;
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
- reportApplyResult(result, plan);
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
  }