@everystack/cli 0.4.68 → 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/src/cli/index.ts CHANGED
@@ -372,7 +372,7 @@ Usage:
372
372
  everystack db:sync [--database-url <url>] [--models <barrel>] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against the declared descriptors, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
373
373
  everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
374
374
  everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line)
375
- everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + an attested --snapshot-ref + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there
375
+ everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--attest-lineage-lost <sha>] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — a descent refusal is escaped git-natively: git revert the declaring commit → db:generate → db:plan → db:apply, so the rollback is a recorded forward move; --attest-lineage-lost <sha> waives the rule ONLY for a declaring commit proven gone from every ref), and for DESTRUCTIVE plans require --confirm always + a confirmed rollback point + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there
376
376
  everystack db:build --database-url <url> [--models <barrel>] Build a database FROM the models and KEEP it — the fresh-database bootstrap a deleted migration folder is replaced BY. Runs the same compose db:check proves buildability with (extensions -> schemas -> contract roles -> declared functions -> state -> derived layer), against a real target, then reports MATCH. Venue is EXPLICIT: --database-url only, never the ambient environment, because this writes a whole schema. REFUSES a database that already holds objects — evolving an existing one is db:sync (dev) or db:plan -> db:apply (a stage). Contract roles are created NOLOGIN if absent, and roles are cluster-level
377
377
  everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
378
378
  everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
