@everystack/cli 0.4.73 → 0.4.75

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.
@@ -18,6 +18,7 @@
18
18
  import type { AuthzContract, TableContract, PolicyContract, SequenceGrantContract } from './authz-contract';
19
19
  import { effectivePolicyCheck, parenthesizeOnce, isPolicyDead, isPolicySubsumed } from './authz-contract';
20
20
  import { matchPolicies } from './authz-identity';
21
+ import { tablePoliciesEquivalent } from './authz-canonical';
21
22
  import { quoteQualified } from './pg-ident';
22
23
 
23
24
  /**
@@ -247,7 +248,8 @@ export function inheritedSequenceGrants(table: TableContract): Record<string, st
247
248
  function reconcileSequenceGrants(
248
249
  d: TableContract,
249
250
  liveSequences: readonly SequenceGrantContract[],
250
- out: string[],
251
+ narrow: string[],
252
+ widen: string[],
251
253
  governed: ReadonlySet<string>,
252
254
  ): void {
253
255
  const declared = inheritedSequenceGrants(d);
@@ -275,8 +277,8 @@ function reconcileSequenceGrants(
275
277
  const role = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
276
278
  const toRevoke = [...have].filter((p) => !want.has(p)).sort();
277
279
  const toGrant = [...want].filter((p) => !have.has(p)).sort();
278
- if (toRevoke.length) out.push(`REVOKE ${toRevoke.join(', ')} ON SEQUENCE ${target} FROM ${role};`);
279
- if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON SEQUENCE ${target} TO ${role};`);
280
+ if (toRevoke.length) narrow.push(`REVOKE ${toRevoke.join(', ')} ON SEQUENCE ${target} FROM ${role};`);
281
+ if (toGrant.length) widen.push(`GRANT ${toGrant.join(', ')} ON SEQUENCE ${target} TO ${role};`);
280
282
  }
281
283
  }
282
284
  }
@@ -293,6 +295,13 @@ export function emitReconcileSql(
293
295
  live: AuthzContract,
294
296
  opts: { governedRoles?: ReadonlySet<string> } = {},
295
297
  ): string[] {
298
+ // Condition C — two phases PER TABLE in one transaction: every access-REMOVING statement
299
+ // (policy DROP, REVOKE, RLS ENABLE/FORCE) before every access-ADDING one (policy CREATE, GRANT,
300
+ // RLS DISABLE/NO FORCE). The batch is atomic, so this is defence in depth — but it makes every
301
+ // prefix of the plan fail-safe: a partial application is never broader than both endpoints. A
302
+ // same-name policy replacement stays a DROP (narrow) then CREATE (widen), never co-existing.
303
+ // Per table, not global: grants and policies are table-scoped, so a widen on one table can
304
+ // never be exploited through another's narrow — and tables stay grouped, as the plan reads.
296
305
  const sql: string[] = [];
297
306
  const lTables = tableMap(live);
298
307
  // Default: govern exactly what the models name (plus the always-governed set). A caller
@@ -311,12 +320,15 @@ export function emitReconcileSql(
311
320
  // The contract keys on the bare `<schema>.<table>` (so the diff matches both producers);
312
321
  // the EMITTED SQL must quote it, or a reserved table name (`public.user`) is a syntax error.
313
322
  const table = quoteQualified(d.table);
314
- reconcileRls(table, d, l, sql);
315
- reconcilePolicies(table, d, l, sql, governed);
316
- reconcileGrants(table, d, l, sql, governed);
317
- reconcileColumnGrants(table, d, l, sql, governed);
323
+ const narrow: string[] = [];
324
+ const widen: string[] = [];
325
+ reconcileRls(table, d, l, narrow, widen);
326
+ reconcilePolicies(table, d, l, narrow, widen, governed);
327
+ reconcileGrants(table, d, l, narrow, widen, governed);
328
+ reconcileColumnGrants(table, d, l, narrow, widen, governed);
318
329
  // A6: the sequences behind this table's SERIAL columns inherit its grants (identity ones do not).
319
- reconcileSequenceGrants(d, sequencesByTable.get(d.table) ?? [], sql, governed);
330
+ reconcileSequenceGrants(d, sequencesByTable.get(d.table) ?? [], narrow, widen, governed);
331
+ sql.push(...narrow, ...widen);
320
332
  }
321
333
 
322
334
  return sql;
@@ -339,29 +351,37 @@ export function emitReconcileSql(
339
351
  * already-enabled table is a no-op; the dump carried no grants).
340
352
  */
