@everystack/cli 0.4.78 → 0.4.80

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.
@@ -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
- }
@@ -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
- }