@everystack/cli 0.4.34 → 0.4.36

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.36",
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
  /**
@@ -26,15 +26,21 @@ 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 type { SourceObject } from '../derived-source.js';
30
+ import { pairedDerivedSchemas, renderPairedDerivedBuild, renderSwapSchemaUsage, swapSchemaRoles, expectedIncomingObjects, renderPairedProvenance } from '../swap-pair.js';
31
+ import { introspectDerived } from '../derived-introspect.js';
29
32
  import { createUrlRunner } from '../db-source.js';
33
+ import type { QueryRunner } from '../authz-contract.js';
30
34
  import { executeSwap, type SwapVerdict } from '../swap-execute.js';
35
+ import { startHeartbeat, humanElapsed } from '../swap-heartbeat.js';
36
+ import { formatBytes } from '../bundle-weight.js';
31
37
  import { rewriteStatementLine, opensCopyData, closesCopyData } from '../schema-rewrite.js';
32
38
  import { resolveOperatorUrlViaStage } from '../direct-venue.js';
33
39
  import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
34
40
  import { resolveConfig, opsFunction } from '../config.js';
35
41
  import { invokeAction, presignGet } from '../aws.js';
36
42
  import { keyForArtifactId, metaKey } from '../backup.js';
37
- import { pgEnvFromUrl } from './db.js';
43
+ import { pgEnvFromUrl, pgKeepaliveConninfo } from './db.js';
38
44
  import { step, success, fail, warn, info } from '../output.js';
39
45
 
40
46
  /** A COPY-aware line transform that rewrites the schema token on statement lines only. */
@@ -70,37 +76,254 @@ function schemaRewriteStream(from: string, to: string): Transform {
70
76
  }
71
77
 