341
353
  export function emitSwapAuthzSql(declared: AuthzContract): string[] {
354
+ // Same narrow-before-widen shape as emitReconcileSql (Condition C), per table: RLS enable/force
355
+ // and the idempotent policy DROPs narrow; the CREATEs and GRANTs widen. Drop-BEFORE-create per
356
+ // policy is preserved (drop in the narrow phase, create in the widen phase of the same table),
357
+ // so no CREATE lands on a policy the dump restored.
342
358
  const sql: string[] = [];
343
359
  const governed = governedRoleSet(declared);
344
360
  for (const d of declared.tables) {
345
361
  const table = quoteQualified(d.table);
346
- if (d.rls.enabled) sql.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
347
- if (d.rls.forced) sql.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
362
+ const narrow: string[] = [];
363
+ const widen: string[] = [];
364
+ if (d.rls.enabled) narrow.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
365
+ if (d.rls.forced) narrow.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
348
366
  for (const p of d.policies) {
349
- sql.push(policyDropSql(table, p.name)); // idempotent against the policy the dump restored
350
- sql.push(policyCreateSql(table, p));
367
+ narrow.push(policyDropSql(table, p.name)); // idempotent against the policy the dump restored
368
+ widen.push(policyCreateSql(table, p));
351
369
  }
352
370
  // No live side here, so every grantee comes from the DECLARED contract and is
353
371
  // governed by definition — the exemption can never fire on this path.
354
- reconcileGrants(table, d, undefined, sql, governed);
355
- reconcileColumnGrants(table, d, undefined, sql, governed);
372
+ reconcileGrants(table, d, undefined, narrow, widen, governed);
373
+ reconcileColumnGrants(table, d, undefined, narrow, widen, governed);
374
+ sql.push(...narrow, ...widen);
356
375
  }
357
376
  return sql;
358
377
  }
359
378
 
360
- function reconcileRls(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
361
- if (d.rls.enabled && !l?.rls.enabled) out.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
362
- if (d.rls.forced && !l?.rls.forced) out.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
363
- if (!d.rls.enabled && l?.rls.enabled) out.push(`ALTER TABLE ${table} DISABLE ROW LEVEL SECURITY;`);
364
- if (!d.rls.forced && l?.rls.forced) out.push(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY;`);
379
+ function reconcileRls(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[]): void {
380
+ // ENABLE / FORCE deny by default — narrowing. DISABLE / NO FORCE lift a restriction — widening.
381
+ if (d.rls.enabled && !l?.rls.enabled) narrow.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
382
+ if (d.rls.forced && !l?.rls.forced) narrow.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
383
+ if (!d.rls.enabled && l?.rls.enabled) widen.push(`ALTER TABLE ${table} DISABLE ROW LEVEL SECURITY;`);
384
+ if (!d.rls.forced && l?.rls.forced) widen.push(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY;`);
365
385
  }
366
386
 
367
387
  /**
@@ -375,12 +395,20 @@ function reconcileRls(table: string, d: TableContract, l: TableContract | undefi
375
395
  * The matcher requires full field equality, so this can only turn a DROP + CREATE pair into a
376
396
  * no-op — never emit DDL the name-keyed version would not have emitted.
377
397
  */
378
- function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
398
+ function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[], governed: ReadonlySet<string>): void {
399
+ // Condition B — gate on the canonical equivalence the fingerprint uses, so this surface and
400
+ // that one can never disagree about the same database. A live `FOR ALL` with a dead DELETE arm
401
+ // and the granular pull that omits DELETE are equal here; emitting the DROP + CREATE the
402
+ // per-command matcher would produce is exactly the MATCH-but-nonzero split this closes. Only
403
+ // when the states genuinely differ do we fall back to the per-policy matcher below, which can
404
+ // only over-emit churn, never under-emit a real authorization difference.
405
+ if (l && tablePoliciesEquivalent(d, l, governed)) return;
379
406
  const live = l?.policies ?? [];
380
407
  const m = matchPolicies(d.policies, live);
381
408
  const byName = new Map(live.map((p) => [p.name, p]));
382
409
 
383
- // Drops first, so a replaced policy never briefly co-exists with its old form.
410
+ // Condition C: DROPs go to the narrow phase, CREATEs to the widen phase — a replaced policy is
411
+ // dropped (narrow) before it is re-created (widen), so it never co-exists with its old form.
384
412
  for (const name of m.toDrop) {
385
413
  // A live policy scoped ENTIRELY to roles the models do not govern is left alone, exactly
386
414
  // as reconcileGrants leaves that role's privileges alone. Without this the two lanes
@@ -411,7 +439,7 @@ function reconcilePolicies(table: string, d: TableContract, l: TableContract | u
411
439
  // drops the PUBLIC policy that was covering it, the role-scoped one stops being redundant
412
440
  // the moment the plan lands — and leaving it would keep access the model never declared.
413
441
  if (p && l && isPolicySubsumed(l, p) && isPolicySubsumed(d, p)) continue;
414
- out.push(policyDropSql(table, name));
442
+ narrow.push(policyDropSql(table, name));
415
443
  }
416
444
  // Symmetric with the drop rule above, and required by the same identity: a DECLARED policy
417
445
  // the declared grants do not back is inert — it would authorize nothing the moment it
@@ -420,11 +448,11 @@ function reconcilePolicies(table: string, d: TableContract, l: TableContract | u
420
448
  // moment the model grants the privilege it polices.
421
449
  for (const p of m.toCreate) {
422
450
  if (isPolicyDead(d, p) || isPolicySubsumed(d, p)) continue;
423
- out.push(policyCreateSql(table, p));
451
+ widen.push(policyCreateSql(table, p));
424
452
  }
425
453
  }
426
454
 
427
- function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
455
+ function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[], governed: ReadonlySet<string>): void {
428
456
  const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l?.grants ?? {})]);