@@ -0,0 +1,146 @@
1
+ /**
2
+ * confirmLocalDump — the bare `--database-url` rollback point for a destructive db:apply.
3
+ *
4
+ * Ratified 2026-08-26 (REVERSAL 1) and Fable-adjudicated 2026-08-26 (Option A): over a bare
5
+ * --database-url the tool takes its OWN `pg_dump -Fc` to a local file and VERIFIES it with
6
+ * `pg_restore --list` before the first destructive statement. The dump is the rollback point; the
7
+ * operator restores it by hand with standard tooling if the apply goes wrong
8
+ * (`pg_restore --clean --if-exists -d <url> <file>`) — there is no first-class restore verb here on
9
+ * purpose, because a bare --database-url is the local-dev venue where the operator owns the DB.
10
+ *
11
+ * FAIL CLOSED — a local .dump becomes theater two ways: a truncated file and a missing binary. The
12
+ * verdict is a total, pure function (`interpretLocalDumpVerdict`) so every failure mode refuses
13
+ * WITHOUT a database. `pg_restore --list` exiting 0 is not enough on its own: an empty or truncated
14
+ * archive can still exit 0 with no table of contents, so a non-empty TOC is required too. (The
15
+ * "is this the right database" worry that dogs an attested --snapshot-ref does not arise here: the
16
+ * dump is taken from the SAME url the apply will run against.)
17
+ *
18
+ * The dump carries no fingerprint stamp of its own — like a physical RDS snapshot it is taken fresh
19
+ * and the caller's concurrency lock (live == plan.from, re-read after this returns) is its coverage.
20
+ */
21
+
22
+ import { spawn } from 'node:child_process';
23
+ import { stat } from 'node:fs/promises';
24
+ import { pgEnvFromUrl, pgKeepaliveConninfo } from './commands/db';
25
+
26
+ /**
27
+ * pg_dump args for a rollback dump to `file`: a custom-format (-Fc) archive of the app schemas.
28
+ * Excludes the everystack ops schema — restoring it would rewind the target's own audit history, and
29
+ * pg_restore --clean would fail dropping it (its ledger tables depend on the schema). Ownership- and
30
+ * privilege-neutral so the restore lands on a dev DB the operator owns without role juggling.
31
+ */
32
+ export function pgDumpToFileArgs(file: string): string[] {
33
+ return ['-d', pgKeepaliveConninfo(), '-Fc', '--no-owner', '--no-privileges', '--exclude-schema=everystack', '-f', file];
34
+ }
35
+
36
+ export interface LocalDumpVerdictInput {
37
+ /** Did pg_dump exit 0? (A missing pg_dump binary is a spawn error ⇒ false.) */
38
+ dumpOk: boolean;
39
+ dumpError?: string;
40
+ /** Size of the written archive. Zero means pg_dump produced nothing usable. */
41
+ fileBytes: number;
42
+ /** Did `pg_restore --list` read the archive? (A missing pg_restore, or a truncated archive ⇒ false.) */
43
+ listOk: boolean;
44
+ listError?: string;
45
+ /** Non-comment entries in the archive's table of contents. Zero ⇒ an empty archive that lists clean. */
46
+ tocEntries: number;
47
+ }
48
+
49
+ /**
50
+ * The rollback point is durable ONLY when every stage of the take+verify succeeded. Any failure is a
51
+ * REFUSAL the caller must obey — no destructive statement runs. Ordered so the operator reads the
52
+ * FIRST thing that broke.
53
+ */
54
+ export function interpretLocalDumpVerdict(i: LocalDumpVerdictInput): { ok: true } | { ok: false; reason: string } {
55
+ const takeOne =
56
+ 'A destructive apply over a bare --database-url needs a rollback point it can prove is restorable. '
57
+ + 'Fix the tool (pg_dump/pg_restore must be on PATH and no older major than the server), or attest a '
58
+ + 'rollback point you took yourself with --snapshot-ref <id>, or accept the risk explicitly with --snapshot none.';
59
+ if (!i.dumpOk) {
60
+ return { ok: false, reason: `pg_dump could not take the local rollback point: ${i.dumpError ?? 'unknown error'}. ${takeOne}` };
61
+ }
62
+ if (!(i.fileBytes > 0)) {
63
+ return { ok: false, reason: `the local rollback dump is empty (0 bytes) — pg_dump produced nothing usable. ${takeOne}` };
64
+ }
65
+ if (!i.listOk) {
66
+ return { ok: false, reason: `pg_restore --list could not read the rollback dump (it may be truncated, or pg_restore is missing): ${i.listError ?? 'unknown error'}. ${takeOne}` };
67
+ }
68
+ if (!(i.tocEntries > 0)) {
69
+ return { ok: false, reason: `the rollback dump lists no objects — an empty archive is not a rollback point. ${takeOne}` };
70
+ }
71
+ return { ok: true };
72
+ }
73
+
74
+ /** Count table-of-contents ENTRIES in `pg_restore --list` output: non-empty lines that are not the ';'-prefixed header/comment lines. */
75
+ export function countTocEntries(listOutput: string): number {
76
+ return listOutput
77
+ .split('\n')
78
+ .map((l) => l.trim())
79
+ .filter((l) => l.length > 0 && !l.startsWith(';')).length;
80
+ }
81
+
82
+ async function runToExit(
83
+ bin: string,
84
+ args: string[],
85
+ env: NodeJS.ProcessEnv,
86
+ ): Promise<{ ok: boolean; error?: string; stdout: string }> {
87
+ return new Promise((resolve) => {
88
+ let stdout = '';
89
+ let stderr = '';
90
+ let child;
91
+ try {
92
+ child = spawn(bin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
93
+ } catch (err: any) {
94
+ resolve({ ok: false, error: err?.message ?? String(err), stdout: '' });
95
+ return;
96
+ }
97
+ child.stdout?.on('data', (d) => { stdout += d.toString(); });
98
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
99
+ // ENOENT (binary not on PATH) arrives as an 'error' event, not a non-zero exit.
100
+ child.on('error', (err: any) => resolve({ ok: false, error: err?.message ?? String(err), stdout }));
101
+ child.on('close', (code) =>
102
+ resolve(code === 0 ? { ok: true, stdout } : { ok: false, error: `exited ${code}: ${stderr.trim()}`, stdout }));
103
+ });
104
+ }
105
+
106
+ export interface LocalDumpDeps {
107
+ /** The bare --database-url the apply targets — the same DB the rollback dump captures. */
108
+ url: string;
109
+ /** Absolute path to write the .dump to. */
110
+ file: string;
111
+ /** Binaries, injectable so the fail-closed paths (missing binary) are testable without unstubbing PATH. */
112
+ pgDumpBin?: string;
113
+ pgRestoreBin?: string;
114
+ /** Seam: report the written file's size. Defaults to fs.stat. */
115
+ fileBytes?: (file: string) => Promise<number>;
116
+ }
117
+
118
+ /**
119
+ * Take the local rollback dump and verify it, returning the file path as the rollback ref on success
120
+ * or a refusal on any failure. Never throws into the caller — every failure is a verdict.
121
+ */
122
+ export async function confirmLocalDump(
123
+ deps: LocalDumpDeps,
124
+ ): Promise<{ ok: true; ref: string } | { ok: false; reason: string }> {
125
+ const env = { ...process.env, ...pgEnvFromUrl(deps.url) };
126
+ const dumpBin = deps.pgDumpBin ?? 'pg_dump';
127
+ const restoreBin = deps.pgRestoreBin ?? 'pg_restore';
128
+
129
+ const dump = await runToExit(dumpBin, pgDumpToFileArgs(deps.file), env);
130
+ const fileBytes = dump.ok
131
+ ? await (deps.fileBytes ?? (async (f: string) => (await stat(f)).size.valueOf()))(deps.file).catch(() => 0)
132
+ : 0;
133
+ const list = dump.ok && fileBytes > 0
134
+ ? await runToExit(restoreBin, ['--list', deps.file], env)
135
+ : { ok: false as const, error: 'skipped (pg_dump did not produce a usable archive)', stdout: '' };
136
+
137
+ const verdict = interpretLocalDumpVerdict({
138
+ dumpOk: dump.ok,
139
+ ...(dump.error ? { dumpError: dump.error } : {}),
140
+ fileBytes,
141
+ listOk: list.ok,
142
+ ...(list.error ? { listError: list.error } : {}),
143
+ tocEntries: list.ok ? countTocEntries(list.stdout) : 0,
144
+ });
145
+ return verdict.ok ? { ok: true, ref: deps.file } : { ok: false, reason: verdict.reason };
146
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * promptYesNo — one line of interactive consent, default NO. Used where the tool is about to do
3
+ * something the operator did not explicitly ask for on the command line (db:apply's lane escalation).
4
+ * Callers MUST gate on `process.stdin.isTTY` first: a non-interactive session has no one to answer, so
5
+ * the safe path there is to refuse and name the explicit flag, not to call this.
6
+ */
7
+
8
+ import * as readline from 'node:readline';
9
+
10
+ export async function promptYesNo(question: string): Promise<boolean> {
11
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
12
+ try {
13
+ const answer: string = await new Promise((resolve) => rl.question(question, resolve));
14
+ return /^y(es)?$/i.test(answer.trim());
15
+ } finally {
16
+ rl.close();
17
+ }
18
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * confirmRollbackPoint — take (or account for) a durable rollback point before a destructive
3
+ * database operation, and do not return success until it EXISTS.
4
+ *
5
+ * This is the ceremony db:swap proved and db:apply now shares (apply-legibility Phase 2, Ty
6
+ * ratified 2026-08-26). The property both verbs need is identical: no destructive statement
7
+ * runs before a confirmed-durable rollback point. The old apply design took its OWN async
8
+ * snapshot and did not wait — destroyed data before the snapshot existed — and was removed
9
+ * (26ea947b) for exactly that. The fix is db:swap's pattern: decide the mode, take it, and
10
+ * WAIT for durability. This module is that pattern, verb-neutral, with its executors injected
11
+ * so the wait is provable without AWS.
12
+ *
13
+ * It confirms the point exists; it does NOT re-verify the live fingerprint after the wait —
14
+ * that is the CALLER's concurrency lock (the snapshot wait is a window, and the apply's own
15
+ * `live == plan.from` re-check after this returns is what closes it). Keeping the two separate
16
+ * is deliberate: this module answers "is there a rollback point", the lock answers "is the
17
+ * database still where the plan started".
18
+ */
19
+
20
+ import type { SnapshotDecision } from './swap-snapshot';
21
+
22
+ /** A confirmed, durable rollback point. */
23
+ export interface RollbackPoint {
24
+ mode: 'physical' | 'logical' | 'attested' | 'local' | 'none';
25
+ /** The snapshot/backup id, the attested ref, or the local dump's file path. Absent for `none`. */
26
+ ref?: string;
27
+ /** A logical backup stamps the live fingerprint at dump time — the caller checks it covers plan.from. */
28
+ stampedFingerprint?: string;
29
+ }
30
+
31
+ /**
32
+ * The executors, injected. Each wraps an already-tested primitive:
33
+ * - `confirmPhysical` wraps `confirmPhysicalSnapshot` (takes an RDS snapshot, waits for
34
+ * 'available', throws on failure/timeout naming the id).
35
+ * - `confirmLogical` wraps `runStageBackup` + `interpretBackupPoll` (dispatches db:backup and
36
+ * WAITS for the task to stop, verifying the artifact).
37
+ */
38
+ export interface ConfirmRollbackDeps {
39
+ confirmPhysical: (instanceId: string) => Promise<{ id: string }>;
40
+ confirmLogical: () => Promise<
41
+ | { ok: true; id: string; stampedFingerprint?: string; warning?: string }
42
+ | { ok: false; reason: string }
43
+ >;
44
+ /**
45
+ * Optional: fingerprint-verify an ATTESTED `--snapshot-ref` before it counts as the rollback point
46
+ * ("resolve-else-take" — reuse an existing backup only when it stamps plan.from). db:apply injects
47
+ * this to resolve the ref's backup manifest and run `verifySnapshotRef`; on success it returns the
48
+ * stamped fingerprint so the post-lock re-check ties the backup to live-at-lock too. When ABSENT
49
+ * (db:swap, whose attested ref may be a stampless physical RDS snapshot) an attested ref stays
50
+ * operator-trusted and the concurrency lock is its coverage.
51
+ */
52
+ verifyAttested?: (
53
+ ref: string,
54
+ ) => Promise<{ ok: true; stampedFingerprint?: string } | { ok: false; reason: string }>;
55
+ /**
56
+ * Optional: take our OWN local `pg_dump -Fc` and verify it (bare --database-url, db:apply only).
57
+ * Present only where `decideSnapshotMode` can return mode 'local' (db:apply passes localDump:true),
58
+ * so its absence when a 'local' decision arrives is a wiring bug and refuses rather than proceeding.
59
+ */
60
+ confirmLocal?: () => Promise<{ ok: true; ref: string } | { ok: false; reason: string }>;
61
+ log: (msg: string) => void;
62
+ warn: (msg: string) => void;
63
+ }
64
+
65
+ /**
66
+ * Produce a confirmed rollback point from a non-refusing SnapshotDecision, or return a refusal
67
+ * whose meaning is "there is no usable rollback point, so the caller must NOT proceed". Every
68
+ * branch either confirms durability or refuses; nothing returns a point that is merely in
69
+ * flight — that conflation is the defect this whole module exists to remove.
70
+ */
71
+ export async function confirmRollbackPoint(
72
+ decision: Exclude<SnapshotDecision, { mode: 'refuse' }>,
73
+ deps: ConfirmRollbackDeps,
74
+ ): Promise<{ ok: true; point: RollbackPoint } | { ok: false; reason: string }> {
75
+ switch (decision.mode) {
76
+ case 'attested': {
77
+ // An attested ref is a CLAIM until it is verified. Where a verifier is injected (db:apply), the
78
+ // claim must prove out — the backup exists, covers this stage, is fresh, and stamps plan.from —
79
+ // before it counts. A failure is a REFUSAL, not a fallback to trusting the operator's word: the
80
+ // escape is a fresh backup or a re-plan, never "proceed over an unverified snapshot".
81
+ if (deps.verifyAttested) {
82
+ const verified = await deps.verifyAttested(decision.ref);
83
+ if (!verified.ok) return { ok: false, reason: verified.reason };
84
+ deps.log(`rollback point: ${decision.ref} (attested via --snapshot-ref, fingerprint verified — no new snapshot taken).`);
85
+ return {
86
+ ok: true,
87
+ point: {
88
+ mode: 'attested',
89
+ ref: decision.ref,
90
+ ...(verified.stampedFingerprint ? { stampedFingerprint: verified.stampedFingerprint } : {}),
91
+ },
92
+ };
93
+ }
94
+ deps.log(`rollback point: ${decision.ref} (attested via --snapshot-ref — no new snapshot taken).`);
95
+ return { ok: true, point: { mode: 'attested', ref: decision.ref } };
96
+ }
97
+
98
+ case 'none':
99
+ deps.warn(
100
+ 'NO snapshot (--snapshot none) — there is no rollback point. If this operation lands bad ' +
101
+ 'data it cannot be undone from a snapshot.',
102
+ );
103
+ return { ok: true, point: { mode: 'none' } };
104
+
105
+ case 'physical': {
106
+ deps.log(`Taking a physical RDS snapshot of ${decision.instanceId} and waiting for it to become available...`);
107
+ try {
108
+ const { id } = await deps.confirmPhysical(decision.instanceId);
109
+ deps.log(`rollback point CONFIRMED: RDS snapshot ${id} is available.`);
110
+ return { ok: true, point: { mode: 'physical', ref: id } };
111
+ } catch (err: any) {
112
+ return { ok: false, reason: err?.message ?? String(err) };
113
+ }
114
+ }
115
+
116
+ case 'logical': {
117
+ deps.log('Taking a logical backup (db:backup) and waiting for the dump to finish...');
118
+ const backup = await deps.confirmLogical();
119
+ if (!backup.ok) return { ok: false, reason: backup.reason };
120
+ if (backup.warning) deps.warn(backup.warning);
121
+ deps.log(`rollback point CONFIRMED: backup ${backup.id} complete.`);
122
+ return {
123
+ ok: true,
124
+ point: { mode: 'logical', ref: backup.id, ...(backup.stampedFingerprint ? { stampedFingerprint: backup.stampedFingerprint } : {}) },
125
+ };
126
+ }
127
+
128
+ case 'local': {
129
+ if (!deps.confirmLocal) {
130
+ // A 'local' decision with no executor to run it is a wiring bug, not a rollback point. Refuse
131
+ // — the whole module exists so "no executor" never reads as "point on record".
132
+ return { ok: false, reason: 'a local dump rollback point was chosen but no executor was wired to take it (internal error).' };
133
+ }
134
+ deps.log('Taking a local pg_dump rollback point and verifying it (pg_restore --list)...');
135
+ const local = await deps.confirmLocal();
136
+ if (!local.ok) return { ok: false, reason: local.reason };
137
+ deps.log(`rollback point CONFIRMED: local dump ${local.ref} (restore with: pg_restore --clean --if-exists -d <url> ${local.ref}).`);
138
+ return { ok: true, point: { mode: 'local', ref: local.ref } };
139
+ }
140
+ }
141
+ }
@@ -63,7 +63,7 @@ export type SchemaChange =
63
63
  | { kind: 'setIdentity'; table: string; column: string; identity: 'always' | 'byDefault' | null; from: 'always' | 'byDefault' | null }
64
64
  /** A type change → real `ALTER COLUMN ... TYPE`. `risk` decides USING + a data-loss warning. */
65
65
  | { kind: 'alterType'; table: string; column: string; from: string; to: string; risk: TypeChangeRisk }
66
- | { kind: 'addConstraint'; table: string; constraint: ConstraintSpec }
66
+ | { kind: 'addConstraint'; table: string; constraint: ConstraintSpec; note?: string }
67
67
  | { kind: 'dropConstraint'; table: string; name: string }
68
68
  | { kind: 'addPrimaryKey'; table: string; columns: string[] }
69
69
  /** A PK change other than first-time add — a notice (the drop needs its constraint name). */
@@ -97,7 +97,7 @@ export type SchemaChange =
97
97
  /** An enum value the DB has but the Model dropped/renamed — Postgres can't drop one in place (a notice). */
98
98
  | { kind: 'enumValuesChanged'; name: string; from: string[]; to: string[] }
99
99
  /** A standalone index to create → `CREATE [UNIQUE] INDEX … [USING …] (…) [INCLUDE …] [WHERE …]`. */
100
- | { kind: 'createIndex'; table: string; name: string; columns: string[]; unique: boolean; where?: string; using?: string; include?: string[] }
100
+ | { kind: 'createIndex'; table: string; name: string; columns: string[]; unique: boolean; where?: string; using?: string; include?: string[]; note?: string }
101
101
  /** A standalone index present in the DB but no longer declared → `DROP INDEX` (a removal — gated). */
102
102
  | { kind: 'dropIndex'; table: string; name: string }
103
103
  /** A content-identical index the model names differently → `ALTER INDEX … RENAME TO` (B3). */
@@ -339,6 +339,34 @@ export function normalizeCheck(expr: string): string {
339
339
  return s.replace(/\s+/g, ' ');
340
340
  }
341
341
 
342
+ /**
343
+ * Strip EVERY parenthesis and collapse whitespace. DIAGNOSTIC-ONLY — never an identity or
344
+ * matcher input. Blind paren removal is UNSAFE for identity: it crosses operator precedence,
345
+ * so `(a OR b) AND c` and `a OR (b AND c)` collapse to the same text (a false match, the one
346
+ * disaster the matcher must avoid). It is safe HERE because it only recognizes that a diff is
347
+ * paren-only, to explain the churn — a wrong guess at worst suggests adopting the live text,
348
+ * which always converges. Quote-naive for the same reason (see docs; adjudicated 2026-08-24).
349
+ */
350
+ export function stripAllParens(expr: string): string {
351
+ return expr.replace(/[()]/g, ' ').replace(/\s+/g, ' ').trim();
352
+ }
353
+
354
+ /**
355
+ * When a predicate diff is PAREN-ONLY, the churn is not a real change — Postgres stores
356
+ * predicates fully parenthesized while the model author writes minimal parens. The fix is to
357
+ * declare the live form; this note hands the operator the exact text to paste. `kind` is
358
+ * "index"/"check", `name` the declared object, `liveName` the live object, `liveExpr` the
359
+ * predicate to adopt.
360
+ */
361
+ function parenOnlyNote(kind: string, name: string, liveName: string, liveExpr: string): string {
362
+ // Hedged deliberately. The paren-blind match cannot tell redundant parens from a real
363
+ // PRECEDENCE edit — `a OR (b AND c)` and `(a OR b) AND c` strip to the same text. So the
364
+ // note must NOT assert "this is not a change": if the operator DID intend a logic change,
365
+ // adopting the live form would silently revert it. The "if you did not intend" clause is
366
+ // true in both cases and points the common (redundant-paren) case at the converging fix.
367
+ return `-- NOTE: ${kind} "${name}" differs from the live ${kind} "${liveName}" by parenthesization. If you did NOT intend a change to the predicate's logic, this is churn — Postgres stores predicates fully parenthesized, so declare the live form to converge: ${liveExpr}`;
368
+ }
369
+
342
370
  // ---------------------------------------------------------------------------
343
371
  // Diff.
344
372
  // ---------------------------------------------------------------------------
@@ -532,13 +560,19 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
532
560
  ];
533
561
  }
534
562
 
535
- /** A `createIndex` change from an index spec (the new-table path and the per-table diff share it). */
536
- function createIndexChange(table: string, ix: IndexSchema): SchemaChange {
563
+ /** A `createIndex` change from an index spec (the new-table path and the per-table diff share it).
564
+ * `parenTwin` is a live index that matches this one on everything but parenthesization — when
565
+ * present, the create carries an advisory note pointing at the live form to adopt. */
566
+ function createIndexChange(table: string, ix: IndexSchema, parenTwin?: IndexSchema): SchemaChange {
567
+ const note = parenTwin?.where
568
+ ? parenOnlyNote('index', ix.name, parenTwin.name, `WHERE ${parenTwin.where}`)
569
+ : undefined;
537
570
  return {
538
571
  kind: 'createIndex', table, name: ix.name, columns: ix.columns, unique: ix.unique,
539
572
  ...(ix.where ? { where: ix.where } : {}),
540
573
  ...(ix.using ? { using: ix.using } : {}),
541
574
  ...(ix.include?.length ? { include: ix.include } : {}),
575
+ ...(note ? { note } : {}),
542
576
  };
543
577
  }
544
578
 
@@ -554,6 +588,14 @@ export function indexKey(ix: IndexSchema): string {
554
588
  return `${ix.unique ? 'u' : ''}|${entries}|${normalizeCheck(ix.where ?? '')}|${ix.using ?? 'btree'}|${(ix.include ?? []).join(',')}`;
555
589
  }
556
590
 
591
+ /** The same key with EVERY paren stripped — DIAGNOSTIC-ONLY (see {@link stripAllParens}).
592
+ * Two indexes with the same paren-blind key but different {@link indexKey} differ by
593
+ * parenthesization alone, which is churn, not a change. Never used for matching/identity. */
594
+ function parenBlindIndexKey(ix: IndexSchema): string {
595
+ const entries = ix.columns.map((c) => stripAllParens(c)).join(',');
596
+ return `${ix.unique ? 'u' : ''}|${entries}|${stripAllParens(ix.where ?? '')}|${ix.using ?? 'btree'}|${(ix.include ?? []).join(',')}`;
597
+ }
598
+
557
599
  /**
558
600
  * Diff standalone indexes by CONTENT — an index with the same key entries, uniqueness, predicate,
559
601
  * method and INCLUDE is the same index whatever it is called, so a pulled index re-generates
@@ -580,6 +622,11 @@ function diffIndexes(
580
622
  ): void {
581
623
  const desiredByKey = new Map(d.indexes.map((ix) => [indexKey(ix), ix]));
582
624
  const currentByKey = new Map(c.indexes.map((ix) => [indexKey(ix), ix]));
625
+ // Paren-blind lookup: a desired index with no exact match may still have a live twin that
626
+ // differs only by parenthesization (Postgres deparses predicates fully parenthesized). That
627
+ // is churn, not a change; the create carries a note pointing at the live form. Diagnostic
628
+ // only — this map never decides identity.
629
+ const currentByParenBlind = new Map(c.indexes.map((ix) => [parenBlindIndexKey(ix), ix]));
583
630
  // A same-NAME index whose CONTENT changed is a redefinition, not a removal, and PostgreSQL has
584
631
  // no CREATE OR REPLACE INDEX — it must drop first. Reported by GridironDB: a full unique index
585
632
  // became two partial ones, the new partial index derived the SAME generated name as the live
@@ -597,7 +644,11 @@ function diffIndexes(
597
644
  for (const [k, ix] of desiredByKey) {
598
645
  const live = currentByKey.get(k);
599
646
  if (!live) {
600
- creates.push(createIndexChange(d.table, ix));
647
+ const twin = currentByParenBlind.get(parenBlindIndexKey(ix));
648
+ // Only annotate a twin that is actually being dropped (no exact desired match), so the
649
+ // note names a live index that is really churning, not one that also round-trips.
650
+ const churning = twin && !desiredByKey.has(indexKey(twin));
651
+ creates.push(createIndexChange(d.table, ix, churning ? twin : undefined));
601
652
  } else if (ix.nameDeclared && live.name !== ix.name) {
602
653
  renames.push({ kind: 'renameIndex', table: d.table, from: live.name, to: ix.name });
603
654
  }
@@ -749,11 +800,19 @@ function constraintContentKey(c: ConstraintSpec): string {
749
800
  function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[]): void {
750
801
  const desiredByPred = new Map(d.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
751
802
  const currentByPred = new Map(c.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
803
+ // Paren-blind lookup, diagnostic only (see diffIndexes): a compound CHECK churns for the
804
+ // same reason — pg stores `((a) AND (b))`, the model writes `a AND b`.
805
+ const currentByParenBlind = new Map(c.checks.map((ck) => [stripAllParens(ck.expr), ck]));
752
806
  for (const [pred, ck] of currentByPred) {
753
807
  if (!desiredByPred.has(pred)) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: ck.name });
754
808
  }
755
809
  for (const [pred, ck] of desiredByPred) {
756
- if (!currentByPred.has(pred)) addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: { type: 'check', name: ck.name, expr: ck.expr } });
810
+ if (currentByPred.has(pred)) continue;
811
+ const twin = currentByParenBlind.get(stripAllParens(ck.expr));
812
+ // Annotate only a twin that is itself being dropped (no exact desired match) — a real churn.
813
+ const churning = twin && !desiredByPred.has(normalizeCheck(twin.expr));
814
+ const note = churning ? parenOnlyNote('check', ck.name, twin.name, twin.expr) : undefined;
815
+ addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: { type: 'check', name: ck.name, expr: ck.expr }, ...(note ? { note } : {}) });
757
816
  }
758
817
  }
759
818
 
@@ -937,7 +996,7 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
937
996
  return `${warn}\n${ddl}`;
938
997
  }
939
998
  case 'addConstraint':
940
- return addConstraintSql(change.table, change.constraint);
999
+ return (change.note ? `${change.note}\n` : '') + addConstraintSql(change.table, change.constraint);
941
1000
  case 'dropConstraint':
942
1001
  return `ALTER TABLE ${change.table} DROP CONSTRAINT IF EXISTS ${quote(change.name)};`;
943
1002
  case 'addPrimaryKey':
@@ -983,7 +1042,8 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
983
1042
  const entry = (c: string): string => (/^[a-z_][a-z0-9_$]*$/i.test(c) ? quote(c) : c);
984
1043
  const include = change.include?.length ? ` INCLUDE (${colList(change.include)})` : '';
985
1044
  const where = change.where ? ` WHERE ${change.where}` : '';
986
- return `CREATE ${unique}INDEX ${quote(change.name)} ON ${change.table}${using} (${change.columns.map(entry).join(', ')})${include}${where};`;
1045
+ const stmt = `CREATE ${unique}INDEX ${quote(change.name)} ON ${change.table}${using} (${change.columns.map(entry).join(', ')})${include}${where};`;
1046
+ return change.note ? `${change.note}\n${stmt}` : stmt;
987
1047
  }
988
1048
  case 'dropIndex': {
989
1049
  const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
@@ -324,6 +324,66 @@ export function fingerprintLive(
324
324
  return fingerprintState(snapshot, contract.tables, opts);
325
325
  }
326
326
 
327
+ // ---------------------------------------------------------------------------
328
+ // The retained-canon registry — recompute a live hash under a RECORDED version.
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /**
332
+ * A frozen canonicalizer: the exact live-side hash produced by ONE historical
333
+ * FINGERPRINT_VERSION. `fingerprintState` above always emits the CURRENT form, so it is the
334
+ * retained entry for the current version only. When the canon bumps, the outgoing form does
335
+ * NOT disappear — it is frozen as its own function and registered under its number here.
336
+ */
337
+ export type CanonHasher = (
338
+ snapshot: SchemaSnapshot,
339
+ tables: TableContract[],
340
+ opts: CanonicalizeOptions,
341
+ ) => string;
342
+
343
+ /**
344
+ * Every canon this CLI can still recompute, keyed by its FINGERPRINT_VERSION.
345
+ *
346
+ * WHY THIS EXISTS — the descent strand. `db:apply`'s fast-forward proof reads the
347
+ * `schema_log` ledger and compares each row's recorded `to_fingerprint` against the live
348
+ * state. A row's hash was computed under whatever canon was current at that apply, recorded
349
+ * as its `fp_version`. When the CLI later bumps the canon, comparing that old-format hash
350
+ * against a current-format live hash reports a phantom mismatch — the very kind of false
351
+ * refusal the ledger exists to prevent. So descent recomputes the LIVE side under the ROW'S
352
+ * canon, through this registry.
353
+ *
354
+ * THE STANDING RULE, so the strand cannot recur one bump later: when you increment
355
+ * FINGERPRINT_VERSION in this file, you MUST freeze the outgoing canon as a pure function and
356
+ * register it here under its (now retired) number, alongside the new current one. The frozen
357
+ * function is a self-contained copy of the canonicalization logic as it stood — it can never
358
+ * call back into the live `canonicalizeState`, which always emits the new form. A version
359
+ * absent from this map is unrecomputable: `fingerprintLiveAtVersion` returns null and descent
360
+ * SKIPS that row (fails closed), never guesses a hash.
361
+ *
362
+ * Today the map holds exactly the current version — there is nothing retired yet. It is the
363
+ * mechanism and the rule that ship now; the first retired entry lands with the first bump.
364
+ */
365
+ export const RETAINED_CANONS: Readonly<Record<number, CanonHasher>> = {
366
+ [FINGERPRINT_VERSION]: (snapshot, tables, opts) => fingerprintState(snapshot, tables, opts).hash,
367
+ };
368
+
369
+ /**
370
+ * The raw live fingerprint recomputed under a RECORDED canon version, or null when that
371
+ * version is not retained (unknown, future, or pre-fp_version). Null is the fail-closed
372
+ * answer: a descent row whose canon we cannot reproduce is skipped, never matched on a hash
373
+ * computed under the wrong form.
374
+ */
375
+ export function fingerprintLiveAtVersion(
376
+ version: number | null | undefined,
377
+ snapshot: SchemaSnapshot,
378
+ contract: AuthzContract,
379
+ opts: CanonicalizeOptions = {},
380
+ canons: Readonly<Record<number, CanonHasher>> = RETAINED_CANONS,
381
+ ): string | null {
382
+ if (version == null) return null;
383
+ const canon = canons[version];
384
+ return canon ? canon(snapshot, contract.tables, opts) : null;
385
+ }
386
+
327
387
  // ---------------------------------------------------------------------------
328
388
  // The honesty report: what the base fingerprint does NOT see.
329
389
  // ---------------------------------------------------------------------------
@@ -0,0 +1,70 @@
1
+ /**
2
+ * stageConfirmDeps — the AWS-glue executors confirmRollbackPoint needs on a STAGE venue, built
3
+ * once and shared by db:swap and db:apply (apply-legibility Phase 2).
4
+ *
5
+ * rollback-point.ts stays pure — it takes `confirmPhysical` / `confirmLogical` injected so its
6
+ * wait logic is provable without AWS. This is where those two are wired to the real calls
7
+ * (RDS snapshot + the db:backup Task), so neither destructive verb hand-rolls its own copy.
8
+ * `verb` only tunes the log wording ('swap' vs 'apply').
9
+ */
10
+
11
+ import { createRdsSnapshot, describeRdsSnapshots } from './aws';
12
+ import { rdsSnapshotIdentifier } from './rds-snapshot';
13
+ import { utcStamp } from './backup';
14
+ import { pollTaskUntilStopped } from './task-poll';
15
+ import { confirmPhysicalSnapshot, interpretBackupPoll } from './swap-snapshot';
16
+ import { runStageBackup, stageBackupDeps } from './stage-backup';
17
+ import type { ConfirmRollbackDeps } from './rollback-point';
18
+
19
+ export interface StageConfirmContext {
20
+ region: string;
21
+ /** `config.databaseInstanceId` — 'placeholder'/undefined means no physical snapshot is possible. */
22
+ instanceId?: string;
23
+ opsFn: string;
24
+ stage?: string;
25
+ /** The verb, for log wording only. */
26
+ verb: 'swap' | 'apply';
27
+ log: (msg: string) => void;
28
+ step: (msg: string) => void;
29
+ }
30
+
31
+ /** The `confirmPhysical` / `confirmLogical` half of ConfirmRollbackDeps for a stage venue. */
32
+ export function stageConfirmDeps(ctx: StageConfirmContext): Pick<ConfirmRollbackDeps, 'confirmPhysical' | 'confirmLogical'> {
33
+ return {
34
+ confirmPhysical: async (instanceId) => {
35
+ ctx.step(`Snapshotting the instance before the ${ctx.verb} (RDS physical snapshot of ${instanceId})...`);
36
+ const snapshotId = rdsSnapshotIdentifier(`${ctx.stage ?? ctx.verb}-${ctx.verb}`, utcStamp(new Date()));
37
+ return confirmPhysicalSnapshot(
38
+ {
39
+ create: (sid) => createRdsSnapshot(ctx.region, instanceId, sid),
40
+ describe: () => describeRdsSnapshots(ctx.region, instanceId),
41
+ log: (m) => ctx.log(m),
42
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
43
+ now: () => Date.now(),
44
+ },
45
+ { instanceId, snapshotId },
46
+ );
47
+ },
48
+ // Logical: dispatch the db:backup Task and WAIT. The dispatch returning is not the backup
49
+ // existing — that conflation is what let a pg_dump run concurrently with the operation it
50
+ // was meant to precede.
51
+ confirmLogical: async () => {
52
+ ctx.step(`Snapshotting the stage before the ${ctx.verb} (db:backup — waiting for the dump to finish)...`);
53
+ const backup = await runStageBackup({
54
+ ...stageBackupDeps({ region: ctx.region }, ctx.opsFn, ctx.stage, {
55
+ onStep: ctx.step,
56
+ onDispatch: (d: { id: string; runId: string }) =>
57
+ ctx.log(`backup ${d.id} dispatched (run ${d.runId}) — waiting for the task to stop before the ${ctx.verb} starts.`),
58
+ }),
59
+ awaitTask: async (ids) => {
60
+ const poll = await pollTaskUntilStopped(ctx.region, ctx.opsFn, { runId: ids.runId, taskArn: ids.taskArn });
61
+ const verdict = interpretBackupPoll(poll, { runId: ids.runId, id: ids.id });
62
+ if (!verdict.ok) return verdict;
63
+ return { ok: true, stampedFingerprint: (poll as any).status?.result?.fingerprint as string | undefined };
64
+ },
65
+ });
66
+ if (!backup.ok) return backup;
67
+ return { ok: true, id: backup.id, stampedFingerprint: backup.fingerprint, warning: backup.warning };
68
+ },
69
+ };
70
+ }