@everystack/cli 0.4.99 → 0.4.102
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 +105 -0
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +15 -1
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +61 -7
- package/src/cli/authz-contract.ts +23 -14
- package/src/cli/authz-derive.ts +282 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/catalog-rename.ts +115 -0
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +30 -13
- package/src/cli/commands/db-pull.ts +153 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +4 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +57 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +721 -0
- package/src/cli/lifecycle-pull.ts +177 -0
- package/src/cli/lifecycle-verify.ts +232 -0
- package/src/cli/migration-compile.ts +8 -3
- package/src/cli/migration-generate.ts +23 -3
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +243 -77
- package/src/cli/plain-output.ts +198 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +57 -18
- package/src/cli/schema-diff.ts +184 -41
- package/src/cli/schema-fingerprint.ts +39 -6
- package/src/cli/schema-introspect.ts +66 -2
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- package/src/cli/ts-template.ts +52 -0
|
@@ -34,6 +34,7 @@ import type { ModelDescriptor, DerivedDescriptor } from '@everystack/model';
|
|
|
34
34
|
import { compileDeclaredState } from '../declared-diff';
|
|
35
35
|
import { compileMigration } from '../migration-compile';
|
|
36
36
|
import { compileDrizzleSource } from '../schema-source';
|
|
37
|
+
import { modelMapPath, renderModelMap } from '../model-map';
|
|
37
38
|
import { currentGitRef } from '../state-apply';
|
|
38
39
|
import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build';
|
|
39
40
|
import { resolveModelsPath } from '../models-path';
|
|
@@ -115,6 +116,15 @@ export interface StaticCheckInput {
|
|
|
115
116
|
/** Raw `db/authz-baseline.json`, or null when there is none. The ARTIFACT half of the
|
|
116
117
|
* ungoverned-grantee gate — see the check below for what it deliberately does not do. */
|
|
117
118
|
baselineSource?: string | null;
|
|
119
|
+
/** Where the model map lives: beside `artifactPath` (model-map.ts `modelMapPath`). */
|
|
120
|
+
modelMapPath?: string;
|
|
121
|
+
/**
|
|
122
|
+
* The committed model map, read raw. null = no file there (a NOTE, like the schema artifact at
|
|
123
|
+
* its default path: an app that upgraded and has not run db:generate yet is not broken; the
|
|
124
|
+
* map's path is never named on its own, so its absence is never a claim that vanished).
|
|
125
|
+
* Absent (undefined) = the caller did not read it (older callers): no map finding at all.
|
|
126
|
+
*/
|
|
127
|
+
modelMapSource?: string | null;
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
@@ -303,6 +313,27 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
303
313
|
});
|
|
304
314
|
}
|
|
305
315
|
|
|
316
|
+
// F8 — the model map, the same regenerate-and-compare gate as the drizzle schema. Pure: the
|
|
317
|
+
// map is a function of the models, so nothing here reads a database (T2).
|
|
318
|
+
if (input.modelMapSource !== undefined) {
|
|
319
|
+
const mapPath = input.modelMapPath ?? modelMapPath(input.artifactPath);
|
|
320
|
+
if (input.modelMapSource === null) {
|
|
321
|
+
findings.push({
|
|
322
|
+
level: 'note',
|
|
323
|
+
area: 'artifact',
|
|
324
|
+
message: `no model map at ${mapPath} — nothing to match (\`everystack db:generate\` writes it beside the drizzle schema)`,
|
|
325
|
+
});
|
|
326
|
+
} else if (input.modelMapSource === renderModelMap(input.models)) {
|
|
327
|
+
findings.push({ level: 'ok', area: 'artifact', message: `${mapPath} matches regeneration byte-for-byte` });
|
|
328
|
+
} else {
|
|
329
|
+
findings.push({
|
|
330
|
+
level: 'fail',
|
|
331
|
+
area: 'artifact',
|
|
332
|
+
message: `${mapPath} does NOT match regeneration — the models moved without db:generate, or the artifact was hand-edited (generated artifacts are never edited). Run \`everystack db:generate\` (it rewrites the map from the models before it reads any database) and commit ${mapPath}.`,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
306
337
|
// B7 — db/sql is retired. Files still present would be silently unmanaged: fail
|
|
307
338
|
// with the migration path. (Absence is the normal state; nothing to report.)
|
|
308
339
|
if (input.sqlDirRetired) {
|
|
@@ -407,6 +438,14 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
407
438
|
} catch {
|
|
408
439
|
artifactSource = null;
|
|
409
440
|
}
|
|
441
|
+
// F8 — the model map lives beside the drizzle schema, so it follows the same resolution.
|
|
442
|
+
const mapPath = modelMapPath(artifactPath);
|
|
443
|
+
let modelMapSource: string | null = null;
|
|
444
|
+
try {
|
|
445
|
+
modelMapSource = await fs.readFile(mapPath, 'utf8');
|
|
446
|
+
} catch {
|
|
447
|
+
modelMapSource = null;
|
|
448
|
+
}
|
|
410
449
|
// B7 tombstone — a retired db/sql directory still carrying .sql files is a FAIL
|
|
411
450
|
// finding (the message carries the migration path), never a silent skip. The retired
|
|
412
451
|
// --sql-dir flag is itself a FAIL finding here (the sibling verbs refuse it outright);
|
|
@@ -438,6 +477,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
438
477
|
sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
|
|
439
478
|
moduleModels: declaredDb?.models ?? null,
|
|
440
479
|
baselineSource,
|
|
480
|
+
modelMapPath: mapPath, modelMapSource,
|
|
441
481
|
});
|
|
442
482
|
// Per-finding detail is the --verbose report. The quiet default answers the one question a
|
|
443
483
|
// human asked — is anything pending? — and names the verb that shows it.
|
|
@@ -25,7 +25,8 @@ import { pathToFileURL } from 'node:url';
|
|
|
25
25
|
import type { ModelDescriptor, Module } from '@everystack/model';
|
|
26
26
|
import { linkModels } from '@everystack/model';
|
|
27
27
|
import { introspectSchema } from '../schema-introspect';
|
|
28
|
-
import {
|
|
28
|
+
import { writeGeneratedArtifacts } from '../generated-artifacts';
|
|
29
|
+
import { modelMapPath } from '../model-map';
|
|
29
30
|
import { compileModuleMigration } from '../migration-compile';
|
|
30
31
|
import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, resolveSchemaOut, HELD_DROP_PREFIX, type Journal } from '../migration-generate';
|
|
31
32
|
import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract';
|
|
@@ -103,8 +104,8 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
|
|
|
103
104
|
const models = modules.flatMap((m) => m.models);
|
|
104
105
|
info(`${modules.length} module(s), ${models.length} model(s).`);
|
|
105
106
|
|
|
106
|
-
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
107
|
-
await
|
|
107
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)} and the model map → ${path.relative(process.cwd(), modelMapPath(schemaOut))}...`);
|
|
108
|
+
await writeGeneratedArtifacts(schemaOut, models, modules.flatMap((m) => m.derived));
|
|
108
109
|
|
|
109
110
|
const statements = compileModuleMigration(modules);
|
|
110
111
|
|
|
@@ -252,13 +253,13 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
252
253
|
models = await loadModels(modelsPath);
|
|
253
254
|
declaredDb = await loadDeclaredDerived(flags.models);
|
|
254
255
|
info(`${models.length} model(s).`);
|
|
255
|
-
// The drizzle runtime schema
|
|
256
|
-
// every run (
|
|
257
|
-
// updates
|
|
258
|
-
// typed derived relations — B6a). A dry run refreshes NOTHING
|
|
256
|
+
// The drizzle runtime schema and the model map are derived artifacts — refresh them
|
|
257
|
+
// from the models every run (they need no DB), so a relation-only change with no DDL
|
|
258
|
+
// diff still updates them. db:check asserts both match the models byte for byte (the
|
|
259
|
+
// schema with the typed derived relations — B6a). A dry run refreshes NOTHING.
|
|
259
260
|
if (!dryRun) {
|
|
260
|
-
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
261
|
-
await
|
|
261
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)} and the model map → ${path.relative(process.cwd(), modelMapPath(schemaOut))}...`);
|
|
262
|
+
await writeGeneratedArtifacts(schemaOut, models, declaredDb?.derived);
|
|
262
263
|
}
|
|
263
264
|
const dbSource: DbSource = resolveDbSource(flags);
|
|
264
265
|
if (apply && dbSource.kind !== 'url') {
|
|
@@ -66,7 +66,7 @@ import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
|
|
|
66
66
|
import { loadModels } from './db-generate';
|
|
67
67
|
import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived';
|
|
68
68
|
import { introspectDerived } from '../derived-introspect';
|
|
69
|
-
import { planReconcile } from '../derived-plan';
|
|
69
|
+
import { planReconcile, type ReconcilePlan } from '../derived-plan';
|
|
70
70
|
import { checkFails, buildReconcileReport } from './db-reconcile';
|
|
71
71
|
import { governedRoleSet, ungovernedGrants } from '../authz-reconcile';
|
|
72
72
|
import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline';
|
|
@@ -462,27 +462,41 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
-
// DERIVED-LAYER
|
|
465
|
+
// DERIVED-LAYER PREVIEW — db:apply ALWAYS reconciles functions/views/matviews/triggers
|
|
466
466
|
// against the models, whether or not a --plan is passed; the edge this command mints never
|
|
467
|
-
// covers that layer.
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
467
|
+
// covers that layer. Those objects can enforce access (a SECURITY DEFINER function, a
|
|
468
|
+
// trigger that turns a DELETE into a soft delete), so the reviewer is shown each one in the
|
|
469
|
+
// default report below — not a count. Read-only: the same pure planReconcile() that backs
|
|
470
|
+
// `db:reconcile --check`, with the same declared objects and options db:apply's catch-up
|
|
471
|
+
// passes (derived-catchup.ts), over the session this mint already holds.
|
|
472
|
+
//
|
|
473
|
+
// NOT IN THE PLAN FILE, deliberately: db:apply re-plans this layer from the checkout at
|
|
474
|
+
// apply time and never reads it from the file, and it can go stale between mint and apply
|
|
475
|
+
// (see the header's rationale for why nothing read-time-only belongs in the artifact).
|
|
476
|
+
// Adding it would also move planHash for every such plan. The report header names the file
|
|
477
|
+
// so it is never read as the whole change.
|
|
478
|
+
let derivedPlan: ReconcilePlan | undefined;
|
|
474
479
|
if (declaredDerived) {
|
|
475
480
|
try {
|
|
476
481
|
const liveDerived = await introspectDerived(session);
|
|
477
|
-
|
|
482
|
+
derivedPlan = planReconcile(
|
|
478
483
|
{ objects: declaredDerived.objects, warnings: [] },
|
|
479
484
|
liveDerived,
|
|
480
485
|
{ renamedTables: declaredDerived.renamedTables },
|
|
481
486
|
);
|
|
482
487
|
if (checkFails(derivedPlan)) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
488
|
+
// The objects themselves are change lines in the report. What is left here are the
|
|
489
|
+
// problems (`!` lines: drift, blocked chains, objects that need a baseline) — they
|
|
490
|
+
// stay in the quiet default, as the ownership report's do. --verbose keeps the whole
|
|
491
|
+
// internal report.
|
|
492
|
+
const report = buildReconcileReport(derivedPlan);
|
|
493
|
+
const shown = verbose ? report : report.filter((line) => line.startsWith('!'));
|
|
494
|
+
if (shown.length > 0) {
|
|
495
|
+
warn(verbose
|
|
496
|
+
? 'functions/views/matviews/triggers — the reconcile db:apply runs after the plan:'
|
|
497
|
+
: 'functions/views/matviews/triggers — problems db:apply will report (drift or a blocked chain refuses the apply):');
|
|
498
|
+
for (const line of shown) warn(` ${line}`);
|
|
499
|
+
}
|
|
486
500
|
}
|
|
487
501
|
} catch (err: any) {
|
|
488
502
|
warn(`derived-layer check could not run against this target (${err?.message ?? err}) — db:plan cannot say whether functions/views/matviews/triggers are in sync.`);
|
|
@@ -508,6 +522,9 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
508
522
|
stage: flags.stage ?? null,
|
|
509
523
|
planPath: out === '-' ? null : out,
|
|
510
524
|
verbose,
|
|
525
|
+
// Only when the checkout declares objects: db:apply's catch-up does nothing at all for a
|
|
526
|
+
// checkout that declares none (derived-catchup.ts), so listing drops there would be untrue.
|
|
527
|
+
derived: declaredDerived && declaredDerived.objects.length > 0 ? derivedPlan : undefined,
|
|
511
528
|
})) printLine(text);
|
|
512
529
|
|
|
513
530
|
if (out !== '-') {
|
|
@@ -43,11 +43,14 @@
|
|
|
43
43
|
|
|
44
44
|
import fs from 'node:fs/promises';
|
|
45
45
|
import path from 'node:path';
|
|
46
|
-
import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect';
|
|
46
|
+
import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, FK_TARGETS_SQL, foreignKeyTargets, type ColumnRow, type ColumnSchema, type ForeignKeyTargetRow } from '../schema-introspect';
|
|
47
47
|
import { fingerprintLive } from '../schema-fingerprint';
|
|
48
48
|
import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile';
|
|
49
49
|
import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline';
|
|
50
50
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect';
|
|
51
|
+
import { foldLifecycle } from '../lifecycle-pull';
|
|
52
|
+
import { verifyLifecycle, LifecycleDriftError } from '../lifecycle-verify';
|
|
53
|
+
import { LIFECYCLE_OWNER_ROLE } from '@everystack/model';
|
|
51
54
|
|
|
52
55
|
/**
|
|
53
56
|
* The on-ramp runbook, printed last so the operator is never left guessing what a pull is FOR.
|
|
@@ -81,8 +84,9 @@ export function pullOnRampRunbook(opts: { stage?: string; out?: string; abilitie
|
|
|
81
84
|
import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract';
|
|
82
85
|
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership';
|
|
83
86
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
84
|
-
import { renderDerivedSource, renderDerivedFile, renderDerivedDirectory, type DerivedRenderResult } from '../derived-render';
|
|
85
|
-
import {
|
|
87
|
+
import { renderDerivedSource, renderDerivedFile, renderDerivedDirectory, type DerivedRenderResult, type RenderedDerivedFile } from '../derived-render';
|
|
88
|
+
import { containedPath } from '../generated-names';
|
|
89
|
+
import { renderModelSource, renderModelFiles, pullableTables, skippedInfrastructureTables, modelVarName, externalForeignKeyWarnings, ABILITY_PRESETS, type RenderedModelFile } from '../model-render';
|
|
86
90
|
import { deriveAbilities } from '../authz-derive';
|
|
87
91
|
import type { QueryRunner } from '../authz-contract';
|
|
88
92
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
|
|
@@ -193,12 +197,14 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
193
197
|
process.exit(1);
|
|
194
198
|
}
|
|
195
199
|
|
|
196
|
-
// --govern-roles: the operator's EXPLICIT decision to
|
|
197
|
-
// grants
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
200
|
+
// --govern-roles: the operator's EXPLICIT decision to govern these foreign roles: their
|
|
201
|
+
// grants and policies render into the models (role-scoped can() where they pair exactly,
|
|
202
|
+
// `privileges` otherwise) and the module declares them in `governedRoles`. Required for
|
|
203
|
+
// migration deletion when the migrations created grants to roles outside the vocabulary: a
|
|
204
|
+
// fresh build from models must recreate them. Never inferred from the database (a re-pull
|
|
205
|
+
// must not turn a rendering decision into an access decision); complete-or-refuse per role —
|
|
206
|
+
// a column-scoped grant with no exactly-paired policy, or a restrictive policy, fails the
|
|
207
|
+
// pull (see deriveAbilities).
|
|
202
208
|
const governRoles = (flags['govern-roles'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
203
209
|
if (governRoles.length && abilities !== 'live') {
|
|
204
210
|
fail(`--govern-roles transcribes LIVE grants, so it requires --abilities live.`);
|
|
@@ -209,6 +215,12 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
209
215
|
fail(`--govern-roles ${r}: the vocabulary roles and PUBLIC are always governed — name only foreign roles (an ops/connection role the migrations granted to).`);
|
|
210
216
|
process.exit(1);
|
|
211
217
|
}
|
|
218
|
+
// The name is written into generated source (`can(..., { role: '<r>' })`) and the compiler
|
|
219
|
+
// emits it unquoted into GRANT/CREATE POLICY, which accepts only a bare lowercase identifier.
|
|
220
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(r)) {
|
|
221
|
+
fail(`--govern-roles ${JSON.stringify(r)}: a governed role must be a bare lowercase identifier ([a-z_][a-z0-9_]*) — the compiler emits it unquoted.`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
212
224
|
}
|
|
213
225
|
|
|
214
226
|
let dbSource: DbSource;
|
|
@@ -235,12 +247,16 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
235
247
|
let liveContract: AuthzContract | undefined;
|
|
236
248
|
let liveOwners: TableOwner[] = [];
|
|
237
249
|
let derivedCatalog: DerivedCatalog | undefined;
|
|
250
|
+
/** Tables folded to `lifecycle: true` (lifecycle-pull.ts). */
|
|
251
|
+
let lifecycleTables: ReadonlySet<string> = new Set();
|
|
238
252
|
let matviewColumns: Map<string, ColumnSchema[]> | undefined;
|
|
239
253
|
let candidatesByIdentity: Map<string, string[]> | undefined;
|
|
254
|
+
/** Each FK's exact target, by confrelid: an external target is declared from these names. */
|
|
255
|
+
let fkTargets: Map<string, { schema: string; table: string }> = new Map();
|
|
256
|
+
let end: (() => Promise<void>) | undefined;
|
|
240
257
|
try {
|
|
241
258
|
let runner: QueryRunner;
|
|
242
259
|
let session: SessionRunner;
|
|
243
|
-
let end: (() => Promise<void>) | undefined;
|
|
244
260
|
if (dbSource.kind === 'url') {
|
|
245
261
|
note(connectingVia(dbSource));
|
|
246
262
|
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
@@ -253,16 +269,30 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
253
269
|
}
|
|
254
270
|
note(`Introspecting live database (schema: ${schemas.join(", ")})...`);
|
|
255
271
|
current = await introspectSchema(session);
|
|
272
|
+
fkTargets = foreignKeyTargets((await runner(FK_TARGETS_SQL)) as ForeignKeyTargetRow[]);
|
|
256
273
|
// The derived layer rides the same pull (B5) — views/matviews/functions/sequences
|
|
257
274
|
// render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
|
|
258
275
|
derivedCatalog = await introspectDerived(session);
|
|
276
|
+
const lifecycle = await verifyLifecycle(session, ['anon', 'authenticated', 'admin', LIFECYCLE_OWNER_ROLE, ...governRoles]);
|
|
277
|
+
if (lifecycle.failures.length) throw new LifecycleDriftError(lifecycle.failures);
|
|
278
|
+
for (const warning of lifecycle.notes) caution(warning);
|
|
259
279
|
// --abilities live: the authz half of the on-ramp. Same connection, one more read —
|
|
260
280
|
// the grants and policies that already exist become the models' declared abilities,
|
|
261
281
|
// instead of a human transcribing them by hand.
|
|
262
282
|
if (abilities === 'live') {
|
|
263
283
|
note('Introspecting live authorization (grants + policies)...');
|
|
264
|
-
const
|
|
265
|
-
|
|
284
|
+
const rawSnapshot = current;
|
|
285
|
+
const rawContract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
286
|
+
// A lifecycle table folds back to `lifecycle: true` here only: proving the table is exactly
|
|
287
|
+
// what the compiler writes needs its grants and policies. Only the renderers and the adoption
|
|
288
|
+
// findings see the folded inputs; the fingerprint below is the raw one.
|
|
289
|
+
const fold = foldLifecycle(current, rawContract, derivedCatalog);
|
|
290
|
+
current = fold.snapshot;
|
|
291
|
+
derivedCatalog = fold.catalog;
|
|
292
|
+
lifecycleTables = fold.tables;
|
|
293
|
+
if (lifecycleTables.size) note(`Lifecycle: ${[...lifecycleTables].sort().join(', ')} rendered as lifecycle: true (generated columns, grants and functions folded).`);
|
|
294
|
+
const contract = fold.contract;
|
|
295
|
+
liveContract = rawContract;
|
|
266
296
|
// The owner is NOT rendered into the models — it is the dev user here and the
|
|
267
297
|
// operator role on a stage, so a model that declared it would drift everywhere.
|
|
268
298
|
// It is read to be SAID, on the one surface that is looking at the database.
|
|
@@ -272,18 +302,32 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
272
302
|
// Roles outside the model vocabulary (anon/authenticated/admin) are usually ONE
|
|
273
303
|
// operator account granted across the whole schema. Said once here rather than
|
|
274
304
|
// repeated in all N models, where it would bury the per-table findings.
|
|
305
|
+
// A folded lifecycle table makes the pulled module govern the reserved role (moduleGovernedRoles),
|
|
306
|
+
// so db:plan governs it on EVERY table: its grants on a table that did not fold are revoked.
|
|
307
|
+
const governsLifecycle = lifecycleTables.size > 0;
|
|
275
308
|
const unmapped = new Set<string>();
|
|
276
309
|
for (const t of contract.tables) {
|
|
277
310
|
for (const r of Object.keys(t.grants)) {
|
|
311
|
+
if (governsLifecycle && r === LIFECYCLE_OWNER_ROLE) continue;
|
|
278
312
|
if (!['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r) && !governRoles.includes(r)) unmapped.add(r);
|
|
279
313
|
}
|
|
280
314
|
}
|
|
315
|
+
// Said whether or not anything folded: with no fold the role is not governed, and its grants
|
|
316
|
+
// would otherwise pass into the adoption baseline under a generic "Grants exist" note.
|
|
317
|
+
const unfoldedLifecycleGrants = contract.tables
|
|
318
|
+
.filter((t) => t.grants[LIFECYCLE_OWNER_ROLE] || t.columnGrants?.[LIFECYCLE_OWNER_ROLE]).map((t) => t.table).sort();
|
|
319
|
+
if (unfoldedLifecycleGrants.length) {
|
|
320
|
+
const unfolded = `${LIFECYCLE_OWNER_ROLE} holds grants on ${unfoldedLifecycleGrants.join(', ')}, which did not fold to lifecycle: true (not exactly what the compiler writes).`;
|
|
321
|
+
caution(governsLifecycle
|
|
322
|
+
? `${unfolded} The pulled module governs that role, so db:plan will revoke them.`
|
|
323
|
+
: `${unfolded} No table folded, so the pulled module does not govern that role: those grants are recorded as adoption exemptions and left as they are.`);
|
|
324
|
+
}
|
|
281
325
|
if (governRoles.length) {
|
|
282
|
-
note(`--govern-roles ${governRoles.join(', ')}:
|
|
326
|
+
note(`--govern-roles ${governRoles.join(', ')}: the models now OWN these roles (defineModule governedRoles) — a grant and its policy render as a role-scoped can() where they pair exactly, any other grant as privileges; a fresh build recreates them.`);
|
|
283
327
|
}
|
|
284
328
|
if (unmapped.size) {
|
|
285
329
|
note(`Grants exist for ${[...unmapped].sort().join(', ')} — not rendered: abilities name the roles the model knows (anon/authenticated/admin).`);
|
|
286
|
-
detail(`These are left exactly as they are in the database. Add can(..., { role }) to own them, or re-pull with --govern-roles to
|
|
330
|
+
detail(`These are left exactly as they are in the database. Add can(..., { role }) to own them, or re-pull with --govern-roles to govern them.`);
|
|
287
331
|
}
|
|
288
332
|
// ADOPTION: record the foreign grantees that were already here, per stage. The
|
|
289
333
|
// reconciler exempts them from revocation, so this artifact is what stops that
|
|
@@ -294,15 +338,16 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
294
338
|
// The governed set at ADOPTION is the fixed vocabulary plus any --govern-roles: a
|
|
295
339
|
// governed role's grants are DECLARED (transcribed as privileges), so recording them
|
|
296
340
|
// as exemptions too would double-book them — declared and exempted at once.
|
|
297
|
-
|
|
341
|
+
const governedAtPull = new Set<string>([...ALWAYS_GOVERNED, ...governRoles, ...(governsLifecycle ? [LIFECYCLE_OWNER_ROLE] : [])]);
|
|
342
|
+
pulledExemptions = ungovernedGrants(contract, governedAtPull);
|
|
298
343
|
// A6's advisory. A blanket `GRANT USAGE ON ALL SEQUENCES` beside a blanket SELECT is a
|
|
299
344
|
// common brownfield shape. The advisory says exactly what the next db:plan will do with it,
|
|
300
|
-
// so it must see the SAME governed set the plan will use: the vocabulary plus --govern-roles
|
|
301
|
-
// (With the default set, a --govern-roles grantee
|
|
302
|
-
// it" while the plan minted the REVOKE — closing-gate
|
|
303
|
-
|
|
345
|
+
// so it must see the SAME governed set the plan will use: the vocabulary plus --govern-roles,
|
|
346
|
+
// plus the reserved role when a table folded. (With the default set, a --govern-roles grantee
|
|
347
|
+
// was told "not governed, nothing revokes it" while the plan minted the REVOKE — closing-gate
|
|
348
|
+
// review, 2026-09-07.)
|
|
304
349
|
for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract), governedAtPull)) caution(line);
|
|
305
|
-
pulledFingerprint = fingerprintLive(
|
|
350
|
+
pulledFingerprint = fingerprintLive(rawSnapshot, rawContract).hash;
|
|
306
351
|
}
|
|
307
352
|
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
308
353
|
// matview columns the derived layer (definition-only) doesn't carry.
|
|
@@ -324,6 +369,11 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
324
369
|
}
|
|
325
370
|
await end?.();
|
|
326
371
|
} catch (err: any) {
|
|
372
|
+
await end?.().catch(() => {});
|
|
373
|
+
if (err instanceof LifecycleDriftError) {
|
|
374
|
+
for (const line of err.lines) console.error(line);
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
327
377
|
fail(err.message);
|
|
328
378
|
if (dbSource.kind === 'url') {
|
|
329
379
|
detail('Check the connection string and that the database is accepting connections.');
|
|
@@ -359,18 +409,27 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
359
409
|
// The --govern-roles complete-or-refuse gate, run BEFORE any file is written: a refusal
|
|
360
410
|
// mid-render would leave a half-written models directory. Pure re-derivation, pulled
|
|
361
411
|
// tables only — a governed role's column grant on an UNPULLED table is safe (that table
|
|
362
|
-
// is not declared, so nothing reconciles it).
|
|
412
|
+
// is not declared, so nothing reconciles it). The same pass prints every governed grant or
|
|
413
|
+
// policy can() could not state exactly: it is never silently approximated.
|
|
363
414
|
if (governRoles.length && liveAuthz) {
|
|
364
415
|
const pulledNames = new Set(pulled.map((t) => t.table));
|
|
416
|
+
const warnings: string[] = [];
|
|
365
417
|
try {
|
|
366
418
|
const govern = new Set(governRoles);
|
|
367
|
-
for (const [name, c] of liveAuthz)
|
|
419
|
+
for (const [name, c] of liveAuthz) {
|
|
420
|
+
if (pulledNames.has(name)) warnings.push(...deriveAbilities(c, { governRoles: govern }).warnings);
|
|
421
|
+
}
|
|
368
422
|
} catch (err: any) {
|
|
369
423
|
fail(err.message);
|
|
370
424
|
process.exit(1);
|
|
371
425
|
}
|
|
426
|
+
for (const w of warnings) caution(`WARNING: ${w}`);
|
|
372
427
|
}
|
|
373
428
|
|
|
429
|
+
// F3e — an FK out of the pulled set is declared in the external form; one that CASCADES on
|
|
430
|
+
// delete means a delete in a table this declared state does not govern removes rows here.
|
|
431
|
+
for (const w of externalForeignKeyWarnings(current, schema, fkTargets)) caution(`WARNING: ${w}`);
|
|
432
|
+
|
|
374
433
|
// WHO owns the tables being pulled. Nothing is FLAGGED here: flagging needs a declared
|
|
375
434
|
// write principal to contradict, and the models this pull is about to write do not exist
|
|
376
435
|
// yet. Naming the owner is the half the pull genuinely saw — and the half that vanishes
|
|
@@ -381,20 +440,68 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
381
440
|
)) note(line);
|
|
382
441
|
}
|
|
383
442
|
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
443
|
+
// RENDER EVERYTHING, THEN WRITE. A live name with no safe spelling as a generated identifier
|
|
444
|
+
// or file name (generated-names.ts) refuses inside a renderer; rendering every output first,
|
|
445
|
+
// and resolving every directory write target (containedPath, the second wall), means that a
|
|
446
|
+
// refusal leaves --out and --derived-out exactly as they were.
|
|
447
|
+
let knownTables: Map<string, string>;
|
|
448
|
+
let derived: DerivedRenderResult;
|
|
449
|
+
let derivedFiles: Array<RenderedDerivedFile & { target: string }> | undefined;
|
|
450
|
+
let derivedFileSource: string | undefined;
|
|
451
|
+
let modelFiles: Array<RenderedModelFile & { target: string }> | undefined;
|
|
452
|
+
let modelSource: string | undefined;
|
|
453
|
+
try {
|
|
454
|
+
// The derived layer (B5): rendered from the live catalog with dependsOn from the real
|
|
455
|
+
// edge graph. Everything inexpressible is a stderr warning AND an inline FIXME.
|
|
456
|
+
knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
|
|
457
|
+
derived = renderDerivedSource(derivedCatalog ?? { objects: [], edges: [], provenance: [] }, {
|
|
458
|
+
knownTables,
|
|
459
|
+
sequences: current.sequences,
|
|
460
|
+
...(matviewsAsTables ? {
|
|
461
|
+
matviewsAsTables: {
|
|
462
|
+
columns: matviewColumns ?? new Map(),
|
|
463
|
+
enums: new Map((current.enums ?? []).map((e) => [e.name, e.values])),
|
|
464
|
+
...(candidatesByIdentity ? { keyCandidates: candidatesByIdentity } : {}),
|
|
465
|
+
},
|
|
466
|
+
} : {}),
|
|
467
|
+
});
|
|
468
|
+
if (derivedOut && derivedOutIsDir) {
|
|
469
|
+
// A cycle between split files is fatal at import time (TDZ) — refused here, before
|
|
470
|
+
// anything is written, rather than shipping a barrel nobody can import.
|
|
471
|
+
const outPath = path.resolve(derivedOut);
|
|
472
|
+
derivedFiles = renderDerivedDirectory(derived, knownTables, outPath)
|
|
473
|
+
.map((f) => ({ ...f, target: containedPath(outPath, f.file) }));
|
|
474
|
+
} else if (derivedOut) {
|
|
475
|
+
derivedFileSource = renderDerivedFile(derived, knownTables);
|
|
476
|
+
}
|
|
477
|
+
// The models render unless --derived-out alone was asked for (that path writes no models).
|
|
478
|
+
if (!derivedOut || flags.out) {
|
|
479
|
+
// With --derived-out, the embedded copy would duplicate every descriptor — the models
|
|
480
|
+
// output carries models only, and the barrel IMPORTS the layer from its own file. It used
|
|
481
|
+
// to carry neither, so the same command that wrote 120 descriptors emitted a
|
|
482
|
+
// `defineModule({ models })` that excluded every one of them, and db:plan then compared
|
|
483
|
+
// against a fraction of the database while printing a confident statement count.
|
|
484
|
+
const embeddedDerived = derivedOut ? undefined : derived;
|
|
485
|
+
const externalDerived = derivedOut && flags.out
|
|
486
|
+
? {
|
|
487
|
+
specifier: derivedImportSpecifier(flags.out, derivedOut),
|
|
488
|
+
sequences: derived.sequenceNames.length > 0,
|
|
489
|
+
materializedTables: derived.materializedTableNames.length > 0,
|
|
490
|
+
derived: derived.names.length > 0,
|
|
491
|
+
}
|
|
492
|
+
: undefined;
|
|
493
|
+
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
494
|
+
const dir = path.resolve(flags.out);
|
|
495
|
+
modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables })
|
|
496
|
+
.map((f) => ({ ...f, target: containedPath(dir, f.file) }));
|
|
497
|
+
} else {
|
|
498
|
+
modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
} catch (err: any) {
|
|
502
|
+
fail(err.message);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
398
505
|
for (const w of derived.warnings) caution(w);
|
|
399
506
|
const derivedCount = derived.names.length + derived.sequenceNames.length;
|
|
400
507
|
if (derivedCount > 0) {
|
|
@@ -412,10 +519,8 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
412
519
|
const outPath = path.resolve(derivedOut);
|
|
413
520
|
let fileCount = 1;
|
|
414
521
|
try {
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
// anything is written, rather than shipping a barrel nobody can import.
|
|
418
|
-
const files = renderDerivedDirectory(derived, knownTables, outPath);
|
|
522
|
+
if (derivedFiles) {
|
|
523
|
+
const files = derivedFiles;
|
|
419
524
|
await fs.mkdir(outPath, { recursive: true });
|
|
420
525
|
// The split set SHRINKS: drop the last function in `auth` and this pull no longer
|
|
421
526
|
// writes functions-auth.ts, but the previous pull's copy is still on disk declaring
|
|
@@ -424,7 +529,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
424
529
|
// as the --out <dir> model writer below, which leaves stray files alone and says so.
|
|
425
530
|
const written = new Set(files.map((f) => f.file));
|
|
426
531
|
const stale = (await fs.readdir(outPath)).filter((f) => f.endsWith('.ts') && !written.has(f));
|
|
427
|
-
for (const f of files) await fs.writeFile(
|
|
532
|
+
for (const f of files) await fs.writeFile(f.target, f.source, 'utf8');
|
|
428
533
|
fileCount = files.length;
|
|
429
534
|
if (stale.length) {
|
|
430
535
|
caution(
|
|
@@ -435,7 +540,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
435
540
|
}
|
|
436
541
|
} else {
|
|
437
542
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
438
|
-
await fs.writeFile(outPath,
|
|
543
|
+
await fs.writeFile(outPath, derivedFileSource!, 'utf8');
|
|
439
544
|
}
|
|
440
545
|
} catch (err: any) {
|
|
441
546
|
fail(`Could not write ${derivedOut}: ${err.message}`);
|
|
@@ -451,31 +556,16 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
451
556
|
process.exit(0);
|
|
452
557
|
}
|
|
453
558
|
}
|
|
454
|
-
// With --derived-out, the embedded copy would duplicate every descriptor — the models
|
|
455
|
-
// output carries models only, and the barrel IMPORTS the layer from its own file. It used
|
|
456
|
-
// to carry neither, so the same command that wrote 120 descriptors emitted a
|
|
457
|
-
// `defineModule({ models })` that excluded every one of them, and db:plan then compared
|
|
458
|
-
// against a fraction of the database while printing a confident statement count.
|
|
459
|
-
const embeddedDerived = derivedOut ? undefined : derived;
|
|
460
|
-
const externalDerived = derivedOut && flags.out
|
|
461
|
-
? {
|
|
462
|
-
specifier: derivedImportSpecifier(flags.out, derivedOut),
|
|
463
|
-
sequences: derived.sequenceNames.length > 0,
|
|
464
|
-
materializedTables: derived.materializedTableNames.length > 0,
|
|
465
|
-
derived: derived.names.length > 0,
|
|
466
|
-
}
|
|
467
|
-
: undefined;
|
|
468
|
-
|
|
469
559
|
let source: string;
|
|
470
|
-
if (
|
|
560
|
+
if (modelFiles) {
|
|
471
561
|
// A directory: one file per model + index.ts — the default shape for a real app.
|
|
472
|
-
const dir = path.resolve(flags.out);
|
|
473
|
-
const files =
|
|
562
|
+
const dir = path.resolve(flags.out!);
|
|
563
|
+
const files = modelFiles;
|
|
474
564
|
try {
|
|
475
565
|
await fs.mkdir(dir, { recursive: true });
|
|
476
566
|
const written = new Set(files.map((f) => f.file));
|
|
477
567
|
const existing = (await fs.readdir(dir)).filter((f) => f.endsWith('.ts') && !written.has(f));
|
|
478
|
-
for (const f of files) await fs.writeFile(
|
|
568
|
+
for (const f of files) await fs.writeFile(f.target, f.source, 'utf8');
|
|
479
569
|
if (existing.length) {
|
|
480
570
|
caution(`${existing.length} existing .ts file(s) in ${path.relative(process.cwd(), dir)} were not part of this pull and were left untouched: ${existing.join(', ')}`);
|
|
481
571
|
}
|
|
@@ -487,7 +577,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
487
577
|
source = files.map((f) => f.source).join('\n');
|
|
488
578
|
} else if (flags.out) {
|
|
489
579
|
const outPath = path.resolve(flags.out);
|
|
490
|
-
source =
|
|
580
|
+
source = modelSource!;
|
|
491
581
|
try {
|
|
492
582
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
493
583
|
await fs.writeFile(outPath, source, 'utf8');
|
|
@@ -497,7 +587,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
497
587
|
}
|
|
498
588
|
ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
|
|
499
589
|
} else {
|
|
500
|
-
source =
|
|
590
|
+
source = modelSource!;
|
|
501
591
|
process.stdout.write(source);
|
|
502
592
|
ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
|
|
503
593
|
}
|