@everystack/cli 0.4.69 → 0.4.71
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 +73 -1
- package/README.md +2 -1
- 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-reconcile.ts +2 -0
- package/src/cli/commands/db-swap.ts +27 -54
- package/src/cli/commands/db.ts +142 -2
- package/src/cli/derived-apply.ts +21 -3
- 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
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>] [--
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* named state at both ends" — but nothing here needs a folder.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { execSync } from 'node:child_process';
|
|
20
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
21
21
|
import type { ModelDescriptor } from '@everystack/model';
|
|
22
22
|
import { introspectContract, type AuthzContract, type QueryRunner } from './authz-contract';
|
|
23
23
|
import type { SessionRunner } from './session';
|
|
@@ -25,7 +25,7 @@ import { introspectSchema, type SchemaSnapshot } from './schema-introspect';
|
|
|
25
25
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
|
|
26
26
|
import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate';
|
|
27
27
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
28
|
-
import { fingerprintLive } from './schema-fingerprint';
|
|
28
|
+
import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
|
|
29
29
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
|
|
30
30
|
|
|
31
31
|
const NOTICE_PREFIX = '-- NOTICE';
|
|
@@ -276,14 +276,49 @@ export function currentGitRef(): string | null {
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
/**
|
|
280
|
+
* True when the models the apply ran against differ from HEAD — a dirty mint.
|
|
281
|
+
*
|
|
282
|
+
* schema_log records HEAD (`currentGitRef`) as the commit that declares the applied state. That
|
|
283
|
+
* pointer is only honest if HEAD's committed models ARE what was applied. If the operator
|
|
284
|
+
* applied from a worktree with uncommitted model changes, HEAD does not declare plan.to — so
|
|
285
|
+
* the fast-forward proof must ignore the row (it treats a dirty row like a null git_ref).
|
|
286
|
+
* Scoped to the models path, so an unrelated dirty file (a README) does not disqualify the row.
|
|
287
|
+
* Not a git checkout → not dirty (git_ref is null there anyway).
|
|
288
|
+
*/
|
|
289
|
+
export function worktreeDirty(modelsPath: string, cwd: string = process.cwd()): boolean {
|
|
290
|
+
try {
|
|
291
|
+
const out = execFileSync('git', ['status', '--porcelain', '--', modelsPath], {
|
|
292
|
+
cwd,
|
|
293
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
294
|
+
}).toString();
|
|
295
|
+
return out.trim().length > 0;
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
279
301
|
export interface StateApplyOptions {
|
|
280
302
|
actor?: string | null;
|
|
281
303
|
gitRef?: string | null;
|
|
282
304
|
planRef?: string | null;
|
|
283
305
|
/** The live base fingerprint before this apply (schema_log's from-side). */
|
|
284
306
|
fromFingerprint?: string | null;
|
|
307
|
+
/** The canon the fingerprints were computed under. Defaults to the current FINGERPRINT_VERSION
|
|
308
|
+
* — the hashes this writer records are always current-canon — so the descent witness carries
|
|
309
|
+
* the version needed to recompute across a later bump. */
|
|
310
|
+
fpVersion?: number | null;
|
|
311
|
+
/** The apply ran from a dirty worktree (models differed from HEAD). The recorded git_ref then
|
|
312
|
+
* does not declare what was applied, so descent ignores the row. */
|
|
313
|
+
dirty?: boolean;
|
|
285
314
|
/** Injectable clock for tests. */
|
|
286
315
|
now?: () => number;
|
|
316
|
+
/**
|
|
317
|
+
* When the fast-forward rule was waived by a `--attest-lineage-lost` attestation, a short note read
|
|
318
|
+
* at WRITE time (a getter, because the attestation is decided in the descent step that runs before
|
|
319
|
+
* this) and folded into the `applied` schema_log outcome — the durable audit of a deliberate waiver.
|
|
320
|
+
*/
|
|
321
|
+
descentNote?: () => string | undefined;
|
|
287
322
|
}
|
|
288
323
|
|
|
289
324
|
export interface StateApplyResult {
|
|
@@ -327,16 +362,19 @@ export async function applyGeneratedStatements(
|
|
|
327
362
|
outcome: `failed: ${String(err?.message ?? err).slice(0, 500)}`,
|
|
328
363
|
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
329
364
|
fromFingerprint: options.fromFingerprint,
|
|
365
|
+
fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
|
|
330
366
|
durationMs: now() - started,
|
|
331
367
|
}));
|
|
332
368
|
} catch { /* the memoir is best-effort on failure */ }
|
|
333
369
|
throw err;
|
|
334
370
|
}
|
|
335
371
|
|
|
372
|
+
const note = options.descentNote?.();
|
|
336
373
|
const logRows = await runner(renderSchemaLogInsert({
|
|
337
|
-
kind: 'state apply', sql: batchSql, outcome: 'applied',
|
|
374
|
+
kind: 'state apply', sql: batchSql, outcome: note ? `applied — ${note}` : 'applied',
|
|
338
375
|
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
339
376
|
fromFingerprint: options.fromFingerprint,
|
|
377
|
+
fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
|
|
340
378
|
durationMs: now() - started,
|
|
341
379
|
}));
|
|
342
380
|
const logId = logRows?.[0]?.id !== undefined ? Number(logRows[0].id) : undefined;
|
package/src/cli/swap-snapshot.ts
CHANGED
|
@@ -33,6 +33,8 @@ export type SnapshotDecision =
|
|
|
33
33
|
| { mode: 'logical' }
|
|
34
34
|
/** The operator already took one and named it — nothing to do but record it. */
|
|
35
35
|
| { mode: 'attested'; ref: string }
|
|
36
|
+
/** Take our OWN local `pg_dump -Fc` and verify it (bare --database-url, db:apply only). */
|
|
37
|
+
| { mode: 'local' }
|
|
36
38
|
/** The operator explicitly accepted no rollback point. */
|
|
37
39
|
| { mode: 'none' }
|
|
38
40
|
/** Nothing safe is available and no consent was given — refuse before anything moves. */
|
|
@@ -51,6 +53,13 @@ export interface SnapshotDecisionInput {
|
|
|
51
53
|
snapshotRef?: string;
|
|
52
54
|
/** `--snapshot <physical|logical|none>`. */
|
|
53
55
|
requested?: SnapshotModeRequest;
|
|
56
|
+
/**
|
|
57
|
+
* Bare-URL only: may the tool take its OWN local `pg_dump -Fc` as the rollback point? db:apply
|
|
58
|
+
* passes true (REVERSAL 1 — bare-URL apply takes+verifies a local dump); db:swap leaves it false, so
|
|
59
|
+
* a bare-URL swap still REFUSES without an attested ref (a multi-GB schema restore is not a local
|
|
60
|
+
* pg_dump's job). Default false keeps the shared decision unchanged for db:swap.
|
|
61
|
+
*/
|
|
62
|
+
localDump?: boolean;
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
/** Is this instance id something we can actually snapshot, or an absent/placeholder output? */
|
|
@@ -73,7 +82,7 @@ function usableInstanceId(id?: string): id is string {
|
|
|
73
82
|
* is optional in practice.
|
|
74
83
|
*/
|
|
75
84
|
export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecision {
|
|
76
|
-
const { venue, instanceId, snapshotRef, requested } = input;
|
|
85
|
+
const { venue, instanceId, snapshotRef, requested, localDump } = input;
|
|
77
86
|
|
|
78
87
|
if (requested !== undefined && !SNAPSHOT_MODES.includes(requested)) {
|
|
79
88
|
return {
|
|
@@ -113,6 +122,11 @@ export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecisi
|
|
|
113
122
|
|
|
114
123
|
if (requested === 'logical') {
|
|
115
124
|
if (venue === 'url') {
|
|
125
|
+
// On the venue that CAN take its own local dump (db:apply), an explicit `--snapshot logical`
|
|
126
|
+
// means exactly that — a pg_dump rollback point — so honour it as the local dump. Where the
|
|
127
|
+
// local dump is not offered (db:swap), the stage's Task lane is the only logical backup and a
|
|
128
|
+
// bare URL has no stage, so refuse.
|
|
129
|
+
if (localDump) return { mode: 'local' };
|
|
116
130
|
return {
|
|
117
131
|
mode: 'refuse',
|
|
118
132
|
reason: 'A logical snapshot runs in the stage\'s Task lane via the ops Lambda, and a bare --database-url has no stage behind it. '
|
|
@@ -127,6 +141,9 @@ export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecisi
|
|
|
127
141
|
if (venue === 'stage') {
|
|
128
142
|
return usableInstanceId(instanceId) ? { mode: 'physical', instanceId } : { mode: 'logical' };
|
|
129
143
|
}
|
|
144
|
+
// Bare --database-url. Where the tool takes its own local dump (db:apply), that is the default; where
|
|
145
|
+
// it does not (db:swap), a destructive run over a bare URL still refuses without an attested ref.
|
|
146
|
+
if (localDump) return { mode: 'local' };
|
|
130
147
|
return {
|
|
131
148
|
mode: 'refuse',
|
|
132
149
|
reason: 'db:swap over a bare --database-url does not take a snapshot for you, and it will not run destructively without one. '
|