72
78
  /**
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.
79
+ * Consecutive polls reporting NO server-side backend before the watchdog kills psql. Three at the
80
+ * 10s cadence is 30s of agreement, which is well past any single-poll blip and far short of the
81
+ * eight minutes the unguarded version cost.
75
82
  */
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()}`)));
83
+ const DEAD_BACKEND_POLLS = 3;
84
+
85
+ /**
86
+ * TOC entry types that a DERIVED object can appear as. Order matters: the longer types must be
87
+ * tested first, or `MATERIALIZED VIEW DATA` parses as `MATERIALIZED VIEW` with a mangled schema.
88
+ */
89
+ const DERIVED_TOC_TYPES = [
90
+ 'MATERIALIZED VIEW DATA',
91
+ 'MATERIALIZED VIEW',
92
+ 'PROCEDURE',
93
+ 'FUNCTION',
94
+ 'TRIGGER',
95
+ 'VIEW',
96
+ ] as const;
97
+
98
+ /**
99
+ * Strip the DECLARED DERIVED objects out of a pg_restore TOC listing.
100
+ *
101
+ * An artifact should carry BASE STATE — tables, data, indexes, constraints, sequences. It should
102
+ * not carry the derived layer, because db:reconcile owns that and rebuilds it from the descriptors.
103
+ * `pg_dump --schema=<s>` cannot make that distinction: it dumps everything in the schema, derived
104
+ * objects included.
105
+ *
106
+ * Shipping them is not merely redundant, it DEADLOCKS the swap. A derived object inside the base
107
+ * schema may reference the derived schema built on top of it (`stats.draft_value` returns
108
+ * `SETOF stats_view.draft_value_row`). The restore then cannot run until the derived layer exists,
109
+ * while the derived layer cannot be built until the new base tables land. Neither can go first.
110
+ * Measured on a real artifact: the only entries referencing the derived schema were the 16
111
+ * reconcile-managed functions; every other entry was state.
112
+ *
113
+ * Filtering by TOC entry rather than by SQL text is deliberate: `pg_restore -L` is the supported
114
+ * mechanism for restoring a subset, and the alternative is pattern-matching a six-million-line SQL
115
+ * file. Entries are COMMENTED OUT rather than deleted so the listing stays diffable.
116
+ */
117
+ export function filterDerivedFromToc(
118
+ toc: string,
119
+ declaredIdentities: Iterable<string>,
120
+ ): { listing: string; skipped: string[] } {
121
+ const declared = new Set(declaredIdentities);
122
+ const skipped: string[] = [];
123
+ const listing = toc.split('\n').map((line) => {
124
+ // `<dumpId>; <catalogOid> <oid> <TYPE> <schema> <name(args)> <owner>`
125
+ const m = /^(\d+;\s+\d+\s+\d+)\s+(.+)$/.exec(line);
126
+ if (!m) return line; // header/comment/blank — pass through untouched
127
+ const rest = m[2];
128
+ const type = DERIVED_TOC_TYPES.find((t) => rest.startsWith(`${t} `));
129
+ if (!type) return line; // not a derived-capable entry (TABLE, INDEX, POLICY, ...)
130
+ const after = rest.slice(type.length + 1);
131
+ const parts = after.split(/\s+/);
132
+ if (parts.length < 2) return line;
133
+ const schema = parts[0];
134
+ // The owner is the last token; everything between it and the schema is the name (+args).
135
+ const nameWithArgs = parts.slice(1, -1).join(' ');
136
+ const name = nameWithArgs.replace(/\(.*$/, ''); // drop the argument list
137
+ if (!declared.has(`${schema}.${name}`)) return line;
138
+ skipped.push(`${schema}.${name} (${type.toLowerCase()})`);
139
+ return `;${line}`;
140
+ }).join('\n');
141
+ return { listing, skipped };
142
+ }
143
+
144
+ /** A pass-through that counts the bytes crossing it — Phase A's progress signal. */
145
+ function countingTap(onBytes: (total: number) => void): Transform {
146
+ let total = 0;
147
+ return new Transform({
148
+ transform(chunk, _enc, cb) {
149
+ total += chunk.length;
150
+ onBytes(total);
151
+ cb(null, chunk);
152
+ },
98
153
  });
99
- await Promise.all([
100
- pipeline(restore.stdout!, schemaRewriteStream(schema, incoming), psql.stdin!),
101
- restoreExit,
102
- psqlExit,
103
- ]);
154
+ }
155
+
156
+ /** The observation sinks the restore reports through. Injected so the phases stay testable. */
157
+ interface RestoreIO {
158
+ /** The operator connection — IDLE for the whole restore, so the Phase B heartbeat reuses it. */
159
+ runner: QueryRunner;
160
+ log: (msg: string) => void;
161
+ warn: (msg: string) => void;
162
+ /**
163
+ * `schema.name` of every DECLARED derived object. These are stripped from the restore: the
164
+ * artifact carries base state, db:reconcile owns the derived layer. See filterDerivedFromToc.
165
+ */
166
+ declaredIdentities: string[];
167
+ }
168
+
169
+ /**
170
+ * Restore a `-Fc` artifact into `<schema>_incoming`, in TWO phases split by a local temp file:
171
+ *
172
+ * Phase A (local, no network): pg_restore -f - (archive → SQL) → the COPY-safe schema rewrite →
173
+ * a local `.sql` file. The archive names `<schema>`; the rewrite lands it as `<incoming>`.
174
+ * Phase B (network): psql -f <file> streams that file to the target at psql's own pace.
175
+ *
176
+ * Why the temp file and not a live `pg_restore | rewrite | psql` pipe: decoupling the producer
177
+ * (local, fast) from the consumer removes all cross-process backpressure, and it makes Phase A
178
+ * measurable on its own. Costs one temp file (~the uncompressed dump), cleaned up in `finally`.
179
+ *
180
+ * A CORRECTION, because the original note here sent two debugging sessions down the wrong path.
181
+ * It claimed the three-way pipe DEADLOCKED at the DDL→data boundary and that the temp file was the
182
+ * cure. The temp file went in, and the restore STILL died at the same place. The pipe was never the
183
+ * root cause; the `write EPIPE` it produced was a downstream symptom.
184
+ *
185
+ * The real cause (proven 2026-07-27, see db.ts's keepalive note): the connection dies during the
186
+ * long quiet stretch of the index/constraint phase, the server terminates the backend, and the
187
+ * client — which never sees a FIN or RST — blocks forever on a socket its kernel still calls
188
+ * ESTABLISHED. The fix is TCP keepalives on psql's connection, plus the watchdog below, NOT the
189
+ * process topology. The temp file is kept because it is genuinely better instrumented, not because
190
+ * it fixes a deadlock.
191
+ *
192
+ * Both phases are INSTRUMENTED. This ran silent once — a multi-GB push with no output at all — and
193
+ * a run that died unattended was indistinguishable from one still working. Now: a byte counter on
194
+ * Phase A, a server-side heartbeat on Phase B (see swap-heartbeat.ts), and both child processes'
195
+ * stderr streamed as it arrives instead of being withheld until exit.
196
+ */
197
+ async function restoreIntoIncoming(
198
+ url: string,
199
+ artifactPath: string,
200
+ schema: string,
201
+ incoming: string,
202
+ io: RestoreIO,
203
+ ): Promise<void> {
204
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'everystack-restore-'));
205
+ const sqlPath = path.join(tmpDir, `${incoming}.sql`);
206
+ const t0 = Date.now();
207
+ try {
208
+ // Phase A0: strip the declared derived layer from the restore. The artifact is base STATE;
209
+ // db:reconcile owns derived. Landing them here is redundant at best and deadlocks the restore
210
+ // when a derived object in the base schema references the derived schema above it.
211
+ const tocPath = path.join(tmpDir, 'toc.list');
212
+ const toc = await new Promise<string>((res, rej) => {
213
+ const p = spawn('pg_restore', ['-l', artifactPath], { stdio: ['ignore', 'pipe', 'pipe'] });
214
+ let out = '', err = '';
215
+ p.stdout.on('data', (d) => { out += d.toString(); });
216
+ p.stderr.on('data', (d) => { err += d.toString(); });
217
+ p.on('error', rej);
218
+ p.on('close', (c) => c === 0 ? res(out) : rej(new Error(`pg_restore -l exited ${c}: ${err.trim()}`)));
219
+ });
220
+ const { listing, skipped } = filterDerivedFromToc(toc, io.declaredIdentities);
221
+ await fs.promises.writeFile(tocPath, listing, 'utf8');
222
+ io.log(
223
+ skipped.length
224
+ ? `restore: skipping ${skipped.length} declared derived object(s) — db:reconcile owns them (${skipped.slice(0, 6).join(', ')}${skipped.length > 6 ? `, +${skipped.length - 6} more` : ''}).`
225
+ : 'restore: the artifact carries no declared derived objects.',
226
+ );
227
+
228
+ // Phase A: pg_restore → rewrite → local file. Only local processes; nothing can stall here.
229
+ io.log(`restore phase A: pg_restore → schema rewrite (${schema} → ${incoming}) → ${sqlPath}`);
230
+ const restore = spawn('pg_restore', ['-L', tocPath, '-f', '-', artifactPath], { stdio: ['ignore', 'pipe', 'pipe'] });
231
+ let rErr = '';
232
+ restore.stderr.on('data', (d) => {
233
+ const s = d.toString();
234
+ rErr += s;
235
+ // Surface as it happens — a warning withheld until exit is a warning nobody can act on.
236
+ for (const line of s.split('\n').map((l: string) => l.trim()).filter(Boolean)) io.log(`pg_restore: ${line}`);
237
+ });
238
+ const restoreExit = new Promise<void>((res, rej) => {
239
+ restore.on('error', rej);
240
+ restore.on('close', (c) => c === 0 ? res() : rej(new Error(`pg_restore exited ${c}: ${rErr.trim()}`)));
241
+ });
242
+ let written = 0;
243
+ const phaseATimer = setInterval(() => {
244
+ io.log(`restore phase A: ${formatBytes(written)} of SQL written (t+${humanElapsed(Date.now() - t0)})...`);
245
+ }, 5_000);
246
+ (phaseATimer as any).unref?.();
247
+ try {
248
+ await Promise.all([
249
+ pipeline(
250
+ restore.stdout!,
251
+ countingTap((n) => { written = n; }),
252
+ schemaRewriteStream(schema, incoming),
253
+ fs.createWriteStream(sqlPath),
254
+ ),
255
+ restoreExit,
256
+ ]);
257
+ } finally {
258
+ clearInterval(phaseATimer);
259
+ }
260
+ const aMs = Date.now() - t0;
261
+ io.log(`restore phase A done: ${formatBytes(written)} of SQL in ${humanElapsed(aMs)}.`);
262
+
263
+ // Phase B: psql reads the local file and streams to the target. The credential rides PG* env,
264
+ // never argv (libpq also REJECTS non-keyword URI params like the `search_path` the operator URL
265
+ // bakes in — fine for postgres.js, fatal for a libpq URI). `-d` carries ONLY keepalives, which
266
+ // have no PG* env equivalent and are what keep this connection from dying in the index phase.
267
+ io.log(`restore phase B: psql streaming ${formatBytes(written)} to the target — heartbeat every 10s.`);
268
+ const psql = spawn('psql', ['-d', pgKeepaliveConninfo(), '-v', 'ON_ERROR_STOP=1', '-f', sqlPath], {
269
+ stdio: ['ignore', 'ignore', 'pipe'],
270
+ env: { ...process.env, ...pgEnvFromUrl(url) },
271
+ });
272
+ let pErr = '';
273
+ psql.stderr.on('data', (d) => {
274
+ const s = d.toString();
275
+ pErr += s;
276
+ for (const line of s.split('\n').map((l: string) => l.trim()).filter(Boolean)) io.warn(`psql: ${line}`);
277
+ });
278
+
279
+ // The watchdog. Keepalives should prevent the dead-backend hang, but if it happens anyway the
280
+ // heartbeat SEES it — pg_stat_activity, read over our own live connection, reports no psql
281
+ // backend while the psql process sits there forever. Detection without action is what cost an
282
+ // eight-minute wait: turn it into a kill and a named failure.
283
+ let deadBackendPolls = 0;
284
+ const stopHeartbeat = startHeartbeat(io.runner, {
285
+ incoming,
286
+ log: io.log,
287
+ warn: io.warn,
288
+ onSample: (sample) => {
289
+ if (sample.state === null) deadBackendPolls += 1;
290
+ else deadBackendPolls = 0;
291
+ if (deadBackendPolls === DEAD_BACKEND_POLLS && psql.exitCode === null) {
292
+ io.warn(
293
+ `restore: psql is still running but the server reports NO backend for it after ${deadBackendPolls} consecutive polls. `
294
+ + 'The connection died and psql will never notice (no FIN/RST reaches it). Killing it rather than hanging.',
295
+ );
296
+ psql.kill('SIGTERM');
297
+ // SIGTERM will not land if psql is parked in a blocking read on a dead socket.
298
+ const hardKill = setTimeout(() => { if (psql.exitCode === null) psql.kill('SIGKILL'); }, 5_000);
299
+ (hardKill as any).unref?.();
300
+ }
301
+ },
302
+ });
303
+ const bStart = Date.now();
304
+ try {
305
+ await new Promise<void>((res, rej) => {
306
+ psql.on('error', rej);
307
+ psql.on('close', (c) => {
308
+ if (c === 0) return res();
309
+ if (deadBackendPolls >= DEAD_BACKEND_POLLS) {
310
+ return rej(new Error(
311
+ 'the restore connection died mid-load and psql hung on it (the server had no backend for it). '
312
+ + 'psql was killed by the watchdog; NOTHING was swapped and live is untouched. '
313
+ + 'This is the network path dropping a connection that goes quiet during the index/constraint phase — '
314
+ + 'keepalives are now set, so if you are seeing this the path is dropping the flow faster than a 30s probe interval.',
315
+ ));
316
+ }
317
+ rej(new Error(`psql exited ${c}: ${pErr.trim()}`));
318
+ });
319
+ });
320
+ } finally {
321
+ await stopHeartbeat();
322
+ }
323
+ io.log(`restore phase B done in ${humanElapsed(Date.now() - bStart)} (restore total ${humanElapsed(Date.now() - t0)}).`);
324
+ } finally {
325
+ await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
326
+ }
104
327
  }
105
328
 
106
329
  /**
@@ -193,17 +416,40 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
193
416
  if (!from) { fail('db:swap needs --from <artifact.dump | artifact-id> (the schema-scoped -Fc archive to land).'); process.exit(1); }
194
417
 
195
418
  // Resolve the venue.
196
- // - --database-url (or DATABASE_URL): a local/direct operator connection.
419
+ // - --database-url: a local/direct operator connection. EXPLICIT FLAG ONLY.
197
420
  // - --stage --direct: resolve the stage's OPERATOR connection from its ops Lambda and execute
198
421
  // CLI-side with an unbounded clock (a multi-GB restore blows the 900s Lambda ceiling). The
199
422
  // operator never holds a URL; the swap snapshots the stage via db:backup before it lands.
200
423
  // - --stage alone: refuse, naming --direct — the ops-Lambda venue can't hold the restore clock.
201
- let url = flags['database-url'] || process.env.DATABASE_URL;
424
+ //
425
+ // `process.env.DATABASE_URL` is NOT a venue here, and used to be.
426
+ //
427
+ // It was read first, and the stage branch was guarded by `if (!url && stage)` — so an exported
428
+ // DATABASE_URL SILENTLY OVERRODE `--stage`. An operator asking for dev got whatever the
429
+ // environment named, the stage's snapshot was skipped (the branch that sets snapshotViaStage
430
+ // never ran, hence the "direct v1" warning), and the swap reported success against a database
431
+ // nobody had asked for. A consumer hit exactly this: two runs differing only by an unrelated
432
+ // diagnostic flag went to different databases, because one shell had the variable exported and
433
+ // the other did not. Their dev derived layer was untouched because dev was never the target.
434
+ //
435
+ // On a DESTRUCTIVE verb an ambient variable must never choose the target, and db:export already
436
+ // states the rule: --database-url is explicit-flag-only, never the env — the venue must be
437
+ // deliberate. This is that rule, applied where it mattered most and was missing.
438
+ const urlFlag = flags['database-url'];
439
+ if (urlFlag && stage) {
440
+ fail(`db:swap got BOTH --database-url and --stage ${stage} — that is two different targets and the wrong one is destructive. Pass exactly one.`);
441
+ process.exit(1);
442
+ }
443
+ if (!urlFlag && !stage && process.env.DATABASE_URL) {
444
+ fail('db:swap will not take its target from the DATABASE_URL environment variable — a destructive swap must name its target explicitly. Pass --database-url <url> (local/direct) or --stage <name> --direct.');
445
+ process.exit(1);
446
+ }
447
+ let url = urlFlag;
202
448
  let snapshotViaStage = false;
203
449
  let region: string | undefined;
204
450
  let opsFn: string | undefined;
205
451
 
206
- if (!url && stage) {
452
+ if (stage) {
207
453
  if (!direct) {
208
454
  fail('db:swap --stage needs --direct: a schema restore can exceed the ops-Lambda 900-second clock, so the swap runs CLI-side with an unbounded clock (credential-free — the operator never holds a URL). Re-run with --stage ' + stage + ' --direct.');
209
455
  process.exit(1);
@@ -229,16 +475,40 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
229
475
  process.exit(1);
230
476
  }
231
477
 
478
+ // --rebuild-derived carries a real outage window: the derived layer does not exist between the
479
+ // swap committing and db:reconcile --apply finishing. State it BEFORE the work starts — saying it
480
+ // only afterward tells the operator about an outage they are already in. It is now the OPT-OUT:
481
+ // the paired swap below is the default and has no window at all.
482
+ if (flags['rebuild-derived'] === 'true') {
483
+ 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.');
484
+ warn(' the paired swap (the default, without this flag) rebuilds the layer over the incoming data and renames it in the same transaction — no window. Drop the flag unless you specifically want the old behaviour.');
485
+ }
486
+
232
487
  const modelsPath = resolveModelsPath(flags.models);
233
488
  let models: ModelDescriptor[];
234
489
  let declaredFingerprint: string;
490
+ let declaredDerivedObjects: SourceObject[] = [];
491
+ let paired: string[] = [];
235
492
  try {
236
493
  step(`Loading models from ${modelsPath}...`);
237
494
  models = await loadModels(modelsPath);
238
495
  const declaredDb = await loadDeclaredDerived(flags.models);
239
496
  declaredFingerprint = fingerprintModels(models, { schemas: [schema], sequences: declaredDb?.sequences }).hash;
497
+ // The identities db:reconcile can regenerate — what makes a dependent safe to drop.
498
+ declaredDerivedObjects = declaredDb?.objects ?? [];
499
+ // The PAIRED swap is the default whenever a declared derived schema hangs off this one: the
500
+ // layer is rebuilt over the incoming tables and renamed in the same transaction, so it is
501
+ // never absent. --rebuild-derived is the explicit opt-out (drop, swap, reconcile after), kept
502
+ // for the case where rebuilding twice is not worth the zero-downtime guarantee.
503
+ if (flags['rebuild-derived'] !== 'true' && declaredDb) {
504
+ paired = pairedDerivedSchemas(models, declaredDb.derived, schema);
505
+ }
240
506
  } catch (err: any) { fail(err.message); process.exit(1); }
241
507
 
508
+ if (paired.length > 0) {
509
+ info(`paired swap: ${paired.join(', ')} will be rebuilt over the incoming data and renamed in the SAME transaction — the derived layer is never absent.`);
510
+ }
511
+
242
512
  // Resolve --from to a local plain -Fc dump (a local file, or an S3 export id fetched down).
243
513
  let artifact: ResolvedArtifact;
244
514
  try {
@@ -263,7 +533,82 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
263
533
  models, schema,
264
534
  artifactFingerprint,
265
535
  declaredFingerprint,
266
- applyIncoming: async () => { await restoreIntoIncoming(url!, artifact.dumpPath, schema, `${schema}_incoming`); },
536
+ // What db:reconcile can regenerate the set a dependent must be in to be safe to drop.
537
+ declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
538
+ rebuildDerived: flags['rebuild-derived'] === 'true',
539
+ paired,
540
+ // Schema-level USAGE, re-applied in the swap transaction and asserted after it commits.
541
+ // The incoming schemas arrive with no schema ACL, so without this the swap lands correct
542
+ // data behind schemas the app cannot enter — every endpoint 500s with "does not exist".
543
+ schemaUsage: renderSwapSchemaUsage(models, declaredDerivedObjects, schema, paired),
544
+ schemaUsageRoles: [...swapSchemaRoles(models, declaredDerivedObjects, [schema, ...paired])]
545
+ .flatMap(([s, roles]) => roles.map((role) => ({ schema: s, role }))),
546
+ // Every declared object the paired build must produce. Checked before the rename, so a
547
+ // partial layer refuses with live untouched rather than committing a silent shortfall.
548
+ expectedDerived: paired.length
549
+ ? expectedIncomingObjects(declaredDerivedObjects, schema, paired)
550
+ : undefined,
551
+ // Tell the reconciler what the build made. Without this the next db:reconcile — days
552
+ // later, for an unrelated edit — sees the whole layer as drift and rebuilds it under
553
+ // ACCESS EXCLUSIVE. The objects are correct; only the bookkeeping was missing.
554
+ recordProvenance: paired.length
555
+ ? async (r) => {
556
+ const live = await introspectDerived(r);
557
+ const prov = renderPairedProvenance(declaredDerivedObjects, live.objects, schema, paired);
558
+ for (const st of prov.statements) await r(st);
559
+ if (prov.recorded.length === 0) {
560
+ warn(`provenance recorded NOTHING — no declared object in ${[schema, ...paired].join(', ')} matched a live catalog entry. `
561
+ + `The swap itself succeeded, but the next db:reconcile will treat this layer as drift. Run db:reconcile --check to see what it thinks.`);
562
+ } else {
563
+ info(`provenance recorded for ${prov.recorded.length} object(s) — a post-swap db:reconcile is NOT required.`);
564
+ if (prov.unmatched.length) {
565
+ warn(` ${prov.unmatched.length} declared object(s) had no live catalog entry and were NOT recorded: ${prov.unmatched.slice(0, 10).join(', ')}${prov.unmatched.length > 10 ? ', …' : ''}`);
566
+ }
567
+ }
568
+ }
569
+ : undefined,
570
+ // Build the incoming derived layer over <schema>_incoming, before the rename. Skipped
571
+ // entirely when nothing pairs — an all-public app's swap is byte-identical to before.
572
+ buildPairedDerived: paired.length
573
+ ? async (r) => {
574
+ step(`Building the incoming derived layer (${paired.join(', ')})...`);
575
+ const built = renderPairedDerivedBuild(declaredDerivedObjects, schema, paired);
576
+ for (const s of built.statements) await r(s);
577
+ // --dump-build: diagnostic only, no behaviour change. Writes what the build EMITTED
578
+ // and what the catalog HOLDS immediately afterwards, before the rename. Those two
579
+ // together separate "the build rendered the wrong SQL" from "the build was fine and
580
+ // the rename lost it" — a distinction that is otherwise only reachable by racing a
581
+ // second connection against the build window.
582
+ if (flags['dump-build']) {
583
+ const twins = paired.map((p) => `${p}_incoming`);
584
+ const rows = await r(
585
+ `SELECT n.nspname || '.' || c.relname AS identity, pg_get_viewdef(c.oid) AS definition
586
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
587
+ WHERE n.nspname = ANY (ARRAY[${twins.map((t) => `'${t.replace(/'/g, "''")}'`).join(',')}])
588
+ AND c.relkind IN ('v','m') ORDER BY 1`,
589
+ );
590
+ fs.writeFileSync(flags['dump-build'], JSON.stringify({
591
+ schema, paired,
592
+ searchPath: built.statements[0],
593
+ statementCount: built.statements.length,
594
+ statements: built.statements,
595
+ liveInIncomingAfterBuild: rows,
596
+ }, null, 2));
597
+ info(`--dump-build: wrote ${built.statements.length} rendered statement(s) and ${rows.length} live definition(s) to ${flags['dump-build']}`);
598
+ }
599
+ }
600
+ : undefined,
601
+ log: (m) => info(m),
602
+ // The runner is handed in and USED: it is idle for the whole restore, so the Phase B
603
+ // heartbeat reads the loading backend's state over it (swap-heartbeat.ts).
604
+ applyIncoming: async (r) => {
605
+ await restoreIntoIncoming(url!, artifact.dumpPath, schema, `${schema}_incoming`, {
606
+ runner: r,
607
+ log: (m) => info(m),
608
+ warn: (m) => warn(m),
609
+ declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
610
+ });
611
+ },
267
612
  snapshot: snapshotViaStage
268
613
  ? async () => {
269
614
  step('Snapshotting the stage before the swap (db:backup)...');
@@ -278,8 +623,26 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
278
623
  if (res.status === 'swapped') {
279
624
  success(`Swapped ${schema} — the artifact is live (no refresh ran).`);
280
625
  for (const w of res.warnings ?? []) warn(`verify warning: ${w.name}${w.detail ? ` — ${w.detail}` : ''}`);
626
+ // The derived layer was dropped with the swap. Say so LOUDLY: until reconcile runs, every
627
+ // reader of those objects is looking at a schema that no longer has them.
628
+ if (flags['rebuild-derived'] === 'true') {
629
+ warn(`the derived objects depending on ${schema} were dropped — they do NOT exist until you regenerate them.`);
630
+ warn(` run now: everystack db:reconcile --apply --stage ${stage ?? '<stage>'} --direct`);
631
+ } else if (paired.length > 0) {
632
+ // Say it in the OUTPUT, not just the docs. The two-step "swap then reconcile" was correct
633
+ // advice for the unpaired swap and is baked into deploy scripts written against it. Run
634
+ // now, that reconcile drops and recreates the layer the swap just built correctly, holding
635
+ // ACCESS EXCLUSIVE — turning a zero-downtime swap into an outage. Seen in the field.
636
+ info(`the derived layer (${paired.join(', ')}) is live and complete — a post-swap db:reconcile is NOT required.`);
637
+ info(` if your deploy script still runs one, remove it: it would drop and rebuild these objects under ACCESS EXCLUSIVE and cause the outage this swap exists to avoid.`);
638
+ }
281
639
  } else {
282
640
  fail(`db:swap ${res.status}: ${res.reason}`);
641
+ // Name every object CASCADE would have destroyed, one per line — a comma-joined list of
642
+ // dozens is unreadable, and this is the list the operator has to act on.
643
+ if (res.status === 'refused-dependents') {
644
+ for (const d of res.dependents ?? []) info(` would be destroyed: ${d.schema}.${d.name} (${d.kind})`);
645
+ }
283
646
  process.exit(1);
284
647
  }
285
648
  } 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.`,