@everystack/cli 0.4.34 → 0.4.35

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.34",
3
+ "version": "0.4.35",
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>",
@@ -25,7 +25,7 @@ import {
25
25
  parseBackupRef, keyForId, crossStageGuard, restoreTargetGuard,
26
26
  backupKey, backupId, metaKey, utcStamp,
27
27
  } from '../backup.js';
28
- import { pgEnvFromUrl } from './db.js';
28
+ import { pgEnvFromUrl, pgKeepaliveConninfo } from './db.js';
29
29
  import { pollTaskUntilStopped } from '../task-poll.js';
30
30
  import { step, success, fail, info, warn } from '../output.js';
31
31
 
@@ -57,7 +57,9 @@ export function resolveBackupVenue(flags: Record<string, string>): BackupVenue {
57
57
  * restoring it would rewind that history AND makes pg_restore --clean fail on DROP SCHEMA everystack).
58
58
  */
59
59
  export function pgDumpFullArgs(): string[] {
60
- return ['-Fc', '--no-owner', '--no-privileges', '--no-comments', '--exclude-schema=everystack'];
60
+ // -d carries keepalives only; the credential still rides the PG* env (see db.ts). A full dump of
61
+ // a large database goes quiet for long stretches, which is exactly when an unkept connection dies.
62
+ return ['-d', pgKeepaliveConninfo(), '-Fc', '--no-owner', '--no-privileges', '--no-comments', '--exclude-schema=everystack'];
61
63
  }
62
64
 
63
65
  /** The .meta.json sidecar — byte-shape identical to the server's runBackup meta (backup-run.ts:156),
@@ -26,7 +26,7 @@ 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 { pgEnvFromUrl } from './db.js';
29
+ import { pgEnvFromUrl, pgKeepaliveConninfo } from './db.js';
30
30
  import { utcStamp } from '../backup.js';
31
31
  import { pollTaskUntilStopped } from '../task-poll.js';
32
32
  import { step, success, fail, info } from '../output.js';
@@ -99,7 +99,8 @@ export function localArtifactMeta(opts: {
99
99
 
100
100
  /** pg_dump args for the local venue — same dump shape as the ops venue, written straight to a file. */
101
101
  export function pgDumpLocalArgs(schema: string, dumpPath: string): string[] {
102
- return ['-Fc', '--no-owner', '--no-privileges', '--no-comments', `--schema=${schema}`, '-f', dumpPath];
102
+ // -d carries keepalives only; the credential still rides the PG* env (see db.ts).
103
+ return ['-d', pgKeepaliveConninfo(), '-Fc', '--no-owner', '--no-privileges', '--no-comments', `--schema=${schema}`, '-f', dumpPath];
103
104
  }
104
105
 
105
106
  /**
@@ -27,14 +27,17 @@ import { resolveModelsPath } from '../models-path.js';
27
27
  import { loadModels } from './db-generate.js';
28
28
  import { loadDeclaredDerived } from '../declared-derived.js';
29
29
  import { createUrlRunner } from '../db-source.js';
30
+ import type { QueryRunner } from '../authz-contract.js';
30
31
  import { executeSwap, type SwapVerdict } from '../swap-execute.js';
32
+ import { startHeartbeat, humanElapsed } from '../swap-heartbeat.js';
33
+ import { formatBytes } from '../bundle-weight.js';
31
34
  import { rewriteStatementLine, opensCopyData, closesCopyData } from '../schema-rewrite.js';
32
35
  import { resolveOperatorUrlViaStage } from '../direct-venue.js';
33
36
  import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
34
37
  import { resolveConfig, opsFunction } from '../config.js';
35
38
  import { invokeAction, presignGet } from '../aws.js';
36
39
  import { keyForArtifactId, metaKey } from '../backup.js';
37
- import { pgEnvFromUrl } from './db.js';
40
+ import { pgEnvFromUrl, pgKeepaliveConninfo } from './db.js';
38
41
  import { step, success, fail, warn, info } from '../output.js';
39
42
 
40
43
  /** A COPY-aware line transform that rewrites the schema token on statement lines only. */
@@ -70,37 +73,254 @@ function schemaRewriteStream(from: string, to: string): Transform {
70
73
  }
71
74
 
72
75
  /**
73
- * Restore a `-Fc` artifact into `<schema>_incoming`: pg_restore -f - (archive SQL) the schema
74
- * rewrite psql. The archive names `<schema>`; the rewrite lands it as `<incoming>`, COPY-data-safe.
76
+ * Consecutive polls reporting NO server-side backend before the watchdog kills psql. Three at the
77
+ * 10s cadence is 30s of agreement, which is well past any single-poll blip and far short of the
78
+ * eight minutes the unguarded version cost.
75
79
  */
