@everystack/cli 0.4.69 → 0.4.70
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 +2 -2
- package/src/cli/apply-execute.ts +133 -9
- package/src/cli/apply-lane.ts +102 -0
- package/src/cli/apply-refusals.ts +83 -0
- package/src/cli/backup-resolve.ts +31 -0
- package/src/cli/backup.ts +104 -0
- package/src/cli/commands/db-apply.ts +318 -180
- package/src/cli/commands/db-plan.ts +21 -10
- package/src/cli/commands/db-swap.ts +27 -54
- package/src/cli/derived-apply.ts +17 -2
- package/src/cli/edge-plan.ts +14 -0
- package/src/cli/git-descent.ts +526 -143
- package/src/cli/index.ts +1 -1
- package/src/cli/local-rollback.ts +146 -0
- package/src/cli/prompt.ts +18 -0
- package/src/cli/rollback-point.ts +141 -0
- package/src/cli/schema-fingerprint.ts +60 -0
- package/src/cli/stage-rollback-deps.ts +70 -0
- package/src/cli/state-apply.ts +41 -3
- package/src/cli/swap-snapshot.ts +18 -1
|
@@ -31,10 +31,28 @@
|
|
|
31
31
|
|
|
32
32
|
import fs from 'node:fs/promises';
|
|
33
33
|
import { type QueryRunner } from '../authz-contract';
|
|
34
|
-
import { currentGitRef } from '../state-apply';
|
|
35
|
-
import { planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan';
|
|
36
|
-
import {
|
|
34
|
+
import { currentGitRef, worktreeDirty } from '../state-apply';
|
|
35
|
+
import { planHash, PLAN_VERSION, APPLY_CONTRACT_VERSION, type EdgePlan } from '../edge-plan';
|
|
36
|
+
import {
|
|
37
|
+
proveDescent,
|
|
38
|
+
decideDescentApply,
|
|
39
|
+
attestationNote,
|
|
40
|
+
proveLineageLost,
|
|
41
|
+
realLineageProbe,
|
|
42
|
+
type DescentLedgerRow,
|
|
43
|
+
type LiveState,
|
|
44
|
+
} from '../git-descent';
|
|
37
45
|
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority';
|
|
46
|
+
import { decideSnapshotMode, type SnapshotModeRequest } from '../swap-snapshot';
|
|
47
|
+
import { confirmRollbackPoint, type RollbackPoint } from '../rollback-point';
|
|
48
|
+
import { stageConfirmDeps } from '../stage-rollback-deps';
|
|
49
|
+
import { verifySnapshotRef, SNAPSHOT_FRESHNESS_MS, utcStamp } from '../backup';
|
|
50
|
+
import { fetchBackupManifest } from '../backup-resolve';
|
|
51
|
+
import { confirmLocalDump } from '../local-rollback';
|
|
52
|
+
import { destructiveConfirmRefusal, snapshotMovedRefusal, formatApplyRefusal } from '../apply-refusals';
|
|
53
|
+
import { decideStageLane, checkStageApplyContract } from '../apply-lane';
|
|
54
|
+
import { promptYesNo } from '../prompt';
|
|
55
|
+
import path from 'node:path';
|
|
38
56
|
import { resolveModelsPath } from '../models-path';
|
|
39
57
|
import { loadModels } from './db-generate';
|
|
40
58
|
import { loadDeclaredDerived } from '../declared-derived';
|
|
@@ -45,11 +63,11 @@ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '
|
|
|
45
63
|
import { borrowedSessionRunner, type SessionRunner } from '../session';
|
|
46
64
|
import { resolveConfig, opsFunction } from '../config';
|
|
47
65
|
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws';
|
|
48
|
-
import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute';
|
|
66
|
+
import { executeApplyPlan, readDescentLedger, type ApplyPlanResult } from '../apply-execute';
|
|
49
67
|
import { estimateOpsRuntimeFit } from '../ops-fit';
|
|
50
68
|
import { resolveOperatorUrlViaStage } from '../direct-venue';
|
|
51
69
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease';
|
|
52
|
-
import { readStageStateTwice
|
|
70
|
+
import { readStageStateTwice } from '../stage-read-consistency';
|
|
53
71
|
import { step, success, fail, info, warn } from '../output';
|
|
54
72
|
|
|
55
73
|
// The apply core lives in apply-execute.ts (shared with the ops Lambda's
|
|
@@ -62,60 +80,31 @@ export type { ApplyPlanOptions, ApplyPlanResult } from '../apply-execute';
|
|
|
62
80
|
// The CLI shell.
|
|
63
81
|
// ---------------------------------------------------------------------------
|
|
64
82
|
|
|
65
|
-
/**
|
|
66
|
-
* Collapse a DescentVerdict into the ok/reason shape the apply core (and the
|
|
67
|
-
* ops Lambda's db:apply action) enforce. `no-git` / `fresh-target` pass — the
|
|
68
|
-
* rule can't be verified there — matching the direct-URL path's callback.
|
|
69
|
-
*/
|
|
70
|
-
export function descentVerdictForApply(verdict: DescentVerdict): { ok: true } | { ok: false; reason: string } {
|
|
71
|
-
switch (verdict.status) {
|
|
72
|
-
case 'ok':
|
|
73
|
-
case 'fresh-target':
|
|
74
|
-
case 'no-git':
|
|
75
|
-
return { ok: true };
|
|
76
|
-
case 'diverged':
|
|
77
|
-
case 'drift':
|
|
78
|
-
// `indeterminate` refuses like the others, and must: the search was not exhaustive, so
|
|
79
|
-
// the fast-forward rule is unproven. Its REASON is the difference — it points at this
|
|
80
|
-
// run, not at the database. Never fold it into `drift`.
|
|
81
|
-
case 'indeterminate':
|
|
82
|
-
return { ok: false, reason: verdict.reason };
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
83
|
/**
|
|
87
84
|
* Render an ApplyPlanResult — shared by the direct (`--database-url`) and the
|
|
88
85
|
* credential-free staged (`--stage`) paths, so the two report identically.
|
|
89
86
|
* Every refusal and the verify-fault exit non-zero.
|
|
87
|
+
*
|
|
88
|
+
* Exported so a test can drive the EXIT path directly — the layer the
|
|
89
|
+
* silent-exit-0 bug actually lived (a refusal status with no case printed
|
|
90
|
+
* nothing and exited 0). formatApplyRefusal owns the string; this owns the
|
|
91
|
+
* process.exit(1).
|
|
90
92
|
*/
|
|
91
|
-
function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void {
|
|
93
|
+
export function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void {
|
|
92
94
|
switch (result.status) {
|
|
93
95
|
case 'already-applied':
|
|
94
96
|
success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
|
|
95
|
-
|
|
96
|
-
case 'refused':
|
|
97
|
-
fail(`REFUSED: ${result.reason}`);
|
|
98
|
-
process.exit(1);
|
|
99
|
-
break;
|
|
100
|
-
case 'descent-refused':
|
|
101
|
-
fail(`REFUSED (fast-forward rule): ${result.reason}`);
|
|
102
|
-
process.exit(1);
|
|
103
|
-
break;
|
|
104
|
-
case 'authority-refused':
|
|
105
|
-
fail(`REFUSED (destructive authority): ${result.reason}`);
|
|
106
|
-
process.exit(1);
|
|
107
|
-
break;
|
|
108
|
-
case 'snapshot-refused':
|
|
109
|
-
fail(`REFUSED (snapshot gate): ${result.reason}`);
|
|
110
|
-
process.exit(1);
|
|
111
|
-
break;
|
|
112
|
-
case 'verify-failed':
|
|
113
|
-
fail(`Verify FAILED: ${result.reason}`);
|
|
114
|
-
process.exit(1);
|
|
115
|
-
break;
|
|
97
|
+
return;
|
|
116
98
|
case 'applied':
|
|
117
99
|
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)}).`);
|
|
118
|
-
|
|
100
|
+
return;
|
|
101
|
+
default:
|
|
102
|
+
// Every remaining status is a refusal or a verify fault. TS narrows result.status here to
|
|
103
|
+
// exactly ApplyRefusalStatus, so a future status that is neither success nor a known refusal
|
|
104
|
+
// stops this compiling — the fallthrough that let `authz-refused` print nothing and exit 0
|
|
105
|
+
// cannot come back. The message is an action; the internal cause code stays in schema_log.
|
|
106
|
+
fail(formatApplyRefusal(result.status, result.reason ?? ''));
|
|
107
|
+
process.exit(1);
|
|
119
108
|
}
|
|
120
109
|
}
|
|
121
110
|
|
|
@@ -127,15 +116,20 @@ function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void {
|
|
|
127
116
|
* destructive-approver check. The Lambda re-verifies the concurrency lock,
|
|
128
117
|
* applies the DDL in one transaction, stamps schema_log, and verifies plan.to.
|
|
129
118
|
*/
|
|
119
|
+
/** applyPlanViaStage runs the credential-free ops-lane write, OR asks to escalate to the direct
|
|
120
|
+
* lane (a read-disagreement it cannot resolve on this lane). It never returns after a successful
|
|
121
|
+
* apply — reportApplyResult exits — so a returned value is always the escalate request. */
|
|
122
|
+
type StageOutcome = { escalate: true; reason: string } | void;
|
|
123
|
+
|
|
130
124
|
async function applyPlanViaStage(
|
|
131
125
|
plan: EdgePlan,
|
|
132
126
|
flags: Record<string, string>,
|
|
133
|
-
|
|
134
|
-
): Promise<void> {
|
|
127
|
+
): Promise<StageOutcome> {
|
|
135
128
|
step('Resolving deployed config...');
|
|
136
129
|
const config = await resolveConfig(flags.stage);
|
|
137
130
|
const { region } = config;
|
|
138
131
|
const fn = opsFunction(config);
|
|
132
|
+
const attestLineageLost = flags['attest-lineage-lost']; // validated (not bare 'true') in dbApplyCommand
|
|
139
133
|
|
|
140
134
|
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` : ''})`);
|
|
141
135
|
|
|
@@ -151,45 +145,66 @@ async function applyPlanViaStage(
|
|
|
151
145
|
process.exit(1);
|
|
152
146
|
});
|
|
153
147
|
if (!readPair.ok) {
|
|
148
|
+
// q2-escalate: a read-disagreement no longer dead-ends. INTERACTIVELY, ask to escalate to the
|
|
149
|
+
// direct lane — ONE session against one database, the lane that CAN read consistently (and the
|
|
150
|
+
// apply's concurrency lock still refuses if the state truly moved, so escalation bypasses no
|
|
151
|
+
// safety). NON-interactively, keep the actionable refusal: never auto-escalate an unattended run
|
|
152
|
+
// (it resolves a privileged credential), and the refusal already names --direct.
|
|
153
|
+
if (process.stdin.isTTY) {
|
|
154
|
+
return {
|
|
155
|
+
escalate: true,
|
|
156
|
+
reason: `The stage was read twice and the two reads disagree (${readPair.first.slice(0, 12)} then ${readPair.second.slice(0, 12)}) — the state may be changing, or inconsistent ops-Lambda containers answered. The direct lane reads over ONE session against one database, which resolves this.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
154
159
|
fail(readPair.reason);
|
|
155
160
|
process.exit(1);
|
|
156
161
|
}
|
|
157
162
|
warn(readPair.warning);
|
|
158
163
|
|
|
164
|
+
// q1-contract-version direction B: the reads passed — now verify the deployed stage speaks THIS
|
|
165
|
+
// CLI's db:apply wire contract BEFORE dispatching the write. An older ops Lambda would silently
|
|
166
|
+
// under-enforce a gate the CLI relies on it to run; catch it with a redeploy message, not after a
|
|
167
|
+
// write. Pre-dispatch, so it opens no connection and writes no schema_log row.
|
|
168
|
+
step("Verifying the deployed stage speaks this CLI's apply contract...");
|
|
169
|
+
const contract = checkStageApplyContract(await invokeAction(region, fn, '_health', {}));
|
|
170
|
+
if (!contract.ok) {
|
|
171
|
+
fail(`REFUSED: ${contract.reason}`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
159
175
|
try {
|
|
160
176
|
// Descent — the fast-forward rule needs the git checkout, so the CLI
|
|
161
177
|
// decides it (the Lambda has no checkout). It runs against the state the
|
|
162
178
|
// double read already produced (no third trip), and the verdict goes to the
|
|
163
|
-
// write action, which enforces + records it in order.
|
|
179
|
+
// write action, which enforces + records it in order. A refusal's escape is
|
|
180
|
+
// git-native (git revert / db:generate / fetch); the one attestation
|
|
181
|
+
// (--attest-lineage-lost) is honoured only when the CLI proves the declaring
|
|
182
|
+
// commit is gone from every ref.
|
|
164
183
|
let descentVerdict: { ok: true } | { ok: false; reason: string };
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
descentVerdict = { ok: true };
|
|
168
|
-
} else {
|
|
184
|
+
let descentAttested: string | undefined;
|
|
185
|
+
{
|
|
169
186
|
const { snapshot, contract } = readPair.state;
|
|
170
|
-
step("Descent:
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
warn(`descent: ${verdict.unevaluated.length} historical tree(s) could not be extracted — the search was not exhaustive.`);
|
|
190
|
-
break;
|
|
187
|
+
step("Descent: reading the stage's schema_log ledger...");
|
|
188
|
+
const ledger = await readDescentLedger(lambdaSessionRunner(region, fn));
|
|
189
|
+
step("Descent: proving the fast-forward rule against the ledger...");
|
|
190
|
+
const verdict = await proveDescent(resolveModelsPath(flags.models), { snapshot, contract }, ledger, {});
|
|
191
|
+
if (verdict.status === 'proven') {
|
|
192
|
+
info(`descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.`);
|
|
193
|
+
} else if (verdict.status === 'fresh-target') {
|
|
194
|
+
info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
|
|
195
|
+
} else if (verdict.status === 'no-git') {
|
|
196
|
+
warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
|
|
197
|
+
}
|
|
198
|
+
const decision = decideDescentApply(verdict, {
|
|
199
|
+
...(attestLineageLost ? { attestation: attestLineageLost } : {}),
|
|
200
|
+
destructive: plan.destructive > 0,
|
|
201
|
+
proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(process.cwd())),
|
|
202
|
+
});
|
|
203
|
+
if (decision.ok && decision.attested) {
|
|
204
|
+
descentAttested = attestationNote(decision.attested);
|
|
205
|
+
warn(`DESCENT ATTESTED — ${descentAttested}. The declaring commit is proven gone from every ref; the fast-forward rule is waived for this apply and the waiver is recorded.`);
|
|
191
206
|
}
|
|
192
|
-
descentVerdict =
|
|
207
|
+
descentVerdict = decision.ok ? { ok: true } : { ok: false, reason: decision.reason };
|
|
193
208
|
}
|
|
194
209
|
|
|
195
210
|
// Destructive ceremony — the approver check is against the caller's REAL
|
|
@@ -206,7 +221,7 @@ async function applyPlanViaStage(
|
|
|
206
221
|
if (plan.destructive > 0) {
|
|
207
222
|
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
208
223
|
if (flags.confirm !== 'true') {
|
|
209
|
-
fail(
|
|
224
|
+
fail(destructiveConfirmRefusal(plan.destructive, shape));
|
|
210
225
|
process.exit(1);
|
|
211
226
|
}
|
|
212
227
|
step(`Destructive authority: checking the approver set for stage ${flags.stage}...`);
|
|
@@ -226,9 +241,14 @@ async function applyPlanViaStage(
|
|
|
226
241
|
step('Applying in the ops Lambda (credential-free — the operator never holds a database URL)...');
|
|
227
242
|
const result: any = await invokeAction(region, fn, 'db:apply', {
|
|
228
243
|
plan,
|
|
244
|
+
// q1-contract-version direction A: the server refuses on exact mismatch, so a payload-key or
|
|
245
|
+
// gate-semantics skew is a hard refusal, never a silently dropped gate.
|
|
246
|
+
contractVersion: APPLY_CONTRACT_VERSION,
|
|
229
247
|
actor: process.env.USER ?? null,
|
|
230
248
|
gitRef: currentGitRef() ?? plan.gitRef,
|
|
249
|
+
dirty: worktreeDirty(resolveModelsPath(flags.models)),
|
|
231
250
|
descentVerdict,
|
|
251
|
+
...(descentAttested ? { descentAttested } : {}),
|
|
232
252
|
...(authorityVerdict ? { authorityVerdict } : {}),
|
|
233
253
|
...(flags['snapshot-ref'] ? { snapshotRef: flags['snapshot-ref'] } : {}),
|
|
234
254
|
stage: flags.stage,
|
|
@@ -237,6 +257,19 @@ async function applyPlanViaStage(
|
|
|
237
257
|
fail(`Apply failed: ${result.error}`);
|
|
238
258
|
process.exit(1);
|
|
239
259
|
}
|
|
260
|
+
// The probe/dispatch split-brain belt (q1-contract-version, Fable red team). Direction B's _health
|
|
261
|
+
// handshake and this write are SEPARATE invokes; mid-deploy they can land on two container
|
|
262
|
+
// versions, so the health check can pass on a new container while the WRITE runs on an old one
|
|
263
|
+
// that ignores the contract and silently skips the fast-forward rule — the two-invokes-two-
|
|
264
|
+
// containers blend, reproduced inside its own fix. It cannot be prevented (the write already
|
|
265
|
+
// committed), so surface it LOUDLY post-write: a current server stamps every applied result with
|
|
266
|
+
// the contract it ran under, so an applied result missing that stamp means an older container
|
|
267
|
+
// served the write. A refused result is not affected (nothing landed).
|
|
268
|
+
const landed = result?.status === 'applied' || result?.status === 'already-applied';
|
|
269
|
+
if (landed && result?.contractVersion !== APPLY_CONTRACT_VERSION) {
|
|
270
|
+
fail(`APPLIED, BUT UNVERIFIED: the write landed, but the ops Lambda did not stamp the apply contract (got v${result?.contractVersion ?? '(none)'}, expected v${APPLY_CONTRACT_VERSION}). A mid-deploy split-brain can route the health check and the write to different code versions — an older container silently skips the fast-forward rule, so this apply MAY have reverted merged work. Verify NOW: everystack db:fingerprint --stage ${flags.stage}, and inspect everystack.schema_log. Once the deploy has fully rolled out, re-plan and re-apply.`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
240
273
|
if (result?.snapshotId) {
|
|
241
274
|
info(`snapshot on record: ${result.snapshotId} — restore with db:restore --from ${result.snapshotId} --confirm.`);
|
|
242
275
|
}
|
|
@@ -247,6 +280,36 @@ async function applyPlanViaStage(
|
|
|
247
280
|
}
|
|
248
281
|
}
|
|
249
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Escalate a stage apply to the direct lane. Announce WHY, get one line of consent BEFORE any
|
|
285
|
+
* credential is resolved, then resolve the operator connection from the stage into memory (property
|
|
286
|
+
* 4: the operator never holds a URL) and return it as the db source. Shared by the two escalation
|
|
287
|
+
* triggers — a plan the ops lane cannot run (destructive/too-large), and a read-disagreement the ops
|
|
288
|
+
* lane cannot resolve (q2-escalate). On decline or resolve-failure it exits non-zero, never falls
|
|
289
|
+
* through. Reached only where a person can consent — the callers gate on interactivity first.
|
|
290
|
+
*/
|
|
291
|
+
async function escalateStageToDirect(
|
|
292
|
+
reason: string,
|
|
293
|
+
plan: EdgePlan,
|
|
294
|
+
planPath: string,
|
|
295
|
+
stage: string,
|
|
296
|
+
): Promise<Extract<DbSource, { kind: 'url' }>> {
|
|
297
|
+
info(reason);
|
|
298
|
+
info('It needs the direct lane, which resolves the operator connection FROM the stage itself (you never hold a URL) and runs CLI-side over ONE session with an unbounded clock.');
|
|
299
|
+
const consented = await promptYesNo(`Resolve the operator connection and run this apply on the direct lane for stage ${stage}? [y/N] `);
|
|
300
|
+
if (!consented) {
|
|
301
|
+
fail(`Aborted — no lane chosen. Re-run with --direct to name the lane (everystack db:apply --plan ${planPath} --stage ${stage} --direct${plan.destructive > 0 ? ' --confirm' : ''}), or make the plan additive.`);
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
step('Resolving the operator connection from the stage...');
|
|
305
|
+
const op = await resolveOperatorUrlViaStage(stage).catch((err: any) => {
|
|
306
|
+
fail(err.message);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
});
|
|
309
|
+
info(`operator credential resolved (${op.source}) — executing CLI-side, unbounded clock.`);
|
|
310
|
+
return { kind: 'url', url: op.url, from: 'operator' };
|
|
311
|
+
}
|
|
312
|
+
|
|
250
313
|
export async function dbApplyCommand(flags: Record<string, string>): Promise<void> {
|
|
251
314
|
const planPath = flags.plan;
|
|
252
315
|
if (!planPath) {
|
|
@@ -274,19 +337,15 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
274
337
|
process.exit(1);
|
|
275
338
|
}
|
|
276
339
|
|
|
277
|
-
// The
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (flags.confirm !== 'true') {
|
|
287
|
-
fail('--force-descent bypasses the fast-forward rule — confirm explicitly with --confirm.');
|
|
288
|
-
process.exit(1);
|
|
289
|
-
}
|
|
340
|
+
// The narrow descent attestation (replaces the retired --force-descent, Ty-ratified 2026-08-26).
|
|
341
|
+
// `--attest-lineage-lost <sha>` waives the fast-forward rule for the ONE case no re-plan can express:
|
|
342
|
+
// the declaring commit is proven unreachable from every ref. It NAMES the commit (must equal the one
|
|
343
|
+
// the refusal reports) and is honoured only when the CLI's own git proof concurs — every other
|
|
344
|
+
// descent refusal has a flagless git-native escape. `true` (a bare flag) is a usage error.
|
|
345
|
+
const attestLineageLost = flags['attest-lineage-lost'];
|
|
346
|
+
if (attestLineageLost === 'true') {
|
|
347
|
+
fail('--attest-lineage-lost needs the declaring commit as its value: --attest-lineage-lost <sha> (the commit the descent refusal names). It is honoured only when that commit is proven gone from every ref.');
|
|
348
|
+
process.exit(1);
|
|
290
349
|
}
|
|
291
350
|
|
|
292
351
|
// `--stage --direct` (lane 1): resolve the stage's OPERATOR connection from its ops
|
|
@@ -305,75 +364,149 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
305
364
|
}
|
|
306
365
|
}
|
|
307
366
|
|
|
308
|
-
// Deployed stage, no direct
|
|
309
|
-
//
|
|
367
|
+
// Deployed stage, no explicit --direct: INFER the lane from the plan (p2-infer-lane). The ops-Lambda
|
|
368
|
+
// lane is credential-free but cannot run a destructive plan (its fingerprint can be a container
|
|
369
|
+
// collage) and cannot outlast the 900s clock. When the plan needs neither, it runs there. When it
|
|
370
|
+
// needs the direct lane, the tool reaches for the operator connection ITSELF — announcing why,
|
|
371
|
+
// getting one line of consent, and only then resolving the credential (into memory only, property 4).
|
|
310
372
|
if (dbSource.kind === 'stage') {
|
|
311
|
-
|
|
312
|
-
// The stage lane's fingerprint (the concurrency lock) can be assembled from
|
|
313
|
-
// several ops-Lambda containers, and the double read below only DETECTS that;
|
|
314
|
-
// it cannot rule it out. Dropping data on a read that may be a collage is the
|
|
315
|
-
// one trade never worth making, so the destructive path is the direct lane's.
|
|
316
|
-
if (plan.destructive > 0) {
|
|
317
|
-
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
318
|
-
fail(stageDestructiveRefusal(shape, planPath, flags.stage));
|
|
319
|
-
process.exit(1);
|
|
320
|
-
}
|
|
321
|
-
// Will this edge fit the ops-Lambda's 900-second clock? If not, refuse up front and
|
|
322
|
-
// name --direct (credential-free, unbounded) instead of burning 15 minutes to learn it.
|
|
373
|
+
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
323
374
|
const fit = estimateOpsRuntimeFit(plan);
|
|
324
|
-
|
|
325
|
-
|
|
375
|
+
const lane = decideStageLane({
|
|
376
|
+
destructive: plan.destructive > 0,
|
|
377
|
+
destructiveShape: shape,
|
|
378
|
+
destructiveCount: plan.destructive,
|
|
379
|
+
fits: fit.fits,
|
|
380
|
+
...(fit.fits ? {} : { fitReason: fit.reason }),
|
|
381
|
+
interactive: !!process.stdin.isTTY,
|
|
382
|
+
confirmed: flags.confirm === 'true',
|
|
383
|
+
planPath,
|
|
384
|
+
stage: flags.stage,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
if (lane.action === 'refuse') {
|
|
388
|
+
fail(`REFUSED: ${lane.reason}`);
|
|
326
389
|
process.exit(1);
|
|
327
390
|
}
|
|
328
|
-
|
|
329
|
-
|
|
391
|
+
if (lane.action === 'ops') {
|
|
392
|
+
// Run credential-free in the ops Lambda. A void return = applied (or an internal refusal already
|
|
393
|
+
// exited). A returned escalate request = a read-disagreement this lane could not resolve; take
|
|
394
|
+
// it to the direct lane with the same announce→consent→resolve machinery (q2-escalate).
|
|
395
|
+
const outcome = await applyPlanViaStage(plan, flags);
|
|
396
|
+
if (!outcome) return;
|
|
397
|
+
dbSource = await escalateStageToDirect(outcome.reason, plan, planPath, flags.stage);
|
|
398
|
+
} else {
|
|
399
|
+
// escalate — the plan needs the direct lane (destructive / too-large). flags.stage stays set, so
|
|
400
|
+
// the direct path below runs the full stage-aware ceremony (approvers, the stage's snapshot lane).
|
|
401
|
+
dbSource = await escalateStageToDirect(lane.reason, plan, planPath, flags.stage);
|
|
402
|
+
}
|
|
330
403
|
}
|
|
331
404
|
|
|
332
405
|
// Losing data should be hard (decision 11): a destructive plan applies
|
|
333
|
-
// only confirmed (--confirm, always),
|
|
334
|
-
//
|
|
335
|
-
// set — identity-approved (decision 12).
|
|
406
|
+
// only confirmed (--confirm, always), with a durable rollback point, and —
|
|
407
|
+
// when the stage declares an approver set — identity-approved (decision 12).
|
|
336
408
|
const isDestructive = plan.destructive > 0;
|
|
337
409
|
let verifyAuthority: (() => Promise<{ ok: true } | { ok: false; reason: string }>) | undefined;
|
|
410
|
+
let rollbackPoint: RollbackPoint | undefined;
|
|
338
411
|
if (isDestructive) {
|
|
339
412
|
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
340
413
|
if (flags.confirm !== 'true') {
|
|
341
|
-
fail(
|
|
414
|
+
fail(destructiveConfirmRefusal(plan.destructive, shape));
|
|
342
415
|
process.exit(1);
|
|
343
416
|
}
|
|
344
|
-
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
417
|
+
|
|
418
|
+
// The rollback point, CONFIRMED durable BEFORE the first destructive statement (Ty-ratified
|
|
419
|
+
// 2026-08-26 — the apply takes it and waits, reusing db:swap's ceremony). On --stage --direct
|
|
420
|
+
// the tool takes it for you (RDS physical snapshot by default, logical backup fallback) and
|
|
421
|
+
// waits until it exists — no --snapshot-ref dance. Over a bare --database-url the tool cannot
|
|
422
|
+
// take one, so it refuses unless you attest one (--snapshot-ref) or opt out (--snapshot none).
|
|
423
|
+
// The check that the database did not MOVE under the snapshot wait is executeApplyPlan's
|
|
424
|
+
// concurrency lock below: it re-reads live and refuses unless live == plan.from, AFTER this.
|
|
425
|
+
const venue: 'stage' | 'url' = flags.stage ? 'stage' : 'url';
|
|
426
|
+
const stageConfig = flags.stage ? await resolveConfig(flags.stage) : undefined;
|
|
427
|
+
// Captured here where dbSource is narrowed to the url variant (the stage lane returned above), so
|
|
428
|
+
// the bare-URL confirmLocal closure below can read it without re-narrowing a reassigned `let`.
|
|
429
|
+
const targetUrl = dbSource.url;
|
|
430
|
+
const decision = decideSnapshotMode({
|
|
431
|
+
venue,
|
|
432
|
+
instanceId: stageConfig?.databaseInstanceId,
|
|
433
|
+
snapshotRef: flags['snapshot-ref'],
|
|
434
|
+
requested: flags.snapshot as SnapshotModeRequest | undefined,
|
|
435
|
+
// Bare --database-url takes its OWN pg_dump -Fc rollback point (REVERSAL 1). The stage lane
|
|
436
|
+
// never needs it — it has physical/logical — so scope it to the url venue.
|
|
437
|
+
localDump: venue === 'url',
|
|
438
|
+
});
|
|
439
|
+
if (decision.mode === 'refuse') {
|
|
440
|
+
fail(`REFUSED: ${decision.reason}`);
|
|
367
441
|
process.exit(1);
|
|
368
442
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
443
|
+
const confirmed = await confirmRollbackPoint(decision, {
|
|
444
|
+
// Resolve-else-take: an attested --snapshot-ref is only a rollback point once it fingerprint-
|
|
445
|
+
// verifies against plan.from. On a stage the CLI resolves the ref's backup manifest from the
|
|
446
|
+
// stage's backups bucket and runs the same gate the server used (verifySnapshotRef). Over a
|
|
447
|
+
// bare --database-url there is no bucket to resolve against, so no verifier is injected and the
|
|
448
|
+
// attested ref stays operator-trusted + lock-guarded (its own fp gate is sub-step 3's work).
|
|
449
|
+
...(stageConfig
|
|
450
|
+
? {
|
|
451
|
+
verifyAttested: async (ref: string) => {
|
|
452
|
+
if (!stageConfig.backupsBucket) {
|
|
453
|
+
return {
|
|
454
|
+
ok: false as const,
|
|
455
|
+
reason: `stage ${flags.stage} has no backupsBucket in its deployed config, so --snapshot-ref ${ref} cannot be verified. Add \`backupsBucket: backups.name\` to the sst.config outputs and redeploy, or let db:apply take a fresh snapshot (drop --snapshot-ref).`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const manifest = await fetchBackupManifest(stageConfig.region, stageConfig.backupsBucket, ref);
|
|
459
|
+
const verdict = verifySnapshotRef({
|
|
460
|
+
manifest,
|
|
461
|
+
planFrom: plan.fromFingerprint,
|
|
462
|
+
targetStage: flags.stage,
|
|
463
|
+
now: new Date(),
|
|
464
|
+
freshnessWindowMs: SNAPSHOT_FRESHNESS_MS,
|
|
465
|
+
});
|
|
466
|
+
return verdict.ok
|
|
467
|
+
? { ok: true as const, stampedFingerprint: manifest!.fingerprint }
|
|
468
|
+
: { ok: false as const, reason: verdict.reason };
|
|
469
|
+
},
|
|
470
|
+
}
|
|
471
|
+
: {}),
|
|
472
|
+
...(stageConfig
|
|
473
|
+
? stageConfirmDeps({
|
|
474
|
+
region: stageConfig.region,
|
|
475
|
+
instanceId: stageConfig.databaseInstanceId,
|
|
476
|
+
opsFn: opsFunction(stageConfig),
|
|
477
|
+
stage: flags.stage,
|
|
478
|
+
verb: 'apply',
|
|
479
|
+
log: info,
|
|
480
|
+
step,
|
|
481
|
+
})
|
|
482
|
+
: {
|
|
483
|
+
// Bare --database-url. physical/logical are unreachable here (decideSnapshotMode refuses
|
|
484
|
+
// them on this venue) — present and loud so a future routing change cannot fail silently.
|
|
485
|
+
// What this venue DOES take is its own local dump (mode 'local'): pg_dump -Fc to a file in
|
|
486
|
+
// the working directory, verified with pg_restore --list before any destructive statement.
|
|
487
|
+
confirmPhysical: async () => { throw new Error('a physical snapshot needs a stage; a bare --database-url cannot take one'); },
|
|
488
|
+
confirmLogical: async () => ({ ok: false as const, reason: 'a logical snapshot needs a stage; a bare --database-url cannot take one' }),
|
|
489
|
+
confirmLocal: () => confirmLocalDump({
|
|
490
|
+
url: targetUrl,
|
|
491
|
+
file: path.resolve(process.cwd(), `everystack-rollback-${utcStamp(new Date())}.dump`),
|
|
492
|
+
}),
|
|
493
|
+
}),
|
|
494
|
+
log: info,
|
|
495
|
+
warn,
|
|
496
|
+
});
|
|
497
|
+
if (!confirmed.ok) {
|
|
498
|
+
fail(`REFUSED: ${confirmed.reason} — nothing was applied.`);
|
|
499
|
+
process.exit(1);
|
|
500
|
+
}
|
|
501
|
+
rollbackPoint = confirmed.point;
|
|
502
|
+
|
|
503
|
+
if (stageConfig) {
|
|
373
504
|
verifyAuthority = async () => {
|
|
374
505
|
step(`Destructive authority: checking the approver set for stage ${flags.stage}...`);
|
|
375
|
-
const
|
|
376
|
-
const
|
|
506
|
+
const { parseAppName } = await import('../discover');
|
|
507
|
+
const appName = await parseAppName();
|
|
508
|
+
const approvers = await readApproverParam(stageConfig.region, appName, flags.stage);
|
|
509
|
+
const identity = approvers === null ? null : await resolveCallerIdentity(stageConfig.region);
|
|
377
510
|
const verdict = checkDestructiveAuthority({ approvers, identity });
|
|
378
511
|
if (!verdict.ok) return verdict;
|
|
379
512
|
if (verdict.reason === 'approved') {
|
|
@@ -383,9 +516,6 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
383
516
|
}
|
|
384
517
|
return { ok: true };
|
|
385
518
|
};
|
|
386
|
-
warn(`DESTRUCTIVE direct apply — proceeding on ceremony: --confirm + attested snapshot ${flags['snapshot-ref']}.`);
|
|
387
|
-
} else {
|
|
388
|
-
warn(`DESTRUCTIVE apply over a bare connection — approver gating unavailable (no --stage), proceeding on ceremony: --confirm + attested snapshot ${flags['snapshot-ref']}.`);
|
|
389
519
|
}
|
|
390
520
|
}
|
|
391
521
|
|
|
@@ -394,9 +524,9 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
394
524
|
|
|
395
525
|
try {
|
|
396
526
|
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` : ''})`);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
527
|
+
// Set inside checkDescent when --attest-lineage-lost is honoured; read at write time so the
|
|
528
|
+
// waiver lands in the schema_log outcome (the descent decision runs before the apply writes).
|
|
529
|
+
let descentAttested: string | undefined;
|
|
400
530
|
step('Verifying the target is where the plan started...');
|
|
401
531
|
// The mutation lease: one operator mutates a database at a time. Acquired on this apply's
|
|
402
532
|
// own session (the max:1 createUrlRunner) before any write; a second operator is refused
|
|
@@ -408,6 +538,22 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
408
538
|
actor: process.env.USER ?? null,
|
|
409
539
|
gitRef: currentGitRef() ?? plan.gitRef,
|
|
410
540
|
...(verifyAuthority ? { verifyAuthority } : {}),
|
|
541
|
+
// The rollback point must capture the state this plan STARTS from. A logical backup stamps
|
|
542
|
+
// the live fingerprint at dump time; if it does not equal plan.from, restoring it would not
|
|
543
|
+
// return the database to where the plan began — so refuse (the escape is re-plan, not a
|
|
544
|
+
// flag). A physical snapshot carries no stamp: it is fresh and the concurrency lock below
|
|
545
|
+
// proves live == plan.from, so temporal ordering is its coverage. `none` opted out.
|
|
546
|
+
...(rollbackPoint?.stampedFingerprint
|
|
547
|
+
? {
|
|
548
|
+
verifySnapshot: async ({ planFrom }: { planFrom: string }) =>
|
|
549
|
+
rollbackPoint!.stampedFingerprint === planFrom
|
|
550
|
+
? { ok: true as const }
|
|
551
|
+
: {
|
|
552
|
+
ok: false as const,
|
|
553
|
+
reason: snapshotMovedRefusal(rollbackPoint!.ref!, rollbackPoint!.stampedFingerprint!, planFrom),
|
|
554
|
+
},
|
|
555
|
+
}
|
|
556
|
+
: {}),
|
|
411
557
|
// Re-verified here, not just at mint: a plan is an artifact with a lifetime, and a
|
|
412
558
|
// foreign role granted between minting and applying would otherwise ride through.
|
|
413
559
|
verifyAuthzBaseline: async ({ contract }) => {
|
|
@@ -421,37 +567,29 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
421
567
|
? { ok: true as const }
|
|
422
568
|
: { ok: false as const, reason: baselineRefusal(stageName, violations) };
|
|
423
569
|
},
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
return { ok: true };
|
|
448
|
-
case 'diverged':
|
|
449
|
-
case 'drift':
|
|
450
|
-
case 'indeterminate':
|
|
451
|
-
return { ok: false, reason: verdict.reason };
|
|
452
|
-
}
|
|
453
|
-
},
|
|
454
|
-
} : {}),
|
|
570
|
+
dirty: worktreeDirty(resolveModelsPath(flags.models)),
|
|
571
|
+
descentNote: () => descentAttested,
|
|
572
|
+
checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[] }) => {
|
|
573
|
+
step('Descent: proving the fast-forward rule against the schema_log ledger...');
|
|
574
|
+
const verdict = await proveDescent(resolveModelsPath(flags.models), live, live.ledger, {});
|
|
575
|
+
if (verdict.status === 'proven') {
|
|
576
|
+
info(`descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.`);
|
|
577
|
+
} else if (verdict.status === 'fresh-target') {
|
|
578
|
+
info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
|
|
579
|
+
} else if (verdict.status === 'no-git' && plan.destructive === 0) {
|
|
580
|
+
warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
|
|
581
|
+
}
|
|
582
|
+
const decision = decideDescentApply(verdict, {
|
|
583
|
+
...(attestLineageLost ? { attestation: attestLineageLost } : {}),
|
|
584
|
+
destructive: plan.destructive > 0,
|
|
585
|
+
proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(process.cwd())),
|
|
586
|
+
});
|
|
587
|
+
if (decision.ok && decision.attested) {
|
|
588
|
+
descentAttested = attestationNote(decision.attested);
|
|
589
|
+
warn(`DESCENT ATTESTED — ${descentAttested}. The declaring commit is proven gone from every ref; the fast-forward rule is waived for this apply and the waiver is recorded.`);
|
|
590
|
+
}
|
|
591
|
+
return decision.ok ? { ok: true } : { ok: false, reason: decision.reason };
|
|
592
|
+
},
|
|
455
593
|
}),
|
|
456
594
|
);
|
|
457
595
|
|