@everystack/cli 0.4.78 → 0.4.79
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/CHANGELOG.md +21 -4
- package/package.json +1 -1
- package/src/cli/commands/db-pull.ts +31 -0
- package/src/cli/git-descent.ts +4 -3
- package/src/cli/index.ts +1 -6
- package/src/cli/adopt.ts +0 -58
- package/src/cli/commands/db-adopt.ts +0 -243
- package/src/cli/live-models.ts +0 -65
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to `@everystack/cli` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.79] - 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Removed
|
|
10
|
+
|
|
11
|
+
- remove db:adopt — its proof was "would db:plan be empty", and db:plan answers that on the review surface
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- db:pull ends with the on-ramp runbook; docs and help say the on-ramp is pull, commit, plan
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- closing-gate round 1 — the as-is lane runs the shipped db:pull, the runbook is gated on --abilities live, the fixture proves live derivation, live-models.ts is gone
|
|
20
|
+
|
|
7
21
|
## [0.4.78] - 2026-09-07
|
|
8
22
|
|
|
9
23
|
### Fixed
|
|
@@ -941,6 +955,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
941
955
|
|
|
942
956
|
## [0.2.37] - 2026-06-17
|
|
943
957
|
|
|
958
|
+
### Removed
|
|
959
|
+
|
|
960
|
+
- remove onMutation callback — events replaces it
|
|
961
|
+
- remove unused @everystack/jobs devDependency
|
|
962
|
+
- remove secret auto-injection, skip SSR check for web-only updates
|
|
963
|
+
- remove media asset pruning from update command
|
|
964
|
+
|
|
944
965
|
### Added
|
|
945
966
|
|
|
946
967
|
- rename @everystack/updates → @everystack/cli, add db:migrate/db:seed, remove AdminSecret
|
|
@@ -992,7 +1013,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
992
1013
|
### Fixed
|
|
993
1014
|
|
|
994
1015
|
- normalize cache:purge --path to strip query params and ensure leading slash
|
|
995
|
-
- remove unused @everystack/jobs devDependency
|
|
996
1016
|
- include updateId in register-web payload
|
|
997
1017
|
- psql Lambda-only queries, composite key notify, logging defaults
|
|
998
1018
|
- add AWS KVS + SigV4a as direct dependencies
|
|
@@ -1011,14 +1031,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
1011
1031
|
- drop GetFunctionConfiguration from log group resolution
|
|
1012
1032
|
- detect export completion to prevent static pre-rendering hang
|
|
1013
1033
|
- rewrite secrets for SST Ion, filter EXPO_PUBLIC_* only
|
|
1014
|
-
- remove secret auto-injection, skip SSR check for web-only updates
|
|
1015
1034
|
- clear Metro transform cache on export
|
|
1016
1035
|
- worker function discovery and log group resolution for SQS subscribers
|
|
1017
1036
|
- replace infrastructure headers with single X-Server-Debug timestamp
|
|
1018
1037
|
- version bumps and publish fixes for cli, server, jobs
|
|
1019
1038
|
- invalidate stale config cache on schema change
|
|
1020
1039
|
- fall back to outputs.json for custom-named media buckets
|
|
1021
|
-
- remove media asset pruning from update command
|
|
1022
1040
|
- media-sync test uses same minimatch interop as source
|
|
1023
1041
|
- discover the Router distribution by domain and SST tags
|
|
1024
1042
|
- use minimatch's named export — the package ships types now
|
|
@@ -1027,7 +1045,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
1027
1045
|
|
|
1028
1046
|
### Changed
|
|
1029
1047
|
|
|
1030
|
-
- remove onMutation callback — events replaces it
|
|
1031
1048
|
- migrate all package schemas to everyschema canonical tables
|
|
1032
1049
|
- lazy-load AWS SDK imports for faster CLI startup
|
|
1033
1050
|
- drop type module from all packages, switch Jest to CJS mode
|
package/package.json
CHANGED
|
@@ -38,6 +38,36 @@ import { fingerprintLive } from '../schema-fingerprint';
|
|
|
38
38
|
import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile';
|
|
39
39
|
import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline';
|
|
40
40
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The on-ramp runbook, printed last so the operator is never left guessing what a pull is FOR.
|
|
44
|
+
*
|
|
45
|
+
* db:pull is the only way an existing database comes in. What happens next is decided by
|
|
46
|
+
* db:plan, not by a verb: an EMPTY plan means the committed Models declare the database as it
|
|
47
|
+
* is (adopted — descent proves the pull commit by recompile, and every later apply descends from
|
|
48
|
+
* it); a NON-EMPTY plan is the hardening edge, reviewed and applied as the origin. db:adopt used
|
|
49
|
+
* to bolt a proof onto this render; the proof was "would the plan be empty", so the verb is gone
|
|
50
|
+
* (cli 0.4.79) and this is what replaced it. Exported so a test pins the lines and the
|
|
51
|
+
* refusal-runbook lint reads them.
|
|
52
|
+
*/
|
|
53
|
+
export function pullOnRampRunbook(opts: { stage?: string; out?: string; abilities?: string }): string[] {
|
|
54
|
+
const target = opts.stage ? `--stage ${opts.stage}` : '--stage <name>';
|
|
55
|
+
const what = opts.out ? `the files in ${opts.out}` : 'these Models';
|
|
56
|
+
// Only a LIVE pull transcribes the database's own authz; only then can an empty plan mean
|
|
57
|
+
// "adopted as it is". A commented or public-read pull carries authored abilities — its plan is
|
|
58
|
+
// an edge to review (public-read on an existing database GRANTS public read of every table),
|
|
59
|
+
// never an adoption, and the runbook must not call it one (closing-gate review, 2026-09-07).
|
|
60
|
+
if (opts.abilities !== 'live') {
|
|
61
|
+
return [
|
|
62
|
+
`Next: author each model's abilities (db:check fails until every model declares) — for an EXISTING database, re-pull with --abilities live to derive them from the grants and policies already there. Then commit ${what} and run \`everystack db:plan ${target}\`: the plan is an edge to review, not an adoption of what is live.`,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
return [
|
|
66
|
+
`Next: commit ${what}, then \`everystack db:plan ${target}\`.`,
|
|
67
|
+
'EMPTY plan = adopted as it is: that commit declares the database, and every later db:apply descends from it. Nothing to apply.',
|
|
68
|
+
`NON-EMPTY plan = the hardening edge: review it, then \`everystack db:apply --plan <file> ${target}\`. No commit declares this database yet, so the reviewed plan is the ORIGIN — db:apply proves that and records it.`,
|
|
69
|
+
];
|
|
70
|
+
}
|
|
41
71
|
import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract';
|
|
42
72
|
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership';
|
|
43
73
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
@@ -461,5 +491,6 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
461
491
|
note(`Committing this file ADOPTS those roles. Anything not in it — or anything that grows beyond it — refuses at db:plan.`);
|
|
462
492
|
}
|
|
463
493
|
}
|
|
494
|
+
for (const line of pullOnRampRunbook({ stage: flags.stage, out: flags.out, abilities })) note(line);
|
|
464
495
|
process.exit(0);
|
|
465
496
|
}
|
package/src/cli/git-descent.ts
CHANGED
|
@@ -278,8 +278,9 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
|
|
|
278
278
|
// not a failure to read. Every pre-Models repo has such ancestors: the models directory
|
|
279
279
|
// scaffolded (or present for another reason) before its barrel existed. Letting the loader
|
|
280
280
|
// throw on the absent file would classify it `compile-failed`, poisoning the verdict to
|
|
281
|
-
// UNVERIFIABLE — which sends a never-adopted database to "retry/upgrade" instead of to
|
|
282
|
-
// db:
|
|
281
|
+
// UNVERIFIABLE — which sends a never-adopted database to "retry/upgrade" instead of to the
|
|
282
|
+
// first-adoption lane (db:pull → commit → db:plan). So the missing barrel is decided here,
|
|
283
|
+
// before the loader is ever consulted.
|
|
283
284
|
const barrelPath = path.join(dest, ctx.barrel);
|
|
284
285
|
if (!fs.existsSync(barrelPath)) return { kind: 'no' };
|
|
285
286
|
|
|
@@ -433,7 +434,7 @@ const firstLine = summarizeCompileError;
|
|
|
433
434
|
* in a monorepo the app (`apps/expo/db/models`) has `apps/expo/node_modules`, and the root
|
|
434
435
|
* `node_modules` may not carry the package at all (closing-gate review round 4, reproduced). No
|
|
435
436
|
* `node_modules` anywhere up to the repo root → the repo root itself (a nested checkout still walks
|
|
436
|
-
* up to its host's). Never the OS temp dir — that resolves nothing (the
|
|
437
|
+
* up to its host's). Never the OS temp dir — that resolves nothing (the 0.4.74 sandbox-resolution bug).
|
|
437
438
|
*/
|
|
438
439
|
export function materializeBase(repoRoot: string, modelsAbs: string): string {
|
|
439
440
|
let dir = path.dirname(modelsAbs);
|
package/src/cli/index.ts
CHANGED
|
@@ -11,7 +11,6 @@ import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDocto
|
|
|
11
11
|
import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzOwnerCommand, dbAuthzReportCommand } from './commands/db-authz';
|
|
12
12
|
import { dbGenerateCommand } from './commands/db-generate';
|
|
13
13
|
import { dbPullCommand } from './commands/db-pull';
|
|
14
|
-
import { dbAdoptCommand } from './commands/db-adopt';
|
|
15
14
|
import { dbReconcileCommand } from './commands/db-reconcile';
|
|
16
15
|
import { dbRefreshCommand } from './commands/db-refresh';
|
|
17
16
|
import { dbFingerprintCommand } from './commands/db-fingerprint';
|
|
@@ -186,9 +185,6 @@ async function main() {
|
|
|
186
185
|
case 'db:pull':
|
|
187
186
|
await dbPullCommand(flags);
|
|
188
187
|
break;
|
|
189
|
-
case 'db:adopt':
|
|
190
|
-
await dbAdoptCommand(flags);
|
|
191
|
-
break;
|
|
192
188
|
case 'db:reconcile':
|
|
193
189
|
await dbReconcileCommand(flags);
|
|
194
190
|
break;
|
|
@@ -368,9 +364,8 @@ Usage:
|
|
|
368
364
|
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
|
|
369
365
|
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
|
|
370
366
|
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
|
|
371
|
-
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models
|
|
367
|
+
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models — THE on-ramp for an existing database: commit what it renders, then db:plan --stage <s>. EMPTY plan = adopted as it is (that commit declares the database; every later db:apply descends from it). NON-EMPTY plan = the hardening edge: review it, db:apply --plan <f> --stage <s> — no commit declares the database yet, so the reviewed plan is the ORIGIN. Render from the stage, never from a local restore of a dump. --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 live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --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
|
|
372
368
|
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.
|
|
373
|
-
everystack db:adopt [--stage <name> | --database-url <url>] [--schema public] [--out db/models] [--govern-roles r1,r2] First-time adoption of a pre-Models database AS IT IS, BY PROOF. A production DB that never went through Models has no committed ancestor declaring it; db:adopt is the on-ramp that makes one. It renders field() Models from the live DB (the same renderers db:pull uses, --abilities live), compiles them back, and finalizes the baseline ONLY when the compiled Models declare the live state EXACTLY (predicted == governed live — the same declares-test descent and db:check use). On success it writes --out (default db/models) and prints the \`chore(db): adopt live baseline <fp>\` commit — that commit legitimately declares the live state, so a later db:apply descends for real. PROVE not attest: there is NO bypass flag. When the round-trip is not exact, adoption REFUSES, names the surfaces that diverged, prints the edge, and writes NOTHING — that is the HARDENING case (the Models vocabulary cannot declare what is live, e.g. a sequence grant with no INSERT behind it), and it needs no adoption commit: commit the Models you intend, db:plan --stage, db:apply --stage. No commit declares the database yet, so db:apply PROVES that (proven drift) and treats the reviewed plan as the ORIGIN. Commit AND push the baseline before db:apply — an unpushed baseline is an unknown commit to teammates and the ops Lambda.
|
|
374
369
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
375
370
|
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
|
|
376
371
|
everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url refreshes over a local connection (dev); --stage <name> --direct resolves the stage's operator connection from its ops Lambda (credential never on argv) and refreshes CLI-side on an UNBOUNDED clock, for a matview set that exceeds the ops-Lambda 900s limit; --only refreshes a named subset (full identity or bare name); --verify-nonempty gates on populated-but-zero-rows matviews (the dark-panel check — a scoped EXISTS on just what was refreshed, so a promotion script gets the gate with no read authority) and FAILS naming any empty; --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.
|
package/src/cli/adopt.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* adopt — the PROVE core of `db:adopt` (P2a). Pure: live introspection + compiled Models in, a
|
|
3
|
-
* verdict out. The command shell (commands/db-adopt.ts) does the IO — introspect, render, load,
|
|
4
|
-
* write files, print the commit.
|
|
5
|
-
*
|
|
6
|
-
* The doctrine (Fable-ruled 2026-09-05): PROVE, not attest. There is no bypass flag. db:adopt
|
|
7
|
-
* renders Models from a live database and may finalize them ONLY when the compiled Models declare
|
|
8
|
-
* that database EXACTLY — the same declares-test descent and db:check use (predicted == governed
|
|
9
|
-
* live). A commit that carries such Models legitimately declares the live state, so a later
|
|
10
|
-
* db:apply descends for real (proveDescent finds the adopt commit) instead of being forced past a
|
|
11
|
-
* drift refusal. When the round-trip is not exact, adoption REFUSES and names what diverged —
|
|
12
|
-
* because a baseline that silently papered over a difference is the bypass under another name.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { ModelDescriptor } from '@everystack/model';
|
|
16
|
-
import type { SchemaSnapshot } from './schema-introspect';
|
|
17
|
-
import type { AuthzContract } from './authz-contract';
|
|
18
|
-
import { governedRolesForModels, fingerprintState, fingerprintLive, divergentSurfaces } from './schema-fingerprint';
|
|
19
|
-
import { predictLiveState, governedLiveFingerprint } from './edge-plan';
|
|
20
|
-
|
|
21
|
-
export interface AdoptionProof {
|
|
22
|
-
/** True when the compiled Models declare the live state exactly — safe to finalize the baseline. */
|
|
23
|
-
clean: boolean;
|
|
24
|
-
/** The RAW live fingerprint — the baseline id that names the adopt commit (`adopt live baseline <fp>`). */
|
|
25
|
-
fingerprint: string;
|
|
26
|
-
/** When not clean, the surfaces that still diverge (base schema / authorization / sequence ACLs,
|
|
27
|
-
* with the tables named), so the refusal says exactly what the render could not capture. Empty when clean. */
|
|
28
|
-
divergentSurfaces: string[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Does this compiled Models set declare the live state exactly? The declares-test, governed on
|
|
33
|
-
* both sides through the SAME set the Models imply — a governed prediction against a raw live
|
|
34
|
-
* hash can never converge on a brownfield database with foreign roles. Reuses the very functions
|
|
35
|
-
* descent (`evaluateTree`) and db:fingerprint compare with, so "adoption is clean" and "the plan
|
|
36
|
-
* is empty" are one answer, not two that can disagree.
|
|
37
|
-
*/
|
|
38
|
-
export function proveAdoption(
|
|
39
|
-
models: ModelDescriptor[],
|
|
40
|
-
snapshot: SchemaSnapshot,
|
|
41
|
-
contract: AuthzContract,
|
|
42
|
-
opts: { schema?: string } = {},
|
|
43
|
-
): AdoptionProof {
|
|
44
|
-
const governedRoles = governedRolesForModels(models);
|
|
45
|
-
const predicted = predictLiveState(models, snapshot, contract, { schema: opts.schema, governedRoles });
|
|
46
|
-
const governedLiveHash = governedLiveFingerprint(snapshot, contract, governedRoles);
|
|
47
|
-
const clean = predicted.hash === governedLiveHash;
|
|
48
|
-
return {
|
|
49
|
-
clean,
|
|
50
|
-
fingerprint: fingerprintLive(snapshot, contract).hash,
|
|
51
|
-
divergentSurfaces: clean
|
|
52
|
-
? []
|
|
53
|
-
: divergentSurfaces(
|
|
54
|
-
predicted.canonical,
|
|
55
|
-
fingerprintState(snapshot, contract.tables, { governedRoles, sequenceGrants: contract.sequenceGrants }).canonical,
|
|
56
|
-
),
|
|
57
|
-
};
|
|
58
|
-
}
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `everystack db:adopt` — first-time adoption of a pre-Models database, by PROOF.
|
|
3
|
-
*
|
|
4
|
-
* db:adopt [--stage <name> | --database-url <url>] [--schema public] [--out db/models]
|
|
5
|
-
* [--govern-roles r1,r2]
|
|
6
|
-
*
|
|
7
|
-
* The problem it solves: a production database that never went through Models has no committed
|
|
8
|
-
* ancestor declaring it. db:adopt is the deliberate, auditable on-ramp for taking it AS IT IS: it renders
|
|
9
|
-
* Models from the live database (the same renderers db:pull uses), compiles them back, and PROVES
|
|
10
|
-
* the compiled Models declare the live state EXACTLY — predicted == governed live, the same
|
|
11
|
-
* declares-test descent and db:check use. Only then does it write the model files and print the
|
|
12
|
-
* `chore(db): adopt live baseline <fp>` commit: that commit legitimately declares the live state,
|
|
13
|
-
* so a later db:apply descends for real instead of hitting the drift refusal.
|
|
14
|
-
*
|
|
15
|
-
* The doctrine (Fable-ruled 2026-09-05): PROVE, not attest. There is NO bypass flag. When the
|
|
16
|
-
* round-trip is not exact, adoption REFUSES, names the surfaces that diverged, and prints the
|
|
17
|
-
* edge — because a baseline that papered over a difference is the bypass under another name. It
|
|
18
|
-
* writes NOTHING on refusal; --out is left untouched. That refusal is the HARDENING case (the
|
|
19
|
-
* vocabulary cannot declare something live), and it needs no adoption commit: commit the intended
|
|
20
|
-
* Models, db:plan, db:apply — descent proves no commit declares the database and treats the
|
|
21
|
-
* reviewed plan as the origin (git-descent.ts, proven drift, 2026-09-07).
|
|
22
|
-
*
|
|
23
|
-
* The pure proof core is adopt.ts (proveAdoption); the shared render is live-models.ts
|
|
24
|
-
* (renderLiveModelFiles). This shell is the IO: connect, render into a sandbox to compile, prove,
|
|
25
|
-
* and either finalize or refuse.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import fs from 'node:fs/promises';
|
|
29
|
-
import path from 'node:path';
|
|
30
|
-
import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
|
|
31
|
-
import { introspectContract, type AuthzContract, type QueryRunner } from '../authz-contract';
|
|
32
|
-
import { introspectDerived } from '../derived-introspect';
|
|
33
|
-
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
34
|
-
import { renderLiveModelFiles } from '../live-models';
|
|
35
|
-
import { proveAdoption, type AdoptionProof } from '../adopt';
|
|
36
|
-
import { generateMigrationSql } from '../migration-generate';
|
|
37
|
-
import { loadModels } from './db-generate';
|
|
38
|
-
import type { ModelDescriptor } from '@everystack/model';
|
|
39
|
-
import type { RenderedModelFile } from '../model-render';
|
|
40
|
-
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
|
|
41
|
-
import type { SessionRunner } from '../session';
|
|
42
|
-
import { resolveConfig, opsFunction } from '../config';
|
|
43
|
-
import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
|
|
44
|
-
import { step, success, fail, info, warn } from '../output';
|
|
45
|
-
import { opsAdviceLines } from '../ops-advice';
|
|
46
|
-
|
|
47
|
-
const DEFAULT_OUT = 'db/models';
|
|
48
|
-
|
|
49
|
-
export interface AdoptResult {
|
|
50
|
-
/** The proof: clean means the compiled Models declare the live state exactly. */
|
|
51
|
-
proof: AdoptionProof;
|
|
52
|
-
/** The rendered model files (directory shape) — written to --out only when the proof is clean. */
|
|
53
|
-
files: RenderedModelFile[];
|
|
54
|
-
/** The live schema, as introspected. */
|
|
55
|
-
snapshot: SchemaSnapshot;
|
|
56
|
-
/** The live authorization contract, as introspected. */
|
|
57
|
-
contract: AuthzContract;
|
|
58
|
-
/** `chore(db): adopt live baseline <fp>` when clean; null on refusal. */
|
|
59
|
-
commitMessage: string | null;
|
|
60
|
-
/** On refusal, the db:generate edge (what the Models and the live database still differ by). */
|
|
61
|
-
refusalDiff: string[];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* The adopt CORE: introspect the live database, render Models with the shared renderer, compile
|
|
66
|
-
* them in `sandboxDir`, and prove they declare the live state. Pure of policy — it does not decide
|
|
67
|
-
* to write --out or to exit; the caller does that from the verdict. The sandbox is where the
|
|
68
|
-
* rendered barrel is written so it can be dynamically imported (the compile the proof needs);
|
|
69
|
-
* --out is written by the shell only after the proof is clean.
|
|
70
|
-
*/
|
|
71
|
-
export async function adoptFromLive(opts: {
|
|
72
|
-
session: SessionRunner;
|
|
73
|
-
runner: QueryRunner;
|
|
74
|
-
schema?: string | string[];
|
|
75
|
-
governRoles?: string[];
|
|
76
|
-
sandboxDir: string;
|
|
77
|
-
/** How to compile the rendered barrel back to descriptors. Defaults to loadModels (the barrel
|
|
78
|
-
* loader db:generate uses). Injectable so a test runner that cannot dynamic-import a `.ts` via
|
|
79
|
-
* a file:// URL (jest) can supply its own transforming import. */
|
|
80
|
-
loadBarrel?: (indexPath: string) => Promise<ModelDescriptor[]>;
|
|
81
|
-
}): Promise<AdoptResult> {
|
|
82
|
-
const { session, sandboxDir } = opts;
|
|
83
|
-
const schema = opts.schema ?? 'public';
|
|
84
|
-
const load = opts.loadBarrel ?? loadModels;
|
|
85
|
-
|
|
86
|
-
const snapshot = await introspectSchema(session);
|
|
87
|
-
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
88
|
-
const derivedCatalog = await introspectDerived(session);
|
|
89
|
-
|
|
90
|
-
const rendered = renderLiveModelFiles(snapshot, contract, derivedCatalog, { schema, governRoles: opts.governRoles });
|
|
91
|
-
|
|
92
|
-
// Compile the rendered barrel: proveAdoption needs the loaded ModelDescriptors, and loading is a
|
|
93
|
-
// dynamic import, so the files must exist on disk under a tree where @everystack/model resolves.
|
|
94
|
-
await fs.mkdir(sandboxDir, { recursive: true });
|
|
95
|
-
for (const f of rendered.files) await fs.writeFile(path.join(sandboxDir, f.file), f.source, 'utf8');
|
|
96
|
-
const models = await load(path.join(sandboxDir, 'index.ts'));
|
|
97
|
-
|
|
98
|
-
const proof = proveAdoption(models, snapshot, contract, { schema: typeof schema === 'string' ? schema : undefined });
|
|
99
|
-
const commitMessage = proof.clean ? `chore(db): adopt live baseline ${proof.fingerprint}` : null;
|
|
100
|
-
// On refusal, show WHAT still differs — the same edge db:generate would mint against the live DB.
|
|
101
|
-
const refusalDiff = proof.clean ? [] : generateMigrationSql(models, snapshot, { liveAuthz: contract });
|
|
102
|
-
|
|
103
|
-
return { proof, files: rendered.files, snapshot, contract, commitMessage, refusalDiff };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Turn the adopt verdict into action — the SECURITY BOUNDARY, isolated so it is testable without a
|
|
108
|
-
* database. This is where "PROVE not attest, no bypass" is actually enforced: on refusal it writes
|
|
109
|
-
* NOTHING to --out and reports; on a clean proof it writes the baseline and prints the commit/push
|
|
110
|
-
* runbook. It never calls process.exit (the caller maps the returned code) — so a test can assert,
|
|
111
|
-
* against a synthetic verdict, that a dirty result leaves --out untouched and a clean one finalizes.
|
|
112
|
-
* A future edit that let the refusal path fall through to the write is caught by that test, red.
|
|
113
|
-
*/
|
|
114
|
-
export async function applyAdoptVerdict(result: AdoptResult, outDir: string): Promise<{ exitCode: 0 | 1; wrote: boolean }> {
|
|
115
|
-
if (!result.proof.clean) {
|
|
116
|
-
// REFUSAL — the round trip is not exact. Name what diverged and show the edge; write NOTHING.
|
|
117
|
-
console.log('');
|
|
118
|
-
fail('db:adopt refused — the rendered Models do NOT declare the live database exactly.');
|
|
119
|
-
warn('A baseline that papered over this difference would be a bypass. Nothing was written to --out.');
|
|
120
|
-
info('Surfaces that diverge:');
|
|
121
|
-
for (const s of result.proof.divergentSurfaces) info(` • ${s}`);
|
|
122
|
-
if (result.refusalDiff.length) {
|
|
123
|
-
console.log('');
|
|
124
|
-
info(`What still differs (the db:generate edge, ${result.refusalDiff.length} statement(s)):`);
|
|
125
|
-
for (const stmt of result.refusalDiff) console.log(stmt.endsWith(';') ? stmt : `${stmt};`);
|
|
126
|
-
}
|
|
127
|
-
console.log('');
|
|
128
|
-
info('The Models vocabulary cannot declare the surfaces above as they are live (a sequence grant with no INSERT behind it, for example), so no render can round-trip this database. That is the HARDENING case, and it needs no adoption commit: commit the Models you intend, then');
|
|
129
|
-
info(' everystack db:plan --stage <name> # mints the edge above as a reviewable plan');
|
|
130
|
-
info(' everystack db:apply --plan <file.plan.json> --stage <name> # credential-free; no commit declares this database yet, so the reviewed plan is the ORIGIN and is recorded as such');
|
|
131
|
-
info('Every later db:apply descends from that recorded origin.');
|
|
132
|
-
return { exitCode: 1, wrote: false };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// CLEAN — finalize. Write the rendered barrel to --out, then print the runbook.
|
|
136
|
-
try {
|
|
137
|
-
await fs.mkdir(outDir, { recursive: true });
|
|
138
|
-
const written = new Set(result.files.map((f) => f.file));
|
|
139
|
-
const existing = (await fs.readdir(outDir)).filter((f) => f.endsWith('.ts') && !written.has(f));
|
|
140
|
-
for (const f of result.files) await fs.writeFile(path.join(outDir, f.file), f.source, 'utf8');
|
|
141
|
-
if (existing.length) {
|
|
142
|
-
warn(`${existing.length} existing .ts file(s) in ${path.relative(process.cwd(), outDir)} were not part of this adoption and were left untouched: ${existing.join(', ')}`);
|
|
143
|
-
}
|
|
144
|
-
} catch (err: any) {
|
|
145
|
-
fail(`Could not write ${outDir}: ${err.message}`);
|
|
146
|
-
return { exitCode: 1, wrote: false };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
console.log('');
|
|
150
|
-
success(`db:adopt — the rendered Models declare the live database exactly (${result.files.length - 1} model(s)).`);
|
|
151
|
-
info(`Wrote ${path.relative(process.cwd(), outDir)} — the adopted baseline.`);
|
|
152
|
-
console.log('');
|
|
153
|
-
info('Finalize the baseline — commit, then PUSH before you apply:');
|
|
154
|
-
info(` git add ${path.relative(process.cwd(), outDir)}`);
|
|
155
|
-
info(` git commit -m "${result.commitMessage}"`);
|
|
156
|
-
info(' git push # an unpushed baseline is an unknown commit to teammates and the ops Lambda');
|
|
157
|
-
info(' everystack db:plan --stage <name> # empty — the live database is now declared');
|
|
158
|
-
return { exitCode: 0, wrote: true };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export async function dbAdoptCommand(
|
|
162
|
-
flags: Record<string, string>,
|
|
163
|
-
opts: {
|
|
164
|
-
/** Injected barrel loader (jest cannot file:// import a .ts). Default: adoptFromLive's loadModels. */
|
|
165
|
-
loadBarrel?: (indexPath: string) => Promise<ModelDescriptor[]>;
|
|
166
|
-
/** Injected sandbox dir, so a test can assert it is cleaned up. Default: a fresh mkdtemp. */
|
|
167
|
-
sandboxDir?: string;
|
|
168
|
-
} = {},
|
|
169
|
-
): Promise<void> {
|
|
170
|
-
const schemas = (flags.schema || 'public').split(',').map((s) => s.trim()).filter(Boolean);
|
|
171
|
-
if (schemas.length === 0) fail('--schema needs at least one schema name.');
|
|
172
|
-
const schema = schemas.length === 1 ? schemas[0] : schemas;
|
|
173
|
-
const outDir = path.resolve(flags.out || DEFAULT_OUT);
|
|
174
|
-
|
|
175
|
-
// --govern-roles: the operator's EXPLICIT decision to transcribe foreign roles' grants as
|
|
176
|
-
// privileges (so a fresh build recreates them). Same vocabulary guard as db:pull.
|
|
177
|
-
const governRoles = (flags['govern-roles'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
178
|
-
for (const r of governRoles) {
|
|
179
|
-
if (['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) {
|
|
180
|
-
fail(`--govern-roles ${r}: the vocabulary roles and PUBLIC are always governed — name only foreign roles (an ops/connection role the migrations granted to).`);
|
|
181
|
-
process.exit(1);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
let dbSource: DbSource;
|
|
186
|
-
try {
|
|
187
|
-
dbSource = resolveDbSource(flags);
|
|
188
|
-
} catch (err: any) {
|
|
189
|
-
fail(err.message);
|
|
190
|
-
process.exit(1);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// The proof-compile sandbox MUST live inside the project, never the OS temp dir. The rendered
|
|
194
|
-
// barrel imports `@everystack/model`, and Node resolves a bare specifier by walking UP from the
|
|
195
|
-
// importing file looking for node_modules. pnpm hoists workspace packages to the repo root (an
|
|
196
|
-
// app dir has no local copy), so a dir under $TMPDIR cannot reach them and the compile crashes
|
|
197
|
-
// with "Cannot find module '@everystack/model'". cwd is inside the project, so a sandbox under it
|
|
198
|
-
// resolves exactly as the real db/models barrel will. (Reported by a consumer on 0.4.74; the
|
|
199
|
-
// scratch-sweep harness documents the same rule: $TMPDIR → MODULE_NOT_FOUND, project dir resolves.)
|
|
200
|
-
const sandboxDir = opts.sandboxDir ?? await fs.mkdtemp(path.join(process.cwd(), '.everystack-adopt-'));
|
|
201
|
-
let exitCode: 0 | 1 = 1;
|
|
202
|
-
let end: (() => Promise<void>) | undefined;
|
|
203
|
-
try {
|
|
204
|
-
let runner: QueryRunner;
|
|
205
|
-
let session: SessionRunner;
|
|
206
|
-
if (dbSource.kind === 'url') {
|
|
207
|
-
step(connectingVia(dbSource));
|
|
208
|
-
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
209
|
-
} else {
|
|
210
|
-
step('Resolving deployed config...');
|
|
211
|
-
const config = await resolveConfig(flags.stage);
|
|
212
|
-
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
213
|
-
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
214
|
-
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
215
|
-
}
|
|
216
|
-
step(`Introspecting live database (schema: ${schemas.join(', ')})...`);
|
|
217
|
-
const result = await adoptFromLive({ session, runner, schema, governRoles, sandboxDir, loadBarrel: opts.loadBarrel });
|
|
218
|
-
// Only after a proof do we act. A throw before here (connect, introspect, render, compile)
|
|
219
|
-
// never reaches applyAdoptVerdict, so --out is untouched — a bad baseline is impossible.
|
|
220
|
-
({ exitCode } = await applyAdoptVerdict(result, outDir));
|
|
221
|
-
} catch (err: any) {
|
|
222
|
-
fail(err.message);
|
|
223
|
-
// A failure to load the rendered barrel is a COMPILE error, not a connection or IAM problem —
|
|
224
|
-
// don't send the operator to check their credentials for it.
|
|
225
|
-
if (/Could not load models|Cannot find module/i.test(err.message ?? '')) {
|
|
226
|
-
info('This is the proof-compile step, not the database. The rendered Models could not be loaded —');
|
|
227
|
-
info('run db:adopt from inside your project (where @everystack/model resolves), and check the models compile.');
|
|
228
|
-
} else if (dbSource.kind === 'url') {
|
|
229
|
-
info('Check the connection string and that the database is accepting connections.');
|
|
230
|
-
} else {
|
|
231
|
-
for (const line of opsAdviceLines(err, [
|
|
232
|
-
'Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.',
|
|
233
|
-
'Adopting a local database instead? Pass --database-url or set DATABASE_URL.',
|
|
234
|
-
])) info(line);
|
|
235
|
-
}
|
|
236
|
-
exitCode = 1;
|
|
237
|
-
} finally {
|
|
238
|
-
// The sandbox is the compile scratch, never the baseline — remove it on EVERY exit path.
|
|
239
|
-
await end?.();
|
|
240
|
-
await fs.rm(sandboxDir, { recursive: true, force: true });
|
|
241
|
-
}
|
|
242
|
-
process.exit(exitCode);
|
|
243
|
-
}
|
package/src/cli/live-models.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* renderLiveModelFiles — the render db:pull and db:adopt SHARE.
|
|
3
|
-
*
|
|
4
|
-
* Both commands turn a live database into `field()` Models with the same posture: derive each
|
|
5
|
-
* model's authz from the grants and policies already there (`abilities: 'live'`), render the
|
|
6
|
-
* derived layer from the live catalog, and write the directory shape (one file per model + an
|
|
7
|
-
* index barrel). Factoring it here is not only DRY — it is the round-trip's correctness. db:adopt
|
|
8
|
-
* PROVES the rendered Models declare the live state; if adopt rendered even slightly differently
|
|
9
|
-
* from pull, the proof could refuse on a cosmetic difference pull produces fine. One renderer,
|
|
10
|
-
* one result.
|
|
11
|
-
*
|
|
12
|
-
* Pure: introspection results in, rendered files out. The command shells do the IO.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { SchemaSnapshot } from './schema-introspect';
|
|
16
|
-
import type { AuthzContract } from './authz-contract';
|
|
17
|
-
import type { DerivedCatalog } from './derived-introspect';
|
|
18
|
-
import { renderModelFiles, pullableTables, modelVarName, type RenderedModelFile } from './model-render';
|
|
19
|
-
import { renderDerivedSource } from './derived-render';
|
|
20
|
-
import { fingerprintLive } from './schema-fingerprint';
|
|
21
|
-
import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from './authz-reconcile';
|
|
22
|
-
import { deriveAbilities } from './authz-derive';
|
|
23
|
-
|
|
24
|
-
export interface LiveModelRender {
|
|
25
|
-
/** The directory shape: one file per model + `index.ts` (the barrel that exports `models`). */
|
|
26
|
-
files: RenderedModelFile[];
|
|
27
|
-
/** The RAW live fingerprint — the baseline id db:adopt names the commit by. */
|
|
28
|
-
fingerprint: string;
|
|
29
|
-
/** Foreign grantees present at pull, exempt from revocation (the adoption baseline observation). */
|
|
30
|
-
exemptions: GrantExemption[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Render `field()` Models from a live database in the live-abilities, directory shape. The
|
|
35
|
-
* `--govern-roles` complete-or-refuse gate runs here (pure re-derivation on the pulled tables),
|
|
36
|
-
* before any file is written, so a listed role holding a column-scoped grant throws instead of
|
|
37
|
-
* leaving a half-written tree.
|
|
38
|
-
*/
|
|
39
|
-
export function renderLiveModelFiles(
|
|
40
|
-
snapshot: SchemaSnapshot,
|
|
41
|
-
contract: AuthzContract,
|
|
42
|
-
derivedCatalog: DerivedCatalog,
|
|
43
|
-
opts: { schema?: string | string[]; governRoles?: string[] } = {},
|
|
44
|
-
): LiveModelRender {
|
|
45
|
-
const schema = opts.schema ?? 'public';
|
|
46
|
-
const governRoles = opts.governRoles ?? [];
|
|
47
|
-
const pulled = pullableTables(snapshot, schema);
|
|
48
|
-
const knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
|
|
49
|
-
const derived = renderDerivedSource(derivedCatalog, { knownTables, sequences: snapshot.sequences });
|
|
50
|
-
const liveAuthz = new Map(contract.tables.map((t) => [t.table, t]));
|
|
51
|
-
|
|
52
|
-
// The --govern-roles gate, BEFORE any file is written: a governed role holding a column-scoped
|
|
53
|
-
// grant fails the render (deriveAbilities throws) rather than producing a half-owned barrel.
|
|
54
|
-
if (governRoles.length) {
|
|
55
|
-
const pulledNames = new Set(pulled.map((t) => t.table));
|
|
56
|
-
const govern = new Set(governRoles);
|
|
57
|
-
for (const [name, c] of liveAuthz) if (pulledNames.has(name)) deriveAbilities(c, { governRoles: govern });
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const files = renderModelFiles(snapshot, { schema, abilities: 'live', liveAuthz, governRoles, derived });
|
|
61
|
-
// A governed role's grants are DECLARED (transcribed as privileges), so they are not also
|
|
62
|
-
// recorded as exemptions — that would double-book them, declared and exempted at once.
|
|
63
|
-
const exemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
|
|
64
|
-
return { files, fingerprint: fingerprintLive(snapshot, contract).hash, exemptions };
|
|
65
|
-
}
|