76
- async function restoreIntoIncoming(url: string, artifactPath: string, schema: string, incoming: string): Promise<void> {
77
- // Connect via PG* env, not `-d <url>`. libpq VALIDATES URI query params against its keyword
78
- // list and REJECTS non-keywords like `search_path` ("invalid URI query parameter") — and the
79
- // operator URL db:operator-url mints bakes search_path in (fine for postgres.js, fatal for a
80
- // libpq client). pgEnvFromUrl extracts only libpq keywords (dropping search_path, routing
81
- // sslmode→PGSSLMODE) and puts the password in PGPASSWORD, off the process argv. pg_restore -f -
82
- // just reads the archive file to SQL on stdout (no connection).
83
- const restore = spawn('pg_restore', ['-f', '-', artifactPath], { stdio: ['ignore', 'pipe', 'pipe'] });
84
- const psql = spawn('psql', ['-v', 'ON_ERROR_STOP=1'], {
85
- stdio: ['pipe', 'ignore', 'pipe'],
86
- env: { ...process.env, ...pgEnvFromUrl(url) },
87
- });
88
- let rErr = '', pErr = '';
89
- restore.stderr.on('data', (d) => { rErr += d.toString(); });
90
- psql.stderr.on('data', (d) => { pErr += d.toString(); });
91
- const psqlExit = new Promise<void>((res, rej) => {
92
- psql.on('error', rej);
93
- psql.on('close', (c) => c === 0 ? res() : rej(new Error(`psql exited ${c}: ${pErr.trim()}`)));
94
- });
95
- const restoreExit = new Promise<void>((res, rej) => {
96
- restore.on('error', rej);
97
- restore.on('close', (c) => c === 0 ? res() : rej(new Error(`pg_restore exited ${c}: ${rErr.trim()}`)));
80
+ const DEAD_BACKEND_POLLS = 3;
81
+
82
+ /**
83
+ * TOC entry types that a DERIVED object can appear as. Order matters: the longer types must be
84
+ * tested first, or `MATERIALIZED VIEW DATA` parses as `MATERIALIZED VIEW` with a mangled schema.
85
+ */
86
+ const DERIVED_TOC_TYPES = [
87
+ 'MATERIALIZED VIEW DATA',
88
+ 'MATERIALIZED VIEW',
89
+ 'PROCEDURE',
90
+ 'FUNCTION',
91
+ 'TRIGGER',
92
+ 'VIEW',
93
+ ] as const;
94
+
95
+ /**
96
+ * Strip the DECLARED DERIVED objects out of a pg_restore TOC listing.
97
+ *
98
+ * An artifact should carry BASE STATE — tables, data, indexes, constraints, sequences. It should
99
+ * not carry the derived layer, because db:reconcile owns that and rebuilds it from the descriptors.
100
+ * `pg_dump --schema=<s>` cannot make that distinction: it dumps everything in the schema, derived
101
+ * objects included.
102
+ *
103
+ * Shipping them is not merely redundant, it DEADLOCKS the swap. A derived object inside the base
104
+ * schema may reference the derived schema built on top of it (`stats.draft_value` returns
105
+ * `SETOF stats_view.draft_value_row`). The restore then cannot run until the derived layer exists,
106
+ * while the derived layer cannot be built until the new base tables land. Neither can go first.
107
+ * Measured on a real artifact: the only entries referencing the derived schema were the 16
108
+ * reconcile-managed functions; every other entry was state.
109
+ *
110
+ * Filtering by TOC entry rather than by SQL text is deliberate: `pg_restore -L` is the supported
111
+ * mechanism for restoring a subset, and the alternative is pattern-matching a six-million-line SQL
112
+ * file. Entries are COMMENTED OUT rather than deleted so the listing stays diffable.
113
+ */
114
+ export function filterDerivedFromToc(
115
+ toc: string,
116
+ declaredIdentities: Iterable<string>,
117
+ ): { listing: string; skipped: string[] } {
118
+ const declared = new Set(declaredIdentities);
119
+ const skipped: string[] = [];
120
+ const listing = toc.split('\n').map((line) => {
121
+ // `<dumpId>; <catalogOid> <oid> <TYPE> <schema> <name(args)> <owner>`
122
+ const m = /^(\d+;\s+\d+\s+\d+)\s+(.+)$/.exec(line);
123
+ if (!m) return line; // header/comment/blank — pass through untouched
124
+ const rest = m[2];
125
+ const type = DERIVED_TOC_TYPES.find((t) => rest.startsWith(`${t} `));
126
+ if (!type) return line; // not a derived-capable entry (TABLE, INDEX, POLICY, ...)
127
+ const after = rest.slice(type.length + 1);
128
+ const parts = after.split(/\s+/);
129
+ if (parts.length < 2) return line;
130
+ const schema = parts[0];
131
+ // The owner is the last token; everything between it and the schema is the name (+args).
132
+ const nameWithArgs = parts.slice(1, -1).join(' ');
133
+ const name = nameWithArgs.replace(/\(.*$/, ''); // drop the argument list
134
+ if (!declared.has(`${schema}.${name}`)) return line;
135
+ skipped.push(`${schema}.${name} (${type.toLowerCase()})`);
136
+ return `;${line}`;
137
+ }).join('\n');
138
+ return { listing, skipped };
139
+ }
140
+
141
+ /** A pass-through that counts the bytes crossing it — Phase A's progress signal. */
142
+ function countingTap(onBytes: (total: number) => void): Transform {
143
+ let total = 0;
144
+ return new Transform({
145
+ transform(chunk, _enc, cb) {
146
+ total += chunk.length;
147
+ onBytes(total);
148
+ cb(null, chunk);
149
+ },
98
150
  });
99
- await Promise.all([
100
- pipeline(restore.stdout!, schemaRewriteStream(schema, incoming), psql.stdin!),
101
- restoreExit,
102
- psqlExit,
103
- ]);
151
+ }
152
+
153
+ /** The observation sinks the restore reports through. Injected so the phases stay testable. */
154
+ interface RestoreIO {
155
+ /** The operator connection — IDLE for the whole restore, so the Phase B heartbeat reuses it. */
156
+ runner: QueryRunner;
157
+ log: (msg: string) => void;
158
+ warn: (msg: string) => void;
159
+ /**
160
+ * `schema.name` of every DECLARED derived object. These are stripped from the restore: the
161
+ * artifact carries base state, db:reconcile owns the derived layer. See filterDerivedFromToc.
162
+ */
163
+ declaredIdentities: string[];
164
+ }
165
+
166
+ /**
167
+ * Restore a `-Fc` artifact into `<schema>_incoming`, in TWO phases split by a local temp file:
168
+ *
169
+ * Phase A (local, no network): pg_restore -f - (archive → SQL) → the COPY-safe schema rewrite →
170
+ * a local `.sql` file. The archive names `<schema>`; the rewrite lands it as `<incoming>`.
171
+ * Phase B (network): psql -f <file> streams that file to the target at psql's own pace.
172
+ *
173
+ * Why the temp file and not a live `pg_restore | rewrite | psql` pipe: decoupling the producer
174
+ * (local, fast) from the consumer removes all cross-process backpressure, and it makes Phase A
175
+ * measurable on its own. Costs one temp file (~the uncompressed dump), cleaned up in `finally`.
176
+ *
177
+ * A CORRECTION, because the original note here sent two debugging sessions down the wrong path.
178
+ * It claimed the three-way pipe DEADLOCKED at the DDL→data boundary and that the temp file was the
179
+ * cure. The temp file went in, and the restore STILL died at the same place. The pipe was never the
180
+ * root cause; the `write EPIPE` it produced was a downstream symptom.
181
+ *
182
+ * The real cause (proven 2026-07-27, see db.ts's keepalive note): the connection dies during the
183
+ * long quiet stretch of the index/constraint phase, the server terminates the backend, and the
184
+ * client — which never sees a FIN or RST — blocks forever on a socket its kernel still calls
185
+ * ESTABLISHED. The fix is TCP keepalives on psql's connection, plus the watchdog below, NOT the
186
+ * process topology. The temp file is kept because it is genuinely better instrumented, not because
187
+ * it fixes a deadlock.
188
+ *
189
+ * Both phases are INSTRUMENTED. This ran silent once — a multi-GB push with no output at all — and
190
+ * a run that died unattended was indistinguishable from one still working. Now: a byte counter on
191
+ * Phase A, a server-side heartbeat on Phase B (see swap-heartbeat.ts), and both child processes'
192
+ * stderr streamed as it arrives instead of being withheld until exit.
193
+ */
194
+ async function restoreIntoIncoming(
195
+ url: string,
196
+ artifactPath: string,
197
+ schema: string,
198
+ incoming: string,
199
+ io: RestoreIO,
200
+ ): Promise<void> {
201
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'everystack-restore-'));
202
+ const sqlPath = path.join(tmpDir, `${incoming}.sql`);
203
+ const t0 = Date.now();
204
+ try {
205
+ // Phase A0: strip the declared derived layer from the restore. The artifact is base STATE;
206
+ // db:reconcile owns derived. Landing them here is redundant at best and deadlocks the restore
207
+ // when a derived object in the base schema references the derived schema above it.
208
+ const tocPath = path.join(tmpDir, 'toc.list');
209
+ const toc = await new Promise<string>((res, rej) => {
210
+ const p = spawn('pg_restore', ['-l', artifactPath], { stdio: ['ignore', 'pipe', 'pipe'] });
211
+ let out = '', err = '';
212
+ p.stdout.on('data', (d) => { out += d.toString(); });
213
+ p.stderr.on('data', (d) => { err += d.toString(); });
214
+ p.on('error', rej);
215
+ p.on('close', (c) => c === 0 ? res(out) : rej(new Error(`pg_restore -l exited ${c}: ${err.trim()}`)));
216
+ });
217
+ const { listing, skipped } = filterDerivedFromToc(toc, io.declaredIdentities);
218
+ await fs.promises.writeFile(tocPath, listing, 'utf8');
219
+ io.log(
220
+ skipped.length
221
+ ? `restore: skipping ${skipped.length} declared derived object(s) — db:reconcile owns them (${skipped.slice(0, 6).join(', ')}${skipped.length > 6 ? `, +${skipped.length - 6} more` : ''}).`
222
+ : 'restore: the artifact carries no declared derived objects.',
223
+ );
224
+
225
+ // Phase A: pg_restore → rewrite → local file. Only local processes; nothing can stall here.
226
+ io.log(`restore phase A: pg_restore → schema rewrite (${schema} → ${incoming}) → ${sqlPath}`);
227
+ const restore = spawn('pg_restore', ['-L', tocPath, '-f', '-', artifactPath], { stdio: ['ignore', 'pipe', 'pipe'] });
228
+ let rErr = '';
229
+ restore.stderr.on('data', (d) => {
230
+ const s = d.toString();
231
+ rErr += s;
232
+ // Surface as it happens — a warning withheld until exit is a warning nobody can act on.
233
+ for (const line of s.split('\n').map((l: string) => l.trim()).filter(Boolean)) io.log(`pg_restore: ${line}`);
234
+ });
235
+ const restoreExit = new Promise<void>((res, rej) => {
236
+ restore.on('error', rej);
237
+ restore.on('close', (c) => c === 0 ? res() : rej(new Error(`pg_restore exited ${c}: ${rErr.trim()}`)));
238
+ });
239
+ let written = 0;
240
+ const phaseATimer = setInterval(() => {
241
+ io.log(`restore phase A: ${formatBytes(written)} of SQL written (t+${humanElapsed(Date.now() - t0)})...`);
242
+ }, 5_000);
243
+ (phaseATimer as any).unref?.();
244
+ try {
245
+ await Promise.all([
246
+ pipeline(
247
+ restore.stdout!,
248
+ countingTap((n) => { written = n; }),
249
+ schemaRewriteStream(schema, incoming),
250
+ fs.createWriteStream(sqlPath),
251
+ ),
252
+ restoreExit,
253
+ ]);
254
+ } finally {
255
+ clearInterval(phaseATimer);
256
+ }
257
+ const aMs = Date.now() - t0;
258
+ io.log(`restore phase A done: ${formatBytes(written)} of SQL in ${humanElapsed(aMs)}.`);
259
+
260
+ // Phase B: psql reads the local file and streams to the target. The credential rides PG* env,
261
+ // never argv (libpq also REJECTS non-keyword URI params like the `search_path` the operator URL
262
+ // bakes in — fine for postgres.js, fatal for a libpq URI). `-d` carries ONLY keepalives, which
263
+ // have no PG* env equivalent and are what keep this connection from dying in the index phase.
264
+ io.log(`restore phase B: psql streaming ${formatBytes(written)} to the target — heartbeat every 10s.`);
265
+ const psql = spawn('psql', ['-d', pgKeepaliveConninfo(), '-v', 'ON_ERROR_STOP=1', '-f', sqlPath], {
266
+ stdio: ['ignore', 'ignore', 'pipe'],
267
+ env: { ...process.env, ...pgEnvFromUrl(url) },
268
+ });
269
+ let pErr = '';
270
+ psql.stderr.on('data', (d) => {
271
+ const s = d.toString();
272
+ pErr += s;
273
+ for (const line of s.split('\n').map((l: string) => l.trim()).filter(Boolean)) io.warn(`psql: ${line}`);
274
+ });
275
+
276
+ // The watchdog. Keepalives should prevent the dead-backend hang, but if it happens anyway the
277
+ // heartbeat SEES it — pg_stat_activity, read over our own live connection, reports no psql
278
+ // backend while the psql process sits there forever. Detection without action is what cost an
279
+ // eight-minute wait: turn it into a kill and a named failure.
280
+ let deadBackendPolls = 0;
281
+ const stopHeartbeat = startHeartbeat(io.runner, {
282
+ incoming,
283
+ log: io.log,
284
+ warn: io.warn,
285
+ onSample: (sample) => {
286
+ if (sample.state === null) deadBackendPolls += 1;
287
+ else deadBackendPolls = 0;
288
+ if (deadBackendPolls === DEAD_BACKEND_POLLS && psql.exitCode === null) {
289
+ io.warn(
290
+ `restore: psql is still running but the server reports NO backend for it after ${deadBackendPolls} consecutive polls. `
291
+ + 'The connection died and psql will never notice (no FIN/RST reaches it). Killing it rather than hanging.',
292
+ );
293
+ psql.kill('SIGTERM');
294
+ // SIGTERM will not land if psql is parked in a blocking read on a dead socket.
295
+ const hardKill = setTimeout(() => { if (psql.exitCode === null) psql.kill('SIGKILL'); }, 5_000);
296
+ (hardKill as any).unref?.();
297
+ }
298
+ },
299
+ });
300
+ const bStart = Date.now();
301
+ try {
302
+ await new Promise<void>((res, rej) => {
303
+ psql.on('error', rej);
304
+ psql.on('close', (c) => {
305
+ if (c === 0) return res();
306
+ if (deadBackendPolls >= DEAD_BACKEND_POLLS) {
307
+ return rej(new Error(
308
+ 'the restore connection died mid-load and psql hung on it (the server had no backend for it). '
309
+ + 'psql was killed by the watchdog; NOTHING was swapped and live is untouched. '
310
+ + 'This is the network path dropping a connection that goes quiet during the index/constraint phase — '
311
+ + 'keepalives are now set, so if you are seeing this the path is dropping the flow faster than a 30s probe interval.',
312
+ ));
313
+ }
314
+ rej(new Error(`psql exited ${c}: ${pErr.trim()}`));
315
+ });
316
+ });
317
+ } finally {
318
+ await stopHeartbeat();
319
+ }
320
+ io.log(`restore phase B done in ${humanElapsed(Date.now() - bStart)} (restore total ${humanElapsed(Date.now() - t0)}).`);
321
+ } finally {
322
+ await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
323
+ }
104
324
  }
105
325
 
106
326
  /**
@@ -229,14 +449,24 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
229
449
  process.exit(1);
230
450
  }
231
451
 
452
+ // --rebuild-derived carries a real outage window: the derived layer does not exist between the
453
+ // swap committing and db:reconcile --apply finishing. State it BEFORE the work starts — saying it
454
+ // only afterward tells the operator about an outage they are already in.
455
+ if (flags['rebuild-derived'] === 'true') {
456
+ warn('--rebuild-derived drops the dependent derived objects as part of the swap. They do NOT exist until db:reconcile --apply finishes — an outage window proportional to the size of the derived layer.');
457
+ }
458
+
232
459
  const modelsPath = resolveModelsPath(flags.models);
233
460
  let models: ModelDescriptor[];
234
461
  let declaredFingerprint: string;
462
+ let declaredDerivedObjects: Array<{ identity: string }> = [];
235
463
  try {
236
464
  step(`Loading models from ${modelsPath}...`);
237
465
  models = await loadModels(modelsPath);
238
466
  const declaredDb = await loadDeclaredDerived(flags.models);
239
467
  declaredFingerprint = fingerprintModels(models, { schemas: [schema], sequences: declaredDb?.sequences }).hash;
468
+ // The identities db:reconcile can regenerate — what makes a dependent safe to drop.
469
+ declaredDerivedObjects = declaredDb?.objects ?? [];
240
470
  } catch (err: any) { fail(err.message); process.exit(1); }
241
471
 
242
472
  // Resolve --from to a local plain -Fc dump (a local file, or an S3 export id fetched down).
@@ -263,7 +493,20 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
263
493
  models, schema,
264
494
  artifactFingerprint,
265
495
  declaredFingerprint,
266
- applyIncoming: async () => { await restoreIntoIncoming(url!, artifact.dumpPath, schema, `${schema}_incoming`); },
496
+ // What db:reconcile can regenerate the set a dependent must be in to be safe to drop.
497
+ declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
498
+ rebuildDerived: flags['rebuild-derived'] === 'true',
499
+ log: (m) => info(m),
500
+ // The runner is handed in and USED: it is idle for the whole restore, so the Phase B
501
+ // heartbeat reads the loading backend's state over it (swap-heartbeat.ts).
502
+ applyIncoming: async (r) => {
503
+ await restoreIntoIncoming(url!, artifact.dumpPath, schema, `${schema}_incoming`, {
504
+ runner: r,
505
+ log: (m) => info(m),
506
+ warn: (m) => warn(m),
507
+ declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
508
+ });
509
+ },
267
510
  snapshot: snapshotViaStage
268
511
  ? async () => {
269
512
  step('Snapshotting the stage before the swap (db:backup)...');
@@ -278,8 +521,19 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
278
521
  if (res.status === 'swapped') {
279
522
  success(`Swapped ${schema} — the artifact is live (no refresh ran).`);
280
523
  for (const w of res.warnings ?? []) warn(`verify warning: ${w.name}${w.detail ? ` — ${w.detail}` : ''}`);
524
+ // The derived layer was dropped with the swap. Say so LOUDLY: until reconcile runs, every
525
+ // reader of those objects is looking at a schema that no longer has them.
526
+ if (flags['rebuild-derived'] === 'true') {
527
+ warn(`the derived objects depending on ${schema} were dropped — they do NOT exist until you regenerate them.`);
528
+ warn(` run now: everystack db:reconcile --apply --stage ${stage ?? '<stage>'} --direct`);
529
+ }
281
530
  } else {
282
531
  fail(`db:swap ${res.status}: ${res.reason}`);
532
+ // Name every object CASCADE would have destroyed, one per line — a comma-joined list of
533
+ // dozens is unreadable, and this is the list the operator has to act on.
534
+ if (res.status === 'refused-dependents') {
535
+ for (const d of res.dependents ?? []) info(` would be destroyed: ${d.schema}.${d.name} (${d.kind})`);
536
+ }
283
537
  process.exit(1);
284
538
  }
285
539
  } catch (err: any) {
@@ -38,6 +38,51 @@ export function pgEnvFromUrl(url: string): Record<string, string> {
38
38
  return env;
39
39
  }
40
40
 
41
+ // --- TCP keepalives: why every long-running pg binary needs them -------------------------------
42
+ //
43
+ // Verified failure, 2026-07-27, on a real db:swap against a deployed stage. A restore's COPY phase
44
+ // saturates the connection; the index/constraint phase that FOLLOWS it sends one statement and then
45
+ // goes quiet for minutes. During that quiet period a stateful middlebox on the path (NAT gateway,
46
+ // firewall) drops the flow's state. The server's own keepalive then finds a dead peer and
47
+ // terminates the backend. The CLIENT, on the far side of the break, never receives a FIN or RST:
48
+ // its socket stays ESTABLISHED and it blocks on a read that will never return.
49
+ //
50
+ // Measured: psql alive at 8m54s with 0.05s CPU, socket ESTABLISHED and Send-Q 0, while a SECOND
51
+ // connection to the same database confirmed via pg_stat_activity that no psql backend existed.
52
+ //
53
+ // Client keepalives fix both halves. Probing every 30s of quiet keeps the middlebox's state alive
54
+ // so the flow is never dropped, and if the peer does die the client declares it dead in ~80s
55
+ // (30 + 5 x 10) instead of hanging forever.
56
+ //
57
+ // These MUST travel as libpq connection parameters — there is no PG* environment variable for
58
+ // keepalives. Passing them via `-d` keyword/value form still leaves host/user/password/sslmode to
59
+ // the PG* env, because libpq resolves each parameter from the conninfo first and the environment
60
+ // second. So the credential stays off argv. Mirrors @everystack/server's backup.ts.
61
+
62
+ export const PG_KEEPALIVE_IDLE_S = 30;
63
+ export const PG_KEEPALIVE_INTERVAL_S = 10;
64
+ export const PG_KEEPALIVE_COUNT = 5;
65
+
66
+ /** Escape a value for libpq keyword/value conninfo (single-quoted, backslash-escaped). */
67
+ function conninfoValue(v: string): string {
68
+ return `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
69
+ }
70
+
71
+ /**
72
+ * A libpq keyword/value conninfo carrying ONLY keepalives (plus `dbname` when given). Everything
73
+ * else still resolves from the PG* env — see the note above. Safe to log: it holds no credential.
74
+ */
75
+ export function pgKeepaliveConninfo(dbName?: string): string {
76
+ const parts = [
77
+ 'keepalives=1',
78
+ `keepalives_idle=${PG_KEEPALIVE_IDLE_S}`,
79
+ `keepalives_interval=${PG_KEEPALIVE_INTERVAL_S}`,
80
+ `keepalives_count=${PG_KEEPALIVE_COUNT}`,
81
+ ];
82
+ if (dbName) parts.unshift(`dbname=${conninfoValue(dbName)}`);
83
+ return parts.join(' ');
84
+ }
85
+
41
86
  export async function dbMigrateCommand(flags: Record<string, string>): Promise<void> {
42
87
  step('Resolving deployed config...');
43
88
  let config;
@@ -160,10 +205,14 @@ export interface ProvisionSecretPlan {
160
205
  }
161
206
 
162
207
  /**
163
- * Decide what db:provision writes and what it announces. Both spellings of each secret
164
- * are written — PascalCase is the only name an `sst.Secret` component can carry (so the
165
- * wiring text and the store finally agree), the raw name keeps env-style consumers and
166
- * `secrets export` working. Pure, so the announcement contract is pinned by tests.
208
+ * Decide what db:provision writes and what it announces. Writes the canonical
209
+ * SCREAMING_SNAKE names only (DATABASE_URL / ADMIN_DATABASE_URL) the names the
210
+ * reference app declares (`new sst.Secret('DATABASE_URL')`), getDatabaseUrl() /
211
+ * getAdminDatabaseUrl() read, and `secrets export` emits to .env. (The old both-
212
+ * spellings write hedged against an sst.Secret needing a PascalCase name — it does
213
+ * not; underscore names are valid, so the PascalCase copy had no consumer.) The
214
+ * READS of `existing` below still tolerate the legacy PascalCase spelling so a stage
215
+ * provisioned by an older CLI is still detected. Pure — the contract is pinned by tests.
167
216
  */
168
217
  export function buildProvisionSecretPlan(args: {
169
218
  result: { loginRole: string; adminRole?: string; adminVerified?: boolean | null };
@@ -174,13 +223,12 @@ export function buildProvisionSecretPlan(args: {
174
223
  const { result, authUrl, adminUrl, existing } = args;
175
224
  const updates: Record<string, string> = {
176
225
  DATABASE_URL: authUrl,
177
- DatabaseUrl: authUrl,
178
226
  };
179
227
  const notes: string[] = [];
180
228
  const warnings: string[] = [];
181
229
 
182
230
  const prevAuthRole = roleOfUrl(existing.DATABASE_URL ?? existing.DatabaseUrl);
183
- notes.push(`DATABASE_URL / DatabaseUrl: ${prevAuthRole ?? 'unset'} → ${result.loginRole}`);
231
+ notes.push(`DATABASE_URL: ${prevAuthRole ?? 'unset'} → ${result.loginRole}`);
184
232
  if (prevAuthRole && prevAuthRole !== result.loginRole) {
185
233
  warnings.push(
186
234
  `DATABASE_URL was already set (role '${prevAuthRole}') — any function linked to it connects as '${result.loginRole}' after its next cold start. Ensure grants are in place: everystack db:reconcile && everystack db:doctor.`,
@@ -189,10 +237,9 @@ export function buildProvisionSecretPlan(args: {
189
237
 
190
238
  if (result.adminRole && adminUrl) {
191
239
  updates.ADMIN_DATABASE_URL = adminUrl;
192
- updates.AdminDatabaseUrl = adminUrl;
193
240
  const prevAdminRole = roleOfUrl(existing.AdminDatabaseUrl ?? existing.ADMIN_DATABASE_URL);
194
241
  const verified = result.adminVerified === true ? 'login verified' : 'login NOT verified';
195
- notes.push(`AdminDatabaseUrl / ADMIN_DATABASE_URL: ${prevAdminRole ?? 'unset'} → ${result.adminRole} (${verified})`);
242
+ notes.push(`ADMIN_DATABASE_URL: ${prevAdminRole ?? 'unset'} → ${result.adminRole} (${verified})`);
196
243
  if (result.adminVerified !== true) {
197
244
  warnings.push(
198
245
  `The '${result.adminRole}' login could not be verified from the ops function — confirm operator connectivity before relying on it: everystack db:doctor.`,
@@ -54,11 +54,181 @@ export interface ExecuteSwapOptions {
54
54
  verify?: () => Promise<SwapVerdict>;
55
55
  /** Restore the stage to the snapshot from step 2 when verify is fatal. */
56
56
  rollbackToSnapshot?: () => Promise<void>;
57
+ /** Progress/instrumentation sink (per-table landed rows, the count assertion). Defaults to a no-op. */
58
+ log?: (msg: string) => void;
59
+ /**
60
+ * `schema.name` of every object the DECLARED derived layer can regenerate (db:reconcile's source
61
+ * of truth). A dependent in this set is safe to drop: reconcile rebuilds it from the descriptor.
62
+ * A dependent NOT in it can never be rebuilt, so it is refused unconditionally.
63
+ */
64
+ declaredIdentities?: string[];
65
+ /**
66
+ * Informed consent to DROP the dependent derived objects as part of the swap (`--rebuild-derived`).
67
+ *
68
+ * This is the intended workflow — swap the schema, then regenerate the derived layer — not a
69
+ * workaround. The gate exists to stop SILENT destruction, so an operator who says "drop them, I
70
+ * will reconcile after" has supplied exactly the consent that was missing. Undeclared dependents
71
+ * are still refused: consent cannot cover an object nothing knows how to rebuild.
72
+ */
73
+ rebuildDerived?: boolean;
74
+ }
75
+
76
+ /** One table's landed-vs-live count, the intrinsic post-swap assertion's unit. */
77
+ export interface RowCount {
78
+ table: string;
79
+ incoming: number;
80
+ live: number;
81
+ }
82
+
83
+ /**
84
+ * The intrinsic count assertion: after the rename, the LIVE schema must hold exactly what the
85
+ * artifact landed in the incoming schema. A no-op swap (the rename silently not taking) leaves the
86
+ * OLD table under `<schema>.<table>` — its count differs from what was restored, and this catches it.
87
+ * A table present in incoming but absent live (a partial rename) is a mismatch too. Pure — the impure
88
+ * path reads the two count maps and hands them here, so the verdict wording is unit-tested.
89
+ */
90
+ export function diffRowCounts(counts: RowCount[]): SwapCheck[] {
91
+ const bad: SwapCheck[] = [];
92
+ for (const c of counts) {
93
+ if (c.incoming !== c.live) {
94
+ bad.push({
95
+ name: `rowcount:${c.table}`,
96
+ ok: false,
97
+ detail: `artifact landed ${c.incoming} rows but live ${c.table} has ${c.live} after swap — the swap did not take effect (or landed partial data).`,
98
+ severity: 'fatal',
99
+ });
100
+ }
101
+ }
102
+ return bad;
103
+ }
104
+
105
+ /** One object OUTSIDE the swapped schema that depends on something inside it. */
106
+ export interface CrossSchemaDependent {
107
+ schema: string;
108
+ name: string;
109
+ /** `view`, `materialized view`, `function`, `procedure`. */
110
+ kind: string;
111
+ /** Identity arguments for a function/procedure — DROP FUNCTION needs them when overloaded. */
112
+ args?: string;
113
+ }
114
+
115
+ /** `schema.name`, the join key against the declared derived layer's identities. */
116
+ export function dependentIdentity(d: CrossSchemaDependent): string {
117
+ return `${d.schema}.${d.name}`;
118
+ }
119
+
120
+ /**
121
+ * The DROP for one dependent. CASCADE is safe here and ordering does not matter: the dependent set
122
+ * is a transitive CLOSURE, so anything CASCADE could reach is already in the set being dropped.
123
+ */
124
+ export function dropDependentSql(d: CrossSchemaDependent): string {
125
+ const ref = `"${d.schema.replace(/"/g, '""')}"."${d.name.replace(/"/g, '""')}"`;
126
+ if (d.kind === 'materialized view') return `DROP MATERIALIZED VIEW IF EXISTS ${ref} CASCADE;`;
127
+ if (d.kind === 'view') return `DROP VIEW IF EXISTS ${ref} CASCADE;`;
128
+ // Functions can be overloaded, so the identity arguments are load-bearing.
129
+ const kw = d.kind === 'procedure' ? 'PROCEDURE' : 'FUNCTION';
130
+ return `DROP ${kw} IF EXISTS ${ref}(${d.args ?? ''}) CASCADE;`;
131
+ }
132
+
133
+ /**
134
+ * Objects in OTHER schemas that depend on the swapped schema — the CASCADE blast radius.
135
+ *
136
+ * Why this gate exists. The swap ends with `DROP SCHEMA <schema>_retiring CASCADE`. PostgreSQL
137
+ * binds a view, matview or function to its dependencies by OID, so the rename carries every such
138
+ * binding onto the RETIRING schema — and CASCADE then drops them. Silently. The swap already
139
+ * understands this for foreign keys (it drops and recreates them around the rename, see
140
+ * crossSchemaForeignKeys) and was blind to everything else.
141
+ *
142
+ * The walk must be TRANSITIVE, and the first version was not. Matching only DIRECT dependents,
143
+ * given `stats.t` ← `other.v1` ← `other.v2`, it found v1 and missed v2 entirely. v2 binds to v1 by
144
+ * OID, so when v1 goes so does v2. Worse: when the only direct dependent lives INSIDE the swapped
145
+ * schema, a direct-only query reports NOTHING while CASCADE still destroys everything downstream.
146
+ *
147
+ * So: seed the closure with every relation, type and function in the schema, then walk pg_depend
148
+ * forward to fixpoint. Views and matviews reach their dependencies through pg_rewrite, so an edge
149
+ * is normalized to the OWNING relation (`rw.ev_class`), not the rewrite rule. Types are seeded
150
+ * because `RETURNS SETOF <schema>.<table>` records against the table's composite TYPE rather than
151
+ * the table — a pg_class-only walk misses every such function.
152
+ *
153
+ * Objects INSIDE the schema are excluded (they ride along with the swap), as are the incoming and
154
+ * retiring schemas (transient, and a leftover `<schema>_incoming` from a failed run must not make
155
+ * the gate refuse forever). Foreign keys never surface here: they are pg_constraint, and the swap
156
+ * drops and recreates them itself.
157
+ *
158
+ * Verified against a live PostgreSQL 16 fixture: a three-deep view chain, a function returning
159
+ * SETOF a table, a view inside the schema, and an unrelated view in another schema.
160
+ */
161
+ export function crossSchemaDependentsQuery(schema: string, incoming: string, retiring: string): string {
162
+ const q = (s: string) => s.replace(/'/g, "''");
163
+ const skip = `ARRAY['${q(incoming)}','${q(retiring)}']`;
164
+ const notSelf = `n.nspname <> '${q(schema)}' AND n.nspname <> ALL (${skip})`;
165
+ return `WITH RECURSIVE seed AS (
166
+ SELECT c.oid AS oid, 'pg_class'::regclass AS cls
167
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '${q(schema)}'
168
+ UNION
169
+ SELECT t.oid, 'pg_type'::regclass
170
+ FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = '${q(schema)}'
171
+ UNION
172
+ SELECT p.oid, 'pg_proc'::regclass
173
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = '${q(schema)}'
174
+ ), closure AS (
175
+ SELECT oid, cls FROM seed
176
+ UNION
177
+ SELECT CASE WHEN d.classid = 'pg_rewrite'::regclass THEN rw.ev_class ELSE d.objid END,
178
+ CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'::regclass ELSE d.classid END
179
+ FROM closure cl
180
+ JOIN pg_depend d ON d.refclassid = cl.cls AND d.refobjid = cl.oid
181
+ LEFT JOIN pg_rewrite rw ON rw.oid = d.objid AND d.classid = 'pg_rewrite'::regclass
182
+ WHERE d.classid IN ('pg_rewrite'::regclass, 'pg_class'::regclass, 'pg_proc'::regclass, 'pg_type'::regclass)
183
+ )
184
+ SELECT DISTINCT n.nspname AS dep_schema, c.relname AS dep_name,
185
+ CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE c.relkind::text END AS dep_kind,
186
+ ''::text AS dep_args
187
+ FROM closure cl
188
+ JOIN pg_class c ON c.oid = cl.oid AND cl.cls = 'pg_class'::regclass
189
+ JOIN pg_namespace n ON n.oid = c.relnamespace
190
+ WHERE c.relkind IN ('v', 'm') AND ${notSelf}
191
+ UNION
192
+ SELECT DISTINCT n.nspname, p.proname,
193
+ CASE p.prokind WHEN 'p' THEN 'procedure' ELSE 'function' END,
194
+ pg_get_function_identity_arguments(p.oid)
195
+ FROM closure cl
196
+ JOIN pg_proc p ON p.oid = cl.oid AND cl.cls = 'pg_proc'::regclass
197
+ JOIN pg_namespace n ON n.oid = p.pronamespace
198
+ WHERE ${notSelf}
199
+ ORDER BY 1, 2`;
200
+ }
201
+
202
+ /** The refusal message — names what is at risk, and which of the two refusals this is. */
203
+ export function crossSchemaDependentsRefusal(
204
+ schema: string,
205
+ deps: CrossSchemaDependent[],
206
+ opts: { rebuildRequested?: boolean; undeclared?: CrossSchemaDependent[] } = {},
207
+ ): string {
208
+ const name = (d: CrossSchemaDependent) => `${d.schema}.${d.name} (${d.kind})`;
209
+ const undeclared = opts.undeclared ?? [];
210
+
211
+ // Refusal 1: consent was given, but something in the set cannot be regenerated.
212
+ if (opts.rebuildRequested && undeclared.length > 0) {
213
+ return `${undeclared.length} of the ${deps.length} object(s) depending on ${schema} are NOT declared, so nothing can regenerate them: `
214
+ + `${undeclared.slice(0, 20).map(name).join(', ')}${undeclared.length > 20 ? `, and ${undeclared.length - 20} more` : ''}. `
215
+ + `--rebuild-derived drops dependents on the promise that db:reconcile --apply rebuilds them, and that promise does not hold for an undeclared object — dropping it would destroy it. `
216
+ + `Nothing was changed. Declare them in db/models (then db:reconcile --apply), or drop them yourself if they are genuinely disposable.`;
217
+ }
218
+
219
+ // Refusal 2: no consent given. Explain the danger and the flag.
220
+ const listed = deps.slice(0, 20).map(name).join(', ');
221
+ const more = deps.length > 20 ? `, and ${deps.length - 20} more` : '';
222
+ return `${deps.length} object(s) outside ${schema} depend on it, and the swap would DESTROY them: ${listed}${more}. `
223
+ + `PostgreSQL binds views, matviews and functions to their dependencies by OID, so renaming ${schema} carries those bindings onto ${schema}_retiring, and the swap's final DROP SCHEMA ... CASCADE drops them silently. `
224
+ + `Nothing was changed — this refusal happens before the snapshot. `
225
+ + `If regenerating them is what you want, re-run with --rebuild-derived: the swap drops them in the same transaction and db:reconcile --apply rebuilds them from the declared descriptors.`;
57
226
  }
58
227
 
59
228
  export type SwapStatus =
60
229
  | 'swapped'
61
230
  | 'refused-fingerprint'
231
+ | 'refused-dependents'
62
232
  | 'refused-integrity'
63
233
  | 'rolled-back-verify';
64
234
 
@@ -69,6 +239,10 @@ export interface SwapResult {
69
239
  verdict?: SwapVerdict;
70
240
  /** Warn-severity checks that did NOT trigger rollback (surfaced). */
71
241
  warnings?: SwapCheck[];
242
+ /** On `refused-dependents`: exactly what CASCADE would have destroyed. */
243
+ dependents?: CrossSchemaDependent[];
244
+ /** The subset nothing can regenerate — the reason --rebuild-derived was not enough. */
245
+ undeclaredDependents?: CrossSchemaDependent[];
72
246
  }
73
247
 
74
248
  /** Does the verdict force a rollback? Any failing check whose severity is fatal (the default). */
@@ -79,7 +253,27 @@ export function isFatalVerdict(verdict: SwapVerdict): boolean {
79
253
  return checks.some((c) => !c.ok && (c.severity ?? 'fatal') === 'fatal');
80
254
  }
81
255
 
256
+ /** Double-quote a Postgres identifier read from the catalog (embedded quotes doubled). */
257
+ function quoteIdent(name: string): string {
258
+ return `"${name.replace(/"/g, '""')}"`;
259
+ }
260
+
261
+ /** The base tables physically present in `schema` (what a restore actually landed / what live serves). */
262
+ async function baseTablesIn(runner: QueryRunner, schema: string): Promise<string[]> {
263
+ const rows = await runner(
264
+ `SELECT table_name FROM information_schema.tables WHERE table_schema = '${schema.replace(/'/g, "''")}' AND table_type = 'BASE TABLE' ORDER BY table_name`,
265
+ );
266
+ return rows.map((r: any) => r.table_name as string);
267
+ }
268
+
269
+ /** `count(*)` for one table, as a number. */
270
+ async function countRows(runner: QueryRunner, schema: string, table: string): Promise<number> {
271
+ const rows = await runner(`SELECT count(*)::bigint AS n FROM ${quoteIdent(schema)}.${quoteIdent(table)}`);
272
+ return Number(rows[0]?.n ?? 0);
273
+ }
274
+
82
275
  export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions): Promise<SwapResult> {
276
+ const log = opts.log ?? (() => {});
83
277
  // 1. Fingerprint gate — declared-vs-declared: does the artifact and the target agree on the shape.
84
278
  if (opts.artifactFingerprint !== opts.declaredFingerprint) {
85
279
  return {
@@ -90,12 +284,63 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
90
284
 
91
285
  const plan = renderSchemaSwap(opts.models, { schema: opts.schema, incoming: opts.incoming, retiring: opts.retiring });
92
286
 
287
+ // 1b. THE CASCADE GATE. Refuse before the snapshot — before anything moves at all — if any object
288
+ // outside this schema depends on it. The swap's final DROP SCHEMA ... CASCADE would destroy
289
+ // them silently, and a silent destroyer is the worst thing this command could be.
290
+ const depRows = await runner(crossSchemaDependentsQuery(opts.schema, plan.incoming, plan.retiring));
291
+ const dependents: CrossSchemaDependent[] = depRows.map((r: any) => ({
292
+ schema: r.dep_schema, name: r.dep_name, kind: r.dep_kind, args: r.dep_args || undefined,
293
+ }));
294
+ if (dependents.length > 0) {
295
+ const declared = new Set(opts.declaredIdentities ?? []);
296
+ const undeclared = dependents.filter((d) => !declared.has(dependentIdentity(d)));
297
+
298
+ // Consent covers only what can be rebuilt. An undeclared dependent is refused even WITH
299
+ // --rebuild-derived: dropping it destroys it, because nothing knows how to recreate it.
300
+ if (!opts.rebuildDerived || undeclared.length > 0) {
301
+ return {
302
+ status: 'refused-dependents',
303
+ reason: crossSchemaDependentsRefusal(opts.schema, dependents, {
304
+ rebuildRequested: !!opts.rebuildDerived,
305
+ undeclared,
306
+ }),
307
+ dependents,
308
+ undeclaredDependents: undeclared.length ? undeclared : undefined,
309
+ };
310
+ }
311
+
312
+ // Consented, and every one is declared: drop them inside the swap transaction, before the
313
+ // rename. db:reconcile --apply regenerates them from the descriptors afterwards.
314
+ // Future tense on purpose: these DROPs ride the swap transaction, which is several steps away
315
+ // and may never run. Announcing them as done was misleading on a restore that failed first.
316
+ log(`${dependents.length} declared derived object(s) depend on ${opts.schema} and WILL BE DROPPED by the swap transaction (regenerate with db:reconcile --apply). Nothing is dropped unless the swap itself commits.`);
317
+ plan.statements.unshift(...dependents.map(dropDependentSql));
318
+ }
319
+
93
320
  // 2. Snapshot (the rollback point) before anything destructive.
94
321
  if (opts.snapshot) await opts.snapshot();
95
322
 
96
- // 3. Land the incoming schema while live keeps serving.
323
+ // 3. Land the incoming schema while live keeps serving. Drop a stale incoming FIRST so a retry
324
+ // after a failed restore is idempotent (a half-landed <schema>_incoming from a prior crash
325
+ // would otherwise collide on the artifact's CREATE SCHEMA and fail every retry).
326
+ await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(plan.incoming)} CASCADE`);
97
327
  await opts.applyIncoming(runner);
98
328
 
329
+ // Capture what the artifact actually landed, per table — the count the live schema must match
330
+ // after the swap. Empty here means the restore created the schema but no tables: a silent
331
+ // partial that must fail, not pass.
332
+ const incomingTables = await baseTablesIn(runner, plan.incoming);
333
+ const expected = new Map<string, number>();
334
+ for (const t of incomingTables) expected.set(t, await countRows(runner, plan.incoming, t));
335
+ log(`landed ${incomingTables.length} table(s) into ${plan.incoming}: ${incomingTables.map((t) => `${t}=${expected.get(t)}`).join(', ') || '(none)'}`);
336
+ if (incomingTables.length === 0) {
337
+ try { await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(plan.incoming)} CASCADE`); } catch { /* best effort */ }
338
+ return {
339
+ status: 'refused-integrity',
340
+ reason: `the restore landed NO base tables into ${plan.incoming} — the artifact did not apply (a restore that reported success but wrote nothing). The swap was NOT applied; live is untouched.`,
341
+ };
342
+ }
343
+
99
344
  // 4. The atomic swap. A FK re-validation failure (a bad artifact) rolls the whole thing back.
