@everystack/cli 0.4.73 → 0.4.74
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 -0
- package/package.json +1 -1
- package/src/cli/adopt.ts +58 -0
- package/src/cli/apply-execute.ts +8 -4
- package/src/cli/authz-canonical.ts +146 -54
- package/src/cli/authz-contract.ts +151 -7
- package/src/cli/authz-reconcile.ts +58 -29
- package/src/cli/commands/db-adopt.ts +227 -0
- package/src/cli/commands/db-fingerprint.ts +24 -5
- package/src/cli/edge-plan.ts +24 -3
- package/src/cli/git-descent.ts +17 -4
- package/src/cli/index.ts +5 -0
- package/src/cli/live-models.ts +65 -0
- package/src/cli/schema-fingerprint-v10.ts +152 -0
- package/src/cli/schema-fingerprint.ts +150 -31
|
@@ -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
|
-
|
|
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)
|
|
279
|
-
if (toGrant.length)
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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) ?? [],
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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,
|
|
355
|
-
reconcileColumnGrants(table, d, undefined,
|
|
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,
|
|
361
|
-
|
|
362
|
-
if (d.rls.
|
|
363
|
-
if (
|
|
364
|
-
if (!d.rls.
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
451
|
+
widen.push(policyCreateSql(table, p));
|
|
424
452
|
}
|
|
425
453
|
}
|
|
426
454
|
|
|
427
|
-
function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined,
|
|
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
|
-
|
|
443
|
-
if (
|
|
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,
|
|
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)
|
|
469
|
-
if (toGrant.length)
|
|
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,227 @@
|
|
|
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 os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
|
|
30
|
+
import { introspectContract, type AuthzContract, type QueryRunner } from '../authz-contract';
|
|
31
|
+
import { introspectDerived } from '../derived-introspect';
|
|
32
|
+
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
33
|
+
import { renderLiveModelFiles } from '../live-models';
|
|
34
|
+
import { proveAdoption, type AdoptionProof } from '../adopt';
|
|
35
|
+
import { generateMigrationSql } from '../migration-generate';
|
|
36
|
+
import { loadModels } from './db-generate';
|
|
37
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
38
|
+
import type { RenderedModelFile } from '../model-render';
|
|
39
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
|
|
40
|
+
import type { SessionRunner } from '../session';
|
|
41
|
+
import { resolveConfig, opsFunction } from '../config';
|
|
42
|
+
import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
|
|
43
|
+
import { step, success, fail, info, warn } from '../output';
|
|
44
|
+
import { opsAdviceLines } from '../ops-advice';
|
|
45
|
+
|
|
46
|
+
const DEFAULT_OUT = 'db/models';
|
|
47
|
+
|
|
48
|
+
export interface AdoptResult {
|
|
49
|
+
/** The proof: clean means the compiled Models declare the live state exactly. */
|
|
50
|
+
proof: AdoptionProof;
|
|
51
|
+
/** The rendered model files (directory shape) — written to --out only when the proof is clean. */
|
|
52
|
+
files: RenderedModelFile[];
|
|
53
|
+
/** The live schema, as introspected. */
|
|
54
|
+
snapshot: SchemaSnapshot;
|
|
55
|
+
/** The live authorization contract, as introspected. */
|
|
56
|
+
contract: AuthzContract;
|
|
57
|
+
/** `chore(db): adopt live baseline <fp>` when clean; null on refusal. */
|
|
58
|
+
commitMessage: string | null;
|
|
59
|
+
/** On refusal, the db:generate edge (what the Models and the live database still differ by). */
|
|
60
|
+
refusalDiff: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The adopt CORE: introspect the live database, render Models with the shared renderer, compile
|
|
65
|
+
* them in `sandboxDir`, and prove they declare the live state. Pure of policy — it does not decide
|
|
66
|
+
* to write --out or to exit; the caller does that from the verdict. The sandbox is where the
|
|
67
|
+
* rendered barrel is written so it can be dynamically imported (the compile the proof needs);
|
|
68
|
+
* --out is written by the shell only after the proof is clean.
|
|
69
|
+
*/
|
|
70
|
+
export async function adoptFromLive(opts: {
|
|
71
|
+
session: SessionRunner;
|
|
72
|
+
runner: QueryRunner;
|
|
73
|
+
schema?: string | string[];
|
|
74
|
+
governRoles?: string[];
|
|
75
|
+
sandboxDir: string;
|
|
76
|
+
/** How to compile the rendered barrel back to descriptors. Defaults to loadModels (the barrel
|
|
77
|
+
* loader db:generate uses). Injectable so a test runner that cannot dynamic-import a `.ts` via
|
|
78
|
+
* a file:// URL (jest) can supply its own transforming import. */
|
|
79
|
+
loadBarrel?: (indexPath: string) => Promise<ModelDescriptor[]>;
|
|
80
|
+
}): Promise<AdoptResult> {
|
|
81
|
+
const { session, sandboxDir } = opts;
|
|
82
|
+
const schema = opts.schema ?? 'public';
|
|
83
|
+
const load = opts.loadBarrel ?? loadModels;
|
|
84
|
+
|
|
85
|
+
const snapshot = await introspectSchema(session);
|
|
86
|
+
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
87
|
+
const derivedCatalog = await introspectDerived(session);
|
|
88
|
+
|
|
89
|
+
const rendered = renderLiveModelFiles(snapshot, contract, derivedCatalog, { schema, governRoles: opts.governRoles });
|
|
90
|
+
|
|
91
|
+
// Compile the rendered barrel: proveAdoption needs the loaded ModelDescriptors, and loading is a
|
|
92
|
+
// dynamic import, so the files must exist on disk under a tree where @everystack/model resolves.
|
|
93
|
+
await fs.mkdir(sandboxDir, { recursive: true });
|
|
94
|
+
for (const f of rendered.files) await fs.writeFile(path.join(sandboxDir, f.file), f.source, 'utf8');
|
|
95
|
+
const models = await load(path.join(sandboxDir, 'index.ts'));
|
|
96
|
+
|
|
97
|
+
const proof = proveAdoption(models, snapshot, contract, { schema: typeof schema === 'string' ? schema : undefined });
|
|
98
|
+
const commitMessage = proof.clean ? `chore(db): adopt live baseline ${proof.fingerprint}` : null;
|
|
99
|
+
// On refusal, show WHAT still differs — the same edge db:generate would mint against the live DB.
|
|
100
|
+
const refusalDiff = proof.clean ? [] : generateMigrationSql(models, snapshot, { liveAuthz: contract });
|
|
101
|
+
|
|
102
|
+
return { proof, files: rendered.files, snapshot, contract, commitMessage, refusalDiff };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Turn the adopt verdict into action — the SECURITY BOUNDARY, isolated so it is testable without a
|
|
107
|
+
* database. This is where "PROVE not attest, no bypass" is actually enforced: on refusal it writes
|
|
108
|
+
* NOTHING to --out and reports; on a clean proof it writes the baseline and prints the commit/push
|
|
109
|
+
* runbook. It never calls process.exit (the caller maps the returned code) — so a test can assert,
|
|
110
|
+
* against a synthetic verdict, that a dirty result leaves --out untouched and a clean one finalizes.
|
|
111
|
+
* A future edit that let the refusal path fall through to the write is caught by that test, red.
|
|
112
|
+
*/
|
|
113
|
+
export async function applyAdoptVerdict(result: AdoptResult, outDir: string): Promise<{ exitCode: 0 | 1; wrote: boolean }> {
|
|
114
|
+
if (!result.proof.clean) {
|
|
115
|
+
// REFUSAL — the round trip is not exact. Name what diverged and show the edge; write NOTHING.
|
|
116
|
+
console.log('');
|
|
117
|
+
fail('db:adopt refused — the rendered Models do NOT declare the live database exactly.');
|
|
118
|
+
warn('A baseline that papered over this difference would be a bypass. Nothing was written to --out.');
|
|
119
|
+
info('Surfaces that diverge:');
|
|
120
|
+
for (const s of result.proof.divergentSurfaces) info(` • ${s}`);
|
|
121
|
+
if (result.refusalDiff.length) {
|
|
122
|
+
console.log('');
|
|
123
|
+
info(`What still differs (the db:generate edge, ${result.refusalDiff.length} statement(s)):`);
|
|
124
|
+
for (const stmt of result.refusalDiff) console.log(stmt.endsWith(';') ? stmt : `${stmt};`);
|
|
125
|
+
}
|
|
126
|
+
console.log('');
|
|
127
|
+
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.');
|
|
128
|
+
return { exitCode: 1, wrote: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// CLEAN — finalize. Write the rendered barrel to --out, then print the runbook.
|
|
132
|
+
try {
|
|
133
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
134
|
+
const written = new Set(result.files.map((f) => f.file));
|
|
135
|
+
const existing = (await fs.readdir(outDir)).filter((f) => f.endsWith('.ts') && !written.has(f));
|
|
136
|
+
for (const f of result.files) await fs.writeFile(path.join(outDir, f.file), f.source, 'utf8');
|
|
137
|
+
if (existing.length) {
|
|
138
|
+
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(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
} catch (err: any) {
|
|
141
|
+
fail(`Could not write ${outDir}: ${err.message}`);
|
|
142
|
+
return { exitCode: 1, wrote: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log('');
|
|
146
|
+
success(`db:adopt — the rendered Models declare the live database exactly (${result.files.length - 1} model(s)).`);
|
|
147
|
+
info(`Wrote ${path.relative(process.cwd(), outDir)} — the adopted baseline.`);
|
|
148
|
+
console.log('');
|
|
149
|
+
info('Finalize the baseline — commit, then PUSH before you apply:');
|
|
150
|
+
info(` git add ${path.relative(process.cwd(), outDir)}`);
|
|
151
|
+
info(` git commit -m "${result.commitMessage}"`);
|
|
152
|
+
info(' git push # an unpushed baseline is an unknown commit to teammates and the ops Lambda');
|
|
153
|
+
info(' everystack db:plan --stage <name> # empty — the live database is now declared');
|
|
154
|
+
return { exitCode: 0, wrote: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function dbAdoptCommand(
|
|
158
|
+
flags: Record<string, string>,
|
|
159
|
+
opts: {
|
|
160
|
+
/** Injected barrel loader (jest cannot file:// import a .ts). Default: adoptFromLive's loadModels. */
|
|
161
|
+
loadBarrel?: (indexPath: string) => Promise<ModelDescriptor[]>;
|
|
162
|
+
/** Injected sandbox dir, so a test can assert it is cleaned up. Default: a fresh mkdtemp. */
|
|
163
|
+
sandboxDir?: string;
|
|
164
|
+
} = {},
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
const schemas = (flags.schema || 'public').split(',').map((s) => s.trim()).filter(Boolean);
|
|
167
|
+
if (schemas.length === 0) fail('--schema needs at least one schema name.');
|
|
168
|
+
const schema = schemas.length === 1 ? schemas[0] : schemas;
|
|
169
|
+
const outDir = path.resolve(flags.out || DEFAULT_OUT);
|
|
170
|
+
|
|
171
|
+
// --govern-roles: the operator's EXPLICIT decision to transcribe foreign roles' grants as
|
|
172
|
+
// privileges (so a fresh build recreates them). Same vocabulary guard as db:pull.
|
|
173
|
+
const governRoles = (flags['govern-roles'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
174
|
+
for (const r of governRoles) {
|
|
175
|
+
if (['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) {
|
|
176
|
+
fail(`--govern-roles ${r}: the vocabulary roles and PUBLIC are always governed — name only foreign roles (an ops/connection role the migrations granted to).`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let dbSource: DbSource;
|
|
182
|
+
try {
|
|
183
|
+
dbSource = resolveDbSource(flags);
|
|
184
|
+
} catch (err: any) {
|
|
185
|
+
fail(err.message);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const sandboxDir = opts.sandboxDir ?? await fs.mkdtemp(path.join(os.tmpdir(), 'everystack-adopt-'));
|
|
190
|
+
let exitCode: 0 | 1 = 1;
|
|
191
|
+
let end: (() => Promise<void>) | undefined;
|
|
192
|
+
try {
|
|
193
|
+
let runner: QueryRunner;
|
|
194
|
+
let session: SessionRunner;
|
|
195
|
+
if (dbSource.kind === 'url') {
|
|
196
|
+
step(connectingVia(dbSource));
|
|
197
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
198
|
+
} else {
|
|
199
|
+
step('Resolving deployed config...');
|
|
200
|
+
const config = await resolveConfig(flags.stage);
|
|
201
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
202
|
+
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
203
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
204
|
+
}
|
|
205
|
+
step(`Introspecting live database (schema: ${schemas.join(', ')})...`);
|
|
206
|
+
const result = await adoptFromLive({ session, runner, schema, governRoles, sandboxDir, loadBarrel: opts.loadBarrel });
|
|
207
|
+
// Only after a proof do we act. A throw before here (connect, introspect, render, compile)
|
|
208
|
+
// never reaches applyAdoptVerdict, so --out is untouched — a bad baseline is impossible.
|
|
209
|
+
({ exitCode } = await applyAdoptVerdict(result, outDir));
|
|
210
|
+
} catch (err: any) {
|
|
211
|
+
fail(err.message);
|
|
212
|
+
if (dbSource.kind === 'url') {
|
|
213
|
+
info('Check the connection string and that the database is accepting connections.');
|
|
214
|
+
} else {
|
|
215
|
+
for (const line of opsAdviceLines(err, [
|
|
216
|
+
'Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.',
|
|
217
|
+
'Adopting a local database instead? Pass --database-url or set DATABASE_URL.',
|
|
218
|
+
])) info(line);
|
|
219
|
+
}
|
|
220
|
+
exitCode = 1;
|
|
221
|
+
} finally {
|
|
222
|
+
// The sandbox is the compile scratch, never the baseline — remove it on EVERY exit path.
|
|
223
|
+
await end?.();
|
|
224
|
+
await fs.rm(sandboxDir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
process.exit(exitCode);
|
|
227
|
+
}
|
|
@@ -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 {
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
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
|
|
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:`);
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/cli/git-descent.ts
CHANGED
|
@@ -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(
|
|
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.
|
|
627
|
-
'
|
|
628
|
-
'
|
|
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.
|