@everystack/cli 0.4.35 → 0.4.38

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.
@@ -26,6 +26,9 @@ import { fingerprintModels } from '../schema-fingerprint.js';
26
26
  import { resolveModelsPath } from '../models-path.js';
27
27
  import { loadModels } from './db-generate.js';
28
28
  import { loadDeclaredDerived } from '../declared-derived.js';
29
+ import 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';
30
33
  import type { QueryRunner } from '../authz-contract.js';
31
34
  import { executeSwap, type SwapVerdict } from '../swap-execute.js';
@@ -34,9 +37,12 @@ import { formatBytes } from '../bundle-weight.js';
34
37
  import { rewriteStatementLine, opensCopyData, closesCopyData } from '../schema-rewrite.js';
35
38
  import { resolveOperatorUrlViaStage } from '../direct-venue.js';
36
39
  import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
37
- import { resolveConfig, opsFunction } from '../config.js';
38
- import { invokeAction, presignGet } from '../aws.js';
39
- import { keyForArtifactId, metaKey } from '../backup.js';
40
+ import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
41
+ import { invokeAction, presignGet, createRdsSnapshot, describeRdsSnapshots } from '../aws.js';
42
+ import { keyForArtifactId, metaKey, utcStamp } from '../backup.js';
43
+ import { rdsSnapshotIdentifier } from '../rds-snapshot.js';
44
+ import { pollTaskUntilStopped } from '../task-poll.js';
45
+ import { decideSnapshotMode, confirmPhysicalSnapshot, interpretBackupPoll, type SnapshotModeRequest } from '../swap-snapshot.js';
40
46
  import { pgEnvFromUrl, pgKeepaliveConninfo } from './db.js';
41
47
  import { step, success, fail, warn, info } from '../output.js';
42
48
 
