@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.
@@ -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 { verifyDescent } from '../git-descent';
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 verdict = await verifyDescent(modelsPath, { snapshot, contract }, {});
282
- // A switch, not an `if (diverged || drift)`. That form silently ignored every verdict
283
- // it did not name, so `indeterminate` would have minted a plan with no warning at all
284
- // and then been refused at apply time with no hint of why. Exhaustive here means a new
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 'ok':
293
+ case 'proven':
288
294
  case 'fresh-target':
289
295
  case 'no-git':
290
296
  break;
291
- case 'diverged':
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 'indeterminate':
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 failed, so the rule is unproven either way.
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, createRdsSnapshot, describeRdsSnapshots } from '../aws';
44
- import { keyForArtifactId, metaKey, utcStamp } from '../backup';
45
- import { rdsSnapshotIdentifier } from '../rds-snapshot';
46
- import { pollTaskUntilStopped } from '../task-poll';
47
- import { decideSnapshotMode, confirmPhysicalSnapshot, interpretBackupPoll, type SnapshotModeRequest } from '../swap-snapshot';
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
- if (plan.mode === 'attested') {
452
- info(`pre-swap rollback point: ${plan.ref} (attested via --snapshot-ref — no new snapshot taken).`);
453
- return;
454
- }
455
-
456
- if (plan.mode === 'none') {
457
- warn('NO pre-swap snapshot (--snapshot none). If this swap lands bad data there is no rollback point — the retiring schema is dropped once verify passes.');
458
- return;
459
- }
460
-
461
- if (plan.mode === 'physical') {
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
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
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
- // Swap keeps its OWN reading of a finished poll: it is the only caller that can tell the
488
- // operator to re-run with `--snapshot-ref <id>` once the dump lands, and each outcome needs
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
- if (!backup.ok) {
499
- throw new Error(`the pre-swap backup is not a usable rollback point, so the swap was NOT applied and live is untouched: ${backup.reason}`);
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
- if (backup.warning) warn(backup.warning);
502
- info(`rollback point CONFIRMED: backup ${backup.id} complete — restore with db:restore --from ${backup.id} --confirm.`);
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> {
@@ -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
- return `INSERT INTO everystack.schema_log (kind, actor, git_ref, plan_ref, from_fingerprint, to_fingerprint, sql, duration_ms, outcome)
563
- 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)})
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
 
@@ -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` —