429
457
  for (const grantee of [...grantees].sort()) {
430
458
  // A live grantee the declared authz does not govern is LEFT ALONE. The union below
@@ -439,8 +467,9 @@ function reconcileGrants(table: string, d: TableContract, l: TableContract | und
439
467
 
440
468
  const toRevoke = [...live].filter((p) => !declared.has(p)).sort();
441
469
  const toGrant = [...declared].filter((p) => !live.has(p)).sort();
442
- if (toRevoke.length) out.push(`REVOKE ${toRevoke.join(', ')} ON ${table} FROM ${target};`);
443
- if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON ${table} TO ${target};`);
470
+ // REVOKE narrows, GRANT widens (Condition C).
471
+ if (toRevoke.length) narrow.push(`REVOKE ${toRevoke.join(', ')} ON ${table} FROM ${target};`);
472
+ if (toGrant.length) widen.push(`GRANT ${toGrant.join(', ')} ON ${table} TO ${target};`);
444
473
  }
445
474
  }
446
475
 
@@ -450,7 +479,7 @@ function reconcileGrants(table: string, d: TableContract, l: TableContract | und
450
479
  * those live-but-not-declared. This is how `auth.users` exposes id/email/role (and only those)
451
480
  * to `authenticated` without a whole-table grant. Columns are sorted, so a re-pull is a no-op.
452
481
  */
453
- function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
482
+ function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[], governed: ReadonlySet<string>): void {
454
483
  const dcg = d.columnGrants ?? {};
455
484
  const lcg = l?.columnGrants ?? {};
456
485
  for (const grantee of [...new Set([...Object.keys(dcg), ...Object.keys(lcg)])].sort()) {
@@ -465,8 +494,8 @@ function reconcileColumnGrants(table: string, d: TableContract, l: TableContract
465
494
  const live = new Set(lPriv[priv] ?? []);
466
495
  const toRevoke = [...live].filter((c) => !declared.has(c)).sort();
467
496
  const toGrant = [...declared].filter((c) => !live.has(c)).sort();
468
- if (toRevoke.length) out.push(`REVOKE ${priv} (${toRevoke.join(', ')}) ON ${table} FROM ${target};`);
469
- if (toGrant.length) out.push(`GRANT ${priv} (${toGrant.join(', ')}) ON ${table} TO ${target};`);
497
+ if (toRevoke.length) narrow.push(`REVOKE ${priv} (${toRevoke.join(', ')}) ON ${table} FROM ${target};`);
498
+ if (toGrant.length) widen.push(`GRANT ${priv} (${toGrant.join(', ')}) ON ${table} TO ${target};`);
470
499
  }
471
500
  }
472
501
  }
@@ -0,0 +1,238 @@
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, so db:apply refuses it as unverifiable drift — and no waiver fits (there
9
+ * is nothing to attest a lineage FROM). db:adopt is the deliberate, auditable on-ramp. It renders
10
+ * Models from the live database (the same renderers db:pull uses), compiles them back, and PROVES
11
+ * the compiled Models declare the live state EXACTLY — predicted == governed live, the same
12
+ * declares-test descent and db:check use. Only then does it write the model files and print the
13
+ * `chore(db): adopt live baseline <fp>` commit: that commit legitimately declares the live state,
14
+ * so a later db:apply descends for real instead of hitting the drift refusal.
15
+ *
16
+ * The doctrine (Fable-ruled 2026-09-05): PROVE, not attest. There is NO bypass flag. When the
17
+ * round-trip is not exact, adoption REFUSES, names the surfaces that diverged, and prints the
18
+ * db:generate edge — because a baseline that papered over a difference is the bypass under another
19
+ * name. It writes NOTHING on refusal; --out is left untouched.
20
+ *
21
+ * The pure proof core is adopt.ts (proveAdoption); the shared render is live-models.ts
22
+ * (renderLiveModelFiles). This shell is the IO: connect, render into a sandbox to compile, prove,
23
+ * and either finalize or refuse.
24
+ */
25
+
26
+ import fs from 'node:fs/promises';
27
+ import path from 'node:path';
28
+ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
29
+ import { introspectContract, type AuthzContract, type QueryRunner } from '../authz-contract';
30
+ import { introspectDerived } from '../derived-introspect';
31
+ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
32
+ import { renderLiveModelFiles } from '../live-models';
33
+ import { proveAdoption, type AdoptionProof } from '../adopt';
34
+ import { generateMigrationSql } from '../migration-generate';
35
+ import { loadModels } from './db-generate';
36
+ import type { ModelDescriptor } from '@everystack/model';
37
+ import type { RenderedModelFile } from '../model-render';
38
+ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
39
+ import type { SessionRunner } from '../session';
40
+ import { resolveConfig, opsFunction } from '../config';
41
+ import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
42
+ import { step, success, fail, info, warn } from '../output';
43
+ import { opsAdviceLines } from '../ops-advice';
44
+
45
+ const DEFAULT_OUT = 'db/models';
46
+
47
+ export interface AdoptResult {
48
+ /** The proof: clean means the compiled Models declare the live state exactly. */
49
+ proof: AdoptionProof;
50
+ /** The rendered model files (directory shape) — written to --out only when the proof is clean. */
51
+ files: RenderedModelFile[];
52
+ /** The live schema, as introspected. */
53
+ snapshot: SchemaSnapshot;
54
+ /** The live authorization contract, as introspected. */
55
+ contract: AuthzContract;
56
+ /** `chore(db): adopt live baseline <fp>` when clean; null on refusal. */
57
+ commitMessage: string | null;
58
+ /** On refusal, the db:generate edge (what the Models and the live database still differ by). */
59
+ refusalDiff: string[];
60
+ }
61
+
62
+ /**
63
+ * The adopt CORE: introspect the live database, render Models with the shared renderer, compile
64
+ * them in `sandboxDir`, and prove they declare the live state. Pure of policy — it does not decide
65
+ * to write --out or to exit; the caller does that from the verdict. The sandbox is where the
66
+ * rendered barrel is written so it can be dynamically imported (the compile the proof needs);
67
+ * --out is written by the shell only after the proof is clean.
68
+ */
69
+ export async function adoptFromLive(opts: {
70
+ session: SessionRunner;
71
+ runner: QueryRunner;
72
+ schema?: string | string[];
73
+ governRoles?: string[];
74
+ sandboxDir: string;
75
+ /** How to compile the rendered barrel back to descriptors. Defaults to loadModels (the barrel
76
+ * loader db:generate uses). Injectable so a test runner that cannot dynamic-import a `.ts` via
77
+ * a file:// URL (jest) can supply its own transforming import. */
78
+ loadBarrel?: (indexPath: string) => Promise<ModelDescriptor[]>;
79
+ }): Promise<AdoptResult> {
80
+ const { session, sandboxDir } = opts;
81
+ const schema = opts.schema ?? 'public';
82
+ const load = opts.loadBarrel ?? loadModels;
83
+
84
+ const snapshot = await introspectSchema(session);
85
+ const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
86
+ const derivedCatalog = await introspectDerived(session);
87
+
88
+ const rendered = renderLiveModelFiles(snapshot, contract, derivedCatalog, { schema, governRoles: opts.governRoles });
89
+
90
+ // Compile the rendered barrel: proveAdoption needs the loaded ModelDescriptors, and loading is a
91
+ // dynamic import, so the files must exist on disk under a tree where @everystack/model resolves.
92
+ await fs.mkdir(sandboxDir, { recursive: true });
93
+ for (const f of rendered.files) await fs.writeFile(path.join(sandboxDir, f.file), f.source, 'utf8');
94
+ const models = await load(path.join(sandboxDir, 'index.ts'));
95
+
96
+ const proof = proveAdoption(models, snapshot, contract, { schema: typeof schema === 'string' ? schema : undefined });
97
+ const commitMessage = proof.clean ? `chore(db): adopt live baseline ${proof.fingerprint}` : null;
98
+ // On refusal, show WHAT still differs — the same edge db:generate would mint against the live DB.
99
+ const refusalDiff = proof.clean ? [] : generateMigrationSql(models, snapshot, { liveAuthz: contract });
100
+
101
+ return { proof, files: rendered.files, snapshot, contract, commitMessage, refusalDiff };
102
+ }
103
+
104
+ /**
105
+ * Turn the adopt verdict into action — the SECURITY BOUNDARY, isolated so it is testable without a
106
+ * database. This is where "PROVE not attest, no bypass" is actually enforced: on refusal it writes
107
+ * NOTHING to --out and reports; on a clean proof it writes the baseline and prints the commit/push
108
+ * runbook. It never calls process.exit (the caller maps the returned code) — so a test can assert,
109
+ * against a synthetic verdict, that a dirty result leaves --out untouched and a clean one finalizes.
110
+ * A future edit that let the refusal path fall through to the write is caught by that test, red.
111
+ */
112
+ export async function applyAdoptVerdict(result: AdoptResult, outDir: string): Promise<{ exitCode: 0 | 1; wrote: boolean }> {
113
+ if (!result.proof.clean) {
114
+ // REFUSAL — the round trip is not exact. Name what diverged and show the edge; write NOTHING.
115
+ console.log('');
116
+ fail('db:adopt refused — the rendered Models do NOT declare the live database exactly.');
117
+ warn('A baseline that papered over this difference would be a bypass. Nothing was written to --out.');
118
+ info('Surfaces that diverge:');
119
+ for (const s of result.proof.divergentSurfaces) info(` • ${s}`);
120
+ if (result.refusalDiff.length) {
121
+ console.log('');
122
+ info(`What still differs (the db:generate edge, ${result.refusalDiff.length} statement(s)):`);
123
+ for (const stmt of result.refusalDiff) console.log(stmt.endsWith(';') ? stmt : `${stmt};`);
124
+ }
125
+ console.log('');
126
+ info('The renderer could not yet capture the surfaces above. Refine by hand until db:generate is empty, then commit the Models — a later db:apply will descend normally.');
127
+ return { exitCode: 1, wrote: false };
128
+ }
129
+
130
+ // CLEAN — finalize. Write the rendered barrel to --out, then print the runbook.
131
+ try {
132
+ await fs.mkdir(outDir, { recursive: true });
133
+ const written = new Set(result.files.map((f) => f.file));
134
+ const existing = (await fs.readdir(outDir)).filter((f) => f.endsWith('.ts') && !written.has(f));
135
+ for (const f of result.files) await fs.writeFile(path.join(outDir, f.file), f.source, 'utf8');
136
+ if (existing.length) {
137
+ 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(', ')}`);
138
+ }
139
+ } catch (err: any) {
140
+ fail(`Could not write ${outDir}: ${err.message}`);
141
+ return { exitCode: 1, wrote: false };
142
+ }
143
+
144
+ console.log('');
145
+ success(`db:adopt — the rendered Models declare the live database exactly (${result.files.length - 1} model(s)).`);
146
+ info(`Wrote ${path.relative(process.cwd(), outDir)} — the adopted baseline.`);
147
+ console.log('');
148
+ info('Finalize the baseline — commit, then PUSH before you apply:');
149
+ info(` git add ${path.relative(process.cwd(), outDir)}`);
150
+ info(` git commit -m "${result.commitMessage}"`);
151
+ info(' git push # an unpushed baseline is an unknown commit to teammates and the ops Lambda');
152
+ info(' everystack db:plan --stage <name> # empty — the live database is now declared');
153
+ return { exitCode: 0, wrote: true };
154
+ }
155
+
156
+ export async function dbAdoptCommand(
157
+ flags: Record<string, string>,
158
+ opts: {
159
+ /** Injected barrel loader (jest cannot file:// import a .ts). Default: adoptFromLive's loadModels. */
160
+ loadBarrel?: (indexPath: string) => Promise<ModelDescriptor[]>;
161
+ /** Injected sandbox dir, so a test can assert it is cleaned up. Default: a fresh mkdtemp. */
162
+ sandboxDir?: string;
163
+ } = {},
164
+ ): Promise<void> {
165
+ const schemas = (flags.schema || 'public').split(',').map((s) => s.trim()).filter(Boolean);
166
+ if (schemas.length === 0) fail('--schema needs at least one schema name.');
167
+ const schema = schemas.length === 1 ? schemas[0] : schemas;
168
+ const outDir = path.resolve(flags.out || DEFAULT_OUT);
169
+
170
+ // --govern-roles: the operator's EXPLICIT decision to transcribe foreign roles' grants as
171
+ // privileges (so a fresh build recreates them). Same vocabulary guard as db:pull.
172
+ const governRoles = (flags['govern-roles'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
173
+ for (const r of governRoles) {
174
+ if (['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) {
175
+ fail(`--govern-roles ${r}: the vocabulary roles and PUBLIC are always governed — name only foreign roles (an ops/connection role the migrations granted to).`);
176
+ process.exit(1);
177
+ }
178
+ }
179
+
180
+ let dbSource: DbSource;
181
+ try {
182
+ dbSource = resolveDbSource(flags);
183
+ } catch (err: any) {
184
+ fail(err.message);
185
+ process.exit(1);
186
+ }
187
+
188
+ // The proof-compile sandbox MUST live inside the project, never the OS temp dir. The rendered
189
+ // barrel imports `@everystack/model`, and Node resolves a bare specifier by walking UP from the
190
+ // importing file looking for node_modules. pnpm hoists workspace packages to the repo root (an
191
+ // app dir has no local copy), so a dir under $TMPDIR cannot reach them and the compile crashes
192
+ // with "Cannot find module '@everystack/model'". cwd is inside the project, so a sandbox under it
193
+ // resolves exactly as the real db/models barrel will. (Reported by a consumer on 0.4.74; the
194
+ // scratch-sweep harness documents the same rule: $TMPDIR → MODULE_NOT_FOUND, project dir resolves.)
195
+ const sandboxDir = opts.sandboxDir ?? await fs.mkdtemp(path.join(process.cwd(), '.everystack-adopt-'));
196
+ let exitCode: 0 | 1 = 1;
197
+ let end: (() => Promise<void>) | undefined;
198
+ try {
199
+ let runner: QueryRunner;
200
+ let session: SessionRunner;
201
+ if (dbSource.kind === 'url') {
202
+ step(connectingVia(dbSource));
203
+ ({ runner, session, end } = await createUrlRunner(dbSource.url));
204
+ } else {
205
+ step('Resolving deployed config...');
206
+ const config = await resolveConfig(flags.stage);
207
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
208
+ runner = lambdaQueryRunner(config.region, opsFunction(config));
209
+ session = lambdaSessionRunner(config.region, opsFunction(config));
210
+ }
211
+ step(`Introspecting live database (schema: ${schemas.join(', ')})...`);
212
+ const result = await adoptFromLive({ session, runner, schema, governRoles, sandboxDir, loadBarrel: opts.loadBarrel });
213
+ // Only after a proof do we act. A throw before here (connect, introspect, render, compile)
214
+ // never reaches applyAdoptVerdict, so --out is untouched — a bad baseline is impossible.
215
+ ({ exitCode } = await applyAdoptVerdict(result, outDir));
216
+ } catch (err: any) {
217
+ fail(err.message);
218
+ // A failure to load the rendered barrel is a COMPILE error, not a connection or IAM problem —
219
+ // don't send the operator to check their credentials for it.
220
+ if (/Could not load models|Cannot find module/i.test(err.message ?? '')) {
221
+ info('This is the proof-compile step, not the database. The rendered Models could not be loaded —');
222
+ info('run db:adopt from inside your project (where @everystack/model resolves), and check the models compile.');
223
+ } else if (dbSource.kind === 'url') {
224
+ info('Check the connection string and that the database is accepting connections.');
225
+ } else {
226
+ for (const line of opsAdviceLines(err, [
227
+ 'Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.',
228
+ 'Adopting a local database instead? Pass --database-url or set DATABASE_URL.',
229
+ ])) info(line);
230
+ }
231
+ exitCode = 1;
232
+ } finally {
233
+ // The sandbox is the compile scratch, never the baseline — remove it on EVERY exit path.
234
+ await end?.();
235
+ await fs.rm(sandboxDir, { recursive: true, force: true });
236
+ }
237
+ process.exit(exitCode);
238
+ }
@@ -21,12 +21,14 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
21
21
  import {
22
22
  fingerprintLive,
23
23
  fingerprintModels,
24
+ fingerprintState,
24
25
  governedRolesForModels,
26
+ divergentSurfaces,
25
27
  mapUnfingerprintedRows,
26
28
  UNFINGERPRINTED_SQL,
27
29
  type UnfingerprintedObject,
28
30
  } from '../schema-fingerprint';
29
- import { predictLiveFingerprint, governedLiveFingerprint } from '../edge-plan';
31
+ import { predictLiveState, governedLiveFingerprint } from '../edge-plan';
30
32
  import { resolveModelsPath } from '../models-path';
31
33
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
32
34
  import { borrowedSessionRunner, INTROSPECTION_SESSION, type SessionRunner } from '../session';
@@ -56,6 +58,9 @@ export interface FingerprintStatus {
56
58
  */
57
59
  predicted?: string;
58
60
  match: boolean | null;
61
+ /** On a MISMATCH, the surfaces that diverge (base schema / authorization / sequence ACLs, with
62
+ * the tables named) — so the report says WHERE to look. Absent when MATCH or no models. */
63
+ divergentSurfaces?: string[];
59
64
  unfingerprinted: UnfingerprintedObject[];
60
65
  }
61
66
 
@@ -77,8 +82,14 @@ export async function computeFingerprintStatus(
77
82
  const live = fingerprintLive(snapshot, contract).hash;
78
83
  const declared = models ? fingerprintModels(models, { sequences }).hash : null;
79
84
  const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
80
- const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
81
- const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
85
+ // Compute the governed-live and predicted states as full canonical forms, so a MISMATCH can
86
+ // name the diverging surface — not just their hashes.
87
+ const governedLiveState = governedRoles
88
+ ? fingerprintState(snapshot, contract.tables, { governedRoles, sequenceGrants: contract.sequenceGrants })
89
+ : undefined;
90
+ const governedLive = governedLiveState?.hash;
91
+ const predictedState = models ? predictLiveState(models, snapshot, contract, { governedRoles }) : undefined;
92
+ const predicted = predictedState?.hash;
82
93
  // B5 — a declared owner IS hashed (derived-source prefixes it), so reporting it as
83
94
  // unfingerprinted would be a false claim in the one report whose whole job is honesty about
84
95
  // coverage. SQL cannot know what the models declare; the filter belongs here.
@@ -89,12 +100,17 @@ export async function computeFingerprintStatus(
89
100
  );
90
101
  const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[])
91
102
  .filter((u) => u.kind !== 'function owner' || !ownerDeclared.has(u.identity.split(' → ')[0]));
103
+ const match = declared === null ? null : predicted === governedLive;
104
+ const surfaces = match === false && predictedState && governedLiveState
105
+ ? divergentSurfaces(predictedState.canonical, governedLiveState.canonical)
106
+ : undefined;
92
107
  return {
93
108
  live,
94
109
  ...(governedLive !== undefined ? { governedLive } : {}),
95
110
  declared,
96
111
  ...(predicted !== undefined ? { predicted } : {}),
97
- match: declared === null ? null : predicted === governedLive,
112
+ match,
113
+ ...(surfaces && surfaces.length ? { divergentSurfaces: surfaces } : {}),
98
114
  unfingerprinted,
99
115
  };
100
116
  }
@@ -172,7 +188,10 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
172
188
  info(`predicted: ${status.predicted} (declared + unmodeled tables riding through — generate's no-op state)`);
173
189
  }
174
190
  if (status.match) success('MATCH — the database is the state the models declare.');
175
- else fail('MISMATCH — run `everystack db:generate` to see the difference as SQL.');
191
+ else {
192
+ const where = status.divergentSurfaces?.length ? ` — diverges on ${status.divergentSurfaces.join('; ')}` : '';
193
+ fail(`MISMATCH${where}. Run \`everystack db:generate\` to see the difference as SQL.`);
194
+ }
176
195
  }
