@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.30",
3
+ "version": "0.4.31",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -37,6 +37,10 @@
37
37
  "types": "./src/cli/apply-execute.ts",
38
38
  "default": "./src/cli/apply-execute.ts"
39
39
  },
40
+ "./db-source": {
41
+ "types": "./src/cli/db-source.ts",
42
+ "default": "./src/cli/db-source.ts"
43
+ },
40
44
  "./reconcile": {
41
45
  "types": "./src/reconcile.ts",
42
46
  "default": "./src/reconcile.ts"
@@ -49,6 +53,10 @@
49
53
  "types": "./src/backfill.ts",
50
54
  "default": "./src/backfill.ts"
51
55
  },
56
+ "./exec": {
57
+ "types": "./src/exec.ts",
58
+ "default": "./src/exec.ts"
59
+ },
52
60
  "./audit/source": {
53
61
  "types": "./src/cli/audit-source-api.ts",
54
62
  "default": "./src/cli/audit-source-api.ts"
@@ -62,18 +62,20 @@ export interface ApplyPlanOptions {
62
62
  */
63
63
  verifyAuthority?: () => Promise<{ ok: true } | { ok: false; reason: string }>;
64
64
  /**
65
- * Runs after every verification passes and BEFORE the edge executes, only
66
- * for destructive plans the auto-snapshot seam ("losing data should be
67
- * hard": confirmed, approved, snapshotted, in that order). A throw here
68
- * aborts the apply with nothing executed.
65
+ * The destructive-apply safety gate (B3): called only for destructive plans, after the lock,
66
+ * descent, and authority pass and BEFORE the edge executes. The apply NO LONGER takes its own
67
+ * snapshot the caller must be covered by a verified backup. Return `ok: false` to refuse (the
68
+ * refusal is recorded in schema_log, like the others). `planFrom` is handed in so the caller can
69
+ * assert `backup.fingerprint == planFrom` server-side. Omitted = no gate (bare connections that
70
+ * attest their own --snapshot-ref keep the ceremony the command shell enforces).
69
71
  */
70
- beforeDestructive?: () => Promise<void>;
72
+ verifySnapshot?: (ctx: { planFrom: string }) => Promise<{ ok: true } | { ok: false; reason: string }>;
71
73
  /** Injectable clock for tests. */
72
74
  now?: () => number;
73
75
  }
74
76
 
75
77
  export interface ApplyPlanResult {
76
- status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'verify-failed';
78
+ status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'snapshot-refused' | 'verify-failed';
77
79
  liveFingerprint: string;
78
80
  reason?: string;
79
81
  logId?: number;
@@ -110,15 +112,33 @@ async function recordRefusal(
110
112
  }
111
113
  }
112
114
 
115
+ /** The live base-schema fingerprint + the two introspections it's built from. */
116
+ export interface LiveBaseFingerprint {
117
+ hash: string;
118
+ snapshot: SchemaSnapshot;
119
+ contract: AuthzContract;
120
+ }
121
+
122
+ /**
123
+ * The live base-schema fingerprint over a runner — the exact value db:plan stamps as plan.from and
124
+ * the apply's concurrency lock recomputes. Extracted so EVERY producer computes one identical hash:
125
+ * the apply lock (here), db:fingerprint, and the db:backup Task that stamps its manifest for the
126
+ * destructive-apply safety gate (backup.fp == plan.from). A second copy would silently drift the
127
+ * fingerprint chain — the whole point of the gate.
128
+ */
129
+ export async function liveBaseFingerprint(runner: QueryRunner): Promise<LiveBaseFingerprint> {
130
+ const snapshot = await introspectSchema(runner);
131
+ const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
132
+ return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract };
133
+ }
134
+
113
135
  /** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
114
136
  export async function executeApplyPlan(
115
137
  runner: QueryRunner,
116
138
  plan: EdgePlan,
117
139
  options: ApplyPlanOptions = {},
118
140
  ): Promise<ApplyPlanResult> {
119
- const before = await introspectSchema(runner);
120
- const beforeAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
121
- const live = fingerprintLive(before, beforeAuthz).hash;
141
+ const { hash: live, snapshot: before, contract: beforeAuthz } = await liveBaseFingerprint(runner);
122
142
 
123
143
  if (live === plan.toFingerprint) {
124
144
  return { status: 'already-applied', liveFingerprint: live };
@@ -145,8 +165,12 @@ export async function executeApplyPlan(
145
165
  }
146
166
  }
147
167
 
148
- if (plan.destructive > 0 && options.beforeDestructive) {
149
- await options.beforeDestructive();
168
+ if (plan.destructive > 0 && options.verifySnapshot) {
169
+ const snapshot = await options.verifySnapshot({ planFrom: live });
170
+ if (!snapshot.ok) {
171
+ await recordRefusal(runner, plan, live, 'snapshot gate', snapshot.reason, options);
172
+ return { status: 'snapshot-refused', liveFingerprint: live, reason: snapshot.reason };
173
+ }
150
174
  }
151
175
 
152
176
  const result = await applyGeneratedStatements(runner, plan.statements, {
@@ -157,9 +181,7 @@ export async function executeApplyPlan(
157
181
  now: options.now,
158
182
  });
159
183
 
160
- const after = await introspectSchema(runner);
161
- const afterAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
162
- const landed = fingerprintLive(after, afterAuthz).hash;
184
+ const { hash: landed } = await liveBaseFingerprint(runner);
163
185
  if (result.logId !== undefined) {
164
186
  await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
165
187
  }
@@ -38,7 +38,6 @@ import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute.js';
38
38
  import { estimateOpsRuntimeFit } from '../ops-fit.js';
39
39
  import { resolveOperatorUrlViaStage } from '../direct-venue.js';
40
40
  import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
41
- import { pgDumpPreflightError } from './db-backup.js';
42
41
  import { step, success, fail, info, warn } from '../output.js';
43
42
 
44
43
  // The apply core lives in apply-execute.ts (shared with the ops Lambda's
@@ -90,6 +89,10 @@ function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void {
90
89
  fail(`REFUSED (destructive authority): ${result.reason}`);
91
90
  process.exit(1);
92
91
  break;
92
+ case 'snapshot-refused':
93
+ fail(`REFUSED (snapshot gate): ${result.reason}`);
94
+ process.exit(1);
95
+ break;
93
96
  case 'verify-failed':
94
97
  fail(`Verify FAILED: ${result.reason}`);
95
98
  process.exit(1);
@@ -120,19 +123,6 @@ async function applyPlanViaStage(
120
123
 
121
124
  info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
122
125
 
123
- // A destructive staged apply snapshots via db:backup INSIDE the Lambda, before the DDL. Preflight
124
- // that capability HERE so a missing/incompatible pg_dump layer fails fast with the install remedy —
125
- // not deep in the ceremony after the authority check, the way it once surfaced mid-apply.
126
- if (plan.destructive > 0) {
127
- step('Preflighting the pg_dump layer (the destructive apply snapshots before the DDL)...');
128
- const probe: any = await invokeAction(region, fn, 'db:backup:probe', {}).catch((err: any) => ({ error: err?.message ?? String(err) }));
129
- const remedy = pgDumpPreflightError(probe);
130
- if (remedy) {
131
- fail(`Cannot take the pre-apply snapshot — the destructive plan was NOT applied.\n${remedy}`);
132
- process.exit(1);
133
- }
134
- }
135
-
136
126
  try {
137
127
  // Descent — the fast-forward rule needs the git checkout, so the CLI
138
128
  // decides it (the Lambda has no checkout). Read the stage's state read-only
@@ -168,8 +158,9 @@ async function applyPlanViaStage(
168
158
 
169
159
  // Destructive ceremony — the approver check is against the caller's REAL
170
160
  // IAM identity (STS), which the Lambda cannot see, so the CLI resolves it
171
- // and checks it against the stage's declared approver set (SSM). The
172
- // snapshot itself runs IN the Lambda, before the DDL. --confirm always.
161
+ // and checks it against the stage's declared approver set (SSM). The safety
162
+ // net is a VERIFIED backup: the Lambda auto-resolves the stage's newest (or
163
+ // the id in --snapshot-ref) and refuses unless it covers this plan. --confirm always.
173
164
  let authorityVerdict: AuthorityVerdict | undefined;
174
165
  if (plan.destructive > 0) {
175
166
  const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
@@ -198,6 +189,7 @@ async function applyPlanViaStage(
198
189
  gitRef: currentGitRef() ?? plan.gitRef,
199
190
  descentVerdict,
200
191
  ...(authorityVerdict ? { authorityVerdict } : {}),
192
+ ...(flags['snapshot-ref'] ? { snapshotRef: flags['snapshot-ref'] } : {}),
201
193
  stage: flags.stage,
202
194
  });
203
195
  if (result?.error) {
@@ -258,8 +250,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
258
250
 
259
251
  // `--stage --direct` (lane 1): resolve the stage's OPERATOR connection from its ops
260
252
  // Lambda, then execute CLI-side with an unbounded clock — the same ceremony as the
261
- // direct path below (which is stage-aware: approver check + auto-snapshot when --stage
262
- // is set), just a longer clock. The operator never holds a URL; it lives in memory only.
253
+ // direct path below (which is stage-aware: approver check + an attested --snapshot-ref
254
+ // for destructive plans), just a longer clock. The operator never holds a URL; it lives in memory only.
263
255
  if (dbSource.kind === 'stage' && flags.direct === 'true') {
264
256
  try {
265
257
  step('Resolving the operator connection from the stage (--direct)...');
@@ -292,13 +284,21 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
292
284
  // set — identity-approved (decision 12).
293
285
  const isDestructive = plan.destructive > 0;
294
286
  let verifyAuthority: (() => Promise<{ ok: true } | { ok: false; reason: string }>) | undefined;
295
- let beforeDestructive: (() => Promise<void>) | undefined;
296
287
  if (isDestructive) {
297
288
  const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
298
289
  if (flags.confirm !== 'true') {
299
290
  fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
300
291
  process.exit(1);
301
292
  }
293
+ // A DIRECT apply is operator-attested: the operator holds the URL, so the safety net is a backup
294
+ // they took and NAME here. (The credential-free path — db:apply --plan … --stage <name>, WITHOUT
295
+ // --direct — auto-resolves and verifies the stage's latest backup server-side; a direct/bare
296
+ // connection has no ops Lambda to verify against, so the ref is required and attested.)
297
+ if (!flags['snapshot-ref']) {
298
+ const takeIt = `everystack db:backup${flags.stage ? ` --stage ${flags.stage}` : ''}`;
299
+ fail(`This plan is DESTRUCTIVE (${shape}) over a direct connection — the apply does not snapshot for you. Take a safety point (${takeIt}) and name it: --snapshot-ref <id>. Or drop --direct and let the credential-free --stage apply auto-verify your latest backup.`);
300
+ process.exit(1);
301
+ }
302
302
  if (flags.stage) {
303
303
  const config = await resolveConfig(flags.stage);
304
304
  const { parseAppName } = await import('../discover.js');
@@ -316,19 +316,9 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
316
316
  }
317
317
  return { ok: true };
318
318
  };
319
- beforeDestructive = async () => {
320
- step('Destructive apply — taking a snapshot first (db:backup)...');
321
- const result: any = await invokeAction(config.region, opsFunction(config), 'db:backup', { stage: flags.stage });
322
- if (result?.error) {
323
- throw new Error(`auto-snapshot failed, so the destructive plan was NOT applied: ${result.error}`);
324
- }
325
- info(`snapshot on record: ${result?.id ?? 'backup complete'} — restore with db:restore --from ${result?.id ?? '<id>'} --confirm.`);
326
- };
327
- } else if (!flags['snapshot-ref']) {
328
- fail(`This plan is DESTRUCTIVE (${shape}) and the target is a bare connection — the apply cannot take the snapshot itself. Take one (db:snapshot / db:backup / pg_dump) and name it: --snapshot-ref <ref>. Or pass --stage and the apply snapshots automatically, with the stage's approver set enforced.`);
329
- process.exit(1);
319
+ warn(`DESTRUCTIVE direct apply proceeding on ceremony: --confirm + attested snapshot ${flags['snapshot-ref']}.`);
330
320
  } else {
331
- warn(`DESTRUCTIVE apply over a bare connection — approver gating unavailable (no --stage), proceeding on ceremony: --confirm + snapshot ${flags['snapshot-ref']}.`);
321
+ warn(`DESTRUCTIVE apply over a bare connection — approver gating unavailable (no --stage), proceeding on ceremony: --confirm + attested snapshot ${flags['snapshot-ref']}.`);
332
322
  }
333
323
  }
334
324
 
@@ -351,7 +341,6 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
351
341
  actor: process.env.USER ?? null,
352
342
  gitRef: currentGitRef() ?? plan.gitRef,
353
343
  ...(verifyAuthority ? { verifyAuthority } : {}),
354
- ...(beforeDestructive ? { beforeDestructive } : {}),
355
344
  ...(forceDescent === undefined ? {
356
345
  verifyDescent: async (live: LiveState & { fingerprint: string }) => {
357
346
  step('Descent: searching git for the commit that declares the target\'s state...');
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * db:backup family — logical pg_dump/pg_restore backups (the postgres-native, DB-agnostic path).
3
3
  *
4
- * These run server-side in the ops Lambda (the DB is private), so the CLI is a thin invokeAction
5
- * wrapper. db:backup:probe is the deploy-verification for the pg_dump layer (B1); the
6
- * dump/list/restore/download commands land in B2/B3. See docs/plans/db-backup-restore.md.
4
+ * db:backup/db:export run in the ephemeral Task lane (the ops Lambda holds no pg binaries); the CLI
5
+ * dispatches and polls. db:backups/db:restore/download are thin invokeAction wrappers. The image's
6
+ * version handshake is the compatibility gate — verify a deploy with `everystack task:probe`.
7
7
  */