100
345
  await runner('BEGIN');
101
346
  try {
@@ -111,7 +356,23 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
111
356
  };
112
357
  }
113
358
 
114
- // 5. Verify (post-commit). Fatal roll back to the snapshot; warn surface only.
359
+ // 5a. The intrinsic count assertion (ALWAYS runs this is the safety net a silent no-op needs).
360
+ // After the rename, live must hold exactly what landed. A mismatch means the swap did not
361
+ // take effect; roll back to the snapshot and refuse LOUD.
362
+ const counts: RowCount[] = [];
363
+ for (const t of incomingTables) counts.push({ table: t, incoming: expected.get(t)!, live: await countRows(runner, opts.schema, t) });
364
+ log(`post-swap live counts: ${counts.map((c) => `${c.table}=${c.live}`).join(', ')}`);
365
+ const countChecks = diffRowCounts(counts);
366
+ if (countChecks.length > 0) {
367
+ if (opts.rollbackToSnapshot) await opts.rollbackToSnapshot();
368
+ return {
369
+ status: 'rolled-back-verify',
370
+ reason: `post-swap row counts do not match the artifact — the swap did not land: ${countChecks.map((c) => c.detail).join(' ')} ${opts.rollbackToSnapshot ? 'Rolled back to the pre-swap snapshot.' : 'NO snapshot was configured to roll back to.'}`,
371
+ verdict: { ok: false, checks: countChecks },
372
+ };
373
+ }
374
+
375
+ // 5b. Verify (post-commit). Fatal → roll back to the snapshot; warn → surface only.
115
376
  let verdict: SwapVerdict | undefined;