177
196
  if (status.unfingerprinted.length > 0) {
178
197
  warn(`unfingerprinted (${status.unfingerprinted.length}) — outside the base fingerprint and the derived manifest:`);
@@ -31,7 +31,7 @@ import type { AuthzContract } from './authz-contract';
31
31
  import { generateMigrationSql, unmodeledTables } from './migration-generate';
32
32
  import { compileTableContract } from './authz-compile';
33
33
  import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply';
34
- import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, FINGERPRINT_VERSION } from './schema-fingerprint';
34
+ import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
35
35
  import { compileDeclaredState } from './declared-diff';
36
36
  import { compileTableRenames, compileTableMoves } from './schema-compile';
37
37
 
@@ -164,6 +164,20 @@ export function predictLiveFingerprint(
164
164
  contract: AuthzContract,
165
165
  opts: { schema?: string; governedRoles?: ReadonlySet<string> } = {},
166
166
  ): string {
167
+ return predictLiveState(models, snapshot, contract, opts).hash;
168
+ }
169
+
170
+ /**
171
+ * The predicted endpoint as a full {@link Fingerprint} (hash AND canonical form). The hash is
172
+ * the plan's `to` and the declares-test; the canonical form lets a MISMATCH report name WHICH
173
+ * surface diverged (base schema, authorization, sequence ACLs) against the live one.
174
+ */
175
+ export function predictLiveState(
176
+ models: ModelDescriptor[],
177
+ snapshot: SchemaSnapshot,
178
+ contract: AuthzContract,
179
+ opts: { schema?: string; governedRoles?: ReadonlySet<string> } = {},
180
+ ): Fingerprint {
167
181
  // The prediction is a DECLARED-vs-live comparison, so it must hash through the
168
182
  // models' governed set — the live side of any comparison against it must use
169
183
  // the same set (see governedLiveFingerprint). Unfiltered, every ride-through
@@ -204,7 +218,12 @@ export function predictLiveFingerprint(
204
218
  ...declared.contract.tables,
205
219
  ...contract.tables.filter((t) => ridesThrough(t.table)),
206
220
  ];
207
- return fingerprintState(mergedSnapshot, mergedAuthz, { governedRoles }).hash;
221
+ // Sequence ACLs (v11): declared tables DERIVE their reconciled (inherited) target, so pass
222
+ // measured ACLs ONLY for rides-through tables — the ones reconciliation never touches, whose
223
+ // live ACLs carry through unchanged. Passing all measured would make a declared table's
224
+ // predicted endpoint read its pre-reconcile ACL and never converge with the plan it mints.
225
+ const ridesThroughSeqGrants = (contract.sequenceGrants ?? []).filter((s) => ridesThrough(s.table));
226
+ return fingerprintState(mergedSnapshot, mergedAuthz, { governedRoles, sequenceGrants: ridesThroughSeqGrants });
208
227
  }