8
8
 
9
9
  import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
10
10
  import { invokeAction, presignGet } from '../aws.js';
11
11
  import { parseBackupRef, keyForId, crossStageGuard, restoreTargetGuard } from '../backup.js';
12
+ import { pollTaskUntilStopped } from '../task-poll.js';
12
13
  import { step, success, fail, info, warn } from '../output.js';
13
14
 
14
15
  function fmtBytes(n?: number): string {
@@ -21,65 +22,10 @@ function fmtBytes(n?: number): string {
21
22
  }
22
23
 
23
24
  /**
24
- * db:backup:probeconfirm the pg_dump layer is attached to the ops function AND that its major
25
- * version can dump the live server (pg_dump >= server major; there is no bypass flag). This is how
26
- * we prove B1 after a deploy before trusting db:backup.
25
+ * db:backup — pg_dump the stage's DB to S3, in the ephemeral Task lane (the ops Lambda holds no pg
26
+ * binaries). The CLI dispatches the task and polls until it stops; the task's version handshake is
27
+ * the compatibility gate (a too-old image fails the run loudly — no separate layer pre-flight).
27
28
  */
28
- export async function dbBackupProbeCommand(flags: Record<string, string>): Promise<void> {
29
- step('Resolving deployed config...');
30
- let config: CliConfig;
31
- try {
32
- config = await resolveConfig(flags.stage);
33
- } catch (err: any) {
34
- fail(err.message);
35
- process.exit(1);
36
- }
37
-
38
- info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
39
- step('Probing pg_dump layer + server version...');
40
-
41
- let result: any;
42
- try {
43
- result = await invokeAction(config.region, opsFunction(config), 'db:backup:probe', {});
44
- } catch (err: any) {
45
- fail(`Probe failed: ${err.message}`);
46
- process.exit(1);
47
- }
48
-
49
- if (result?.error) {
50
- fail(result.error);
51
- info('Install @everystack/pg-tools and redeploy — the layer attaches automatically. For a bring-your-own layer, pass pgDumpLayer(..., { layerArn }) in sst.config.ts.');
52
- process.exit(1);
53
- }
54
-
55
- info(`pg_dump: ${result.pgDump} (major ${result.pgDumpMajor})`);
56
- info(`server: ${result.serverVersionNum} (major ${result.serverMajor})`);
57
- if (result.compatible) {
58
- success('Compatible — pg_dump can back up and restore this server. db:backup is ready.');
59
- } else {
60
- warn('INCOMPATIBLE — pg_dump is older than the server major; it will refuse to dump (no bypass exists).');
61
- warn('Rebuild the layer with a pg_dump >= the server major.');
62
- process.exit(1);
63
- }
64
- }
65
-
66
- /**
67
- * Interpret a `db:backup:probe` result into a fail-clean remedy, or `null` when a dump is safe to
68
- * run. Pre-flighting this before the dump turns a missing or version-incompatible pg_dump layer
69
- * into a one-line remedy instead of a cryptic runtime crash mid-dump.
70
- */
71
- export function pgDumpPreflightError(probe: any): string | null {
72
- if (!probe || probe.error) {
73
- const detail = probe?.error ?? 'the pg_dump layer probe returned no result';
74
- return `${detail}\nInstall @everystack/pg-tools and redeploy — the layer attaches automatically. For a bring-your-own layer, pass pgDumpLayer(..., { layerArn }) in sst.config.ts.`;
75
- }
76
- if (probe.compatible === false) {
77
- return `pg_dump ${probe.pgDump ?? '(unknown)'} (major ${probe.pgDumpMajor}) is older than the server (major ${probe.serverMajor}) — it will refuse to dump (no bypass exists). Rebuild the layer with pg_dump >= ${probe.serverMajor}.`;
78
- }
79
- return null;
80
- }
81
-
82
- /** db:backup — pg_dump the stage's DB to S3 (runs in the ops Lambda). Prints the new backup id. */
83
29
  export async function dbBackupCommand(flags: Record<string, string>): Promise<void> {
84
30
  step('Resolving deployed config...');
85
31
  let config: CliConfig;
@@ -91,38 +37,41 @@ export async function dbBackupCommand(flags: Record<string, string>): Promise<vo
91
37
  }
92
38
 
93
39
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
40
+ const fn = opsFunction(config);
94
41
 
95
- // Pre-flight the pg_dump layer before the dump — a missing/incompatible layer becomes a one-line
96
- // remedy here instead of crashing the runtime mid-dump (the probe returns a clean error, never a
97
- // crash, when the layer is absent).
98
- step('Checking the pg_dump layer...');
99
- let probe: any;
42
+ step('Dispatching the backup task (credential-free, via the ops Lambda)...');
43
+ let dispatched: any;
100
44
  try {
101
- probe = await invokeAction(config.region, opsFunction(config), 'db:backup:probe', {});
45
+ dispatched = await invokeAction(config.region, fn, 'db:backup', { stage: flags.stage, actor: process.env.USER ?? null });
102
46
  } catch (err: any) {
103
- fail(`Backup pre-flight failed: ${err.message}`);
47
+ fail(`Backup failed to dispatch: ${err.message}`);
104
48
  process.exit(1);
105
49
  }
106
- const preflightError = pgDumpPreflightError(probe);
107
- if (preflightError) {
108
- fail(preflightError);
50
+ if (dispatched?.error) {
51
+ fail(`Backup failed: ${dispatched.error}`);
109
52
  process.exit(1);
110
53
  }
54
+ const { runId, taskArn, id, warning } = dispatched as { runId: string; taskArn: string; id: string; warning?: string };
55
+ info(`backup ${id} — task ${taskArn}`);
56
+ info(`run id: ${runId} (everystack.task_log)`);
57
+ if (warning) info(`note: ${warning}`);
111
58
 
112
59
  step('Running pg_dump → S3 (this may take a while for large databases)...');
113
-
114
- let result: any;
115
- try {
116
- result = await invokeAction(config.region, opsFunction(config), 'db:backup', { stage: flags.stage });
117
- } catch (err: any) {
118
- fail(`Backup failed: ${err.message}`);
60
+ const poll = await pollTaskUntilStopped(config.region, fn, { runId, taskArn });
61
+ if (poll.outcome === 'timeout') {
62
+ fail(`db:backup timed out (last status: ${poll.lastStatus}). Run id ${runId} — reconcile via ECS/everystack.task_log.`);
119
63
  process.exit(1);
120
64
  }
121
- if (result?.error) {
122
- fail(`Backup failed: ${result.error}`);
65
+ if (poll.outcome === 'error') {
66
+ fail(`task:status failed repeatedly: ${poll.status.error}. The backup may still be running — reconcile run id ${runId}.`);
123
67
  process.exit(1);
124
68
  }
125
- success(`Backup ${result.id} (${fmtBytes(result.bytes)}). Restore with: everystack db:restore --from ${result.id} --confirm`);
69
+ if (poll.status.exitCode !== 0) {
70
+ fail(`Backup failed (exit ${poll.status.exitCode ?? 'unknown'})${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. Read the task logs (CloudWatch).`);
71
+ process.exit(1);
72
+ }
73
+ const bytes = (poll.status.result?.bytes as number | undefined) ?? undefined;
74
+ success(`Backup ${id} (${fmtBytes(bytes)}). Restore with: everystack db:restore --from ${id} --confirm`);
126
75
  }
127
76
 
128
77
  /** db:backups — list a stage's logical backups. */
@@ -198,16 +147,37 @@ export async function dbRestoreCommand(flags: Record<string, string>): Promise<v
198
147
  }
199
148
 
200
149
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
201
- step(`Restoring ${id} into ${toStage} (pg_restore)...`);
202
- let result: any;
150
+ const fn = opsFunction(config);
151
+
152
+ step('Dispatching the restore task (credential-free, via the ops Lambda)...');
153
+ let dispatched: any;
203
154
  try {
204
- result = await invokeAction(config.region, opsFunction(config), 'db:restore', { id, confirm: true, stage: flags.stage });
155
+ dispatched = await invokeAction(config.region, fn, 'db:restore', { id, confirm: true, stage: flags.stage, actor: process.env.USER ?? null });
205
156
  } catch (err: any) {
206
- fail(`Restore failed: ${err.message}`);
157
+ fail(`Restore failed to dispatch: ${err.message}`);
207
158
  process.exit(1);
208
159
  }
209
- if (result?.error) {
210
- fail(`Restore failed: ${result.error}`);
160
+ if (dispatched?.error) {
161
+ fail(`Restore failed: ${dispatched.error}`);
162
+ process.exit(1);
163
+ }
164
+ const { runId, taskArn, intentKey, warning } = dispatched as { runId: string; taskArn: string; intentKey: string; warning?: string };
165
+ info(`restore of ${id} → ${toStage} — task ${taskArn}`);
166
+ info(`intent: ${intentKey} (durable S3 record — survives the restore)`);
167
+ if (warning) info(`note: ${warning}`);
168
+
169
+ step(`Restoring ${id} into ${toStage} (pg_restore in the Task; this may take a while)...`);
170
+ const poll = await pollTaskUntilStopped(config.region, fn, { runId, taskArn });
171
+ if (poll.outcome === 'timeout') {
172
+ fail(`db:restore timed out (last status: ${poll.lastStatus}). Intent ${intentKey} — reconcile via ECS/the S3 intent.`);
173
+ process.exit(1);
174
+ }
175
+ if (poll.outcome === 'error') {
176
+ fail(`task:status failed repeatedly: ${poll.status.error}. The restore may still be running — reconcile intent ${intentKey}.`);
177
+ process.exit(1);
178
+ }
179
+ if (poll.status.exitCode !== 0) {
180
+ fail(`Restore failed (exit ${poll.status.exitCode ?? 'unknown'})${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. The target DB may be partially restored — check the S3 intent ${intentKey} and the task logs.`);
211
181
  process.exit(1);
212
182
  }
213
183
  success(`Restored ${id} into ${toStage}.`);
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `everystack db:exec <file.sql>` — credential-free write SQL, the daily driver.
3
+ *
4
+ * db:exec seed.sql --stage dev # run in the ops Lambda (no URL held), DML-only
5
+ * db:exec --stage prod some.sql --confirm # prod-tier writes require --confirm
6
+ * cat some.sql | db:exec --stage dev # stdin also works (a `psql < file.sql` replacement)
7
+ * db:exec some.sql --database-url <url> # local-direct (dev), unbounded clock
8
+ *
9
+ * The file runs as ONE transaction, DML-only enforced SEMANTICALLY: a catalog digest taken before
10
+ * vs after the file rejects ANY schema change — however caused (a function, a DO block, dynamic
11
+ * SQL) — by rolling the whole transaction back. Schema changes go through db:apply / db:reconcile.
12
+ * Every run is recorded, crash-truthfully, in everystack.exec_log (intent before, outcome after).
13
+ *
14
+ * Mirrors db:backfill's two venues: `--stage` ships the SQL to the ops Lambda's db:exec action
15
+ * (the operator holds no URL); `--database-url` connects directly for local dev.
16
+ */
17
+
18
+ import fs from 'node:fs/promises';
19
+ import { resolveDbSource, sslDefaults } from '../db-source.js';
20
+ import { resolveConfig, opsFunction } from '../config.js';
21
+ import { invokeAction } from '../aws.js';
22
+ import { isProductionTier } from '../backup.js';
23
+ import { executeExec, assertNoTxnControl } from '../exec-execute.js';
24
+ import { runExecInTx } from '../exec-run.js';
25
+ import { ENSURE_EXEC_LOG_SQL, renderExecIntentInsert, renderExecOutcomeUpdate } from '../exec-log.js';
26
+ import { step, success, fail, info } from '../output.js';
27
+
28
+ async function readStdin(): Promise<string> {
29
+ const chunks: Buffer[] = [];
30
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
31
+ return Buffer.concat(chunks).toString('utf8');
32
+ }
33
+
34
+ /** The app schemas the digest guard covers on the direct path (--schemas a,b; default public). */
35
+ function directSchemas(flags: Record<string, string>): string[] {
36
+ if (flags.schemas && flags.schemas !== 'true') {
37
+ return flags.schemas.split(',').map((s) => s.trim()).filter(Boolean);
38
+ }
39
+ return ['public'];
40
+ }
41
+
42
+ function reportOk(res: { rowsAffected?: number[] }): void {
43
+ const rows = res.rowsAffected ?? [];
44
+ const total = rows.reduce((a, b) => a + b, 0);
45
+ success(`db:exec applied ${rows.length} statement(s), ${total} row(s) affected — recorded in everystack.exec_log.`);
46
+ }
47
+
48
+ export async function dbExecCommand(flags: Record<string, string>, file?: string): Promise<void> {
49
+ // Read the SQL: a positional file, `-`/absent for stdin (the `psql < file.sql` replacement).
50
+ let fileSql: string;
51
+ try {
52
+ fileSql = file && file !== '-' ? await fs.readFile(file, 'utf8') : await readStdin();
53
+ } catch (err: any) {
54
+ fail(`db:exec: cannot read SQL (${err.message}).`);
55
+ process.exit(1);
56
+ }
57
+ if (!fileSql.trim()) {
58
+ fail('db:exec: no SQL to run — pass a file or pipe SQL on stdin.');
59
+ process.exit(1);
60
+ }
61
+
62
+ // Fail fast on a self-transacting file before any invoke/connection (the real DML-only guard is
63
+ // the catalog digest, server-side; this is the courtesy pre-check).
64
+ try {
65
+ assertNoTxnControl(fileSql);
66
+ } catch (err: any) {
67
+ fail(err.message);
68
+ process.exit(1);
69
+ }
70
+
71
+ const source = resolveDbSource(flags);
72
+ const actor = process.env.USER ?? null;
73
+
74
+ // --- Stage venue: ship the SQL to the ops Lambda's db:exec action (operator holds no URL). ---
75
+ if (source.kind === 'stage') {
76
+ const stage = flags.stage;
77
+ if (stage && isProductionTier(stage) && flags.confirm !== 'true') {
78
+ fail(`db:exec writes to production-tier "${stage}" — pass --confirm to proceed.`);
79
+ process.exit(1);
80
+ }
81
+ step('Resolving deployed config...');
82
+ const config = await resolveConfig(stage);
83
+ step('Running db:exec in the ops Lambda (credential-free, DML-only, one transaction)...');
84
+ const res: any = await invokeAction(config.region, opsFunction(config), 'db:exec', {
85
+ sql: fileSql, actor, stage: stage ?? null,
86
+ });
87
+ if (res?.error) {
88
+ fail(`db:exec failed: ${res.error}`);
89
+ if (/Unknown action/i.test(String(res.error))) {
90
+ info('The deployed handler predates the db:exec ops action. Upgrade @everystack/server, or run local: db:exec <file> --database-url <url>.');
91
+ }
92
+ process.exit(1);
93
+ }
94
+ reportOk(res);
95
+ return;
96
+ }
97
+
98
+ // --- Direct venue (local dev): the same ceremony, CLI-side, unbounded clock. ---
99
+ step('Connecting via ' + (source.from === 'flag' ? '--database-url' : source.from) + ' (DML-only, one transaction)...');
100
+ const postgres = (await import('postgres')).default;
101
+ const sqlc = postgres(source.url, { max: 1, onnotice: () => {}, ...sslDefaults(source.url) });
102
+ try {
103
+ const audit = async (query: string): Promise<any[]> => Array.from(await sqlc.unsafe(query)) as any[];
104
+ const res = await executeExec({
105
+ sql: fileSql,
106
+ actor: actor ?? 'unknown',
107
+ stage: flags.stage ?? 'local',
108
+ writeIntent: async (intent) => {
109
+ for (const ddl of ENSURE_EXEC_LOG_SQL) await audit(ddl);
110
+ const rows = await audit(renderExecIntentInsert(intent));
111
+ return String(rows[0].id);
112
+ },
113
+ run: () =>
114
+ sqlc.begin(async (tx: any) => {
115
+ const txRun = async (stmt: string) => {
116
+ const r = await tx.unsafe(stmt);
117
+ return { rows: Array.from(r) as any[], count: (r as any).count ?? 0 };
118
+ };
119
+ return runExecInTx(txRun, fileSql, { schemas: directSchemas(flags) });
120
+ }) as Promise<number[]>,
121
+ writeOutcome: async (id, outcome) => {
122
+ await audit(renderExecOutcomeUpdate(id, outcome));
123
+ },
124
+ });
125
+ reportOk(res);
126
+ } catch (err: any) {
127
+ fail(`db:exec failed: ${err.message}`);
128
+ process.exit(1);
129
+ } finally {
130
+ await sqlc.end({ timeout: 5 });
131
+ }
132
+ }
@@ -26,9 +26,9 @@ import { fingerprintModels } from '../schema-fingerprint.js';
26
26
  import { resolveModelsPath } from '../models-path.js';
27
27
  import { loadModels } from './db-generate.js';
28
28
  import { loadDeclaredDerived } from '../declared-derived.js';
29
- import { pgDumpPreflightError } from './db-backup.js';
30
29
  import { pgEnvFromUrl } from './db.js';
31
30
  import { utcStamp } from '../backup.js';
31
+ import { pollTaskUntilStopped } from '../task-poll.js';
32
32
  import { step, success, fail, info } from '../output.js';
33
33
 
34
34
  const fmtBytes = (b?: number): string =>
@@ -190,30 +190,41 @@ export async function dbExportCommand(flags: Record<string, string>): Promise<vo
190
190
  process.exit(1);
191
191
  }
192
192
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
193
+ const fn = opsFunction(config);
193
194
 
194
- // The dump needs the pg_dump layer surface a missing/incompatible layer as a one-line remedy
195
- // here, not a mid-dump crash (same preflight db:backup runs).
196
- step('Checking the pg_dump layer...');
195
+ // The dump runs in the ephemeral Task lane (the ops Lambda holds no pg binaries). Dispatch the
196
+ // task and poll; the task's version handshake is the compatibility gate (no separate layer probe).
197
+ step('Dispatching the export task (credential-free, via the ops Lambda)...');
198
+ let dispatched: any;
197
199
  try {
198
- const probe = await invokeAction(config.region, opsFunction(config), 'db:backup:probe', {});
199
- const preflightError = pgDumpPreflightError(probe);
200
- if (preflightError) { fail(preflightError); process.exit(1); }
200
+ dispatched = await invokeAction(config.region, fn, 'db:export', { schema, stage: venue.stage, fingerprint, actor: process.env.USER ?? null });
201
201
  } catch (err: any) {
202
- fail(`Export pre-flight failed: ${err.message}`);
202
+ fail(`Export failed to dispatch: ${err.message}`);
203
203
  process.exit(1);
204
204
  }
205
+ if (dispatched?.error) {
206
+ fail(`Export failed: ${dispatched.error}`);
207
+ process.exit(1);
208
+ }
209
+ const { runId, taskArn, id, warning } = dispatched as { runId: string; taskArn: string; id: string; warning?: string };
210
+ info(`artifact ${id} — task ${taskArn}`);
211
+ info(`run id: ${runId} (everystack.task_log)`);
212
+ if (warning) info(`note: ${warning}`);
205
213
 
206
214
  step(`Running pg_dump --schema=${schema} → S3 (this may take a while for large schemas)...`);
207
- let result: any;
208
- try {
209
- result = await invokeAction(config.region, opsFunction(config), 'db:export', { schema, stage: venue.stage, fingerprint });
210
- } catch (err: any) {
211
- fail(`Export failed: ${err.message}`);
215
+ const poll = await pollTaskUntilStopped(config.region, fn, { runId, taskArn });
216
+ if (poll.outcome === 'timeout') {
217
+ fail(`db:export timed out (last status: ${poll.lastStatus}). Run id ${runId} reconcile via ECS/everystack.task_log.`);
218
+ process.exit(1);
219
+ }
220
+ if (poll.outcome === 'error') {
221
+ fail(`task:status failed repeatedly: ${poll.status.error}. The export may still be running — reconcile run id ${runId}.`);
212
222
  process.exit(1);
213
223
  }
214
- if (result?.error) {
215
- fail(`Export failed: ${result.error}`);
224
+ if (poll.status.exitCode !== 0) {
225
+ fail(`Export failed (exit ${poll.status.exitCode ?? 'unknown'})${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. Read the task logs (CloudWatch).`);
216
226
  process.exit(1);
217
227
  }
218
- success(`Artifact ${result.id} (${fmtBytes(result.bytes)}, fingerprint ${String(result.fingerprint).slice(0, 12)}). Deploy with: everystack db:swap --schema ${schema} --from ${result.id} --stage <target> --direct --confirm`);
228
+ const bytes = poll.status.result?.bytes as number | undefined;
229
+ success(`Artifact ${id} (${fmtBytes(bytes)}, fingerprint ${fingerprint.slice(0, 12)}). Deploy with: everystack db:swap --schema ${schema} --from ${id} --stage <target> --direct --confirm`);
219
230
  }