@everystack/cli 0.4.50 → 0.4.52
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/package.json +2 -2
- package/src/cli/authz-compile.ts +6 -1
- package/src/cli/authz-derive.ts +39 -2
- package/src/cli/authz-lint.ts +33 -19
- package/src/cli/commands/db-check.ts +40 -5
- package/src/cli/commands/db-generate.ts +1 -1
- package/src/cli/commands/db-plan.ts +16 -5
- package/src/cli/commands/db-pull.ts +66 -12
- package/src/cli/db-build.ts +60 -0
- package/src/cli/derived-render.ts +2 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/migration-compile.ts +20 -7
- package/src/cli/model-barrel-lint.ts +115 -0
- package/src/cli/model-render.ts +112 -17
- package/src/cli/schema-introspect.ts +37 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.52",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"structured-headers": "1.0.1",
|
|
110
110
|
"tsx": "4.21.0",
|
|
111
111
|
"typescript": "5.9.3",
|
|
112
|
-
"@everystack/model": "0.4.
|
|
112
|
+
"@everystack/model": "0.4.14"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -555,7 +555,12 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
555
555
|
// policies. 'app' writes through its policies -> FORCE; 'worker'/'functions'
|
|
556
556
|
// write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
|
|
557
557
|
// owner is not a superuser, so a FORCEd table would block the owner's own writes).
|
|
558
|
-
rls:
|
|
558
|
+
// `rls: false` (grants-only table) declares the flag OFF — defineModel guarantees
|
|
559
|
+
// zero abilities and a non-'app' principal there. Compared against `false`, not
|
|
560
|
+
// truthiness: a descriptor minted by an older @everystack/model carries no `rls`
|
|
561
|
+
// key, and undefined must mean ENABLED — the pre-flag behavior — never a silent
|
|
562
|
+
// security downgrade via version skew.
|
|
563
|
+
rls: { enabled: model.rls !== false, forced: model.rls !== false && model.writtenBy === 'app' },
|
|
559
564
|
grants: compileGrants(abilities, model.privileges),
|
|
560
565
|
...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
|
|
561
566
|
policies,
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -326,8 +326,19 @@ function renderColumnAbility(
|
|
|
326
326
|
* Deliberately conservative. Every branch that cannot prove what it would emit falls
|
|
327
327
|
* through to `notes` rather than guessing — the caller renders those as comments beside
|
|
328
328
|
* the model, so the human sees the real rule and decides.
|
|
329
|
+
*
|
|
330
|
+
* `governRoles` (db:pull --govern-roles) is the operator's EXPLICIT decision to transcribe a
|
|
331
|
+
* foreign role's grants into `privileges` — which GOVERNS the role, per the doctrine on
|
|
332
|
+
* GOVERNED_VOCABULARY. Never inferred: a re-pull must not silently convert a rendering
|
|
333
|
+
* decision into an access decision. Transcription is complete-or-refuse — a governed role
|
|
334
|
+
* holding a column-scoped grant THROWS, because the model cannot express it for a foreign
|
|
335
|
+
* role and governing the role would make the next plan REVOKE it.
|
|
329
336
|
*/
|
|
330
|
-
export function deriveAbilities(
|
|
337
|
+
export function deriveAbilities(
|
|
338
|
+
contract: TableContract,
|
|
339
|
+
opts: { governRoles?: ReadonlySet<string> } = {},
|
|
340
|
+
): DerivedAbilities {
|
|
341
|
+
const govern = opts.governRoles ?? new Set<string>();
|
|
331
342
|
const abilities: string[] = [];
|
|
332
343
|
const notes: string[] = [];
|
|
333
344
|
const table = contract.table;
|
|
@@ -599,6 +610,20 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
599
610
|
const renderedColumnRoles = new Set<string>();
|
|
600
611
|
for (const grantee of Object.keys(contract.columnGrants ?? {}).sort()) {
|
|
601
612
|
const byPriv = contract.columnGrants![grantee];
|
|
613
|
+
if (govern.has(grantee)) {
|
|
614
|
+
// Complete-or-refuse. Governing this role reconciles ALL its grants, and `privileges`
|
|
615
|
+
// has no column axis for a foreign role — a partial transcription would leave this
|
|
616
|
+
// grant undeclared on a governed role, and the very next plan would REVOKE it.
|
|
617
|
+
const held = Object.entries(byPriv)
|
|
618
|
+
.filter(([, cols]) => (cols ?? []).length)
|
|
619
|
+
.map(([priv, cols]) => `${priv}(${(cols ?? []).join(', ')})`);
|
|
620
|
+
throw new Error(
|
|
621
|
+
`${table}: --govern-roles ${grantee} refused — the role holds a COLUMN-scoped grant here: ${held.join('; ')}. ` +
|
|
622
|
+
`Governing a role transcribes and reconciles ALL of its grants, and the model cannot express a ` +
|
|
623
|
+
`column-scoped grant for a foreign role, so the next plan would revoke it. Normalize the grant to ` +
|
|
624
|
+
`table-wide in the database first, or leave the role ungoverned.`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
602
627
|
for (const priv of Object.keys(byPriv).sort()) {
|
|
603
628
|
const cols = byPriv[priv] ?? [];
|
|
604
629
|
if (!cols.length) continue;
|
|
@@ -621,7 +646,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
621
646
|
|
|
622
647
|
// --- roles the compiler has no vocabulary for ----------------------------------------
|
|
623
648
|
const unmappedRoles = Object.keys(contract.grants).filter(
|
|
624
|
-
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r),
|
|
649
|
+
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r) && !govern.has(r),
|
|
625
650
|
);
|
|
626
651
|
|
|
627
652
|
if (!abilities.length && !notes.length && !unmappedRoles.length) {
|
|
@@ -636,6 +661,18 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
636
661
|
privileges[role] = [...new Set([...(privileges[role] ?? []), ...privs])].sort();
|
|
637
662
|
}
|
|
638
663
|
|
|
664
|
+
// --- roles the operator chose to GOVERN (--govern-roles) ------------------------------
|
|
665
|
+
//
|
|
666
|
+
// The whole grant, verbatim — DML and beyond-CRUD alike. These are the grants a fresh
|
|
667
|
+
// build must recreate (the migrations being deleted are what used to create them); a
|
|
668
|
+
// declared grant with no policy stays subject to the naked-grant WARN, which on an
|
|
669
|
+
// rls: false table correctly names it as live, deliberate, table-wide access.
|
|
670
|
+
for (const role of Object.keys(contract.grants).sort()) {
|
|
671
|
+
if (!govern.has(role)) continue;
|
|
672
|
+
const held = (contract.grants[role] ?? []).sort();
|
|
673
|
+
if (held.length) privileges[role] = [...new Set([...(privileges[role] ?? []), ...held])].sort();
|
|
674
|
+
}
|
|
675
|
+
|
|
639
676
|
return { abilities, notes, unmappedRoles, privileges };
|
|
640
677
|
}
|
|
641
678
|
|
package/src/cli/authz-lint.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* authz-lint — the "force-RLS with no read authz" gate.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* A modeled table gets RLS enabled unless it declares `rls: false` (grants-only), and the
|
|
5
5
|
* model is default-deny: no matching `can()` means no policy. So an EXPOSED table that declares no
|
|
6
6
|
* read ability is a superuser-drop landmine — it reads fine while a bypassing role (a superuser
|
|
7
7
|
* api) is in front, then returns empty for every app role the instant that role becomes
|
|
@@ -37,11 +37,18 @@ export function findReadAuthzGaps(models: readonly ModelDescriptor[]): ReadAuthz
|
|
|
37
37
|
gaps.push({
|
|
38
38
|
schema: m.schema,
|
|
39
39
|
table: m.table,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
// An rls: false table cannot take the "declare a read" cure — defineModel refuses
|
|
41
|
+
// abilities there — so its message names the two options that exist. db:pull never
|
|
42
|
+
// authors this shape (it renders private: true beside rls: false); only a hand
|
|
43
|
+
// author can, and this is the line that stops them.
|
|
44
|
+
message: m.rls === false
|
|
45
|
+
? `${m.schema}.${m.table} declares rls: false and is exposed to the generic data API — grants ` +
|
|
46
|
+
`are its only gate, so every granted role reads every row, unfiltered. A grants-only table ` +
|
|
47
|
+
`is operational, not API surface: mark it private(), or drop rls: false and declare abilities.`
|
|
48
|
+
: `${m.schema}.${m.table} is exposed and RLS-enabled but declares no read ability — every app role ` +
|
|
49
|
+
`reads empty once it is RLS-subject (e.g. after dropping a superuser api). Declare a read: ` +
|
|
50
|
+
`can('read') for public data, can('read', { owner: '<col>' }) for private, or mark the model ` +
|
|
51
|
+
`private() if it is not part of the data API.`,
|
|
45
52
|
});
|
|
46
53
|
}
|
|
47
54
|
return gaps;
|
|
@@ -63,12 +70,12 @@ export function findReadAuthzGaps(models: readonly ModelDescriptor[]): ReadAuthz
|
|
|
63
70
|
*
|
|
64
71
|
* Severity is a WARNING, never fatal, and that is a considered narrowing of the original ruling.
|
|
65
72
|
* The ruling asked for an ERROR on a table without RLS, where a naked grant is an unrestricted
|
|
66
|
-
* table-wide privilege.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* a database the adopter is trying to adopt would make `db:pull`
|
|
71
|
-
* it exists for.
|
|
73
|
+
* table-wide privilege. Since `rls: false` landed, that case CAN arise from models — a
|
|
74
|
+
* grants-only table declares exactly that shape, deliberately: grants ARE its whole
|
|
75
|
+
* authorization, and there is no policy for the grant to be naked of. So the two RLS postures
|
|
76
|
+
* get two messages (dead grant vs live grants-only access), and both stay WARNINGs: failing CI
|
|
77
|
+
* over a faithful rendering of a database the adopter is trying to adopt would make `db:pull`
|
|
78
|
+
* unusable on exactly the schemas it exists for.
|
|
72
79
|
*/
|
|
73
80
|
export interface NakedGrant {
|
|
74
81
|
schema: string;
|
|
@@ -95,13 +102,20 @@ export function findNakedGrants(models: readonly ModelDescriptor[]): NakedGrant[
|
|
|
95
102
|
table: m.table,
|
|
96
103
|
role,
|
|
97
104
|
privileges: dml,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
// Two postures, two truths. Compared against literal `false`: a descriptor from an
|
|
106
|
+
// older @everystack/model has no rls key, and undefined means enabled (see
|
|
107
|
+
// authz-compile's identical guard).
|
|
108
|
+
message: m.rls === false
|
|
109
|
+
? `${m.schema}.${m.table}: '${role}' holds ${dml.join(', ')} with rls: false — this is LIVE, ` +
|
|
110
|
+
`unrestricted table-wide access, not a dead grant: no row filter applies to '${role}' on ` +
|
|
111
|
+
`this table. That is what a grants-only table declares, so confirm it is deliberate; if ` +
|
|
112
|
+
`'${role}' should see only some rows, drop rls: false and declare can(...) instead.`
|
|
113
|
+
: `${m.schema}.${m.table}: '${role}' holds ${dml.join(', ')} as a grant with no policy beside it. ` +
|
|
114
|
+
`It is dead while RLS is on — the role reads zero rows — and becomes an unrestricted ` +
|
|
115
|
+
`table-wide privilege the day RLS is disabled or a broad policy is added. If this came from ` +
|
|
116
|
+
`db:pull it is a faithful reading of the database; decide whether to give it a policy ` +
|
|
117
|
+
`(can(...)) or revoke it. If you wrote it by hand, you almost certainly want can() instead, ` +
|
|
118
|
+
`which decides the grant and the policy together.`,
|
|
105
119
|
});
|
|
106
120
|
}
|
|
107
121
|
}
|
|
@@ -37,7 +37,8 @@ import { compileDrizzleSource } from '../schema-source.js';
|
|
|
37
37
|
import { currentGitRef } from '../state-apply.js';
|
|
38
38
|
import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build.js';
|
|
39
39
|
import { resolveModelsPath } from '../models-path.js';
|
|
40
|
-
import { loadModels } from './db-generate.js';
|
|
40
|
+
import { loadModels, loadModules } from './db-generate.js';
|
|
41
|
+
import { findUnexportedModels, scanModelDirectory, type ModelFileScan } from '../model-barrel-lint.js';
|
|
41
42
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
42
43
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
43
44
|
import type { SourceObject } from '../derived-source.js';
|
|
@@ -67,6 +68,10 @@ export interface CheckFinding {
|
|
|
67
68
|
|
|
68
69
|
export interface StaticCheckInput {
|
|
69
70
|
modelsPath: string;
|
|
71
|
+
/** Barrel siblings and what each declares — the "on disk but ungoverned" scan. */
|
|
72
|
+
modelFileScans?: ModelFileScan[];
|
|
73
|
+
/** The module's extensions — the bootstrap the from-scratch compose needs. */
|
|
74
|
+
extensions?: string[];
|
|
70
75
|
/** null = the barrel did not load — the gate's first failure. */
|
|
71
76
|
models: ModelDescriptor[] | null;
|
|
72
77
|
modelsError?: string;
|
|
@@ -104,6 +109,17 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
104
109
|
}
|
|
105
110
|
findings.push({ level: 'ok', area: 'models', message: `${input.models.length} model(s) loaded from ${input.modelsPath}` });
|
|
106
111
|
|
|
112
|
+
// A file on disk the barrel never exported is outside governance while looking declared.
|
|
113
|
+
// FAIL, not warn: it is absent from the fingerprint, so nothing downstream will ever
|
|
114
|
+
// notice, and the only other signal is a model COUNT nobody can compare against.
|
|
115
|
+
const barrelGaps = findUnexportedModels(
|
|
116
|
+
input.modelFileScans ?? [],
|
|
117
|
+
new Set(input.models.map((m) => `${m.schema}.${m.table}`)),
|
|
118
|
+
);
|
|
119
|
+
for (const gap of barrelGaps) {
|
|
120
|
+
findings.push({ level: 'fail', area: 'models', message: gap.message });
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
// The semantic merge conflict git can't see: two branches declaring the
|
|
108
124
|
// same table both merge cleanly — the merged state must still compose.
|
|
109
125
|
const seen = new Map<string, number>();
|
|
@@ -144,7 +160,7 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
144
160
|
|
|
145
161
|
try {
|
|
146
162
|
compileDeclaredState(input.models);
|
|
147
|
-
const statements = compileMigration(input.models, { sequences: input.sequences });
|
|
163
|
+
const statements = compileMigration(input.models, { sequences: input.sequences, extensions: input.extensions });
|
|
148
164
|
findings.push({ level: 'ok', area: 'compose', message: `declared state composes — ${statements.length} statement(s) from scratch` });
|
|
149
165
|
} catch (err: any) {
|
|
150
166
|
findings.push({ level: 'fail', area: 'compose', message: `the merged declared state does NOT compose: ${err.message}` });
|
|
@@ -281,7 +297,7 @@ export interface EphemeralComposeResult {
|
|
|
281
297
|
export async function executeEphemeralCompose(
|
|
282
298
|
adminUrl: string,
|
|
283
299
|
models: ModelDescriptor[],
|
|
284
|
-
opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[] } = {},
|
|
300
|
+
opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[]; extensions?: string[] } = {},
|
|
285
301
|
): Promise<EphemeralComposeResult> {
|
|
286
302
|
const database = `escheck_${process.pid}_${Date.now()}`;
|
|
287
303
|
await createDatabase(adminUrl, database);
|
|
@@ -289,6 +305,7 @@ export async function executeEphemeralCompose(
|
|
|
289
305
|
const built = await buildIntoDatabase(withDatabase(adminUrl, database), models, {
|
|
290
306
|
declared: opts.declared,
|
|
291
307
|
sequences: opts.sequences,
|
|
308
|
+
extensions: opts.extensions,
|
|
292
309
|
actor: opts.actor ?? 'db:check',
|
|
293
310
|
gitRef: opts.gitRef ?? null,
|
|
294
311
|
});
|
|
@@ -315,6 +332,19 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
315
332
|
modelsError = err.message;
|
|
316
333
|
}
|
|
317
334
|
|
|
335
|
+
// Only meaningful once the barrel itself loaded — otherwise every sibling reads as
|
|
336
|
+
// ungoverned and the real error is buried under the noise.
|
|
337
|
+
const modelFileScans = models ? await scanModelDirectory(modelsPath) : [];
|
|
338
|
+
|
|
339
|
+
// The module carries the extensions; `models` alone cannot. Without them the from-scratch
|
|
340
|
+
// compose builds a schema whose column types do not exist.
|
|
341
|
+
let extensions: string[] = [];
|
|
342
|
+
if (models) {
|
|
343
|
+
try {
|
|
344
|
+
extensions = [...new Set((await loadModules(modelsPath)).flatMap((m) => m.extensions ?? []))];
|
|
345
|
+
} catch { /* a models-only barrel has no modules export — nothing to bootstrap */ }
|
|
346
|
+
}
|
|
347
|
+
|
|
318
348
|
const artifactPath = flags['schema-out'] || DEFAULT_SCHEMA_OUT;
|
|
319
349
|
let artifactSource: string | null = null;
|
|
320
350
|
try {
|
|
@@ -349,7 +379,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
349
379
|
} catch { /* no baseline — see above */ }
|
|
350
380
|
|
|
351
381
|
const findings = runStaticChecks({
|
|
352
|
-
modelsPath, models, modelsError, artifactPath, artifactSource, sqlDirRetired,
|
|
382
|
+
modelsPath, models, modelsError, modelFileScans, extensions, artifactPath, artifactSource, sqlDirRetired,
|
|
353
383
|
sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
|
|
354
384
|
moduleModels: declaredDb?.models ?? null,
|
|
355
385
|
baselineSource,
|
|
@@ -374,6 +404,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
374
404
|
gitRef: currentGitRef(),
|
|
375
405
|
declared: declaredDb?.objects,
|
|
376
406
|
sequences: declaredDb?.sequences,
|
|
407
|
+
extensions,
|
|
377
408
|
});
|
|
378
409
|
for (const line of ephemeral.report) info(` ${line}`);
|
|
379
410
|
} catch (err: any) {
|
|
@@ -402,7 +433,11 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
402
433
|
} else if (failed) {
|
|
403
434
|
fail('db:check FAILED — the merged declared state is not shippable as-is (findings above).');
|
|
404
435
|
} else if (ephemeral !== null) {
|
|
405
|
-
success(`db:check passed — declared state composes from scratch and lands
|
|
436
|
+
success(`db:check passed — the declared state composes from scratch and lands on its own fingerprint (${ephemeral.fingerprint.slice(0, 12)}).`);
|
|
437
|
+
// Named because it kept being read as the stronger claim (a consumer, 2026-08-07): this
|
|
438
|
+
// ring proves the checkout against ITSELF, on an empty database. Whether an existing
|
|
439
|
+
// database IS this state is a different question with a different verb.
|
|
440
|
+
info('This proves the checkout is self-consistent and buildable — not that any existing database matches it. For that claim, run db:fingerprint against the database.');
|
|
406
441
|
} else {
|
|
407
442
|
success('db:check passed (static ring only — no database provided for the ephemeral compose).');
|
|
408
443
|
}
|
|
@@ -72,7 +72,7 @@ export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]>
|
|
|
72
72
|
* COMPLETE migration. Falls back to wrapping a bare `models` array in one module (an app on the
|
|
73
73
|
* older models-only barrel still gets a schema+authz init, just no package functions).
|
|
74
74
|
*/
|
|
75
|
-
async function loadModules(modelsPath: string): Promise<Module[]> {
|
|
75
|
+
export async function loadModules(modelsPath: string): Promise<Module[]> {
|
|
76
76
|
const abs = path.resolve(modelsPath);
|
|
77
77
|
let mod: any;
|
|
78
78
|
try {
|
|
@@ -221,6 +221,22 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// The class rides on the ARTIFACT, not only the terminal — a reviewer reading
|
|
225
|
+
// db.plan.json had the aggregate and no way to reach the statements behind it, so
|
|
226
|
+
// `unclassified: 5` was unreadable by the only person positioned to catch what it meant.
|
|
227
|
+
//
|
|
228
|
+
// Attached HERE, before the plan is serialized AND before planHash is taken, because
|
|
229
|
+
// both must see the same object. It used to be assigned after the write, onto an
|
|
230
|
+
// instance nobody read again: the artifact carried no adoption at all, and the summary
|
|
231
|
+
// counted statements the file could not show. Hashing after attaching also keeps the
|
|
232
|
+
// ref db:plan prints equal to the one db:apply computes for that file — `planHash` is
|
|
233
|
+
// sha256 over the WHOLE plan, so a field added later silently changes the identity.
|
|
234
|
+
//
|
|
235
|
+
// Re-DERIVED from live every mint, never stored across mints: a recorded claim about
|
|
236
|
+
// what the models fail to capture goes stale the moment someone closes the gap.
|
|
237
|
+
const adoption = classifyAdoption(declaredAuthz, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles) });
|
|
238
|
+
plan.adoption = adoption.statements;
|
|
239
|
+
|
|
224
240
|
const body = JSON.stringify(plan, null, 2) + '\n';
|
|
225
241
|
if (out === '-') {
|
|
226
242
|
console.log(body);
|
|
@@ -238,16 +254,11 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
238
254
|
// fail to capture goes stale the moment someone closes the gap, and a stale note is worse
|
|
239
255
|
// than none. `capability` is the count that says "this plan removes something the model
|
|
240
256
|
// cannot express" — the operator reading this is the last one who can catch it.
|
|
241
|
-
const adoption = classifyAdoption(declaredAuthz, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles) });
|
|
242
257
|
const adoptionTotal = Object.values(adoption.counts).reduce((a, b) => a + b, 0);
|
|
243
258
|
if (adoptionTotal > 0) {
|
|
244
259
|
info(`${adoptionTotal} authorization statement(s), by why they exist:`);
|
|
245
260
|
for (const line of renderAdoptionReport(adoption.counts, adoption.statements)) info(line);
|
|
246
261
|
}
|
|
247
|
-
// The class rides on the ARTIFACT too, not only the terminal. A reviewer reading
|
|
248
|
-
// db.plan.json had the aggregate and no way to reach the statements behind it, so
|
|
249
|
-
// `unclassified: 5` was unreadable by the only person positioned to catch what it meant.
|
|
250
|
-
plan.adoption = adoption.statements;
|
|
251
262
|
|
|
252
263
|
// WHO owns the tables this plan authorizes, and does that owner obey the policies it
|
|
253
264
|
// is about to write? Re-derived live every mint, never stored — the owner is a fact
|
|
@@ -42,7 +42,8 @@ import { introspectContract, type TableContract, type AuthzContract } from '../a
|
|
|
42
42
|
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
|
|
43
43
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
44
44
|
import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
|
|
45
|
-
import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
|
|
45
|
+
import { renderModelSource, renderModelFiles, pullableTables, skippedInfrastructureTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
|
|
46
|
+
import { deriveAbilities } from '../authz-derive.js';
|
|
46
47
|
import type { QueryRunner } from '../authz-contract.js';
|
|
47
48
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
48
49
|
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
@@ -108,7 +109,12 @@ export function derivedImportSpecifier(out: string, derivedOut: string): string
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
|
|
111
|
-
|
|
112
|
+
// A LIST: `--schema public,auth,metrics`. One value still works. Without this there was
|
|
113
|
+
// no way to adopt a multi-schema database — `--out <dir>` regenerates the barrel, so a
|
|
114
|
+
// second pull replaced the first schema's models rather than joining them.
|
|
115
|
+
const schemas = (flags.schema || 'public').split(',').map((s) => s.trim()).filter(Boolean);
|
|
116
|
+
if (schemas.length === 0) fail('--schema needs at least one schema name.');
|
|
117
|
+
const schema = schemas.length === 1 ? schemas[0] : schemas;
|
|
112
118
|
|
|
113
119
|
// --derived-out: the brownfield splice, first-class. A consumer with an existing
|
|
114
120
|
// hand-maintained barrel wants the derived layer as its own file — not codemodded
|
|
@@ -127,6 +133,24 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
127
133
|
process.exit(1);
|
|
128
134
|
}
|
|
129
135
|
|
|
136
|
+
// --govern-roles: the operator's EXPLICIT decision to transcribe these foreign roles'
|
|
137
|
+
// grants into `privileges` — which governs them. Required for migration deletion when
|
|
138
|
+
// the migrations created grants to roles outside the vocabulary: a fresh build from
|
|
139
|
+
// models must recreate them. Never inferred from the database (a re-pull must not turn
|
|
140
|
+
// a rendering decision into an access decision); complete-or-refuse per role — a listed
|
|
141
|
+
// role holding a column-scoped grant fails the pull (see deriveAbilities).
|
|
142
|
+
const governRoles = (flags['govern-roles'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
143
|
+
if (governRoles.length && abilities !== 'live') {
|
|
144
|
+
fail(`--govern-roles transcribes LIVE grants, so it requires --abilities live.`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
for (const r of governRoles) {
|
|
148
|
+
if (['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) {
|
|
149
|
+
fail(`--govern-roles ${r}: the vocabulary roles and PUBLIC are always governed — name only foreign roles (an ops/connection role the migrations granted to).`);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
130
154
|
let dbSource: DbSource;
|
|
131
155
|
try {
|
|
132
156
|
dbSource = resolveDbSource(flags);
|
|
@@ -167,7 +191,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
167
191
|
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
168
192
|
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
169
193
|
}
|
|
170
|
-
note(`Introspecting live database (schema: ${
|
|
194
|
+
note(`Introspecting live database (schema: ${schemas.join(", ")})...`);
|
|
171
195
|
current = await introspectSchema(session);
|
|
172
196
|
// The derived layer rides the same pull (B5) — views/matviews/functions/sequences
|
|
173
197
|
// render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
|
|
@@ -191,12 +215,15 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
191
215
|
const unmapped = new Set<string>();
|
|
192
216
|
for (const t of contract.tables) {
|
|
193
217
|
for (const r of Object.keys(t.grants)) {
|
|
194
|
-
if (!['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) unmapped.add(r);
|
|
218
|
+
if (!['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r) && !governRoles.includes(r)) unmapped.add(r);
|
|
195
219
|
}
|
|
196
220
|
}
|
|
221
|
+
if (governRoles.length) {
|
|
222
|
+
note(`--govern-roles ${governRoles.join(', ')}: their grants are transcribed as privileges — the models now OWN them, and a fresh build recreates them.`);
|
|
223
|
+
}
|
|
197
224
|
if (unmapped.size) {
|
|
198
225
|
note(`Grants exist for ${[...unmapped].sort().join(', ')} — not rendered: abilities name the roles the model knows (anon/authenticated/admin).`);
|
|
199
|
-
detail(`These are left exactly as they are in the database. Add can(..., { role })
|
|
226
|
+
detail(`These are left exactly as they are in the database. Add can(..., { role }) to own them, or re-pull with --govern-roles to transcribe their grants as privileges.`);
|
|
200
227
|
}
|
|
201
228
|
// ADOPTION: record the foreign grantees that were already here, per stage. The
|
|
202
229
|
// reconciler exempts them from revocation, so this artifact is what stops that
|
|
@@ -204,10 +231,10 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
204
231
|
// grown, refuses at db:plan. It lands as a reviewable diff, with writes flagged,
|
|
205
232
|
// because nothing mechanical can tell a legitimate BI role from an attacker's on day
|
|
206
233
|
// one; the defence is forcing the look and making it recur.
|
|
207
|
-
// The governed set at ADOPTION is the fixed vocabulary
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
pulledExemptions = ungovernedGrants(contract, new Set(ALWAYS_GOVERNED));
|
|
234
|
+
// The governed set at ADOPTION is the fixed vocabulary plus any --govern-roles: a
|
|
235
|
+
// governed role's grants are DECLARED (transcribed as privileges), so recording them
|
|
236
|
+
// as exemptions too would double-book them — declared and exempted at once.
|
|
237
|
+
pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
|
|
211
238
|
pulledFingerprint = fingerprintLive(current, contract).hash;
|
|
212
239
|
}
|
|
213
240
|
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
@@ -250,6 +277,33 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
250
277
|
process.exit(1);
|
|
251
278
|
}
|
|
252
279
|
|
|
280
|
+
// The denylist's consequence, stated at the moment it applies: a migration tool's
|
|
281
|
+
// bookkeeping is never modeled, so it can never enter the declared state — and a
|
|
282
|
+
// fully-declared database therefore cannot contain it.
|
|
283
|
+
const skippedInfra = skippedInfrastructureTables(current, schema);
|
|
284
|
+
if (skippedInfra.length) {
|
|
285
|
+
caution(
|
|
286
|
+
`${skippedInfra.length} migration-tool table(s) skipped, never modeled: ${skippedInfra.join(', ')} — `
|
|
287
|
+
+ `a migration journal is the tool's own state, not the app's. To reach a fully-declared database, `
|
|
288
|
+
+ `DROP them once the tool that owns them is retired.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The --govern-roles complete-or-refuse gate, run BEFORE any file is written: a refusal
|
|
293
|
+
// mid-render would leave a half-written models directory. Pure re-derivation, pulled
|
|
294
|
+
// tables only — a governed role's column grant on an UNPULLED table is safe (that table
|
|
295
|
+
// is not declared, so nothing reconciles it).
|
|
296
|
+
if (governRoles.length && liveAuthz) {
|
|
297
|
+
const pulledNames = new Set(pulled.map((t) => t.table));
|
|
298
|
+
try {
|
|
299
|
+
const govern = new Set(governRoles);
|
|
300
|
+
for (const [name, c] of liveAuthz) if (pulledNames.has(name)) deriveAbilities(c, { governRoles: govern });
|
|
301
|
+
} catch (err: any) {
|
|
302
|
+
fail(err.message);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
253
307
|
// WHO owns the tables being pulled. Nothing is FLAGGED here: flagging needs a declared
|
|
254
308
|
// write principal to contradict, and the models this pull is about to write do not exist
|
|
255
309
|
// yet. Naming the owner is the half the pull genuinely saw — and the half that vanishes
|
|
@@ -323,7 +377,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
323
377
|
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
324
378
|
// A directory: one file per model + index.ts — the default shape for a real app.
|
|
325
379
|
const dir = path.resolve(flags.out);
|
|
326
|
-
const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived, externalDerived });
|
|
380
|
+
const files = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived, externalDerived });
|
|
327
381
|
try {
|
|
328
382
|
await fs.mkdir(dir, { recursive: true });
|
|
329
383
|
const written = new Set(files.map((f) => f.file));
|
|
@@ -340,7 +394,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
340
394
|
source = files.map((f) => f.source).join('\n');
|
|
341
395
|
} else if (flags.out) {
|
|
342
396
|
const outPath = path.resolve(flags.out);
|
|
343
|
-
source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
397
|
+
source = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived });
|
|
344
398
|
try {
|
|
345
399
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
346
400
|
await fs.writeFile(outPath, source, 'utf8');
|
|
@@ -350,7 +404,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
350
404
|
}
|
|
351
405
|
ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
|
|
352
406
|
} else {
|
|
353
|
-
source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
407
|
+
source = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived });
|
|
354
408
|
process.stdout.write(source);
|
|
355
409
|
ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
|
|
356
410
|
}
|
package/src/cli/db-build.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { SequenceDescriptor } from '@everystack/model';
|
|
|
17
17
|
import { compileDeclaredState } from './declared-diff.js';
|
|
18
18
|
import { createUrlRunner } from './db-source.js';
|
|
19
19
|
import { executeSync, buildSyncReport } from './commands/db-sync.js';
|
|
20
|
+
import { executeReconcile } from './commands/db-reconcile.js';
|
|
20
21
|
|
|
21
22
|
const SAFE_NAME = /^[a-z_][a-z0-9_$]*$/;
|
|
22
23
|
|
|
@@ -93,6 +94,10 @@ export interface BuildOptions {
|
|
|
93
94
|
declared?: SourceObject[];
|
|
94
95
|
/** Standalone sequences (state — created before tables, in the fingerprint bar). */
|
|
95
96
|
sequences?: SequenceDescriptor[];
|
|
97
|
+
/** Extensions the declared state needs. Applied BEFORE anything else — a column typed
|
|
98
|
+
* `hstore` cannot be created until the type exists, and the sync path is a DIFF, which
|
|
99
|
+
* has no bootstrap phase of its own. `IF NOT EXISTS`, so re-running is free. */
|
|
100
|
+
extensions?: string[];
|
|
96
101
|
actor?: string | null;
|
|
97
102
|
gitRef?: string | null;
|
|
98
103
|
}
|
|
@@ -109,7 +114,62 @@ export async function buildIntoDatabase(
|
|
|
109
114
|
): Promise<BuildResult> {
|
|
110
115
|
const { runner, session, end } = await createUrlRunner(url);
|
|
111
116
|
try {
|
|
117
|
+
// Bootstrap FIRST: roles and tables both come after the types they use exist.
|
|
118
|
+
for (const ext of [...new Set(options.extensions ?? [])].sort()) {
|
|
119
|
+
await runner(`CREATE EXTENSION IF NOT EXISTS "${ext}"`);
|
|
120
|
+
}
|
|
121
|
+
// Schemas, for the same reason. This path is a DIFF (executeSync), and a diff has no
|
|
122
|
+
// bootstrap phase: `compileMigration` emits CREATE SCHEMA for every non-public schema a
|
|
123
|
+
// model lives in, but nothing on the sync path did — so a multi-schema declared state
|
|
124
|
+
// failed with `schema "auth" does not exist` before a single table was created. Taken
|
|
125
|
+
// from the MODELS and from the declared derived objects, because a schema can hold only
|
|
126
|
+
// functions (an `auth` of nothing but SECURITY DEFINER functions is a real shape).
|
|
127
|
+
const modelSchemas = models.map((m) => m.schema ?? 'public');
|
|
128
|
+
const derivedSchemas = (options.declared ?? []).map((o) => o.schema);
|
|
129
|
+
for (const schema of [...new Set([...modelSchemas, ...derivedSchemas])].filter((s) => s && s !== 'public').sort()) {
|
|
130
|
+
await runner(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
|
131
|
+
}
|
|
112
132
|
const createdRoles = await ensureContractRoles(runner, models);
|
|
133
|
+
|
|
134
|
+
// FUNCTIONS BEFORE STATE. An RLS policy's predicate is resolved when the policy is
|
|
135
|
+
// created, so `USING (user_id = auth.user_id())` cannot be created before
|
|
136
|
+
// `auth.user_id()` exists — and the sync path applies state (tables, RLS, policies)
|
|
137
|
+
// before the derived layer. On a real brownfield schema that is not an edge case: 18 of
|
|
138
|
+
// one consumer's policies call an `auth.*` function, so the from-scratch build failed
|
|
139
|
+
// with `function auth.user_id() does not exist` before any of them could be created.
|
|
140
|
+
//
|
|
141
|
+
// Only FUNCTIONS move: views and triggers depend on TABLES, so the layers genuinely
|
|
142
|
+
// interleave and "derived before state" would just fail the other way round.
|
|
143
|
+
//
|
|
144
|
+
// `check_function_bodies = off` for this window — the pg_dump restore idiom. A
|
|
145
|
+
// SQL-language function whose body reads a table that does not exist yet is a forward
|
|
146
|
+
// reference, not an error; plpgsql bodies are never validated at creation, so only the
|
|
147
|
+
// SQL-language ones need it. MEASURED, not assumed: dropping this SET puts the reference
|
|
148
|
+
// brownfield schema straight back to failing, so the escape is load-bearing.
|
|
149
|
+
//
|
|
150
|
+
// It is a BARE set, not SET LOCAL, and that is deliberate: reconcile opens its own
|
|
151
|
+
// transaction, so this pass has none to scope to. It is safe under the rule the repo's
|
|
152
|
+
// own guard documents — `createUrlRunner` gives this command a dedicated max:1
|
|
153
|
+
// connection it owns for the run and closes in a finally, never a pooled or shared one.
|
|
154
|
+
// RESET in a finally so the window closes even on failure.
|
|
155
|
+
//
|
|
156
|
+
// Provenance is recorded by this pass, so executeSync's own reconcile below sees the
|
|
157
|
+
// functions already managed and unchanged, and plans nothing for them.
|
|
158
|
+
const declaredFunctions = (options.declared ?? []).filter((o) => o.kind === 'function');
|
|
159
|
+
if (declaredFunctions.length > 0) {
|
|
160
|
+
await runner('SET check_function_bodies = off');
|
|
161
|
+
try {
|
|
162
|
+
const pre = await executeReconcile(runner, session, {
|
|
163
|
+
declared: declaredFunctions,
|
|
164
|
+
apply: true,
|
|
165
|
+
actor: options.actor ?? 'db-build',
|
|
166
|
+
gitRef: options.gitRef ?? null,
|
|
167
|
+
});
|
|
168
|
+
if (pre.refusal) throw new Error(`could not create declared functions first: ${pre.refusal}`);
|
|
169
|
+
} finally {
|
|
170
|
+
await runner('RESET check_function_bodies');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
113
173
|
const run = await executeSync(runner, session, models, {
|
|
114
174
|
declared: options.declared,
|
|
115
175
|
sequences: options.sequences,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
|
|
17
17
|
import type { ColumnSchema, SequenceSchema, TableSchema } from './schema-introspect.js';
|
|
18
18
|
import { parseIndexDefinition } from './schema-introspect.js';
|
|
19
|
-
import {
|
|
19
|
+
import { modelImportPath, renderFieldLines } from './model-render.js';
|
|
20
20
|
import { splitFunctionIdentity } from './pg-argtypes.js';
|
|
21
21
|
import { GOVERNED_VOCABULARY, KNOWN_ROLES } from './authz-derive.js';
|
|
22
22
|
|
|
@@ -676,7 +676,7 @@ export function renderDerivedFile(result: DerivedRenderResult, knownTables: Map<
|
|
|
676
676
|
lines.push(`import { ${result.imports.join(', ')} } from '@everystack/model';`);
|
|
677
677
|
}
|
|
678
678
|
const refs = result.modelRefs
|
|
679
|
-
.map((table) => ({ varName: knownTables.get(table)!, path:
|
|
679
|
+
.map((table) => ({ varName: knownTables.get(table)!, path: modelImportPath(table) }))
|
|
680
680
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
681
681
|
for (const ref of refs) lines.push(`import { ${ref.varName} } from '${ref.path}';`);
|
|
682
682
|
lines.push('', result.block, '');
|
package/src/cli/index.ts
CHANGED
|
@@ -359,7 +359,7 @@ Usage:
|
|
|
359
359
|
everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
|
|
360
360
|
everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
|
|
361
361
|
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
|
|
362
|
-
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. --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
|
|
362
|
+
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
|
|
363
363
|
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.
|
|
364
364
|
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
|
|
365
365
|
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).
|
|
@@ -38,10 +38,21 @@ function emptyTable(table: string): TableContract {
|
|
|
38
38
|
* schema). Tables, then foreign keys, then authz — the order a fresh database must
|
|
39
39
|
* apply them in.
|
|
40
40
|
*/
|
|
41
|
-
export function compileMigration(
|
|
41
|
+
export function compileMigration(
|
|
42
|
+
models: ModelDescriptor[],
|
|
43
|
+
opts: CompileTableOptions & { sequences?: SequenceDescriptor[]; extensions?: string[] } = {},
|
|
44
|
+
): string[] {
|
|
42
45
|
const sql: string[] = [];
|
|
43
46
|
const schemaOf = (m: ModelDescriptor): string => m.schema ?? 'public';
|
|
44
47
|
|
|
48
|
+
// 0. Extensions — the BOOTSTRAP, before any table that might use one of their types.
|
|
49
|
+
// compileModuleMigration has always emitted these; compileMigration had no way to,
|
|
50
|
+
// so db:check's from-scratch ring compiled a state whose column types did not exist
|
|
51
|
+
// and failed with `type "hstore" does not exist` on an otherwise clean schema.
|
|
52
|
+
// Deduped + sorted, quoted so a hyphenated name (`uuid-ossp`) stays valid.
|
|
53
|
+
const extensions = [...new Set(opts.extensions ?? [])].sort();
|
|
54
|
+
sql.push(...extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}";`));
|
|
55
|
+
|
|
45
56
|
// 0a. Non-public schemas — `CREATE SCHEMA` for each distinct one a model lives in,
|
|
46
57
|
// before any table is created in it. public is the implicit default, never emitted.
|
|
47
58
|
const schemas = [...new Set(models.map(schemaOf))].filter((s) => s !== 'public').sort();
|
|
@@ -137,12 +148,14 @@ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOp
|
|
|
137
148
|
export function compileModuleMigration(modules: Module[], opts: CompileTableOptions = {}): string[] {
|
|
138
149
|
const sql: string[] = [];
|
|
139
150
|
|
|
140
|
-
// 1. Extensions
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
// 1+2. Extensions (the bootstrap) then schema + authz for every modeled table, plus the
|
|
152
|
+
// modules' standalone sequences. compileMigration owns the extension emission now, so
|
|
153
|
+
// the two entry points cannot disagree about the order or the quoting.
|
|
154
|
+
sql.push(...compileMigration(modules.flatMap((m) => m.models), {
|
|
155
|
+
...opts,
|
|
156
|
+
sequences: modules.flatMap((m) => m.sequences),
|
|
157
|
+
extensions: modules.flatMap((m) => m.extensions),
|
|
158
|
+
}));
|
|
146
159
|
|
|
147
160
|
// 3. Package SQL — functions + triggers, after the tables they reference. Each thunk's
|
|
148
161
|
// output is a self-contained multi-statement block applied as one unit.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-barrel-lint — the file that is on disk and outside governance.
|
|
3
|
+
*
|
|
4
|
+
* Every verb reads the BARREL: `db:check`, `db:plan` and `db:fingerprint` all call
|
|
5
|
+
* `loadModels(modelsPath)` and see exactly what it exports. So a model file sitting in
|
|
6
|
+
* `db/models/` that `index.ts` never imports does not exist as far as any of them is
|
|
7
|
+
* concerned — it is absent from the fingerprint, absent from the plan, and absent from the
|
|
8
|
+
* check — while looking entirely declared to a human reading the directory.
|
|
9
|
+
*
|
|
10
|
+
* That is the failure the declared-state model exists to prevent: a table quietly outside
|
|
11
|
+
* governance, with a file that says otherwise. It is also silent by construction, because
|
|
12
|
+
* the only signal is a COUNT ("30 model(s)") that matches nothing the reader can compare it
|
|
13
|
+
* to. A consumer lost an hour to it and drew a false stage-drift conclusion from the
|
|
14
|
+
* resulting one-statement plan.
|
|
15
|
+
*
|
|
16
|
+
* The scan is deliberately shallow: the barrel's OWN directory, non-recursive. A models
|
|
17
|
+
* directory is a flat directory of models by convention, and walking deeper would start
|
|
18
|
+
* flagging fixtures and generated output.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs/promises';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
25
|
+
|
|
26
|
+
/** One file's declared tables, as read off its own exports. */
|
|
27
|
+
export interface ModelFileScan {
|
|
28
|
+
/** Basename, relative to the barrel's directory — what the operator has to go open. */
|
|
29
|
+
file: string;
|
|
30
|
+
/** Qualified tables (`schema.table`) the file declares, in any order. */
|
|
31
|
+
tables: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UnexportedModelGap {
|
|
35
|
+
file: string;
|
|
36
|
+
/** The declared tables no verb can see, sorted. */
|
|
37
|
+
tables: string[];
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Files declaring a table the barrel never exported. Pure — the caller does the reading, so
|
|
43
|
+
* the rule is testable without a filesystem.
|
|
44
|
+
*
|
|
45
|
+
* Findings are per FILE, because the file is the unit that gets fixed (add it to the
|
|
46
|
+
* barrel, or delete it). Everything is sorted: import order is not a contract, and a lint
|
|
47
|
+
* whose output reorders between runs is one nobody can diff.
|
|
48
|
+
*/
|
|
49
|
+
export function findUnexportedModels(
|
|
50
|
+
scans: readonly ModelFileScan[],
|
|
51
|
+
loadedTables: ReadonlySet<string>,
|
|
52
|
+
): UnexportedModelGap[] {
|
|
53
|
+
const gaps: UnexportedModelGap[] = [];
|
|
54
|
+
for (const scan of scans) {
|
|
55
|
+
const missing = scan.tables.filter((t) => !loadedTables.has(t)).sort();
|
|
56
|
+
if (missing.length === 0) continue;
|
|
57
|
+
gaps.push({
|
|
58
|
+
file: scan.file,
|
|
59
|
+
tables: missing,
|
|
60
|
+
message:
|
|
61
|
+
`${scan.file} declares ${missing.join(', ')} but the barrel does not export it — so no verb can see it. ` +
|
|
62
|
+
`db:check, db:plan and db:fingerprint all read the barrel, which means the table is outside governance ` +
|
|
63
|
+
`while the file makes it look declared. Export it from the barrel, or delete the file.`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return gaps.sort((a, b) => a.file.localeCompare(b.file));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A qualified table identity, matching the form the loaded models are keyed by. */
|
|
70
|
+
export function modelIdentity(m: ModelDescriptor): string {
|
|
71
|
+
return `${m.schema}.${m.table}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the barrel's sibling files and report what each one declares.
|
|
76
|
+
*
|
|
77
|
+
* Import failures are SKIPPED, not raised: a sibling that does not compile is a different
|
|
78
|
+
* problem with its own error path, and a lint that turns an unrelated broken file into a
|
|
79
|
+
* governance finding would send the operator to the wrong place. A file exporting no model
|
|
80
|
+
* simply scans as zero tables.
|
|
81
|
+
*/
|
|
82
|
+
export async function scanModelDirectory(modelsPath: string): Promise<ModelFileScan[]> {
|
|
83
|
+
const abs = path.resolve(modelsPath);
|
|
84
|
+
const dir = path.dirname(abs);
|
|
85
|
+
const barrel = path.basename(abs);
|
|
86
|
+
|
|
87
|
+
let entries: string[];
|
|
88
|
+
try {
|
|
89
|
+
entries = await fs.readdir(dir);
|
|
90
|
+
} catch {
|
|
91
|
+
return []; // Not a directory-shaped barrel (a single-module app); nothing to scan.
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const scans: ModelFileScan[] = [];
|
|
95
|
+
for (const entry of entries.sort()) {
|
|
96
|
+
if (entry === barrel) continue;
|
|
97
|
+
if (!entry.endsWith('.ts') || entry.endsWith('.d.ts')) continue;
|
|
98
|
+
let mod: Record<string, unknown>;
|
|
99
|
+
try {
|
|
100
|
+
mod = (await import(pathToFileURL(path.join(dir, entry)).href)) as Record<string, unknown>;
|
|
101
|
+
} catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const tables = Object.values(mod)
|
|
105
|
+
.filter((v): v is ModelDescriptor => (
|
|
106
|
+
typeof v === 'object' && v !== null
|
|
107
|
+
&& typeof (v as ModelDescriptor).table === 'string'
|
|
108
|
+
&& typeof (v as ModelDescriptor).schema === 'string'
|
|
109
|
+
&& Array.isArray((v as ModelDescriptor).abilities)
|
|
110
|
+
))
|
|
111
|
+
.map(modelIdentity);
|
|
112
|
+
scans.push({ file: entry, tables: [...new Set(tables)] });
|
|
113
|
+
}
|
|
114
|
+
return scans;
|
|
115
|
+
}
|
package/src/cli/model-render.ts
CHANGED
|
@@ -61,7 +61,8 @@ export function checkToValidate(expr: string): { column: string; zod: string } |
|
|
|
61
61
|
|
|
62
62
|
export interface RenderOptions {
|
|
63
63
|
/** Only pull tables in this Postgres schema (others are framework-managed). Default: `public`. */
|
|
64
|
-
schema
|
|
64
|
+
/** One schema, or several — a database is not always one schema. Default `'public'`. */
|
|
65
|
+
schema?: string | string[];
|
|
65
66
|
/**
|
|
66
67
|
* The read-model scaffold. Default `'commented'`: every model carries the authz decision
|
|
67
68
|
* as a commented stanza (same guidance as the db:check gate — the model fails the gate
|
|
@@ -78,6 +79,14 @@ export interface RenderOptions {
|
|
|
78
79
|
* authz-derive.ts for the rule (policy presence is not effective privilege).
|
|
79
80
|
*/
|
|
80
81
|
liveAuthz?: Map<string, TableContract>;
|
|
82
|
+
/**
|
|
83
|
+
* `--govern-roles` — the operator's explicit decision to transcribe these foreign roles'
|
|
84
|
+
* grants into `privileges`, which GOVERNS them. Required for migration deletion when the
|
|
85
|
+
* migrations created grants to roles outside the vocabulary (an ops/connection role): a
|
|
86
|
+
* fresh build from models must recreate them or the deletion loses the ops lane its
|
|
87
|
+
* access. Never inferred — see deriveAbilities. Complete-or-refuse per role.
|
|
88
|
+
*/
|
|
89
|
+
governRoles?: string[];
|
|
81
90
|
/** The rendered derived layer (B5) — rides the barrel: block after the models,
|
|
82
91
|
* sequences/derived arrays on the module wrapper, symbols on the import header. */
|
|
83
92
|
derived?: DerivedRenderResult;
|
|
@@ -123,7 +132,7 @@ export function isPublicReadAbility(expr: string): boolean {
|
|
|
123
132
|
* abilities, not a regex over the joined text (a live predicate can span lines and carry its
|
|
124
133
|
* own braces). An unknown preset throws — grants are authored, never guessed.
|
|
125
134
|
*/
|
|
126
|
-
function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): { text: string; publicRead: boolean } {
|
|
135
|
+
function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): { text: string; publicRead: boolean } {
|
|
127
136
|
if (mode === 'live') {
|
|
128
137
|
const contract = table && liveAuthz?.get(table.table);
|
|
129
138
|
if (!contract) {
|
|
@@ -136,8 +145,20 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
|
|
|
136
145
|
publicRead: false,
|
|
137
146
|
};
|
|
138
147
|
}
|
|
139
|
-
const derived = deriveAbilities(contract);
|
|
140
|
-
|
|
148
|
+
const derived = deriveAbilities(contract, { governRoles });
|
|
149
|
+
const lines = [renderDerivedAbilities(derived)];
|
|
150
|
+
// A grants-only table: live RLS is OFF and no ability rendered. Transcribe the flag —
|
|
151
|
+
// without it the compiler declares RLS enabled and the first plan after adoption
|
|
152
|
+
// proposes ENABLE ROW LEVEL SECURITY, which with zero policies denies every non-owner
|
|
153
|
+
// role a table it already uses (a consumer's ops lane, measured 2026-08-07). Never
|
|
154
|
+
// rendered beside abilities: defineModel refuses the pair, and a live-off table whose
|
|
155
|
+
// grants DO derive abilities is adopt-mode territory, not a flag transcription.
|
|
156
|
+
// The writtenBy stanza always accompanies this line (live-off is never FORCEd), which
|
|
157
|
+
// is what lets the rendered model load — rls: false with the 'app' default is refused.
|
|
158
|
+
if (!derived.abilities.length && !contract.rls.enabled) {
|
|
159
|
+
lines.push(` rls: false, // live reality: row security is OFF — authorization here is grants-only.`);
|
|
160
|
+
}
|
|
161
|
+
return { text: lines.join('\n'), publicRead: derived.abilities.some(isPublicReadAbility) };
|
|
141
162
|
}
|
|
142
163
|
if (mode === 'commented') {
|
|
143
164
|
return {
|
|
@@ -241,8 +262,40 @@ const INFRASTRUCTURE_TABLES = new Set([
|
|
|
241
262
|
* database can carry `public.__drizzle_migrations` — the journal is tooling state,
|
|
242
263
|
* never an app model. Exported so the command shell counts the same set it writes.
|
|
243
264
|
*/
|
|
244
|
-
export function pullableTables(snapshot: SchemaSnapshot, schema: string): TableSchema[] {
|
|
245
|
-
|
|
265
|
+
export function pullableTables(snapshot: SchemaSnapshot, schema: string | string[]): TableSchema[] {
|
|
266
|
+
// A LIST, because a database is not one schema. `--schema` took a single value and
|
|
267
|
+
// `--out <dir>` regenerates the barrel, so pulling a second schema overwrote the first —
|
|
268
|
+
// there was no way to land a multi-schema database in one declared state at all.
|
|
269
|
+
//
|
|
270
|
+
// Matched on the exact prefix, never `startsWith(schema)` alone: `pub` must not match
|
|
271
|
+
// `public.users`.
|
|
272
|
+
const schemas = new Set(Array.isArray(schema) ? schema : [schema]);
|
|
273
|
+
return snapshot.tables.filter((t) => {
|
|
274
|
+
const dot = t.table.indexOf('.');
|
|
275
|
+
const owner = dot === -1 ? 'public' : t.table.slice(0, dot);
|
|
276
|
+
return schemas.has(owner) && !INFRASTRUCTURE_TABLES.has(bareName(t.table));
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The infrastructure tables a pull SKIPPED, qualified — so the command can NAME them.
|
|
282
|
+
*
|
|
283
|
+
* The denylist has a consequence nothing used to state: these tables are never modeled,
|
|
284
|
+
* so an adopter with legacy bookkeeping (a retired Rails app's `schema_migrations`) can
|
|
285
|
+
* only reach a fully-declared database by DROPPING them. A consumer did that archaeology
|
|
286
|
+
* by hand (2026-08-07); one line at pull time is what it should have cost.
|
|
287
|
+
*/
|
|
288
|
+
export function skippedInfrastructureTables(snapshot: SchemaSnapshot, schema: string | string[]): string[] {
|
|
289
|
+
const schemas = new Set(Array.isArray(schema) ? schema : [schema]);
|
|
290
|
+
return snapshot.tables
|
|
291
|
+
.map((t) => t.table)
|
|
292
|
+
.filter((table) => schemas.has(schemaOf(table)) && INFRASTRUCTURE_TABLES.has(bareName(table)));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The schema a qualified (or bare) table lives in — bare means public. */
|
|
296
|
+
function schemaOf(table: string): string {
|
|
297
|
+
const dot = table.indexOf('.');
|
|
298
|
+
return dot === -1 ? 'public' : table.slice(0, dot);
|
|
246
299
|
}
|
|
247
300
|
|
|
248
301
|
/**
|
|
@@ -258,7 +311,14 @@ export function pullableTables(snapshot: SchemaSnapshot, schema: string): TableS
|
|
|
258
311
|
export function modelVarName(table: string): string {
|
|
259
312
|
const bare = bareName(table);
|
|
260
313
|
const singular = bare.endsWith('s') ? bare.slice(0, -1) : bare;
|
|
261
|
-
|
|
314
|
+
const pascal = (word: string): string =>
|
|
315
|
+
word.split('_').filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
316
|
+
// Non-public schemas are QUALIFIED, so `public.users` and `auth.users` can live in one
|
|
317
|
+
// barrel. Public stays bare, so an existing single-schema barrel does not churn and
|
|
318
|
+
// nothing renames when a second schema arrives. Same rule the derived renderer already
|
|
319
|
+
// uses for its own symbols.
|
|
320
|
+
const owner = schemaOf(table);
|
|
321
|
+
return owner === 'public' ? pascal(singular) : pascal(owner) + pascal(singular);
|
|
262
322
|
}
|
|
263
323
|
|
|
264
324
|
/**
|
|
@@ -490,7 +550,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
490
550
|
}
|
|
491
551
|
|
|
492
552
|
/** One `export const X = defineModel(...)` block for a table. */
|
|
493
|
-
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>): string {
|
|
553
|
+
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): string {
|
|
494
554
|
// A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
|
|
495
555
|
// the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
|
|
496
556
|
const validates = new Map<string, string>();
|
|
@@ -507,11 +567,19 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
507
567
|
// The authz decision renders FIRST — before fields — because it is the first thing a
|
|
508
568
|
// reviewer must resolve about a model (and where the field-report consumer's codemod
|
|
509
569
|
// put it, proving the position is mechanical-edit-friendly).
|
|
510
|
-
const stanza = abilitiesStanza(abilities, table, liveAuthz);
|
|
570
|
+
const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
|
|
511
571
|
const writtenBy = writtenByStanza(table, liveAuthz);
|
|
512
572
|
const softDelete = softDeleteStanza(table, stanza.publicRead);
|
|
513
573
|
|
|
514
|
-
|
|
574
|
+
// A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
|
|
575
|
+
// qualification cannot ride in the first argument (that would make the table literally
|
|
576
|
+
// named `metrics.impressions`). Without it a multi-schema pull rendered
|
|
577
|
+
// `defineModel('impressions')`, which declares `public.impressions`, and the matview that
|
|
578
|
+
// selects FROM metrics.impressions then failed to build. Same idiom the derived renderer
|
|
579
|
+
// already uses for defineMaterializedTable.
|
|
580
|
+
const owner = schemaOf(table.table);
|
|
581
|
+
const schemaProp = owner === 'public' ? '' : ` schema: '${owner}',\n`;
|
|
582
|
+
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
|
|
515
583
|
}
|
|
516
584
|
|
|
517
585
|
/** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
|
|
@@ -548,6 +616,7 @@ function moduleFooter(
|
|
|
548
616
|
derived?: DerivedRenderResult,
|
|
549
617
|
multiline = false,
|
|
550
618
|
external?: ExternalDerived,
|
|
619
|
+
extensions?: string[],
|
|
551
620
|
): string {
|
|
552
621
|
// A materialized table (--matviews-as-tables) is a MODEL — its block rides the derived
|
|
553
622
|
// render (topo-ordered with the objects around it) but its name belongs in `models`.
|
|
@@ -570,6 +639,22 @@ function moduleFooter(
|
|
|
570
639
|
if (!external) parts.push(`export const derived = [${derived!.names.join(', ')}];`);
|
|
571
640
|
keys.push('derived');
|
|
572
641
|
}
|
|
642
|
+
// Extensions are the BOOTSTRAP half of a declared state: `field.pgType('hstore')` compiles
|
|
643
|
+
// to a column whose type does not exist unless something ran CREATE EXTENSION first.
|
|
644
|
+
// `defineModule` has always accepted the key and migration-compile has always emitted
|
|
645
|
+
// `CREATE EXTENSION IF NOT EXISTS` from it — the renderer was the only missing link, and a
|
|
646
|
+
// consumer found it when db:check's from-scratch ring failed with `type "hstore" does not
|
|
647
|
+
// exist` on a state that was otherwise clean.
|
|
648
|
+
//
|
|
649
|
+
// ALL installed extensions, not only those reachable from a declared column type: an
|
|
650
|
+
// adopter's `pg_stat_statements` and `unaccent` reach no column, and a from-scratch
|
|
651
|
+
// database without them is not their database.
|
|
652
|
+
if (extensions?.length) {
|
|
653
|
+
// Sorted HERE, not just at the introspection: the renderer owns byte-stability, and a
|
|
654
|
+
// caller passing an unsorted list must not produce a diff against an identical database.
|
|
655
|
+
parts.push(`export const extensions = [${[...extensions].sort().map((e) => `'${e}'`).join(', ')}];`);
|
|
656
|
+
keys.push('extensions');
|
|
657
|
+
}
|
|
573
658
|
parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
|
|
574
659
|
parts.push(`export const modules = [appModule];`);
|
|
575
660
|
return parts.join('\n\n');
|
|
@@ -604,14 +689,14 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
|
|
|
604
689
|
const known = new Set(tables.map((t) => bareName(t.table)));
|
|
605
690
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
606
691
|
|
|
607
|
-
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz));
|
|
692
|
+
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles)));
|
|
608
693
|
// The derived layer (B5): sequences + views/matviews/functions, after the models they
|
|
609
694
|
// reference, before the module wrapper that composes all three.
|
|
610
695
|
if (opts.derived?.block) blocks.push(opts.derived.block);
|
|
611
696
|
|
|
612
697
|
// Module composition is the norm: the pulled barrel exports `modules` alongside `models`,
|
|
613
698
|
// so a fresh brownfield project lands on the same shape the framework composes.
|
|
614
|
-
const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived);
|
|
699
|
+
const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived, false, undefined, snapshot.extensions);
|
|
615
700
|
|
|
616
701
|
const header = importHeader([...blocks, footer].join('\n\n'), opts.derived?.imports ?? []);
|
|
617
702
|
|
|
@@ -626,8 +711,18 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
|
|
|
626
711
|
* NAMING CONTRACT — changing this rule is a BREAKING CHANGE: index.ts and cross-file
|
|
627
712
|
* FK imports reference these paths. Pinned by naming-contract.test.ts.
|
|
628
713
|
*/
|
|
714
|
+
/** The import specifier a barrel (or a sibling file) uses for a model — the FILENAME rule
|
|
715
|
+
* minus the extension. Derived from modelFileName so the two can never disagree; they did,
|
|
716
|
+
* and a multi-schema pull emitted `import … from './refresh-tokens'` next to a file called
|
|
717
|
+
* `auth-refresh-tokens.ts`. */
|
|
718
|
+
export function modelImportPath(table: string): string {
|
|
719
|
+
return `./${modelFileName(table).replace(/\.ts$/, '')}`;
|
|
720
|
+
}
|
|
721
|
+
|
|
629
722
|
export function modelFileName(table: string): string {
|
|
630
|
-
|
|
723
|
+
const owner = schemaOf(table);
|
|
724
|
+
const bare = bareName(table).replace(/_/g, '-');
|
|
725
|
+
return owner === 'public' ? `${bare}.ts` : `${owner.replace(/_/g, '-')}-${bare}.ts`;
|
|
631
726
|
}
|
|
632
727
|
|
|
633
728
|
/** The bare tables (other than itself) a table's rendered FKs reference within the pulled set. */
|
|
@@ -662,9 +757,9 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
662
757
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
663
758
|
|
|
664
759
|
const files: RenderedModelFile[] = tables.map((t) => {
|
|
665
|
-
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz);
|
|
760
|
+
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles));
|
|
666
761
|
const crossImports = referencedTables(t, known).map(
|
|
667
|
-
(target) => `import { ${modelVarName(target)} } from '
|
|
762
|
+
(target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
|
|
668
763
|
);
|
|
669
764
|
const header = [importHeader(block), ...crossImports].join('\n');
|
|
670
765
|
return { file: modelFileName(t.table), source: `${header}\n\n${block}\n` };
|
|
@@ -683,13 +778,13 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
683
778
|
// The --derived-out layer, imported rather than re-declared — the barrel must WIRE it
|
|
684
779
|
// or db:plan silently compares against models only.
|
|
685
780
|
...(extNames.length ? [`import { ${extNames.join(', ')} } from '${ext!.specifier}';`] : []),
|
|
686
|
-
...names.map((n, i) => `import { ${n} } from '
|
|
781
|
+
...names.map((n, i) => `import { ${n} } from '${modelImportPath(tables[i].table)}';`),
|
|
687
782
|
].join('\n'),
|
|
688
783
|
`export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
|
|
689
784
|
// Re-exported so the barrel remains the one place that describes the database.
|
|
690
785
|
...(extNames.length ? [`export { ${extNames.join(', ')} };`] : []),
|
|
691
786
|
...(opts.derived?.block ? [opts.derived.block] : []),
|
|
692
|
-
moduleFooter(names, opts.derived, true, ext),
|
|
787
|
+
moduleFooter(names, opts.derived, true, ext, snapshot.extensions),
|
|
693
788
|
].join('\n\n');
|
|
694
789
|
files.push({ file: 'index.ts', source: index + '\n' });
|
|
695
790
|
|
|
@@ -105,6 +105,8 @@ export interface SchemaSnapshot {
|
|
|
105
105
|
enums?: EnumType[];
|
|
106
106
|
/** Standalone sequences (serial-owned ones excluded). Absent/empty when none. */
|
|
107
107
|
sequences?: SequenceSchema[];
|
|
108
|
+
/** Installed extensions, `plpgsql` excluded. Absent when the read predates this. */
|
|
109
|
+
extensions?: string[];
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
// ---------------------------------------------------------------------------
|
|
@@ -216,6 +218,27 @@ ORDER BY n.nspname, t.typname;
|
|
|
216
218
|
* has an 'a'/'i' pg_depend link to its column and stays represented BY that column).
|
|
217
219
|
* These hold state, so they are base-schema: migrated, diffed, fingerprinted.
|
|
218
220
|
*/
|
|
221
|
+
/**
|
|
222
|
+
* Installed extensions — the bootstrap a declared state needs before anything else.
|
|
223
|
+
*
|
|
224
|
+
* Database-wide, not per-schema: `pg_extension` has no schema filter worth applying, and an
|
|
225
|
+
* extension installed into `public` is just as required by a model in `auth`. So this read
|
|
226
|
+
* ignores `--schema` deliberately.
|
|
227
|
+
*
|
|
228
|
+
* `plpgsql` is excluded because PostgreSQL installs it into every database by default —
|
|
229
|
+
* emitting `CREATE EXTENSION IF NOT EXISTS plpgsql` is noise, not bootstrap.
|
|
230
|
+
*
|
|
231
|
+
* Without this, a declared state that renders `field.pgType('hstore')` compiles to a CREATE
|
|
232
|
+
* TABLE whose type does not exist, and `db:check`'s from-scratch ring fails with
|
|
233
|
+
* `type "hstore" does not exist` — the state describes a database it cannot build.
|
|
234
|
+
*/
|
|
235
|
+
export const EXTENSIONS_SQL = `
|
|
236
|
+
SELECT extname AS name
|
|
237
|
+
FROM pg_extension
|
|
238
|
+
WHERE extname <> 'plpgsql'
|
|
239
|
+
ORDER BY extname;
|
|
240
|
+
`.trim();
|
|
241
|
+
|
|
219
242
|
export const SEQUENCES_SQL = `
|
|
220
243
|
SELECT
|
|
221
244
|
n.nspname AS schema,
|
|
@@ -540,6 +563,7 @@ export interface SchemaRows {
|
|
|
540
563
|
enums?: EnumRow[];
|
|
541
564
|
indexes?: IndexRow[];
|
|
542
565
|
sequences?: SequenceRow[];
|
|
566
|
+
extensions?: ExtensionRow[];
|
|
543
567
|
}
|
|
544
568
|
|
|
545
569
|
/**
|
|
@@ -618,10 +642,18 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
|
|
|
618
642
|
.map(sequenceRowToDescriptor)
|
|
619
643
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
620
644
|
|
|
645
|
+
// ABSENT vs EMPTY matters: a caller that did not run the extensions query leaves the key
|
|
646
|
+
// off entirely, and the renderer must not read that as "this database has none" and emit
|
|
647
|
+
// an empty list that later looks authoritative.
|
|
648
|
+
const extensions = rows.extensions === undefined
|
|
649
|
+
? undefined
|
|
650
|
+
: rows.extensions.map((r) => r.name).sort();
|
|
651
|
+
|
|
621
652
|
return {
|
|
622
653
|
tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
|
|
623
654
|
enums,
|
|
624
655
|
...(sequences.length ? { sequences } : {}),
|
|
656
|
+
...(extensions !== undefined ? { extensions } : {}),
|
|
625
657
|
};
|
|
626
658
|
}
|
|
627
659
|
|
|
@@ -631,13 +663,15 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
|
|
|
631
663
|
* (the ops Lambda `db:query` in production, a fake in tests) and folds the rows with
|
|
632
664
|
* `assembleSchema`. This is the `current` side `db:generate` diffs the compiled Models against.
|
|
633
665
|
*/
|
|
666
|
+
export interface ExtensionRow { name: string }
|
|
667
|
+
|
|
634
668
|
export async function introspectSchema(session: SessionRunner): Promise<SchemaSnapshot> {
|
|
635
669
|
// ONE session, so all five queries see one database at one moment, under one pinned
|
|
636
670
|
// search_path. Column defaults, CHECK constraints and index expressions all deparse
|
|
637
671
|
// relative to that path — spread across separate connections these five rows can render
|
|
638
672
|
// the same schema two ways, and a fingerprint minted from the mix describes nothing.
|
|
639
|
-
const [columns, constraints, enums, indexes, sequences] = await session(
|
|
640
|
-
[COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL],
|
|
673
|
+
const [columns, constraints, enums, indexes, sequences, extensions] = await session(
|
|
674
|
+
[COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL],
|
|
641
675
|
INTROSPECTION_SESSION,
|
|
642
676
|
);
|
|
643
677
|
return assembleSchema({
|
|
@@ -646,5 +680,6 @@ export async function introspectSchema(session: SessionRunner): Promise<SchemaSn
|
|
|
646
680
|
enums: enums as EnumRow[],
|
|
647
681
|
indexes: indexes as IndexRow[],
|
|
648
682
|
sequences: sequences as SequenceRow[],
|
|
683
|
+
extensions: extensions as ExtensionRow[],
|
|
649
684
|
});
|
|
650
685
|
}
|