@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.
- package/bin/everystack.mjs +52 -0
- package/package.json +4 -3
- package/src/cli/commands/db-authz.ts +94 -38
- package/src/cli/commands/db-swap.ts +229 -24
- package/src/cli/db-source.ts +67 -16
- package/src/cli/derived-introspect.ts +30 -0
- package/src/cli/derived-plan.ts +16 -1
- package/src/cli/index.ts +6 -5
- package/src/cli/migration-compile.ts +12 -1
- package/src/cli/migration-generate.ts +31 -2
- package/src/cli/mutation-lease.ts +8 -0
- package/src/cli/schema-rewrite.ts +142 -9
- package/src/cli/schema-source.ts +30 -7
- package/src/cli/schema-swap.ts +55 -1
- package/src/cli/swap-execute.ts +268 -10
- package/src/cli/swap-heartbeat.ts +106 -17
- package/src/cli/swap-pair.ts +443 -0
- package/src/cli/swap-snapshot.ts +260 -0
|
@@ -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',
|
|
266
|
-
stdio: ['
|
|
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
|
|
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
|
-
|
|
422
|
-
|
|
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 (
|
|
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
|
-
|
|
437
|
-
region =
|
|
438
|
-
opsFn = opsFunction(
|
|
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:
|
|
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:
|
|
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}`);
|
package/src/cli/db-source.ts
CHANGED
|
@@ -70,13 +70,10 @@ export interface UrlRunner {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -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.
|
|
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> --
|
|
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
|
-
|
|
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
|
}
|