@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
|
@@ -50,7 +50,8 @@ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
|
|
|
50
50
|
import { readStageStateTwice } from '../stage-read-consistency';
|
|
51
51
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
52
52
|
import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan';
|
|
53
|
-
import {
|
|
53
|
+
import { proveDescent } from '../git-descent';
|
|
54
|
+
import { readDescentLedger } from '../apply-execute';
|
|
54
55
|
import { planBackfills, readBackfillLog } from '../backfill';
|
|
55
56
|
import { readSqlDirIfPresent } from './db-sync';
|
|
56
57
|
import { currentGitRef } from '../state-apply';
|
|
@@ -277,25 +278,35 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
277
278
|
// The fast-forward rule, warn-only at mint time (db:apply enforces it):
|
|
278
279
|
// a stale checkout mints a legal-looking plan whose edge REVERTS merged
|
|
279
280
|
// work — say so on the review surface, where a human still reads it.
|
|
281
|
+
//
|
|
282
|
+
// Ledger-first, SYMMETRIC with db:apply — it must warn about exactly what apply will do.
|
|
283
|
+
// The old whole-history recompile warned "rebase first, will refuse" for
|
|
284
|
+
// the very strand apply now PROVES via the ledger and applies, telling the operator the
|
|
285
|
+
// opposite of what happens. proveDescent over the same ledger apply reads keeps them agreed.
|
|
280
286
|
if (plan.statements.length > 0) {
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// verdict cannot be forgotten.
|
|
287
|
+
const ledger = await readDescentLedger(session);
|
|
288
|
+
const verdict = await proveDescent(modelsPath, { snapshot, contract }, ledger, {});
|
|
289
|
+
// A switch, not an `if (revert || drift)`. That form silently ignored every verdict it
|
|
290
|
+
// did not name, so a new one would mint a plan with no warning and then be refused at
|
|
291
|
+
// apply time with no hint of why. Exhaustive here means a new verdict cannot be forgotten.
|
|
286
292
|
switch (verdict.status) {
|
|
287
|
-
case '
|
|
293
|
+
case 'proven':
|
|
288
294
|
case 'fresh-target':
|
|
289
295
|
case 'no-git':
|
|
290
296
|
break;
|
|
291
|
-
case '
|
|
297
|
+
case 'revert':
|
|
292
298
|
case 'drift':
|
|
293
299
|
warn(`descent: ${verdict.reason}`);
|
|
294
300
|
warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
|
|
295
301
|
break;
|
|
296
|
-
case '
|
|
302
|
+
case 'unknown-commit':
|
|
303
|
+
// NOT "rebase first" — the checkout is fine, it is just missing the deploying commit.
|
|
304
|
+
warn(`descent: ${verdict.reason}`);
|
|
305
|
+
warn('db:apply from this checkout will refuse this plan until you fetch that commit — fetch, then re-mint.');
|
|
306
|
+
break;
|
|
307
|
+
case 'unverifiable':
|
|
297
308
|
// NOT "rebase first" — nothing is wrong with the checkout or the database. The
|
|
298
|
-
// search
|
|
309
|
+
// search could not read an ancestor state, so the rule is unproven either way.
|
|
299
310
|
warn(`descent: ${verdict.reason}`);
|
|
300
311
|
warn('db:apply from this checkout will refuse this plan until that search completes — re-mint and try again.');
|
|
301
312
|
break;
|
|
@@ -40,12 +40,11 @@ import { rewriteStatementLine, opensCopyData, closesCopyData } from '../schema-r
|
|
|
40
40
|
import { resolveOperatorUrlViaStage } from '../direct-venue';
|
|
41
41
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease';
|
|
42
42
|
import { resolveConfig, opsFunction, type CliConfig } from '../config';
|
|
43
|
-
import { invokeAction, presignGet
|
|
44
|
-
import { keyForArtifactId, metaKey
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import { runStageBackup, stageBackupDeps } from '../stage-backup';
|
|
43
|
+
import { invokeAction, presignGet } from '../aws';
|
|
44
|
+
import { keyForArtifactId, metaKey } from '../backup';
|
|
45
|
+
import { decideSnapshotMode, type SnapshotModeRequest } from '../swap-snapshot';
|
|
46
|
+
import { confirmRollbackPoint } from '../rollback-point';
|
|
47
|
+
import { stageConfirmDeps } from '../stage-rollback-deps';
|
|
49
48
|
import { pgEnvFromUrl, pgKeepaliveConninfo } from './db';
|
|
50
49
|
import { step, success, fail, warn, info } from '../output';
|
|
51
50
|
|
|
@@ -448,58 +447,32 @@ async function takePreSwapSnapshot(
|
|
|
448
447
|
plan: Exclude<ReturnType<typeof decideSnapshotMode>, { mode: 'refuse' }>,
|
|
449
448
|
ctx: { stage?: string; region?: string; opsFn?: string },
|
|
450
449
|
): Promise<void> {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
step(`Snapshotting the instance before the swap (RDS physical snapshot of ${plan.instanceId})...`);
|
|
463
|
-
const snapshotId = rdsSnapshotIdentifier(`${ctx.stage ?? 'swap'}-swap`, utcStamp(new Date()));
|
|
464
|
-
const region = ctx.region!;
|
|
465
|
-
const { id } = await confirmPhysicalSnapshot({
|
|
466
|
-
create: (sid) => createRdsSnapshot(region, plan.instanceId, sid),
|
|
467
|
-
describe: () => describeRdsSnapshots(region, plan.instanceId),
|
|
450
|
+
// The confirm ceremony is now shared with db:apply (rollback-point.ts) — one definition of
|
|
451
|
+
// "no destructive statement before a durable rollback point", two verbs. Swap keeps only its
|
|
452
|
+
// OWN wording: the restore-from advice, and the "swap was NOT applied, live is untouched"
|
|
453
|
+
// framing on a refusal.
|
|
454
|
+
const result = await confirmRollbackPoint(plan, {
|
|
455
|
+
...stageConfirmDeps({
|
|
456
|
+
region: ctx.region!,
|
|
457
|
+
instanceId: plan.mode === 'physical' ? plan.instanceId : undefined,
|
|
458
|
+
opsFn: ctx.opsFn!,
|
|
459
|
+
stage: ctx.stage,
|
|
460
|
+
verb: 'swap',
|
|
468
461
|
log: (m) => info(m),
|
|
469
|
-
|
|
470
|
-
now: () => Date.now(),
|
|
471
|
-
}, { instanceId: plan.instanceId, snapshotId });
|
|
472
|
-
info(`rollback point CONFIRMED: RDS snapshot ${id} is available — restore the instance from it if this swap goes wrong.`);
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Logical: dispatch the Task and WAIT. The dispatch returning is not the backup existing — that
|
|
477
|
-
// conflation is what let a pg_dump run concurrently with the restore it was supposed to precede.
|
|
478
|
-
step('Snapshotting the stage before the swap (db:backup — waiting for the dump to finish)...');
|
|
479
|
-
// The shared sandwich. A rollback point taken across a concurrent migration holds a schema that
|
|
480
|
-
// matches neither endpoint — restoring from it would not return the stage to where it was, which
|
|
481
|
-
// is the one thing a pre-swap snapshot has to guarantee. An unproven artifact fails the swap.
|
|
482
|
-
const backup = await runStageBackup({
|
|
483
|
-
...stageBackupDeps({ region: ctx.region! }, ctx.opsFn!, ctx.stage, {
|
|
484
|
-
onStep: step,
|
|
485
|
-
onDispatch: (d: { id: string; runId: string }) => info(`backup ${d.id} dispatched (run ${d.runId}) — waiting for the task to stop before the restore starts.`),
|
|
462
|
+
step,
|
|
486
463
|
}),
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
// different advice. The shared helper takes injected deps for exactly this.
|
|
490
|
-
awaitTask: async (ids) => {
|
|
491
|
-
const poll = await pollTaskUntilStopped(ctx.region!, ctx.opsFn!, { runId: ids.runId, taskArn: ids.taskArn });
|
|
492
|
-
const verdict = interpretBackupPoll(poll, { runId: ids.runId, id: ids.id });
|
|
493
|
-
if (!verdict.ok) return verdict;
|
|
494
|
-
// Carry the stamp through, or this caller silently opts out of the image-skew check.
|
|
495
|
-
return { ok: true, stampedFingerprint: (poll as any).status?.result?.fingerprint as string | undefined };
|
|
496
|
-
},
|
|
464
|
+
log: (m) => info(m),
|
|
465
|
+
warn: (m) => warn(m),
|
|
497
466
|
});
|
|
498
|
-
|
|
499
|
-
|
|
467
|
+
|
|
468
|
+
if (!result.ok) {
|
|
469
|
+
throw new Error(`the pre-swap rollback point could not be confirmed, so the swap was NOT applied and live is untouched: ${result.reason}`);
|
|
500
470
|
}
|
|
501
|
-
|
|
502
|
-
|
|
471
|
+
// Swap-specific restore advice on top of the shared confirmation.
|
|
472
|
+
const p = result.point;
|
|
473
|
+
if (p.mode === 'physical') info(`restore the instance from RDS snapshot ${p.ref} if this swap goes wrong.`);
|
|
474
|
+
else if (p.mode === 'logical') info(`restore with db:restore --from ${p.ref} --confirm if this swap goes wrong.`);
|
|
475
|
+
else if (p.mode === 'none') warn('the retiring schema is dropped once verify passes — there is no rollback point.');
|
|
503
476
|
}
|
|
504
477
|
|
|
505
478
|
export async function dbSwapCommand(flags: Record<string, string>): Promise<void> {
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -59,6 +59,15 @@ export const ENSURE_RECONCILER_SQL: string[] = [
|
|
|
59
59
|
duration_ms integer,
|
|
60
60
|
outcome text NOT NULL
|
|
61
61
|
)`,
|
|
62
|
+
// The descent witness columns (apply-legibility Phase 1). Idempotent — legacy databases
|
|
63
|
+
// upgrade in place; their old rows read fp_version = NULL (descent skips them) and
|
|
64
|
+
// dirty = false (harmless, since a NULL fp_version already skips them).
|
|
65
|
+
// - fp_version: the FINGERPRINT_VERSION a row's to_fingerprint was computed under, so the
|
|
66
|
+
// fast-forward proof can recompute the live side under the ROW'S canon across a bump.
|
|
67
|
+
// - dirty: the apply ran from a worktree whose models differed from HEAD, so its git_ref
|
|
68
|
+
// does not declare what was applied — descent must ignore the row (like git_ref = NULL).
|
|
69
|
+
`ALTER TABLE everystack.schema_log ADD COLUMN IF NOT EXISTS fp_version integer`,
|
|
70
|
+
`ALTER TABLE everystack.schema_log ADD COLUMN IF NOT EXISTS dirty boolean NOT NULL DEFAULT false`,
|
|
62
71
|
];
|
|
63
72
|
|
|
64
73
|
export interface RenderedReconcile {
|
|
@@ -555,12 +564,18 @@ export interface SchemaLogEntry {
|
|
|
555
564
|
durationMs?: number;
|
|
556
565
|
fromFingerprint?: string | null;
|
|
557
566
|
toFingerprint?: string | null;
|
|
567
|
+
/** The FINGERPRINT_VERSION the fingerprints were computed under — the descent witness's canon. */
|
|
568
|
+
fpVersion?: number | null;
|
|
569
|
+
/** The apply ran from a dirty worktree (models differed from HEAD) — descent ignores such a row. */
|
|
570
|
+
dirty?: boolean;
|
|
558
571
|
}
|
|
559
572
|
|
|
560
573
|
export function renderSchemaLogInsert(entry: SchemaLogEntry): string {
|
|
561
574
|
const duration = entry.durationMs === undefined ? 'NULL' : String(Math.round(entry.durationMs));
|
|
562
|
-
|
|
563
|
-
|
|
575
|
+
const fpVersion = entry.fpVersion == null ? 'NULL' : String(Math.floor(entry.fpVersion));
|
|
576
|
+
const dirty = entry.dirty ? 'true' : 'false';
|
|
577
|
+
return `INSERT INTO everystack.schema_log (kind, actor, git_ref, plan_ref, from_fingerprint, to_fingerprint, sql, duration_ms, outcome, fp_version, dirty)
|
|
578
|
+
VALUES (${escapeLiteral(entry.kind)}, ${nullable(entry.actor)}, ${nullable(entry.gitRef)}, ${nullable(entry.planRef)}, ${nullable(entry.fromFingerprint)}, ${nullable(entry.toFingerprint)}, ${escapeLiteral(entry.sql)}, ${duration}, ${escapeLiteral(entry.outcome)}, ${fpVersion}, ${dirty})
|
|
564
579
|
RETURNING id`;
|
|
565
580
|
}
|
|
566
581
|
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -37,6 +37,20 @@ import { compileTableRenames, compileTableMoves } from './schema-compile';
|
|
|
37
37
|
|
|
38
38
|
export const PLAN_VERSION = 2;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The db:apply WIRE CONTRACT between the CLI and the ops Lambda — distinct from PLAN_VERSION,
|
|
42
|
+
* which versions the plan ARTIFACT. This versions the invoke PAYLOAD (its keys) and the ops
|
|
43
|
+
* Lambda's enforcement of them: the descent/authority/snapshot verdicts the CLI computes and the
|
|
44
|
+
* gates the server runs from them. PLAN_VERSION can hold while this moves — a payload key added or
|
|
45
|
+
* a hook's meaning changed leaves the plan file identical but breaks a mismatched peer silently.
|
|
46
|
+
*
|
|
47
|
+
* BUMP RULE: increment on ANY change to the db:apply payload keys, the set of gates the server
|
|
48
|
+
* runs, or a gate's semantics. Both packages import THIS binding (the server from the cli it
|
|
49
|
+
* bundles), so a version skew is a hard, exact-equality refusal in both directions — never a
|
|
50
|
+
* silently dropped gate.
|
|
51
|
+
*/
|
|
52
|
+
export const APPLY_CONTRACT_VERSION = 1;
|
|
53
|
+
|
|
40
54
|
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
|
|
41
55
|
export interface PlanClassification {
|
|
42
56
|
/** Statements that positively ADD. Counted from a matcher, never `total - destructive` —
|