@@ -261,11 +267,29 @@ async function restoreIntoIncoming(
261
267
  // never argv (libpq also REJECTS non-keyword URI params like the `search_path` the operator URL
262
268
  // bakes in — fine for postgres.js, fatal for a libpq URI). `-d` carries ONLY keepalives, which
263
269
  // have no PG* env equivalent and are what keep this connection from dying in the index phase.
270
+ //
271
+ // psql reads the file from STDIN (`-f -`) rather than opening it itself, purely so the restore
272
+ // knows its own write position. That position is the fact the heartbeat was missing: a server
273
+ // parked in `Client/ClientRead` is a STALL when bytes remain unsent and the successful TAIL when
274
+ // they do not, and those two used to print the same line. `-f -` keeps psql's `psql:<stdin>:N:`
275
+ // error prefixes, so the line number of a failing statement survives the change (verified
276
+ // against psql 16).
264
277
  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'],
278
+ const psql = spawn('psql', ['-d', pgKeepaliveConninfo(), '-v', 'ON_ERROR_STOP=1', '-f', '-'], {
279
+ stdio: ['pipe', 'ignore', 'pipe'],
267
280
  env: { ...process.env, ...pgEnvFromUrl(url) },
268
281
  });
282
+ let fedBytes = 0;
283
+ let feedDone = false;
284
+ // A psql that exits early (ON_ERROR_STOP) makes this pipeline fail with EPIPE. That is a
285
+ // DOWNSTREAM symptom — psql's own exit code and stderr are the authority on what went wrong, and
286
+ // a previous version of this code mistook the EPIPE for the cause and chased the wrong bug for
287
+ // two sessions. So the feed's error is swallowed here and psql's exit decides.
288
+ const feeding = pipeline(
289
+ fs.createReadStream(sqlPath),
290
+ countingTap((n) => { fedBytes = n; }),
291
+ psql.stdin!,
292
+ ).then(() => { feedDone = true; }).catch(() => { /* psql's exit is the authority */ });
269
293
  let pErr = '';
270
294
  psql.stderr.on('data', (d) => {
271
295
  const s = d.toString();
@@ -282,6 +306,9 @@ async function restoreIntoIncoming(
282
306
  incoming,
283
307
  log: io.log,
284
308
  warn: io.warn,
309
+ // The client half of the picture. Without it the heartbeat cried "deadlock signature" over the
310
+ // last poll of a run that had landed every row and was about to succeed.
311
+ clientFeed: () => ({ fedBytes, totalBytes: written, done: feedDone }),
285
312
  onSample: (sample) => {
286
313
  if (sample.state === null) deadBackendPolls += 1;
287
314
  else deadBackendPolls = 0;
@@ -316,8 +343,11 @@ async function restoreIntoIncoming(
316
343
  });
317
344
  } finally {
318
345
  await stopHeartbeat();
346
+ // The feed is already finished on the success path; on a failure path it is rejecting with
347
+ // EPIPE. Either way, await it so no stream work outlives the phase.
348
+ await feeding;
319
349
  }
320
- io.log(`restore phase B done in ${humanElapsed(Date.now() - bStart)} (restore total ${humanElapsed(Date.now() - t0)}).`);
350
+ io.log(`restore phase B done in ${humanElapsed(Date.now() - bStart)} (restore total ${humanElapsed(Date.now() - t0)}); fed ${formatBytes(fedBytes)} of ${formatBytes(written)}.`);
321
351
  } finally {
322
352
  await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
323
353
  }
@@ -404,6 +434,60 @@ async function resolveSwapArtifact(
404
434
  return fetchArtifactFromS3(from, stage, fingerprintFlag);
405
435
  }
406
436
 
437
+ /**
438
+ * Take (or account for) the pre-swap rollback point, and do not return until it EXISTS.
439
+ *
440
+ * Every branch here either produces a confirmed rollback point or throws — and a throw at this point
441
+ * means executeSwap never reaches the restore, so live is untouched. That property is the entire
442
+ * reason this is not a fire-and-forget dispatch any more.
443
+ */
444
+ async function takePreSwapSnapshot(
445
+ plan: Exclude<ReturnType<typeof decideSnapshotMode>, { mode: 'refuse' }>,
446
+ ctx: { stage?: string; region?: string; opsFn?: string },
447
+ ): Promise<void> {
448
+ if (plan.mode === 'attested') {
449
+ info(`pre-swap rollback point: ${plan.ref} (attested via --snapshot-ref — no new snapshot taken).`);
450
+ return;
451
+ }
452
+
453
+ if (plan.mode === 'none') {
454
+ warn('NO pre-swap snapshot (--snapshot none). If this swap lands bad data there is no rollback point — the retiring schema is dropped once verify passes.');
455
+ return;
456
+ }
457
+
458
+ if (plan.mode === 'physical') {
459
+ step(`Snapshotting the instance before the swap (RDS physical snapshot of ${plan.instanceId})...`);
460
+ const snapshotId = rdsSnapshotIdentifier(`${ctx.stage ?? 'swap'}-swap`, utcStamp(new Date()));
461
+ const region = ctx.region!;
462
+ const { id } = await confirmPhysicalSnapshot({
463
+ create: (sid) => createRdsSnapshot(region, plan.instanceId, sid),
464
+ describe: () => describeRdsSnapshots(region, plan.instanceId),
465
+ log: (m) => info(m),
466
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
467
+ now: () => Date.now(),
468
+ }, { instanceId: plan.instanceId, snapshotId });
469
+ info(`rollback point CONFIRMED: RDS snapshot ${id} is available — restore the instance from it if this swap goes wrong.`);
470
+ return;
471
+ }
472
+
473
+ // Logical: dispatch the Task and WAIT. The dispatch returning is not the backup existing — that
474
+ // conflation is what let a pg_dump run concurrently with the restore it was supposed to precede.
475
+ step('Snapshotting the stage before the swap (db:backup — waiting for the dump to finish)...');
476
+ const dispatched: any = await invokeAction(ctx.region!, ctx.opsFn!, 'db:backup', {
477
+ stage: ctx.stage,
478
+ actor: process.env.USER ?? null,
479
+ });
480
+ if (dispatched?.error) throw new Error(`the pre-swap backup would not dispatch, so the swap was NOT applied: ${dispatched.error}`);
481
+ const { runId, taskArn, id } = dispatched as { runId: string; taskArn: string; id: string };
482
+ info(`backup ${id} dispatched (run ${runId}) — waiting for the task to stop before the restore starts.`);
483
+ const verdict = interpretBackupPoll(
484
+ await pollTaskUntilStopped(ctx.region!, ctx.opsFn!, { runId, taskArn }),
485
+ { runId, id },
486
+ );
487
+ if (!verdict.ok) throw new Error(verdict.reason);
488
+ info(`rollback point CONFIRMED: backup ${id} complete — restore with db:restore --from ${id} --confirm.`);
489
+ }
490
+
407
491
  export async function dbSwapCommand(flags: Record<string, string>): Promise<void> {
408
492
  const schema = flags.schema;
409
493
  const from = flags.from;
@@ -413,17 +497,40 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
413
497
  if (!from) { fail('db:swap needs --from <artifact.dump | artifact-id> (the schema-scoped -Fc archive to land).'); process.exit(1); }
414
498
 
415
499
  // Resolve the venue.
416
- // - --database-url (or DATABASE_URL): a local/direct operator connection.
500
+ // - --database-url: a local/direct operator connection. EXPLICIT FLAG ONLY.
417
501
  // - --stage --direct: resolve the stage's OPERATOR connection from its ops Lambda and execute
418
502
  // CLI-side with an unbounded clock (a multi-GB restore blows the 900s Lambda ceiling). The
419
503
  // operator never holds a URL; the swap snapshots the stage via db:backup before it lands.
420
504
  // - --stage alone: refuse, naming --direct — the ops-Lambda venue can't hold the restore clock.
421
- let url = flags['database-url'] || process.env.DATABASE_URL;
422
- let snapshotViaStage = false;
505
+ //
506
+ // `process.env.DATABASE_URL` is NOT a venue here, and used to be.
507
+ //
508
+ // It was read first, and the stage branch was guarded by `if (!url && stage)` — so an exported
509
+ // DATABASE_URL SILENTLY OVERRODE `--stage`. An operator asking for dev got whatever the
510
+ // environment named, the stage's snapshot was skipped (the branch that sets snapshotViaStage
511
+ // never ran, hence the "direct v1" warning), and the swap reported success against a database
512
+ // nobody had asked for. A consumer hit exactly this: two runs differing only by an unrelated
513
+ // diagnostic flag went to different databases, because one shell had the variable exported and
514
+ // the other did not. Their dev derived layer was untouched because dev was never the target.
515
+ //
516
+ // On a DESTRUCTIVE verb an ambient variable must never choose the target, and db:export already
517
+ // states the rule: --database-url is explicit-flag-only, never the env — the venue must be
518
+ // deliberate. This is that rule, applied where it mattered most and was missing.
519
+ const urlFlag = flags['database-url'];
520
+ if (urlFlag && stage) {
521
+ fail(`db:swap got BOTH --database-url and --stage ${stage} — that is two different targets and the wrong one is destructive. Pass exactly one.`);
522
+ process.exit(1);
523
+ }
524
+ if (!urlFlag && !stage && process.env.DATABASE_URL) {
525
+ 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.');
526
+ process.exit(1);
527
+ }
528
+ let url = urlFlag;
423
529
  let region: string | undefined;
424
530
  let opsFn: string | undefined;
531
+ let stageConfig: CliConfig | undefined;
425
532
 
426
- if (!url && stage) {
533
+ if (stage) {
427
534
  if (!direct) {
428
535
  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.');
429
536
  process.exit(1);
@@ -433,13 +540,12 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
433
540
  process.exit(1);
434
541
  }
435
542
  try {
436
- const config = await resolveConfig(stage);
437
- region = config.region;
438
- opsFn = opsFunction(config);
543
+ stageConfig = await resolveConfig(stage);
544
+ region = stageConfig.region;
545
+ opsFn = opsFunction(stageConfig);
439
546
  step('Resolving the operator connection from the stage (--direct)...');
440
547
  const op = await resolveOperatorUrlViaStage(stage);
441
548
  url = op.url;
442
- snapshotViaStage = true;
443
549
  info(`operator credential resolved (${op.source}) — swapping CLI-side, unbounded clock.`);
444
550
  } catch (err: any) { fail(err.message); process.exit(1); }
445
551
  }
@@ -449,17 +555,43 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
449
555
  process.exit(1);
450
556
  }
451
557
 
558
+ // THE ROLLBACK POINT. Decided here — before the models load, before the artifact is fetched, and
559
+ // long before anything is renamed — so a refusal costs nothing but a second.
560
+ //
561
+ // This step used to be a `db:backup` the swap did not wait for. The ops action dispatches a Task
562
+ // and returns, so the swap printed "snapshot on record" and began restoring while the pg_dump was
563
+ // still running: a consumer measured the restore blocked ~5 minutes on
564
+ // `Lock/relation HELD BY pid [pg_dump]`, the swap contending with its own backup. And a task that
565
+ // failed to start left a destructive swap running against a rollback point that did not exist.
566
+ //
567
+ // A physical RDS snapshot is now the default where the target is RDS (a control-plane call: no
568
+ // locks, no buffer-cache read, no client connection), the logical backup is the non-RDS fallback
569
+ // and is now WAITED ON, and the bare `--database-url` venue refuses rather than warning.
570
+ const snapshotPlan = decideSnapshotMode({
571
+ venue: stage ? 'stage' : 'url',
572
+ instanceId: flags.instance ?? stageConfig?.databaseInstanceId,
573
+ snapshotRef: flags['snapshot-ref'],
574
+ requested: flags.snapshot as SnapshotModeRequest | undefined,
575
+ });
576
+ if (snapshotPlan.mode === 'refuse') {
577
+ fail(snapshotPlan.reason);
578
+ process.exit(1);
579
+ }
580
+
452
581
  // --rebuild-derived carries a real outage window: the derived layer does not exist between the
453
582
  // 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.
583
+ // only afterward tells the operator about an outage they are already in. It is now the OPT-OUT:
584
+ // the paired swap below is the default and has no window at all.
455
585
  if (flags['rebuild-derived'] === 'true') {
456
586
  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.');
587
+ 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.');
457
588
  }
458
589
 
459
590
  const modelsPath = resolveModelsPath(flags.models);
460
591
  let models: ModelDescriptor[];
461
592
  let declaredFingerprint: string;
462
- let declaredDerivedObjects: Array<{ identity: string }> = [];
593
+ let declaredDerivedObjects: SourceObject[] = [];
594
+ let paired: string[] = [];
463
595
  try {
464
596
  step(`Loading models from ${modelsPath}...`);
465
597
  models = await loadModels(modelsPath);
@@ -467,8 +599,19 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
467
599
  declaredFingerprint = fingerprintModels(models, { schemas: [schema], sequences: declaredDb?.sequences }).hash;
468
600
  // The identities db:reconcile can regenerate — what makes a dependent safe to drop.
469
601
  declaredDerivedObjects = declaredDb?.objects ?? [];
602
+ // The PAIRED swap is the default whenever a declared derived schema hangs off this one: the
603
+ // layer is rebuilt over the incoming tables and renamed in the same transaction, so it is
604
+ // never absent. --rebuild-derived is the explicit opt-out (drop, swap, reconcile after), kept
605
+ // for the case where rebuilding twice is not worth the zero-downtime guarantee.
606
+ if (flags['rebuild-derived'] !== 'true' && declaredDb) {
607
+ paired = pairedDerivedSchemas(models, declaredDb.derived, schema);
608
+ }
470
609
  } catch (err: any) { fail(err.message); process.exit(1); }
471
610
 
611
+ if (paired.length > 0) {
612
+ info(`paired swap: ${paired.join(', ')} will be rebuilt over the incoming data and renamed in the SAME transaction — the derived layer is never absent.`);
613
+ }
614
+
472
615
  // Resolve --from to a local plain -Fc dump (a local file, or an S3 export id fetched down).
473
616
  let artifact: ResolvedArtifact;
474
617
  try {
@@ -496,6 +639,68 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
496
639
  // What db:reconcile can regenerate — the set a dependent must be in to be safe to drop.
497
640
  declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
498
641
  rebuildDerived: flags['rebuild-derived'] === 'true',
642
+ paired,
643
+ // Schema-level USAGE, re-applied in the swap transaction and asserted after it commits.
644
+ // The incoming schemas arrive with no schema ACL, so without this the swap lands correct
645
+ // data behind schemas the app cannot enter — every endpoint 500s with "does not exist".
646
+ schemaUsage: renderSwapSchemaUsage(models, declaredDerivedObjects, schema, paired),
647
+ schemaUsageRoles: [...swapSchemaRoles(models, declaredDerivedObjects, [schema, ...paired])]
648
+ .flatMap(([s, roles]) => roles.map((role) => ({ schema: s, role }))),
649
+ // Every declared object the paired build must produce. Checked before the rename, so a
650
+ // partial layer refuses with live untouched rather than committing a silent shortfall.
651
+ expectedDerived: paired.length
652
+ ? expectedIncomingObjects(declaredDerivedObjects, schema, paired)
653
+ : undefined,
654
+ // Tell the reconciler what the build made. Without this the next db:reconcile — days
655
+ // later, for an unrelated edit — sees the whole layer as drift and rebuilds it under
656
+ // ACCESS EXCLUSIVE. The objects are correct; only the bookkeeping was missing.
657
+ recordProvenance: paired.length
658
+ ? async (r) => {
659
+ const live = await introspectDerived(r);
660
+ const prov = renderPairedProvenance(declaredDerivedObjects, live.objects, schema, paired);
661
+ for (const st of prov.statements) await r(st);
662
+ if (prov.recorded.length === 0) {
663
+ warn(`provenance recorded NOTHING — no declared object in ${[schema, ...paired].join(', ')} matched a live catalog entry. `
664
+ + `The swap itself succeeded, but the next db:reconcile will treat this layer as drift. Run db:reconcile --check to see what it thinks.`);
665
+ } else {
666
+ info(`provenance recorded for ${prov.recorded.length} object(s) — a post-swap db:reconcile is NOT required.`);
667
+ if (prov.unmatched.length) {
668
+ 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 ? ', …' : ''}`);
669
+ }
670
+ }
671
+ }
672
+ : undefined,
673
+ // Build the incoming derived layer over <schema>_incoming, before the rename. Skipped
674
+ // entirely when nothing pairs — an all-public app's swap is byte-identical to before.
675
+ buildPairedDerived: paired.length
676
+ ? async (r) => {
677
+ step(`Building the incoming derived layer (${paired.join(', ')})...`);
678
+ const built = renderPairedDerivedBuild(declaredDerivedObjects, schema, paired);
679
+ for (const s of built.statements) await r(s);
680
+ // --dump-build: diagnostic only, no behaviour change. Writes what the build EMITTED
681
+ // and what the catalog HOLDS immediately afterwards, before the rename. Those two
682
+ // together separate "the build rendered the wrong SQL" from "the build was fine and
683
+ // the rename lost it" — a distinction that is otherwise only reachable by racing a
684
+ // second connection against the build window.
685
+ if (flags['dump-build']) {
686
+ const twins = paired.map((p) => `${p}_incoming`);
687
+ const rows = await r(
688
+ `SELECT n.nspname || '.' || c.relname AS identity, pg_get_viewdef(c.oid) AS definition
689
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
690
+ WHERE n.nspname = ANY (ARRAY[${twins.map((t) => `'${t.replace(/'/g, "''")}'`).join(',')}])
691
+ AND c.relkind IN ('v','m') ORDER BY 1`,
692
+ );
693
+ fs.writeFileSync(flags['dump-build'], JSON.stringify({
694
+ schema, paired,
695
+ searchPath: built.statements[0],
696
+ statementCount: built.statements.length,
697
+ statements: built.statements,
698
+ liveInIncomingAfterBuild: rows,
699
+ }, null, 2));
700
+ info(`--dump-build: wrote ${built.statements.length} rendered statement(s) and ${rows.length} live definition(s) to ${flags['dump-build']}`);
701
+ }
702
+ }
703
+ : undefined,
499
704
  log: (m) => info(m),
500
705
  // The runner is handed in and USED: it is idle for the whole restore, so the Phase B
501
706
  // heartbeat reads the loading backend's state over it (swap-heartbeat.ts).
@@ -507,14 +712,7 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
507
712
  declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
508
713
  });
509
714
  },
510
- snapshot: snapshotViaStage
511
- ? async () => {
512
- step('Snapshotting the stage before the swap (db:backup)...');
513
- const r: any = await invokeAction(region!, opsFn!, 'db:backup', { stage });
514
- if (r?.error) throw new Error(`pre-swap snapshot failed, so the swap was NOT applied: ${r.error}`);
515
- info(`snapshot on record: ${r?.id ?? 'backup complete'} — restore with db:restore --from ${r?.id ?? '<id>'} --confirm.`);
516
- }
517
- : async () => { warn('no snapshot taken (direct v1) — take one first: everystack db:backup --database-url … before a production swap.'); },
715
+ snapshot: () => takePreSwapSnapshot(snapshotPlan, { stage, region, opsFn }),
518
716
  }),
519
717
  );
520
718
 
@@ -526,6 +724,13 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
526
724
  if (flags['rebuild-derived'] === 'true') {
527
725
  warn(`the derived objects depending on ${schema} were dropped — they do NOT exist until you regenerate them.`);
528
726
  warn(` run now: everystack db:reconcile --apply --stage ${stage ?? '<stage>'} --direct`);
727
+ } else if (paired.length > 0) {
728
+ // Say it in the OUTPUT, not just the docs. The two-step "swap then reconcile" was correct
729
+ // advice for the unpaired swap and is baked into deploy scripts written against it. Run
730
+ // now, that reconcile drops and recreates the layer the swap just built correctly, holding
731
+ // ACCESS EXCLUSIVE — turning a zero-downtime swap into an outage. Seen in the field.
732
+ info(`the derived layer (${paired.join(', ')}) is live and complete — a post-swap db:reconcile is NOT required.`);
733
+ 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.`);
529
734
  }
530
735
  } else {
531
736
  fail(`db:swap ${res.status}: ${res.reason}`);
@@ -70,13 +70,10 @@ export interface UrlRunner {
70
70
  }
71
71
 
72
72
  /**
73
- * A QueryRunner over a direct postgres.js connection. One connection is enough —
74
- * introspection is a handful of sequential catalog queries.
73
+ * Load the postgres.js driver, or explain how to get it. Shared by every direct-connection
74
+ * runner below so the missing-driver instructions can never drift between them.
75
75
  */
76
- export async function createUrlRunner(
77
- url: string,
78
- load: () => Promise<any> = () => import('postgres'),
79
- ): Promise<UrlRunner> {
76
+ async function loadPostgresDriver(load: () => Promise<any>): Promise<any> {
80
77
  let mod: any;
81
78
  try {
82
79
  mod = await load();
@@ -85,7 +82,18 @@ export async function createUrlRunner(
85
82
  'The direct-connection path needs the "postgres" driver. It ships with @everystack/server; in a project without it: pnpm add -D postgres',
86
83
  );
87
84
  }
88
- const postgres = mod.default ?? mod;
85
+ return mod.default ?? mod;
86
+ }
87
+
88
+ /**
89
+ * A QueryRunner over a direct postgres.js connection. One connection is enough —
90
+ * introspection is a handful of sequential catalog queries.
91
+ */
92
+ export async function createUrlRunner(
93
+ url: string,
94
+ load: () => Promise<any> = () => import('postgres'),
95
+ ): Promise<UrlRunner> {
96
+ const postgres = await loadPostgresDriver(load);
89
97
  // max_lifetime: null — the driver's default recycles a connection after a random 30–60
90
98
  // minutes, resolving the in-flight query and THEN killing the session. Under reconcile's
91
99
  // BEGIN-across-calls transaction that is a silent session swap mid-batch (the reconcile
@@ -114,15 +122,7 @@ export async function createUrlPipelineRunner(
114
122
  url: string,
115
123
  load: () => Promise<any> = () => import('postgres'),
116
124
  ): Promise<UrlPipelineRunner> {
117
- let mod: any;
118
- try {
119
- mod = await load();
120
- } catch {
121
- throw new Error(
122
- 'The direct-connection path needs the "postgres" driver. It ships with @everystack/server; in a project without it: pnpm add -D postgres',
123
- );
124
- }
125
- const postgres = mod.default ?? mod;
125
+ const postgres = await loadPostgresDriver(load);
126
126
  const sql = postgres(url, { max: 1, onnotice: () => {}, ...sslDefaults(url) });
127
127
  return {
128
128
  query: async (query: string) => Array.from(await sql.unsafe(query)),
@@ -132,6 +132,57 @@ export async function createUrlPipelineRunner(
132
132
  };
133
133
  }
134
134
 
135
+ export interface UrlProbeRunner {
136
+ /** Read-only introspection, for the contract pull/diff. */
137
+ runner: QueryRunner;
138
+ /** The self-reverting red-team probe. See `probe` below. */
139
+ probe: (setup: string, read: string) => Promise<any[]>;
140
+ /** Close the client so the process can exit cleanly. */
141
+ end: () => Promise<void>;
142
+ }
143
+
144
+ /** Thrown to abort the probe transaction; never escapes `probe`. */
145
+ const PROBE_ROLLBACK = Symbol('authz_probe_rollback');
146
+
147
+ /**
148
+ * The direct-connection twin of the server's `db:authz:probe` action
149
+ * (`@everystack/server` plugin.ts) — the local venue for `db:authz:test` / `db:authz:owner`.
150
+ *
151
+ * The probe SQL must WRITE to test INSERT/UPDATE/DELETE privileges, so the only thing
152
+ * standing between a red-team run and a mutated developer database is the rollback. It is
153
+ * therefore unconditional: the transaction body always throws `PROBE_ROLLBACK` after
154
+ * reading the outcome rows, so postgres.js aborts it on every path — success included.
155
+ * There is no code path that commits. `SET ROLE` is transactional (its effect disappears
156
+ * when the transaction aborts), so the session's role is restored by the same rollback.
157
+ *
158
+ * `max: 1` is load-bearing, not tuning: the probe's SET ROLE / savepoint state only makes
159
+ * sense on the single connection that ran the setup.
160
+ */
161
+ export async function createUrlProbeRunner(
162
+ url: string,
163
+ load: () => Promise<any> = () => import('postgres'),
164
+ ): Promise<UrlProbeRunner> {
165
+ const postgres = await loadPostgresDriver(load);
166
+ const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => {}, ...sslDefaults(url) });
167
+ return {
168
+ runner: async (query: string) => Array.from(await sql.unsafe(query)),
169
+ probe: async (setup: string, read: string) => {
170
+ let rows: any[] = [];
171
+ try {
172
+ await sql.begin(async (tx: any) => {
173
+ await tx.unsafe(setup);
174
+ rows = Array.from(await tx.unsafe(read));
175
+ throw PROBE_ROLLBACK; // discard every probe write — unconditional
176
+ });
177
+ } catch (err: unknown) {
178
+ if (err !== PROBE_ROLLBACK) throw err;
179
+ }
180
+ return rows;
181
+ },
182
+ end: () => sql.end({ timeout: 5 }),
183
+ };
184
+ }
185
+
135
186
  /**
136
187
  * Managed Postgres (RDS / Supabase / Neon) requires TLS — a direct connection without it
137
188
  * is rejected with pg_hba "no encryption". Default `ssl: 'require'` for remote hosts so the
@@ -218,6 +218,36 @@ WHERE d.classid = 'pg_rewrite'::regclass
218
218
  AND rn.nspname NOT LIKE 'pg_%'
219
219
  AND NOT EXISTS (
220
220
  SELECT 1 FROM pg_depend dep WHERE dep.objid = rp.oid AND dep.deptype = 'e'
221
+ )
222
+ UNION
223
+ -- A FUNCTION depending on a view/matview's composite ROW TYPE — RETURNS SETOF <view>,
224
+ -- or a view rowtype as an argument.
225
+ --
226
+ -- Both branches above are rooted at pg_rewrite, so their dependent side is always a view or
227
+ -- matview; this edge has a function on the dependent side and is recorded against pg_type, not
228
+ -- pg_class. Nothing found it, so the cascade could not see that dropping the view required
229
+ -- dropping the function first — and PostgreSQL refuses the drop:
230
+ --
231
+ -- cannot drop materialized view post_engagement because other objects depend on it
232
+ -- DETAIL: function analytics.engagement_for_author depends on type analytics_view.post_engagement
233
+ --
234
+ -- Latent until something UPSTREAM of such a view actually changes, which is why an app can carry
235
+ -- this shape for a long time and only meet it the first time the view has to rebuild.
236
+ SELECT DISTINCT
237
+ dn.nspname, dp.proname, rn.nspname, rc.relname
238
+ FROM pg_depend d
239
+ JOIN pg_proc dp ON dp.oid = d.objid
240
+ JOIN pg_namespace dn ON dn.oid = dp.pronamespace
241
+ JOIN pg_type rt ON rt.oid = d.refobjid
242
+ JOIN pg_class rc ON rc.oid = rt.typrelid
243
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
244
+ WHERE d.classid = 'pg_proc'::regclass
245
+ AND d.refclassid = 'pg_type'::regclass
246
+ AND rc.relkind IN ('v', 'm')
247
+ AND rn.nspname NOT IN ('pg_catalog', 'information_schema')
248
+ AND rn.nspname NOT LIKE 'pg_%'
249
+ AND NOT EXISTS (
250
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = dp.oid AND dep.deptype = 'e'
221
251
  );
222
252
  `.trim();
223
253
 
@@ -419,9 +419,19 @@ export function planReconcile(
419
419
  };
420
420
 
421
421
  // Relation roots whose live dependents must be handled: rebuilds and drops.
422
+ //
423
+ // FUNCTIONS ARE IN THE CLOSURE, not filtered out. A function whose return type is a view's
424
+ // composite rowtype (`RETURNS SETOF <view>`) makes PostgreSQL refuse to drop that view while
425
+ // the function exists. Excluding functions here left the cascade unable to express that, so a
426
+ // view backing a function's return type could never be rebuilt: the plan plotted the view's
427
+ // drop, counted the function as up to date, and the apply died on the drop.
428
+ //
429
+ // A function pulled in this way must be DROP + CREATE, never CREATE OR REPLACE. Replace does
430
+ // not drop, so the view's drop still runs with the function present and fails exactly as
431
+ // before — the `rebuild` map is what puts an identity in BOTH the drop set and the create set.
422
432
  const relationRoots = [...rebuild.keys(), ...drop.keys()];
423
433
  for (const root of relationRoots) {
424
- const closure = closureOf(root).filter((id) => liveById.get(id) && isRelation(liveById.get(id)!.kind));
434
+ const closure = closureOf(root).filter((id) => liveById.has(id));
425
435
  const blockers = closure.filter((id) => !srcById.has(id) && !drop.has(id));
426
436
  if (blockers.length > 0) {
427
437
  blocked.push({
@@ -434,7 +444,12 @@ export function planReconcile(
434
444
  }
435
445
  for (const dep of closure) {
436
446
  if (!rebuild.has(dep) && !drop.has(dep) && srcById.has(dep)) {
447
+ const kind = liveById.get(dep)!.kind;
437
448
  rebuild.set(dep, `dependency rebuild (depends on ${root})`);
449
+ // Promoting a function out of the replace lane: a plain replace here would be a no-op
450
+ // against the problem, and leaving it in both lanes would emit a replace AND a
451
+ // drop+create for the same object.
452
+ if (!isRelation(kind)) fnReplace.delete(dep);
438
453
  }
439
454
  }
440
455
  }
package/src/cli/index.ts CHANGED
@@ -373,7 +373,7 @@ Usage:
373
373
  everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
374
374
  everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
375
375
  everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
376
- everystack db:swap --schema <name> --database-url <url> --from <artifact.dump> [--fingerprint <hash>] Land a schema artifact atomically: fingerprint gate → restore into <schema>_incoming (COPY-safe rewrite) → one txn (drop+rename+recreate app→schema FKs, re-apply authz) → verify → drop retiring. Refresh-free; app.* untouched. DESTRUCTIVE (--stage/--direct ops venue rides stage-write-lanes)
376
+ everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
377
377
  everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
378
378
  everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored); --abilities public-read stamps the common stanza (public read, admin write) uncommented — explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
379
379
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
@@ -394,10 +394,11 @@ Usage:
394
394
  everystack db:template:refresh [--database-url <url>] [--models <barrel>] [--seed "<cmd>"] [--no-seed] (Re)build the dev template <base>_tpl FROM THE DECLARED STATE (both layers, fingerprint MATCH bar) + seed-as-code (the app's db:seed script, run with DATABASE_URL pointed at the template; --seed overrides). All or nothing: a failed build/seed leaves NO template. Never a data copy
395
395
  everystack db:branch [--list | --drop --confirm | --prune --confirm] [--database-url <url>] Mint (or find) the current git branch's database from the template (CREATE DATABASE … TEMPLATE — schema, authz, derived layer, and seed rows inherited), print its DATABASE_URL, then db:sync evolves it with the checkout. --list maps every branch DB to its branch; --prune drops the ones whose branch is gone (never guesses: unknown mappings are kept)
396
396
  everystack db:fork --from-stage <src> --stage <target> --confirm [--backup <id>] Fork one DEPLOYED stage's database into another: back up the source (or reuse --backup <id>), presign the dump (the operator's credentials ARE the cross-stage authorization; expires in 1h), restore into the target via its ops Lambda. Production is never a target (that's db:restore); forking FROM production warns about PII; the branch's schema edge then lands via db:plan → db:apply (descent composes). Teardown: sst remove --stage <target>
397
- everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
398
- everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
399
- everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
400
- everystack db:authz:owner [--stage <name>] [--models db/models/index.ts] Red-team owner isolation: two JWT identities per owner-scoped table — catches IDOR (non-zero exit on a leak)
397
+ everystack db:authz:pull [--stage <name> | --database-url <url>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
398
+ everystack db:authz:diff [--stage <name> | --database-url <url>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
399
+ everystack db:authz:test [--stage <name> | --database-url <url>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
400
+ everystack db:authz:owner [--stage <name> | --database-url <url>] [--models db/models/index.ts] Red-team owner isolation: two JWT identities per owner-scoped table — catches IDOR (non-zero exit on a leak)
401
+ All four accept a DIRECT venue (--database-url, or an inherited ADMIN_DATABASE_URL/DATABASE_URL) so a brownfield authz migration can be rehearsed against a local database instead of a deployed stage. Same SQL, same evaluation, same verdict — every run names the venue it judged. The red-team probes WRITE to test INSERT/UPDATE/DELETE privileges and are always rolled back.
401
402
  everystack db:authz:report [--dir authz] Render the committed contract as a human-readable authorization review (no DB)
402
403
  everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
403
404
  everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
@@ -26,6 +26,7 @@ import { compileTableContract } from './authz-compile.js';
26
26
  import { emitReconcileSql } from './authz-reconcile.js';
27
27
  import { emitSchemaSql, nextvalSequence, type SchemaChange } from './schema-diff.js';
28
28
  import { compileDerived } from './derived-compile.js';
29
+ import { renderEnsureObjectSchemas } from './derived-apply.js';
29
30
 
30
31
  /** The empty (not-yet-created) authz state for a table — the greenfield baseline. */
31
32
  function emptyTable(table: string): TableContract {
@@ -155,7 +156,17 @@ export function compileModuleMigration(modules: Module[], opts: CompileTableOpti
155
156
  // objects — compiled in topological order, after every table. Greenfield = one
156
157
  // complete script: state + compute; from then on the layer deploys via db:reconcile.
157
158
  const models = modules.flatMap((m) => m.models);
158
- for (const obj of compileDerived(models, modules.flatMap((m) => m.derived))) {
159
+ const derivedObjects = compileDerived(models, modules.flatMap((m) => m.derived));
160
+
161
+ // 4a. A schema that ONLY derived objects live in has no model to create it, so phase 2
162
+ // never does — and the first CREATE VIEW in it fails with `schema … does not exist`.
163
+ // db:reconcile has always handled this; the greenfield module migration did not, so a
164
+ // derived-only schema was buildable by reconcile and not by a from-scratch deploy.
165
+ // Same renderer as the reconcile path, deliberately: one implementation, one behaviour.
166
+ // Idempotent — a schema phase 2 already created is a no-op here.
167
+ sql.push(...renderEnsureObjectSchemas(derivedObjects).map((s) => `${s};`));
168
+
169
+ for (const obj of derivedObjects) {
159
170
  sql.push(`${obj.sql};`);
160
171
  for (const a of obj.attachments) sql.push(`${a.sql};`);
161
172
  }