@everystack/cli 0.4.30 → 0.4.31

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.
@@ -22,6 +22,7 @@
22
22
  import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
23
23
  import { invokeAction, presignGet } from '../aws.js';
24
24
  import { keyForId, parseBackupRef, isProductionTier, crossStageGuard } from '../backup.js';
25
+ import { pollTaskUntilStopped } from '../task-poll.js';
25
26
  import { step, success, fail, info, warn } from '../output.js';
26
27
 
27
28
  export type ForkGuardVerdict =
@@ -92,13 +93,19 @@ export async function dbForkCommand(flags: Record<string, string>): Promise<void
92
93
  }
93
94
  info(`Reusing backup ${backupId}.`);
94
95
  } else {
95
- step(`Backing up ${from} (pg_dump → S3; may take a while for large databases)...`);
96
- const result: any = await invokeAction(source.region, opsFunction(source), 'db:backup', { stage: from });
97
- if (result?.error) {
98
- fail(`Source backup failed: ${result.error}`);
96
+ step(`Backing up ${from} (pg_dump → S3 in the Task; may take a while for large databases)...`);
97
+ const dispatched: any = await invokeAction(source.region, opsFunction(source), 'db:backup', { stage: from, actor: process.env.USER ?? null });
98
+ if (dispatched?.error) {
99
+ fail(`Source backup failed: ${dispatched.error}`);
100
+ process.exit(1);
101
+ }
102
+ backupId = dispatched.id;
103
+ const poll = await pollTaskUntilStopped(source.region, opsFunction(source), { runId: dispatched.runId, taskArn: dispatched.taskArn });
104
+ if (poll.outcome !== 'stopped' || poll.status.exitCode !== 0) {
105
+ const why = poll.outcome === 'stopped' ? `exit ${poll.status.exitCode}${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}` : poll.outcome;
106
+ fail(`Source backup failed (${why}). Reconcile run ${dispatched.runId}.`);
99
107
  process.exit(1);
100
108
  }
101
- backupId = result.id;
102
109
  info(`Backup ${backupId}.`);
103
110
  }
104
111
 
@@ -121,15 +128,22 @@ export async function dbForkCommand(flags: Record<string, string>): Promise<void
121
128
  fail(`${err.message}\nDeploy the feature stage first: sst deploy --stage ${target}`);
122
129
  process.exit(1);
123
130
  }
124
- step(`Restoring into ${target} (streamed; replaces its database)...`);
131
+ step(`Restoring into ${target} (pg_restore in the Task; replaces its database)...`);
125
132
  const restored: any = await invokeAction(targetConfig.region, opsFunction(targetConfig), 'db:restore', {
126
133
  url,
127
134
  confirm: true,
135
+ actor: process.env.USER ?? null,
128
136
  });
129
137
  if (restored?.error) {
130
138
  fail(`Restore failed: ${restored.error}`);
131
139
  process.exit(1);
132
140
  }
141
+ const rPoll = await pollTaskUntilStopped(targetConfig.region, opsFunction(targetConfig), { runId: restored.runId, taskArn: restored.taskArn });
142
+ if (rPoll.outcome !== 'stopped' || rPoll.status.exitCode !== 0) {
143
+ const why = rPoll.outcome === 'stopped' ? `exit ${rPoll.status.exitCode}${rPoll.status.stoppedReason ? ` — ${rPoll.status.stoppedReason}` : ''}` : rPoll.outcome;
144
+ fail(`Restore failed (${why}). Intent ${restored.intentKey} — the target DB may be partially restored.`);
145
+ process.exit(1);
146
+ }
133
147
 
134
148
  success(`Forked ${from} → ${target} (backup ${backupId}).`);
135
149
  info('Next:');
@@ -38,11 +38,10 @@ export async function deployCommand(flags: Record<string, string>): Promise<void
38
38
  const args = buildDeployArgs(flags);
39
39
 
40
40
  step(`Deploying stage "${stage}"...`);
41
- // PGDUMP_LAYER_ARN is deprecated: the layer now ships via @everystack/pg-tools and pgDumpLayer()
42
- // needs no env var. Still forwarded to sst for one more minor (pgDumpLayer honors it as an alias),
43
- // then removed — surface it loudly so the operator migrates off it.
41
+ // The pg_dump Lambda layer is gone all pg-binary work (backup/export/restore) runs in the
42
+ // ephemeral Fargate Task lane. PGDUMP_LAYER_ARN no longer does anything; flag it so it's dropped.
44
43
  if (process.env.PGDUMP_LAYER_ARN) {
45
- warn('PGDUMP_LAYER_ARN is DEPRECATED and will be removed next minor. The pg_dump layer now ships via @everystack/pg-tools + pgDumpLayer() with no env var. Drop it once your sst.config.ts uses pgDumpLayer().');
44
+ warn('PGDUMP_LAYER_ARN is set but IGNORED the pg_dump layer was removed; pg-binary work runs in the Fargate Task lane. Drop the env var.');
46
45
  }