116
377
  if (opts.verify) {
117
378
  verdict = await opts.verify();
@@ -0,0 +1,407 @@
1
+ /**
2
+ * swap-heartbeat — liveness for db:swap's restore leg, read from the OTHER side of the connection.
3
+ *
4
+ * The problem: `restoreIntoIncoming` spawns `psql -f <file>` and prints nothing until it exits.
5
+ * Both stderr streams are accumulated into strings and surfaced only on a non-zero exit, so for the
6
+ * whole multi-GB push a wedged restore and a healthy one produce the identical observation: silence.
7
+ *
8
+ * The probe: db:swap already holds an operator connection (`createUrlRunner`, max: 1) that sits
9
+ * COMPLETELY IDLE for the duration of the restore — `applyIncoming` ignores the runner it is handed
10
+ * and spawns psql on its own connection. So liveness costs no new connection and no new credential.
11
+ *
12
+ * ============================================================================================
13
+ * THE PROBE MUST NEVER TOUCH A RELATION. Learned the hard way, 2026-07-27.
14
+ * ============================================================================================
15
+ * The first version summed `pg_total_relation_size()` over the incoming schema. That function calls
16
+ * `relation_open(relid, AccessShareLock)` — it LOCKS every relation it measures, and holds those
17
+ * locks until the whole `sum()` completes. Against a restore doing DDL, two things follow:
18
+ *
19
+ * 1. The probe BLOCKS behind the restore's AccessExclusiveLock. Measured: it hung until killed by
20
+ * `statement_timeout`; unbounded without one. A liveness probe that hangs is worse than none —
21
+ * it reports "no backend connected" about a restore that is fine.
22
+ * 2. Worse, it blocks the RESTORE. PostgreSQL queues lock requests fairly, so once the probe's
23
+ * pending AccessShare is in line, the restore's next AccessExclusive queues behind IT. Polling
24
+ * every 10s injected a lock barrier across every table in the schema.
25
+ *
26
+ * Everything below reads ONLY statistics and catalog views — `pg_stat_all_tables`,
27
+ * `pg_stat_progress_copy`, `pg_stat_activity`, `pg_namespace`. All verified lock-free against a held
28
+ * AccessExclusiveLock (0.03s vs. blocking-until-killed). Do not reintroduce a relation-size call,
29
+ * `relpages` arithmetic aside — if you need bytes, `pg_stat_progress_copy.bytes_processed` is the
30
+ * lock-free source.
31
+ *
32
+ * Two signals, and the pair is what discriminates:
33
+ * - rows landed (committed inserts + the in-flight COPY's tuples) — is data actually arriving
34
+ * - the loader's wait state — what is it waiting ON
35
+ *
36
+ * Neither alone is enough. Over a high-latency link a HEALTHY COPY sits in `Client/ClientRead`
37
+ * constantly, between chunks — treating that as a stall cries wolf on every normal restore. The
38
+ * deadlock signature is `ClientRead` with FLAT progress across consecutive polls: the server is
39
+ * waiting for a client that has stopped sending. `Lock` is never healthy here and reports at once.
40
+ */
41
+
42
+ import type { QueryRunner } from './authz-contract.js';
43
+
44
+ /** One poll: what has landed, and what the loading backend is doing. */
45
+ export interface HeartbeatSample {
46
+ /** ms since the restore phase started. */
47
+ elapsedMs: number;
48
+ /**
49
+ * Rows landed, from `n_tup_ins` ALONE. Null before the schema exists.
50
+ *
51
+ * Do NOT add the in-flight COPY's tuples to this. The first version did, on the assumption that
52
+ * a COPY's tuples only enter `n_tup_ins` at commit. They do not: the backend flushes stats while
53
+ * the COPY is still running, so the two overlap. Measured on a real run — it reported 767,883
54
+ * rows landed for an artifact whose entire SQL file contains at most 345,663. In-flight tuples
55
+ * are carried separately as `copyRows` and reported, never summed.
56
+ */
57
+ rows: number | null;
58
+ /** Tuples processed by in-flight COPYs. Separate from `rows` — see above. */
59
+ copyRows: number;
60
+ /** Bytes processed by in-flight COPYs; 0 between them. The finer-grained signal inside one COPY. */
61
+ copyBytes: number;
62
+ /** How many COPY commands are running right now. */
63
+ copies: number;
64
+ /** Tables present in the incoming schema. */
65
+ tables: number;
66
+ /**
67
+ * ALL relations in the schema — tables, indexes, sequences, matviews. Its growth is the progress
68
+ * signal for the DDL and index phases, when no row has landed yet and nothing is COPYing.
69
+ */
70
+ relations: number;
71
+ /** Whether the incoming schema exists yet — keeps "empty" and "absent" distinguishable. */
72
+ schemaExists: boolean;
73
+ /** The loading backend's state, or null when no psql backend is connected. */
74
+ state: string | null;
75
+ waitEventType: string | null;
76
+ waitEvent: string | null;
77
+ /** Who holds the lock we are waiting on: `pid N [app] query...`. Null unless blocked. */
78
+ blockedBy: string | null;
79
+ }
80
+
81
+ export type Liveness =
82
+ /** Rows or COPY bytes grew since the last poll. */
83
+ | 'progressing'
84
+ /** Waiting on a lock — never healthy on this path. */
85
+ | 'blocked'
86
+ /** Flat progress AND the server is waiting on the client: the deadlock signature. */
87
+ | 'client-stall'
88
+ /** Connected and working, but nothing measurable moved this poll (DDL, index build). */
89
+ | 'busy'
90
+ /** No psql backend connected — not started yet, or already gone. */
91
+ | 'absent';
92
+
93
+ /** Consecutive stalled polls before the heartbeat escalates from info to warn. */
94
+ export const STALL_POLLS = 3;
95
+
96
+ /**
97
+ * Minimum quiet time since the last observed progress before a stall is called out.
98
+ *
99
+ * STALL_POLLS alone fires at the TAIL of a healthy run: a real restore ended with three flat polls
100
+ * and the warning printed one second before it completed successfully. Requiring a grace window
101
+ * since progress was last SEEN — not merely N polls in a row — keeps the alarm for genuine stalls.
102
+ */
103
+ export const STALL_GRACE_MS = 60_000;
104
+
105
+ /** Default ms between polls. Long enough to be quiet, short enough to localize a stall. */
106
+ const DEFAULT_INTERVAL_MS = 10_000;
107
+
108
+ /**
109
+ * Client-side cap on one poll. The query is provably lock-free, but a hung CONNECTION (network,
110
+ * server-side termination) could still park it forever, and a probe that never returns is exactly
111
+ * the failure this module exists to report. Racing in JS also covers hangs no `statement_timeout`
112
+ * would catch, because it does not depend on the server being responsive at all.
113
+ */
114
+ export const PROBE_TIMEOUT_MS = 5_000;
115
+
116
+ /** Single-quote a string literal for inlining into the probe SQL. */
117
+ function quoteLiteral(v: string): string {
118
+ return v.replace(/'/g, "''");
119
+ }
120
+
121
+ /**
122
+ * The probe, as ONE round trip. Statistics and catalog views ONLY — see the module header. Every
123
+ * CTE here was verified to return in ~0.03s while another session held an AccessExclusiveLock on
124
+ * the schema's tables.
125
+ */
126
+ export function heartbeatQuery(incoming: string): string {
127
+ const ns = quoteLiteral(incoming);
128
+ return `WITH ns AS (
129
+ SELECT oid FROM pg_namespace WHERE nspname = '${ns}'
130
+ ), tbl AS (
131
+ SELECT coalesce(sum(n_tup_ins), 0)::bigint AS rows_in,
132
+ count(*)::int AS tables
133
+ FROM pg_stat_all_tables WHERE schemaname = '${ns}'
134
+ ), rel AS (
135
+ -- EVERY relation, not just tables: indexes, sequences and matviews are relations, so this keeps
136
+ -- producing a progress signal through the CREATE INDEX phase after the data has landed.
137
+ SELECT count(*)::int AS relations
138
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '${ns}'
139
+ ), cp AS (
140
+ -- Scoped to OUR load: COPY FROM only (never a concurrent pg_dump's COPY TO), and only into
141
+ -- relations in the incoming schema. Unscoped, this counted every COPY in the database — on a
142
+ -- real run it reported 2,269,567 rows in flight for an artifact containing 341,160, because a
143
+ -- concurrent dump was streaming out. Worse than noise: "COPY advancing" reads as progress, so
144
+ -- another session's work could mask our restore being genuinely stuck.
145
+ SELECT count(*)::int AS copies,
146
+ coalesce(sum(p.bytes_processed), 0)::bigint AS copy_bytes,
147
+ coalesce(sum(p.tuples_processed), 0)::bigint AS copy_tuples
148
+ FROM pg_stat_progress_copy p
149
+ JOIN pg_class c ON c.oid = p.relid
150
+ JOIN pg_namespace n ON n.oid = c.relnamespace
151
+ WHERE p.datname = current_database()
152
+ AND p.command = 'COPY FROM'
153
+ AND n.nspname = '${ns}'
154
+ ), be AS (
155
+ -- pg_blocking_pids answers the question a bare wait_event cannot: WHO is holding the lock.
156
+ -- Without it "Lock/relation" is unactionable and invites guessing at the culprit.
157
+ SELECT a.state, a.wait_event_type, a.wait_event,
158
+ (SELECT string_agg(
159
+ format('pid %s [%s] %s', b.pid,
160
+ coalesce(nullif(b.application_name, ''), '?'),
161
+ left(regexp_replace(coalesce(b.query, ''), '\\s+', ' ', 'g'), 80)),
162
+ '; ')
163
+ FROM pg_stat_activity b
164
+ WHERE b.pid = ANY (pg_blocking_pids(a.pid))) AS blocked_by
165
+ FROM pg_stat_activity a
166
+ WHERE a.datname = current_database()
167
+ AND a.pid <> pg_backend_pid()
168
+ AND a.application_name = 'psql'
169
+ ORDER BY a.backend_start DESC
170
+ LIMIT 1
171
+ )
172
+ SELECT (SELECT count(*) FROM ns) > 0 AS schema_exists,
173
+ tbl.rows_in, tbl.tables, rel.relations,
174
+ cp.copies, cp.copy_bytes, cp.copy_tuples,
175
+ be.state, be.wait_event_type, be.wait_event, be.blocked_by
176
+ FROM tbl CROSS JOIN rel CROSS JOIN cp LEFT JOIN be ON true`;
177
+ }
178
+
179
+ /** Map a probe row to a sample. A missing row, or a schema that does not exist, means null rows. */
180
+ export function toSample(row: any, elapsedMs: number): HeartbeatSample {
181
+ const schemaExists = row?.schema_exists === true;
182
+ const committed = row?.rows_in == null ? null : Number(row.rows_in);
183
+ return {
184
+ elapsedMs,
185
+ rows: schemaExists && committed !== null ? committed : null,
186
+ copyRows: Number(row?.copy_tuples ?? 0),
187
+ copyBytes: Number(row?.copy_bytes ?? 0),
188
+ copies: Number(row?.copies ?? 0),
189
+ tables: Number(row?.tables ?? 0),
190
+ relations: Number(row?.relations ?? 0),
191
+ schemaExists,
192
+ state: row?.state ?? null,
193
+ waitEventType: row?.wait_event_type ?? null,
194
+ waitEvent: row?.wait_event ?? null,
195
+ blockedBy: row?.blocked_by ?? null,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Did anything measurable move between two samples? Any of the three independent signals counts.
201
+ * `rows` is monotonic but coarse; the COPY counters are live but reset to 0 when a COPY finishes,
202
+ * so a drop in them is never evidence of a stall — only a rise is evidence of progress.
203
+ */
204
+ function movedForward(prev: HeartbeatSample, cur: HeartbeatSample): boolean {
205
+ if (prev.rows !== null && cur.rows !== null && cur.rows > prev.rows) return true;
206
+ if (cur.copyBytes > prev.copyBytes) return true;
207
+ if (cur.copyRows > prev.copyRows) return true;
208
+ // Schema growth. Without this a restore visibly creating sixteen tables every poll read as a
209
+ // stall and tripped the deadlock alarm, because no ROW had landed yet — the data phase had not
210
+ // started. Every phase of a restore must have a signal, or the quiet ones look like hangs.
211
+ return cur.relations > prev.relations;
212
+ }
213
+
214
+ /**
215
+ * The discrimination. Order is the whole point:
216
+ * 1. no backend → absent (nothing else can be said)
217
+ * 2. Lock → blocked, even mid-progress, and even with no baseline
218
+ * 3. no predecessor → busy (cannot claim progress OR a stall on the first poll)
219
+ * 4. rows/bytes grew → progressing, EVEN in ClientRead (the healthy high-latency case)
220
+ * 5. schema not created → busy (DDL phase, nothing to measure)
221
+ * 6. flat + ClientRead → client-stall (the deadlock signature)
222
+ */
223
+ export function classify(prev: HeartbeatSample | null, cur: HeartbeatSample): Liveness {
224
+ if (cur.state === null) return 'absent';
225
+ if (!prev) return 'busy';
226
+ // PROGRESS OUTRANKS EVERY SCARY WAIT STATE, including Lock. A restore takes relation locks
227
+ // constantly while building 84 indexes and 37 constraints, so a 10s sample catches it mid-wait
228
+ // routinely. The first version returned 'blocked' on sight of a Lock and printed "nothing
229
+ // landing" over a run whose row count was climbing 25k every poll. Same rule as ClientRead: the
230
+ // wait state only means something when nothing is moving.
231
+ if (movedForward(prev, cur)) return 'progressing';
232
+ if (cur.waitEventType === 'Lock') return 'blocked';
233
+ if (!cur.schemaExists) return 'busy';
234
+ if (cur.waitEvent === 'ClientRead') return 'client-stall';
235
+ return 'busy';
236
+ }
237
+
238
+ /** `12s`, `1m30s`, `2h05m01s`. */
239
+ export function humanElapsed(ms: number): string {
240
+ const total = Math.max(0, Math.floor(ms / 1000));
241
+ const h = Math.floor(total / 3600);
242
+ const m = Math.floor((total % 3600) / 60);
243
+ const s = total % 60;
244
+ if (h) return `${h}h${String(m).padStart(2, '0')}m${String(s).padStart(2, '0')}s`;
245
+ if (m) return `${m}m${String(s).padStart(2, '0')}s`;
246
+ return `${s}s`;
247
+ }
248
+
249
+ /** `1,234,567` — rows read better grouped. */
250
+ function groupNum(n: number): string {
251
+ return n.toLocaleString('en-US');
252
+ }
253
+
254
+ /** One operator-readable line per poll. Says what moved, what it is waiting on, and how long in. */
255
+ export function formatSample(prev: HeartbeatSample | null, cur: HeartbeatSample, liveness: Liveness): string {
256
+ const at = `t+${humanElapsed(cur.elapsedMs)}`;
257
+ const wait = cur.waitEventType ? `${cur.waitEventType}/${cur.waitEvent ?? '?'}` : (cur.state ?? 'idle');
258
+
259
+ if (liveness === 'absent') {
260
+ return `restore: no psql backend connected to the target (${at}) — the loader has not started yet, or has already exited.`;
261
+ }
262
+ if (!cur.schemaExists) {
263
+ return `restore: schema not created yet, no tables yet — DDL phase, ${wait} (${at}).`;
264
+ }
265
+
266
+ const rows = cur.rows === null ? 'unknown' : groupNum(cur.rows);
267
+ // In-flight COPY tuples are reported alongside the landed count, never folded into it.
268
+ const copying = cur.copies > 0
269
+ ? `, ${cur.copies} COPY running (${groupNum(cur.copyRows)} rows in flight)`
270
+ : '';
271
+ const scope = `${rows} row(s) landed across ${cur.tables} table(s)${copying}`;
272
+
273
+ // Every branch below states only what it actually read. The first version hardcoded "nothing
274
+ // landing" into the blocked branch, which then printed over a run whose row count was climbing.
275
+ if (liveness === 'blocked') {
276
+ // Naming the holder is the difference between an actionable report and a shrug.
277
+ const by = cur.blockedBy ? ` HELD BY ${cur.blockedBy}` : ' (holder not visible — it may belong to another role)';
278
+ return `restore: waiting on ${wait} with no progress this poll${by} — ${scope} (${at}).`;
279
+ }
280
+ if (liveness === 'client-stall') {
281
+ return `restore: nothing landed since the last poll and the server is waiting on the client (${wait}) — ${scope} (${at}).`;
282
+ }
283
+ if (liveness === 'progressing' && prev) {
284
+ const dRows = (cur.rows ?? 0) - (prev.rows ?? 0);
285
+ const secs = Math.max(1, (cur.elapsedMs - prev.elapsedMs) / 1000);
286
+ const moved = dRows > 0
287
+ ? `+${groupNum(dRows)} row(s) (${groupNum(Math.round(dRows / secs))} rows/s)`
288
+ : 'COPY advancing';
289
+ return `restore: ${scope}, ${moved} — ${wait} (${at}).`;
290
+ }
291
+ return `restore: ${scope}, nothing new this poll — ${wait} (${at}).`;
292
+ }
293
+
294
+ /**
295
+ * Run one probe. Never throws and never hangs: a probe that fails must not kill the restore it is
296
+ * watching, and a probe that parks forever is the failure it exists to report.
297
+ */
298
+ export async function pollOnce(
299
+ runner: QueryRunner,
300
+ incoming: string,
301
+ elapsedMs: number,
302
+ timeoutMs: number = PROBE_TIMEOUT_MS,
303
+ ): Promise<HeartbeatSample> {
304
+ let timer: any;
305
+ const timeout = new Promise<undefined>((resolve) => {
306
+ timer = setTimeout(() => resolve(undefined), timeoutMs);
307
+ timer.unref?.();
308
+ });
309
+ try {
310
+ const rows = await Promise.race([
311
+ runner(heartbeatQuery(incoming)).catch(() => undefined),
312
+ timeout,
313
+ ]);
314
+ return toSample(Array.isArray(rows) ? rows[0] : undefined, elapsedMs);
315
+ } finally {
316
+ clearTimeout(timer);
317
+ }
318
+ }
319
+
320
+ export interface HeartbeatOptions {
321
+ /** The schema the restore is landing into. */
322
+ incoming: string;
323
+ /** Per-poll line sink. */
324
+ log: (msg: string) => void;
325
+ /** Escalation sink for a sustained stall or a lock wait. Defaults to `log`. */
326
+ warn?: (msg: string) => void;
327
+ intervalMs?: number;
328
+ /** Injectable clock, for tests. */
329
+ now?: () => number;
330
+ /** Per-poll cap; exposed for tests. */
331
+ timeoutMs?: number;
332
+ /**
333
+ * Every sample, raw, before any classification. This is how a caller acts on what the probe sees
334
+ * rather than only reading about it — db:swap uses it to kill a psql that the server has no
335
+ * backend for. Must not throw; a sink that does is ignored so it cannot kill the heartbeat.
336
+ */
337
+ onSample?: (sample: HeartbeatSample) => void;
338
+ }
339
+
340
+ /**
341
+ * Poll the idle operator connection until stopped. Returns the stopper, which clears the timer and
342
+ * awaits any in-flight poll so nothing logs after the caller has moved on.
343
+ *
344
+ * Ticks are SKIPPED while one is in flight, never queued. Chaining them (`inFlight.then(tick)`)
345
+ * serializes but does not drop: one slow poll lets `setInterval` stack dozens behind it, and they
346
+ * all drain at once the moment it resolves — a wall of identical lines that buries the real signal.
347
+ * Observed for real: a 7-minute poll produced ~40 stacked lines in a 5-second burst.
348
+ */
349
+ export function startHeartbeat(runner: QueryRunner, opts: HeartbeatOptions): () => Promise<void> {
350
+ const now = opts.now ?? (() => Date.now());
351
+ const warn = opts.warn ?? opts.log;
352
+ const startedAt = now();
353
+ let prev: HeartbeatSample | null = null;
354
+ let consecutiveStalls = 0;
355
+ let lastProgressMs = 0;
356
+ let inFlight: Promise<void> = Promise.resolve();
357
+ let running = false;
358
+ let stopped = false;
359
+
360
+ const tick = async () => {
361
+ const cur = await pollOnce(runner, opts.incoming, now() - startedAt, opts.timeoutMs);
362
+ if (stopped) return;
363
+ // Raw sample first: a caller acting on the probe (the dead-backend watchdog) must see every
364
+ // sample, and must never be able to break the heartbeat by throwing.
365
+ try { opts.onSample?.(cur); } catch { /* a sink's failure is not the probe's problem */ }
366
+ const liveness = classify(prev, cur);
367
+ const line = formatSample(prev, cur, liveness);
368
+
369
+ if (liveness === 'blocked') {
370
+ warn(line);
371
+ } else if (liveness === 'client-stall') {
372
+ consecutiveStalls += 1;
373
+ // Two conditions, not one. A stall must SUSTAIN (one flat poll is normal between COPY
374
+ // chunks) AND enough quiet must have passed since progress was last seen — otherwise the
375
+ // warning fires on the tail of a healthy run that is about to finish.
376
+ const quietMs = cur.elapsedMs - lastProgressMs;
377
+ if (consecutiveStalls >= STALL_POLLS && quietMs >= STALL_GRACE_MS) {
378
+ warn(
379
+ `${line} STALLED for ${consecutiveStalls} consecutive polls and ${humanElapsed(quietMs)} with no progress — this is the deadlock signature (the server is waiting for data psql is not sending).`,
380
+ );
381
+ } else {
382
+ opts.log(line);
383
+ }
384
+ } else {
385
+ consecutiveStalls = 0;
386
+ if (liveness === 'progressing') lastProgressMs = cur.elapsedMs;
387
+ opts.log(line);
388
+ }
389
+ prev = cur;
390
+ };
391
+
392
+ const timer = setInterval(() => {
393
+ if (running || stopped) return; // SKIP, never queue — see the doc comment.
394
+ running = true;
395
+ inFlight = tick()
396
+ .catch(() => {})
397
+ .finally(() => { running = false; });
398
+ }, opts.intervalMs ?? DEFAULT_INTERVAL_MS);
399
+ // Never hold the process open on the heartbeat alone.
400
+ (timer as any).unref?.();
401
+
402
+ return async () => {
403
+ stopped = true;
404
+ clearInterval(timer);
405
+ await inFlight.catch(() => {});
406
+ };
407
+ }