209
228
 
210
229
  /**
@@ -218,7 +237,9 @@ export function governedLiveFingerprint(
218
237
  contract: AuthzContract,
219
238
  governedRoles: ReadonlySet<string>,
220
239
  ): string {
221
- return fingerprintState(snapshot, contract.tables, { governedRoles }).hash;
240
+ // A true live read: measure every table's serial-sequence ACLs (v11), so this half sees a
241
+ // sequence REVOKE the predicted endpoint's derived target does not.
242
+ return fingerprintState(snapshot, contract.tables, { governedRoles, sequenceGrants: contract.sequenceGrants }).hash;
222
243
  }
223
244
 
224
245
  /**
@@ -217,8 +217,17 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
217
217
  }
218
218
  }
219
219
 
220
+ // A materialized tree that carries NO barrel DECLARES NOTHING — a clean, deterministic 'no',
221
+ // not a failure to read. Every pre-Models repo has such ancestors: the models directory
222
+ // scaffolded (or present for another reason) before its barrel existed. Letting the loader
223
+ // throw on the absent file would classify it `compile-failed`, poisoning the verdict to
224
+ // UNVERIFIABLE — which sends a never-adopted database to "retry/upgrade" instead of to
225
+ // db:adopt. So the missing barrel is decided here, before the loader is ever consulted.
226
+ const barrelPath = path.join(dest, ctx.barrel);
227
+ if (!fs.existsSync(barrelPath)) return { kind: 'no' };
228
+
220
229
  const evaluate = async (): Promise<boolean> => {
221
- const models = await ctx.loader(path.join(dest, ctx.barrel));
230
+ const models = await ctx.loader(barrelPath);
222
231
  const governedRoles = governedRolesForModels(models);
223
232
  const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: ctx.schema, governedRoles });
224
233
  return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
@@ -623,9 +632,13 @@ export async function proveDescent(
623
632
  reason:
624
633
  `no ancestor of your checkout declares the target's live fingerprint ` +
625
634
  `${short(fingerprintLive(live.snapshot, live.contract).hash)} — read ${candidates.length} ` +
626
- 'ancestor model state(s), all cleanly, and none matches. The database was hand-edited ' +
627
- '(drift), or the deploying branch is not in this checkout. Align the models with the live ' +
628
- 'database (db:pull, commit), or waive the rule deliberately.',
635
+ 'ancestor model state(s), all cleanly, and none matches. Either this database has never been ' +
636
+ 'adopted into Models (nothing committed can declare it — the first-adoption case), or it ' +
637
+ 'drifted after adoption. THE RUNBOOK: run everystack db:adopt — it renders Models from the ' +
638
+ 'live database and PROVES they declare it exactly (it REFUSES rather than paper over any ' +
639
+ 'difference); commit AND push that baseline, then re-run db:plan / db:apply and it descends ' +
640
+ 'normally. If instead your Models are the source of truth and the database drifted, reconcile ' +
641
+ 'with db:generate, or waive the rule deliberately.',
629
642
  };
630
643
  }
631
644
 
package/src/cli/index.ts CHANGED
@@ -11,6 +11,7 @@ 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';
14
15
  import { dbReconcileCommand } from './commands/db-reconcile';
15
16
  import { dbRefreshCommand } from './commands/db-refresh';
16
17
  import { dbFingerprintCommand } from './commands/db-fingerprint';
@@ -185,6 +186,9 @@ async function main() {
185
186
  case 'db:pull':
186
187
  await dbPullCommand(flags);
187
188
  break;
189
+ case 'db:adopt':
190
+ await dbAdoptCommand(flags);
191
+ break;
188
192
  case 'db:reconcile':
189
193
  await dbReconcileCommand(flags);
190
194
  break;
@@ -366,6 +370,7 @@ Usage:
366
370
  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
367
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 (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 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
368
372
  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, BY PROOF. A production DB that never went through Models has no committed ancestor declaring it, so db:apply refuses it as unverifiable drift and no waiver fits. db:adopt 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 db:generate edge, and writes NOTHING. Commit AND push the baseline before db:apply — an unpushed baseline is an unknown commit to teammates and the ops Lambda.
369
374
  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
370
375
  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).
371
376
  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.