47
46
  await new Promise<void>((resolve) => {
48
47
  // `npx` resolves the consumer's local sst (the same way the export step runs expo).
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `everystack task:probe --stage <name>` — the ephemeral Task lane's smoke test.
3
+ *
4
+ * Dispatches the version-handshake task (via the ops Lambda's `task:probe` action — the operator
5
+ * holds no URL), then polls `task:status` until the task stops. A pass proves the whole substrate
6
+ * end to end: the task started, resolved its injected credential, reached the private DB, and the
7
+ * image's pg tools are compatible. Phase 3+ reuses this dispatch/poll for real pg_dump/pg_restore.
8
+ *
9
+ * The poll is BOUNDED (a deadline) — a Fargate task can sit in PROVISIONING/PENDING on capacity or
10
+ * ENI trouble, and a naked loop would hang the CLI. On deadline it reports the last status and the
11
+ * run id so the operator can reconcile (the task_log row + ECS both carry it).
12
+ */
13
+
14
+ import { resolveConfig, opsFunction } from '../config.js';
15
+ import { invokeAction } from '../aws.js';
16
+ import { pollTaskUntilStopped } from '../task-poll.js';
17
+ import { step, success, fail, info } from '../output.js';
18
+
19
+ const DEADLINE_MS = 10 * 60_000; // 10 min — Fargate cold start + pull + handshake, with headroom.
20
+
21
+ export async function taskProbeCommand(flags: Record<string, string>): Promise<void> {
22
+ const stage = flags.stage;
23
+ if (!stage) {
24
+ fail('task:probe needs --stage <name> (the deployed stage whose Task lane to probe).');
25
+ process.exit(1);
26
+ }
27
+
28
+ step('Resolving deployed config...');
29
+ const config = await resolveConfig(stage);
30
+ const fn = opsFunction(config);
31
+
32
+ step('Dispatching the handshake task (credential-free, via the ops Lambda)...');
33
+ const dispatched: any = await invokeAction(config.region, fn, 'task:probe', {
34
+ kind: 'probe', actor: process.env.USER ?? null, stage,
35
+ });
36
+ if (dispatched?.error) {
37
+ fail(`task:probe failed to dispatch: ${dispatched.error}`);
38
+ if (/Unknown action/i.test(String(dispatched.error))) {
39
+ info('The deployed handler predates the Task lane. Deploy the dbTask() wiring first (Step 5).');
40
+ }
41
+ process.exit(1);
42
+ }
43
+ const { runId, taskArn, warning } = dispatched as { runId: string; taskArn: string; warning?: string };
44
+ info(`task started: ${taskArn}`);
45
+ info(`run id: ${runId} (everystack.task_log)`);
46
+ if (warning) info(`note: ${warning}`);
47
+
48
+ step('Polling until the task stops...');
49
+ const poll = await pollTaskUntilStopped(config.region, fn, { runId, taskArn }, { deadlineMs: DEADLINE_MS });
50
+ if (poll.outcome === 'timeout') {
51
+ fail(`task:probe timed out after ${DEADLINE_MS / 60_000} min (last status: ${poll.lastStatus}). Run id ${runId} — reconcile via ECS/task_log.`);
52
+ process.exit(1);
53
+ }
54
+ if (poll.outcome === 'error') {
55
+ fail(`task:status failed repeatedly: ${poll.status.error}`);
56
+ info(`The task may still be running — reconcile run id ${runId} via ECS/everystack.task_log.`);
57
+ process.exit(1);
58
+ }
59
+ if (poll.status.exitCode === 0) {
60
+ success('Task lane verified: the handshake passed (image pg tools are compatible with the live server).');
61
+ return;
62
+ }
63
+ fail(`Task stopped with exit ${poll.status.exitCode ?? 'unknown'}${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}.`);
64
+ info('Read the task logs (CloudWatch) for the handshake verdict JSON.');
65
+ process.exit(1);
66
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * exec-digest — the catalog content-address that makes db:exec DML-only WITHOUT a role, a
3
+ * superuser, or a regex. A self-contained SQL query that md5s the schema-relevant catalog
4
+ * (pg_class / pg_attribute / pg_constraint / pg_index / pg_proc / pg_type) for the given app
5
+ * schemas. db:exec takes this digest before and after the file inside one transaction; any
6
+ * difference is a schema change — however it was caused (a CREATE/ALTER/DROP, a function, a DO
7
+ * block, dynamic SQL) — and rolls the whole transaction back.
8
+ *
9
+ * The guarantee rests on TWO properties of the projection:
10
+ * - It sees every way the schema can move: relation kind, every column's number/type/typmod/
11
+ * not-null, the full textual def of every constraint and index, each routine's args + body
12
+ * hash, and each type's kind + ordered enum labels. A column TYPE change moves atttypid/
13
+ * atttypmod; a DROP removes rows; a CREATE adds them; a hidden CREATE inside a called function
14
+ * lands a new pg_class/pg_type row before the after-digest is taken.
15
+ * - It is STABLE across pure DML. It deliberately excludes the volatile planner-stats columns
16
+ * (reltuples, relpages, relallvisible, relfrozenxid, …) that INSERT/UPDATE/DELETE and
17
+ * autovacuum move — so an INSERT never trips the guard, only a catalog change does.
18
+ *
19
+ * This reuses everystack's own live-catalog content-addressing idea (schema-fingerprint), scoped
20
+ * to the exact question db:exec asks: "did the schema move at all?" — not "does it match the
21
+ * models?". Server-side, no CLI/model dependency: a plain string that any runner can execute.
22
+ */
23
+
24
+ import { escapeLiteral } from './derived-apply.js';
25
+
26
+ /**
27
+ * Build the digest query for `schemas`. Returns SQL selecting a single `digest` column (md5 hex,
28
+ * or md5('') for an empty catalog). The schemas are embedded as a quoted `text[]` literal — they
29
+ * come from the deploy's declared schema set, never user input, but they are escaped regardless.
30
+ */
31
+ export function catalogDigestQuery(schemas: string[]): string {
32
+ if (schemas.length === 0) {
33
+ throw new Error('catalogDigestQuery needs at least one schema to digest — pass the app schemas.');
34
+ }
35
+ const arr = `ARRAY[${schemas.map(escapeLiteral).join(', ')}]::text[]`;
36
+ return `
37
+ SELECT md5(coalesce(string_agg(line, E'\\n' ORDER BY line), '')) AS digest
38
+ FROM (
39
+ -- Relations: kind + persistence ONLY, never the volatile planner-stats columns (row-count,
40
+ -- page-count, freeze-horizon) — those move under INSERT/UPDATE/DELETE and autovacuum, and
41
+ -- would make the digest lie about pure DML.
42
+ SELECT format('rel:%s.%s:%s:%s', n.nspname, c.relname, c.relkind, c.relpersistence) AS line
43
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
44
+ WHERE n.nspname = ANY(${arr}) AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
45
+ UNION ALL
46
+ -- Columns: number, type, typmod, not-null — the shape ALTER TABLE moves, DML never.
47
+ SELECT format('att:%s.%s.%s:%s:%s:%s:%s',
48
+ n.nspname, c.relname, a.attname, a.attnum, a.atttypid, a.atttypmod, a.attnotnull)
49
+ FROM pg_attribute a
50
+ JOIN pg_class c ON c.oid = a.attrelid
51
+ JOIN pg_namespace n ON n.oid = c.relnamespace
52
+ WHERE n.nspname = ANY(${arr}) AND a.attnum > 0 AND NOT a.attisdropped
53
+ UNION ALL
54
+ -- Constraints: the full textual definition (PK/FK/unique/check predicate).
55
+ SELECT format('con:%s.%s:%s', n.nspname, con.conname, pg_get_constraintdef(con.oid))
56
+ FROM pg_constraint con JOIN pg_namespace n ON n.oid = con.connamespace
57
+ WHERE n.nspname = ANY(${arr})
58
+ UNION ALL
59
+ -- Indexes: the full textual definition (columns, uniqueness, partial WHERE).
60
+ SELECT format('idx:%s.%s:%s', n.nspname, ic.relname, pg_get_indexdef(i.indexrelid))
61
+ FROM pg_index i
62
+ JOIN pg_class ic ON ic.oid = i.indexrelid
63
+ JOIN pg_namespace n ON n.oid = ic.relnamespace
64
+ WHERE n.nspname = ANY(${arr})
65
+ UNION ALL
66
+ -- Routines: name + arg types + a hash of the body. CREATE/REPLACE/DROP FUNCTION all move it.
67
+ SELECT format('proc:%s.%s:%s:%s', n.nspname, p.proname, p.proargtypes::text, md5(coalesce(p.prosrc, '')))
68
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
69
+ WHERE n.nspname = ANY(${arr})
70
+ UNION ALL
71
+ -- Types: kind + ordered enum labels. CREATE TYPE / ALTER TYPE ADD VALUE move it.
72
+ SELECT format('type:%s.%s:%s:%s', n.nspname, t.typname, t.typtype,
73
+ coalesce((SELECT string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder)
74
+ FROM pg_enum e WHERE e.enumtypid = t.oid), ''))
75
+ FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
76
+ WHERE n.nspname = ANY(${arr})
77
+ ) s
78
+ `.trim();
79
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * exec-execute — the db:exec decision spine: credential-free write SQL applied as ONE transaction,
3
+ * bracketed by a crash-truthful audit (intent committed BEFORE the run, outcome AFTER). The IO —
4
+ * audit persistence and the SQL run (single txn, txn rails, rows-affected) — is injected, so this
5
+ * spine is proven without a DB.
6
+ *
7
+ * db:exec runs through the SAME ops connection db:seed uses (opsDb) — no new role, no BYPASSRLS,
8
+ * no superuser dependency; it inherits whatever RLS-bypass the deploy's admin connection already has.
9
+ * DML-only is enforced SEMANTICALLY, in the database, by the concrete run: the file executes in one
10
+ * transaction, and a catalog digest taken before vs after detects ANY schema change (however caused —
11
+ * a function, a DO block, dynamic SQL). If the schema moved, the file did DDL → the whole transaction
12
+ * ROLLS BACK and is rejected. No regex, no string-matching — the guard compares the actual catalog.
13
+ * This spine owns the crash-truthful audit ordering and the single-transaction invariant; the digest
14
+ * guard lives in the injected run (it needs the DB).
15
+ */
16
+
17
+ import { createHash } from 'node:crypto';
18
+ import { lex } from './derived-source.js';
19
+
20
+ export interface ExecIntent {
21
+ /** sha256 of the exact file bytes — content identity + a free join to a consumer's stage sha. */
22
+ sha: string;
23
+ actor: string;
24
+ stage: string;
25
+ }
26
+
27
+ export interface ExecOutcome {
28
+ status: 'succeeded' | 'failed';
29
+ /** Per-statement rows-affected (feeds a consumer's row-count verify). */
30
+ rowsAffected?: number[];
31
+ error?: string;
32
+ }
33
+
34
+ export interface ExecuteExecOptions {
35
+ /** The SQL file content. */
36
+ sql: string;
37
+ actor: string;
38
+ stage: string;
39
+ /** Persist the intent row and return its id. MUST be committed before run() (crash-truthful). */
40
+ writeIntent: (intent: ExecIntent) => Promise<string>;
41
+ /** Run the SQL as the exec role in ONE transaction; returns per-statement rows-affected. */
42
+ run: () => Promise<number[]>;
43
+ /** Persist the terminal outcome against the intent id. */
44
+ writeOutcome: (id: string, outcome: ExecOutcome) => Promise<void>;
45
+ }
46
+
47
+ export interface ExecResult {
48
+ id: string;
49
+ status: 'succeeded';
50
+ rowsAffected: number[];
51
+ }
52
+
53
+ /** Statement-boundary transaction-control keywords — the spine owns the single txn, a file must not. */
54
+ const TXN_CONTROL = /(^|;)\s*(begin|start\s+transaction|commit|rollback|savepoint|release\s+savepoint)\b/i;
55
+
56
+ /**
57
+ * Blank comments and string/dollar-quoted content so the txn-control regex reads STRUCTURE, not
58
+ * payload — a `BEGIN` inside a plpgsql function body (`$$ DECLARE x int; BEGIN … $$`), a DO block,
59
+ * or a string literal is not transaction control and must not be rejected. Only real top-level
60
+ * BEGIN/COMMIT/SAVEPOINT survive into the skeleton. (Same lexing the reconciler/backfill use.)
61
+ */
62
+ function txnControlSkeleton(sql: string): string {
63
+ let out = '';
64
+ lex(sql, (ch, state) => {
65
+ if (state === 'line-comment' || state === 'block-comment') return;
66
+ if (state === 'single' || state === 'dollar') return; // quoted/dollar content blanked
67
+ out += ch;
68
+ });
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Reject a file that opens/closes its own transaction. db:exec wraps the whole file in ONE
74
+ * transaction (all-or-nothing, with the before/after catalog-digest guard); an embedded
75
+ * BEGIN/COMMIT would break that atomicity. A pure, courtesy guard — the real DML-only guarantee
76
+ * is the semantic catalog digest, not keyword matching — so it reads the skeleton, never the
77
+ * payload, and never false-positives on a BEGIN inside a function body.
78
+ */
79
+ export function assertNoTxnControl(sql: string): void {
80
+ if (TXN_CONTROL.test(txnControlSkeleton(sql))) {
81
+ throw new Error(
82
+ 'db:exec runs the whole file as ONE transaction — remove embedded BEGIN/COMMIT/ROLLBACK/SAVEPOINT (the file is atomic; the spine owns the transaction).',
83
+ );
84
+ }
85
+ }
86
+
87
+ /** sha256 hex of the exact file bytes — the content identity stamped on the audit row. */
88
+ export function execSha(sql: string): string {
89
+ return createHash('sha256').update(sql).digest('hex');
90
+ }
91
+
92
+ /**
93
+ * Run the ceremony: reject self-transacting files → commit an intent row → run → record the outcome.
94
+ * A run that throws still records a FAILED outcome (never a silent loss), then rethrows.
95
+ */
96
+ export async function executeExec(opts: ExecuteExecOptions): Promise<ExecResult> {
97
+ // Reject a self-transacting file BEFORE any record is written — db:exec wraps the whole file in one
98
+ // transaction (with the before/after catalog-digest guard), so an embedded BEGIN/COMMIT would break
99
+ // both the atomicity and the guard. The DDL check itself is semantic and lives in the injected run.
100
+ assertNoTxnControl(opts.sql);
101
+
102
+ const sha = execSha(opts.sql);
103
+ // Intent FIRST, committed before the run: a run that dies mid-flight still leaves a truthful
104
+ // record of what was attempted (the M1 fix for the #25 crash-hole).
105
+ const id = await opts.writeIntent({ sha, actor: opts.actor, stage: opts.stage });
106
+
107
+ try {
108
+ const rowsAffected = await opts.run();
109
+ await opts.writeOutcome(id, { status: 'succeeded', rowsAffected });
110
+ return { id, status: 'succeeded', rowsAffected };
111
+ } catch (err: any) {
112
+ await opts.writeOutcome(id, { status: 'failed', error: String(err?.message ?? err) });
113
+ throw err;
114
+ }
115
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * exec-log — the db:exec ledger (`everystack.exec_log`), a per-database record of every file run.
3
+ *
4
+ * The audit is CRASH-TRUTHFUL: the intent row (sha, actor, stage, started_at) is committed on its
5
+ * own — BEFORE the file's transaction opens — so a run that dies mid-flight (SIGKILL, a Lambda
6
+ * timeout) still leaves a truthful record of what was attempted. The M1 fix for the #25 crash-hole.
7
+ * The outcome (status, per-statement rows-affected, finished_at, error) is written AFTER, against
8
+ * the intent's id. The ledger lives in the `everystack` schema — never in the app schemas the
9
+ * digest guard covers — so it is invisible to the guard and untouched by the file txn's rollback.
10
+ *
11
+ * Mirrors everystack.backfill_log: a per-database ledger the way schema_log is a write-only memoir.
12
+ */
13
+
14
+ import { escapeLiteral } from './derived-apply.js';
15
+
16
+ export const ENSURE_EXEC_LOG_SQL: string[] = [
17
+ 'CREATE SCHEMA IF NOT EXISTS everystack',
18
+ `CREATE TABLE IF NOT EXISTS everystack.exec_log (
19
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
20
+ started_at timestamptz NOT NULL DEFAULT now(),
21
+ finished_at timestamptz,
22
+ sha text NOT NULL,
23
+ actor text,
24
+ stage text,
25
+ status text NOT NULL DEFAULT 'started',
26
+ rows_affected integer[],
27
+ error text
28
+ )`,
29
+ ];
30
+
31
+ export interface ExecIntentRow {
32
+ /** sha256 of the exact file bytes — content identity + a free join to a consumer's stage sha. */
33
+ sha: string;
34
+ actor?: string | null;
35
+ stage?: string | null;
36
+ }
37
+
38
+ export interface ExecOutcomeRow {
39
+ status: 'succeeded' | 'failed';
40
+ /** Per-statement rows-affected (feeds a consumer's row-count verify). */
41
+ rowsAffected?: number[];
42
+ error?: string | null;
43
+ }
44
+
45
+ const opt = (v: string | null | undefined): string => (v == null ? 'NULL' : escapeLiteral(v));
46
+
47
+ /** The intent INSERT — committed BEFORE the run. RETURNING id so the outcome can update this row. */
48
+ export function renderExecIntentInsert(intent: ExecIntentRow): string {
49
+ return `INSERT INTO everystack.exec_log (sha, actor, stage, status)
50
+ VALUES (${escapeLiteral(intent.sha)}, ${opt(intent.actor)}, ${opt(intent.stage)}, 'started')
51
+ RETURNING id`;
52
+ }
53
+
54
+ /** The terminal outcome UPDATE against the intent id. */
55
+ export function renderExecOutcomeUpdate(id: string, outcome: ExecOutcomeRow): string {
56
+ const rows =
57
+ outcome.rowsAffected == null
58
+ ? 'NULL'
59
+ : `ARRAY[${outcome.rowsAffected.map((n) => String(Math.trunc(n))).join(', ')}]::integer[]`;
60
+ return `UPDATE everystack.exec_log
61
+ SET status = ${escapeLiteral(outcome.status)}, rows_affected = ${rows}, error = ${opt(outcome.error)}, finished_at = now()
62
+ WHERE id = ${escapeLiteral(id)}`;
63
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * exec-run — the concrete db:exec run, inside ONE transaction, guarded by the catalog digest.
3
+ *
4
+ * `runExecInTx` runs against a transaction that the caller has already opened (the `run` closure
5
+ * is pinned to a single connection — drizzle `.transaction()` in the ops Lambda, postgres.js
6
+ * `sql.begin` on the direct path). It:
7
+ * 1. sets the txn rails (statement/lock/idle timeouts) so a runaway file can't wedge the plane,
8
+ * 2. digests the app-schema catalog BEFORE the file,
9
+ * 3. executes every statement in order, capturing per-statement rows-affected,
10
+ * 4. digests AFTER, and
11
+ * 5. throws SchemaChangedError if the digest moved — the file did DDL, so the caller ROLLS the
12
+ * whole transaction back and nothing lands.
13
+ *
14
+ * This is the semantic DML-only enforcement: not a keyword regex (theatre — `SELECT ddl_fn()`,
15
+ * `DO $$…$$`, dynamic SQL slip past) but a before/after comparison of the actual catalog. It
16
+ * needs no BYPASSRLS role and no superuser — it inherits the ops connection db:seed already uses.
17
+ *
18
+ * The orchestration is driver-agnostic and unit-tested with a fake `run`; the real behaviour
19
+ * (against PostgreSQL) is in __tests__/integration/exec.test.ts.
20
+ */
21
+
22
+ import { splitSqlStatements } from './derived-source.js';
23
+ import { catalogDigestQuery } from './exec-digest.js';
24
+
25
+ /** The normalized result of one statement: its rows and how many it affected. */
26
+ export interface ExecStmtResult {
27
+ rows: any[];
28
+ count: number;
29
+ }
30
+
31
+ /** A statement runner pinned to the open transaction (one connection). */
32
+ export type TxRunner = (stmt: string) => Promise<ExecStmtResult>;
33
+
34
+ /** The DML-only violation: the file moved the schema catalog, so the whole txn was rolled back. */
35
+ export class SchemaChangedError extends Error {
36
+ readonly before: string;
37
+ readonly after: string;
38
+ constructor(before: string, after: string) {
39
+ super(
40
+ `db:exec is DML-only — the file changed the schema catalog (digest ${before.slice(0, 12)} → ${after.slice(0, 12)}). ` +
41
+ 'The whole transaction was rolled back; nothing was applied. Schema changes go through db:apply / db:reconcile, not db:exec.',
42
+ );
43
+ this.name = 'SchemaChangedError';
44
+ this.before = before;
45
+ this.after = after;
46
+ }
47
+ }
48
+
49
+ /** Transaction rails — a file can't hang the plane or block behind a lock forever. */
50
+ export interface ExecRails {
51
+ statementTimeoutMs?: number;
52
+ lockTimeoutMs?: number;
53
+ idleInTxnMs?: number;
54
+ }
55
+
56
+ export const EXEC_RAIL_DEFAULTS: Required<ExecRails> = {
57
+ statementTimeoutMs: 300_000,
58
+ lockTimeoutMs: 30_000,
59
+ idleInTxnMs: 60_000,
60
+ };
61
+
62
+ /** The SET LOCAL rail statements — SET LOCAL so they die with the transaction, never leak. */
63
+ export function railStatements(rails: ExecRails = {}): string[] {
64
+ const r = { ...EXEC_RAIL_DEFAULTS, ...rails };
65
+ return [
66
+ `SET LOCAL statement_timeout = ${Math.trunc(r.statementTimeoutMs)}`,
67
+ `SET LOCAL lock_timeout = ${Math.trunc(r.lockTimeoutMs)}`,
68
+ `SET LOCAL idle_in_transaction_session_timeout = ${Math.trunc(r.idleInTxnMs)}`,
69
+ ];
70
+ }
71
+
72
+ export interface RunExecInTxOptions {
73
+ /** The app schemas the digest guard covers (the deploy's declared set). */
74
+ schemas: string[];
75
+ rails?: ExecRails;
76
+ }
77
+
78
+ /**
79
+ * Run the file inside the caller's open transaction. Returns per-statement rows-affected on
80
+ * success; throws SchemaChangedError (→ caller rolls back) if the file changed the catalog.
81
+ */
82
+ export async function runExecInTx(
83
+ run: TxRunner,
84
+ fileSql: string,
85
+ opts: RunExecInTxOptions,
86
+ ): Promise<number[]> {
87
+ for (const rail of railStatements(opts.rails)) await run(rail);
88
+
89
+ const digestQuery = catalogDigestQuery(opts.schemas);
90
+ const before = readDigest(await run(digestQuery));
91
+
92
+ const rowsAffected: number[] = [];
93
+ for (const stmt of splitSqlStatements(fileSql)) {
94
+ const res = await run(stmt);
95
+ rowsAffected.push(res.count);
96
+ }
97
+
98
+ const after = readDigest(await run(digestQuery));
99
+ if (before !== after) throw new SchemaChangedError(before, after);
100
+ return rowsAffected;
101
+ }
102
+
103
+ function readDigest(res: ExecStmtResult): string {
104
+ const digest = res.rows?.[0]?.digest;
105
+ if (typeof digest !== 'string') {
106
+ throw new Error('catalog digest query returned no digest — cannot enforce DML-only; refusing to run.');
107
+ }
108
+ return digest;
109
+ }
package/src/cli/index.ts CHANGED
@@ -21,11 +21,13 @@ import { dbApplyCommand } from './commands/db-apply.js';
21
21
  import { dbCheckCommand } from './commands/db-check.js';
22
22
  import { dbApproversCommand } from './commands/db-approvers.js';
23
23
  import { dbBackfillCommand } from './commands/db-backfill.js';
24
+ import { dbExecCommand } from './commands/db-exec.js';
25
+ import { taskProbeCommand } from './commands/task-probe.js';
24
26
  import { pipelineRunCommand, pipelineListCommand } from './commands/pipeline-run.js';
25
27
  import { dbTemplateRefreshCommand, dbBranchCommand } from './commands/db-branch.js';
26
28
  import { dbForkCommand } from './commands/db-fork.js';
27
29
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
28
- import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
30
+ import { dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
29
31
  import { dbExportCommand } from './commands/db-export.js';
30
32
  import { dbSwapCommand } from './commands/db-swap.js';
31
33
  import { consoleCommand } from './commands/console.js';
@@ -172,9 +174,6 @@ async function main() {
172
174
  case 'db:snapshots':
173
175
  await dbSnapshotsCommand(flags);
174
176
  break;
175
- case 'db:backup:probe':
176
- await dbBackupProbeCommand(flags);
177
- break;
178
177
  case 'db:backup':
179
178
  await dbBackupCommand(flags);
180
179
  break;
@@ -231,6 +230,15 @@ async function main() {
231
230
  case 'db:backfill':
232
231
  await dbBackfillCommand(flags);
233
232
  break;
233
+ case 'db:exec': {
234
+ // The SQL file is a positional (`db:exec <file.sql>`); `-`/absent reads stdin.
235
+ const file = args[1] && !args[1].startsWith('-') ? args[1] : undefined;
236
+ await dbExecCommand(flags, file);
237
+ break;
238
+ }
239
+ case 'task:probe':
240
+ await taskProbeCommand(flags);
241
+ break;
234
242
  case 'pipeline:run':
235
243
  await pipelineRunCommand(flags);
236
244
  break;
@@ -360,7 +368,6 @@ Usage:
360
368
  everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). No flag = ops-Lambda venue (auto-falls to direct via the ADMIN_DATABASE_URL secret if the handler has no dbPlugin). --direct = direct connection reading that secret (master never on argv); --database-url = explicit URL. Declared schemas are set as the ROLE default (ALTER ROLE … SET search_path) — the secret stays credentials-only, and the URL is libpq-clean
361
369
  everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
362
370
  everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
363
- everystack db:backup:probe [--stage <name>] Verify the pg_dump layer is attached + version-compatible with the server
364
371
  everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id)
365
372
  everystack db:backups [--stage <name>] List logical backups (id, size, created)
366
373
  everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
@@ -380,6 +387,8 @@ Usage:
380
387
  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
381
388
  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
382
389
  everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
390
+ everystack db:exec <file.sql> [--stage <name> | --database-url <url>] [--schemas a,b] [--confirm] Credential-free write SQL — the psql-piped-file replacement (or pipe SQL on stdin). Runs the whole file as ONE transaction, DML-only enforced SEMANTICALLY: a catalog digest before vs after rejects ANY schema change (however caused — a function, a DO block, dynamic SQL) by rolling the txn back. Schema changes go through db:apply / db:reconcile. Every run is recorded crash-truthfully in everystack.exec_log (intent before, per-statement rows-affected after). --stage runs in the ops Lambda (operator holds no URL); prod-tier writes require --confirm; --database-url is local-direct (--schemas scopes the digest, default public)
391
+ everystack task:probe --stage <name> Smoke-test the ephemeral Task lane: dispatch the version-handshake task (credential-free, via the ops Lambda) and poll until it stops. A pass proves the substrate end to end — the task started, resolved its injected operator credential, reached the private DB, and the image's pg tools are compatible with the live server. Recorded in everystack.task_log
383
392
  everystack pipeline:run [--stage <name> | --database-url <url>] [--rebuild | --curate] [--only <substr> | --from <id> | --to <id>] [--resume [--run-id <id>]] [--dry-run] [--continue-on-error] [--json] Reproduce data from committed source: run the pipeline's stages in topological order, each idempotent and inside its own transaction, recorded in everystack.pipeline_log. Lanes: --rebuild (automated+frozen) / --curate (curated) / neither (all in order). --resume picks up the latest run, skipping applied stages. With --stage the orchestrator runs credential-free IN the ops Lambda (no URL held; heavy stages refused — run those local); --database-url is local-direct and unbounded
384
393
  everystack pipeline:list [--rebuild | --curate] [--only <substr> | --from <id> | --to <id>] Show the pipeline's stages in run order (topological) with their lane and dependency edges — no execution, no database
385
394
  everystack db:template:refresh [--database-url <url>] [--models <barrel>] [--seed "<cmd>"] [--no-seed] (Re)build the dev template <base>_tpl FROM THE DECLARED STATE (both layers, fingerprint MATCH bar) + seed-as-code (the app's db:seed script, run with DATABASE_URL pointed at the template; --seed overrides). All or nothing: a failed build/seed leaves NO template. Never a data copy
@@ -0,0 +1,67 @@
1
+ /**
2
+ * task-poll — poll an ephemeral Task run (dispatched via the ops Lambda) until it stops.
3
+ *
4
+ * Shared by task:probe and the pg-binary verbs (db:backup / db:export, later restore/swap): they all
5
+ * dispatch a task, get back a run id + ARN, then poll `task:status` until STOPPED. The poll is
6
+ * BOUNDED — a Fargate task can sit in PROVISIONING/PENDING on capacity or ENI trouble, and a naked
7
+ * loop would hang the CLI. A few consecutive DescribeTasks blips are tolerated (a throttle shouldn't
8
+ * abort a live task); past that, or the deadline, the caller reconciles via the run id (the task_log
9
+ * row + ECS both carry it). This owns the loop; the caller owns the success/failure messaging.
10
+ */
11
+
12
+ import { invokeAction } from './aws.js';
13
+ import { info } from './output.js';
14
+
15
+ const POLL_INTERVAL_MS = 5_000;
16
+ /** Backup/export/restore can run minutes on large databases — far longer than the probe's handshake. */
17
+ export const DEFAULT_DEADLINE_MS = 30 * 60_000;
18
+ const MAX_CONSECUTIVE_ERRORS = 3;
19
+
20
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
21
+
22
+ export interface TaskStatus {
23
+ lastStatus?: string;
24
+ stopped?: boolean;
25
+ exitCode?: number | null;
26
+ stoppedReason?: string | null;
27
+ /** The task's self-reported result row (db:backup/export write id/key/bytes/fingerprint). */
28
+ result?: Record<string, unknown> | null;
29
+ error?: string;
30
+ }
31
+
32
+ export type TaskPollResult =
33
+ | { outcome: 'stopped'; status: TaskStatus }
34
+ | { outcome: 'error'; status: TaskStatus }
35
+ | { outcome: 'timeout'; lastStatus: string };
36
+
37
+ /**
38
+ * Poll until the task stops, printing each lifecycle transition. Returns `stopped` (read exitCode),
39
+ * `error` (task:status failed repeatedly — the task may still be running), or `timeout`.
40
+ */
41
+ export async function pollTaskUntilStopped(
42
+ region: string,
43
+ fn: string,
44
+ ids: { runId: string; taskArn: string },
45
+ opts: { deadlineMs?: number } = {},
46
+ ): Promise<TaskPollResult> {
47
+ const deadline = Date.now() + (opts.deadlineMs ?? DEFAULT_DEADLINE_MS);
48
+ let last = '';
49
+ let consecutiveErrors = 0;
50
+ while (Date.now() < deadline) {
51
+ const status = (await invokeAction(region, fn, 'task:status', { runId: ids.runId, taskArn: ids.taskArn })) as TaskStatus;
52
+ if (status?.error) {
53
+ if (++consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) return { outcome: 'error', status };
54
+ info(` (status check blipped: ${status.error} — retrying)`);
55
+ await sleep(POLL_INTERVAL_MS);
56
+ continue;
57
+ }
58
+ consecutiveErrors = 0;
59
+ if (status.lastStatus && status.lastStatus !== last) {
60
+ info(` ${status.lastStatus}`);
61
+ last = status.lastStatus;
62
+ }
63
+ if (status.stopped) return { outcome: 'stopped', status };
64
+ await sleep(POLL_INTERVAL_MS);
65
+ }
66
+ return { outcome: 'timeout', lastStatus: last || 'unknown' };
67
+ }
package/src/exec.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @everystack/cli/exec — the db:exec core, for the ops-Lambda lane.
3
+ *
4
+ * db:exec applies credential-free write SQL as ONE transaction, DML-only by a semantic catalog
5
+ * digest (no role, no superuser, no regex), bracketed by a crash-truthful ledger. This barrel lets
6
+ * the ops `db:exec` action (in @everystack/server's dbPlugin) load the same core the CLI uses and
7
+ * run it on the operator connection db:seed already uses — so `db:exec --stage` needs no raw admin
8
+ * URL on the operator's machine.
9
+ */
10
+
11
+ export { executeExec, assertNoTxnControl, execSha } from './cli/exec-execute.js';
12
+ export type { ExecIntent, ExecOutcome, ExecuteExecOptions, ExecResult } from './cli/exec-execute.js';
13
+
14
+ export { runExecInTx, SchemaChangedError, railStatements, EXEC_RAIL_DEFAULTS } from './cli/exec-run.js';
15
+ export type { TxRunner, ExecStmtResult, ExecRails, RunExecInTxOptions } from './cli/exec-run.js';
16
+
17
+ export { catalogDigestQuery } from './cli/exec-digest.js';
18
+
19
+ export { ENSURE_EXEC_LOG_SQL, renderExecIntentInsert, renderExecOutcomeUpdate } from './cli/exec-log.js';
20
+ export type { ExecIntentRow, ExecOutcomeRow } from './cli/exec-log.js';