@everystack/cli 0.4.100 → 0.4.103
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 +112 -0
- package/package.json +3 -3
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +91 -14
- package/src/cli/authz-contract.ts +29 -14
- package/src/cli/authz-derive.ts +284 -30
- package/src/cli/authz-reconcile.ts +6 -4
- 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 +32 -13
- package/src/cli/commands/db-pull.ts +155 -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 +5 -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 +92 -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-lock.ts +65 -0
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +787 -0
- package/src/cli/lifecycle-pull.ts +252 -0
- package/src/cli/lifecycle-verify.ts +238 -0
- package/src/cli/migration-compile.ts +11 -5
- package/src/cli/migration-generate.ts +24 -3
- package/src/cli/model-api.ts +9 -0
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +232 -76
- package/src/cli/plain-output.ts +211 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +64 -18
- package/src/cli/schema-diff.ts +166 -31
- package/src/cli/schema-fingerprint.ts +40 -6
- package/src/cli/schema-introspect.ts +97 -4
- 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
|
@@ -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,11 @@ 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,
|
|
528
|
+
lockWarnings: plan.lockWarnings,
|
|
529
|
+
deletedReads: plan.deletedReads,
|
|
511
530
|
})) printLine(text);
|
|
512
531
|
|
|
513
532
|
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,17 @@ 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();
|
|
252
|
+
let lifecycleDeleteWhen: ReadonlyMap<string, string> = new Map();
|
|
238
253
|
let matviewColumns: Map<string, ColumnSchema[]> | undefined;
|
|
239
254
|
let candidatesByIdentity: Map<string, string[]> | undefined;
|
|
255
|
+
/** Each FK's exact target, by confrelid: an external target is declared from these names. */
|
|
256
|
+
let fkTargets: Map<string, { schema: string; table: string }> = new Map();
|
|
257
|
+
let end: (() => Promise<void>) | undefined;
|
|
240
258
|
try {
|
|
241
259
|
let runner: QueryRunner;
|
|
242
260
|
let session: SessionRunner;
|
|
243
|
-
let end: (() => Promise<void>) | undefined;
|
|
244
261
|
if (dbSource.kind === 'url') {
|
|
245
262
|
note(connectingVia(dbSource));
|
|
246
263
|
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
@@ -253,16 +270,31 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
253
270
|
}
|
|
254
271
|
note(`Introspecting live database (schema: ${schemas.join(", ")})...`);
|
|
255
272
|
current = await introspectSchema(session);
|
|
273
|
+
fkTargets = foreignKeyTargets((await runner(FK_TARGETS_SQL)) as ForeignKeyTargetRow[]);
|
|
256
274
|
// The derived layer rides the same pull (B5) — views/matviews/functions/sequences
|
|
257
275
|
// render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
|
|
258
276
|
derivedCatalog = await introspectDerived(session);
|
|
277
|
+
const lifecycle = await verifyLifecycle(session, ['anon', 'authenticated', 'admin', LIFECYCLE_OWNER_ROLE, ...governRoles]);
|
|
278
|
+
if (lifecycle.failures.length) throw new LifecycleDriftError(lifecycle.failures);
|
|
279
|
+
for (const warning of lifecycle.notes) caution(warning);
|
|
259
280
|
// --abilities live: the authz half of the on-ramp. Same connection, one more read —
|
|
260
281
|
// the grants and policies that already exist become the models' declared abilities,
|
|
261
282
|
// instead of a human transcribing them by hand.
|
|
262
283
|
if (abilities === 'live') {
|
|
263
284
|
note('Introspecting live authorization (grants + policies)...');
|
|
264
|
-
const
|
|
265
|
-
|
|
285
|
+
const rawSnapshot = current;
|
|
286
|
+
const rawContract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
287
|
+
// A lifecycle table folds back to `lifecycle: true` here only: proving the table is exactly
|
|
288
|
+
// what the compiler writes needs its grants and policies. Only the renderers and the adoption
|
|
289
|
+
// findings see the folded inputs; the fingerprint below is the raw one.
|
|
290
|
+
const fold = foldLifecycle(current, rawContract, derivedCatalog);
|
|
291
|
+
current = fold.snapshot;
|
|
292
|
+
derivedCatalog = fold.catalog;
|
|
293
|
+
lifecycleTables = fold.tables;
|
|
294
|
+
lifecycleDeleteWhen = fold.deleteWhen;
|
|
295
|
+
if (lifecycleTables.size) note(`Lifecycle: ${[...lifecycleTables].sort().join(', ')} rendered as lifecycle: true (generated columns, grants and functions folded).`);
|
|
296
|
+
const contract = fold.contract;
|
|
297
|
+
liveContract = rawContract;
|
|
266
298
|
// The owner is NOT rendered into the models — it is the dev user here and the
|
|
267
299
|
// operator role on a stage, so a model that declared it would drift everywhere.
|
|
268
300
|
// It is read to be SAID, on the one surface that is looking at the database.
|
|
@@ -272,18 +304,32 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
272
304
|
// Roles outside the model vocabulary (anon/authenticated/admin) are usually ONE
|
|
273
305
|
// operator account granted across the whole schema. Said once here rather than
|
|
274
306
|
// repeated in all N models, where it would bury the per-table findings.
|
|
307
|
+
// A folded lifecycle table makes the pulled module govern the reserved role (moduleGovernedRoles),
|
|
308
|
+
// so db:plan governs it on EVERY table: its grants on a table that did not fold are revoked.
|
|
309
|
+
const governsLifecycle = lifecycleTables.size > 0;
|
|
275
310
|
const unmapped = new Set<string>();
|
|
276
311
|
for (const t of contract.tables) {
|
|
277
312
|
for (const r of Object.keys(t.grants)) {
|
|
313
|
+
if (governsLifecycle && r === LIFECYCLE_OWNER_ROLE) continue;
|
|
278
314
|
if (!['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r) && !governRoles.includes(r)) unmapped.add(r);
|
|
279
315
|
}
|
|
280
316
|
}
|
|
317
|
+
// Said whether or not anything folded: with no fold the role is not governed, and its grants
|
|
318
|
+
// would otherwise pass into the adoption baseline under a generic "Grants exist" note.
|
|
319
|
+
const unfoldedLifecycleGrants = contract.tables
|
|
320
|
+
.filter((t) => t.grants[LIFECYCLE_OWNER_ROLE] || t.columnGrants?.[LIFECYCLE_OWNER_ROLE]).map((t) => t.table).sort();
|
|
321
|
+
if (unfoldedLifecycleGrants.length) {
|
|
322
|
+
const unfolded = `${LIFECYCLE_OWNER_ROLE} holds grants on ${unfoldedLifecycleGrants.join(', ')}, which did not fold to lifecycle: true (not exactly what the compiler writes).`;
|
|
323
|
+
caution(governsLifecycle
|
|
324
|
+
? `${unfolded} The pulled module governs that role, so db:plan will revoke them.`
|
|
325
|
+
: `${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.`);
|
|
326
|
+
}
|
|
281
327
|
if (governRoles.length) {
|
|
282
|
-
note(`--govern-roles ${governRoles.join(', ')}:
|
|
328
|
+
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
329
|
}
|
|
284
330
|
if (unmapped.size) {
|
|
285
331
|
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
|
|
332
|
+
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
333
|
}
|
|
288
334
|
// ADOPTION: record the foreign grantees that were already here, per stage. The
|
|
289
335
|
// reconciler exempts them from revocation, so this artifact is what stops that
|
|
@@ -294,15 +340,16 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
294
340
|
// The governed set at ADOPTION is the fixed vocabulary plus any --govern-roles: a
|
|
295
341
|
// governed role's grants are DECLARED (transcribed as privileges), so recording them
|
|
296
342
|
// as exemptions too would double-book them — declared and exempted at once.
|
|
297
|
-
|
|
343
|
+
const governedAtPull = new Set<string>([...ALWAYS_GOVERNED, ...governRoles, ...(governsLifecycle ? [LIFECYCLE_OWNER_ROLE] : [])]);
|
|
344
|
+
pulledExemptions = ungovernedGrants(contract, governedAtPull);
|
|
298
345
|
// A6's advisory. A blanket `GRANT USAGE ON ALL SEQUENCES` beside a blanket SELECT is a
|
|
299
346
|
// 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
|
-
|
|
347
|
+
// so it must see the SAME governed set the plan will use: the vocabulary plus --govern-roles,
|
|
348
|
+
// plus the reserved role when a table folded. (With the default set, a --govern-roles grantee
|
|
349
|
+
// was told "not governed, nothing revokes it" while the plan minted the REVOKE — closing-gate
|
|
350
|
+
// review, 2026-09-07.)
|
|
304
351
|
for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract), governedAtPull)) caution(line);
|
|
305
|
-
pulledFingerprint = fingerprintLive(
|
|
352
|
+
pulledFingerprint = fingerprintLive(rawSnapshot, rawContract).hash;
|
|
306
353
|
}
|
|
307
354
|
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
308
355
|
// matview columns the derived layer (definition-only) doesn't carry.
|
|
@@ -324,6 +371,11 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
324
371
|
}
|
|
325
372
|
await end?.();
|
|
326
373
|
} catch (err: any) {
|
|
374
|
+
await end?.().catch(() => {});
|
|
375
|
+
if (err instanceof LifecycleDriftError) {
|
|
376
|
+
for (const line of err.lines) console.error(line);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
327
379
|
fail(err.message);
|
|
328
380
|
if (dbSource.kind === 'url') {
|
|
329
381
|
detail('Check the connection string and that the database is accepting connections.');
|
|
@@ -359,18 +411,27 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
359
411
|
// The --govern-roles complete-or-refuse gate, run BEFORE any file is written: a refusal
|
|
360
412
|
// mid-render would leave a half-written models directory. Pure re-derivation, pulled
|
|
361
413
|
// tables only — a governed role's column grant on an UNPULLED table is safe (that table
|
|
362
|
-
// is not declared, so nothing reconciles it).
|
|
414
|
+
// is not declared, so nothing reconciles it). The same pass prints every governed grant or
|
|
415
|
+
// policy can() could not state exactly: it is never silently approximated.
|
|
363
416
|
if (governRoles.length && liveAuthz) {
|
|
364
417
|
const pulledNames = new Set(pulled.map((t) => t.table));
|
|
418
|
+
const warnings: string[] = [];
|
|
365
419
|
try {
|
|
366
420
|
const govern = new Set(governRoles);
|
|
367
|
-
for (const [name, c] of liveAuthz)
|
|
421
|
+
for (const [name, c] of liveAuthz) {
|
|
422
|
+
if (pulledNames.has(name)) warnings.push(...deriveAbilities(c, { governRoles: govern }).warnings);
|
|
423
|
+
}
|
|
368
424
|
} catch (err: any) {
|
|
369
425
|
fail(err.message);
|
|
370
426
|
process.exit(1);
|
|
371
427
|
}
|
|
428
|
+
for (const w of warnings) caution(`WARNING: ${w}`);
|
|
372
429
|
}
|
|
373
430
|
|
|
431
|
+
// F3e — an FK out of the pulled set is declared in the external form; one that CASCADES on
|
|
432
|
+
// delete means a delete in a table this declared state does not govern removes rows here.
|
|
433
|
+
for (const w of externalForeignKeyWarnings(current, schema, fkTargets)) caution(`WARNING: ${w}`);
|
|
434
|
+
|
|
374
435
|
// WHO owns the tables being pulled. Nothing is FLAGGED here: flagging needs a declared
|
|
375
436
|
// write principal to contradict, and the models this pull is about to write do not exist
|
|
376
437
|
// yet. Naming the owner is the half the pull genuinely saw — and the half that vanishes
|
|
@@ -381,20 +442,68 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
381
442
|
)) note(line);
|
|
382
443
|
}
|
|
383
444
|
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
445
|
+
// RENDER EVERYTHING, THEN WRITE. A live name with no safe spelling as a generated identifier
|
|
446
|
+
// or file name (generated-names.ts) refuses inside a renderer; rendering every output first,
|
|
447
|
+
// and resolving every directory write target (containedPath, the second wall), means that a
|
|
448
|
+
// refusal leaves --out and --derived-out exactly as they were.
|
|
449
|
+
let knownTables: Map<string, string>;
|
|
450
|
+
let derived: DerivedRenderResult;
|
|
451
|
+
let derivedFiles: Array<RenderedDerivedFile & { target: string }> | undefined;
|
|
452
|
+
let derivedFileSource: string | undefined;
|
|
453
|
+
let modelFiles: Array<RenderedModelFile & { target: string }> | undefined;
|
|
454
|
+
let modelSource: string | undefined;
|
|
455
|
+
try {
|
|
456
|
+
// The derived layer (B5): rendered from the live catalog with dependsOn from the real
|
|
457
|
+
// edge graph. Everything inexpressible is a stderr warning AND an inline FIXME.
|
|
458
|
+
knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
|
|
459
|
+
derived = renderDerivedSource(derivedCatalog ?? { objects: [], edges: [], provenance: [] }, {
|
|
460
|
+
knownTables,
|
|
461
|
+
sequences: current.sequences,
|
|
462
|
+
...(matviewsAsTables ? {
|
|
463
|
+
matviewsAsTables: {
|
|
464
|
+
columns: matviewColumns ?? new Map(),
|
|
465
|
+
enums: new Map((current.enums ?? []).map((e) => [e.name, e.values])),
|
|
466
|
+
...(candidatesByIdentity ? { keyCandidates: candidatesByIdentity } : {}),
|
|
467
|
+
},
|
|
468
|
+
} : {}),
|
|
469
|
+
});
|
|
470
|
+
if (derivedOut && derivedOutIsDir) {
|
|
471
|
+
// A cycle between split files is fatal at import time (TDZ) — refused here, before
|
|
472
|
+
// anything is written, rather than shipping a barrel nobody can import.
|
|
473
|
+
const outPath = path.resolve(derivedOut);
|
|
474
|
+
derivedFiles = renderDerivedDirectory(derived, knownTables, outPath)
|
|
475
|
+
.map((f) => ({ ...f, target: containedPath(outPath, f.file) }));
|
|
476
|
+
} else if (derivedOut) {
|
|
477
|
+
derivedFileSource = renderDerivedFile(derived, knownTables);
|
|
478
|
+
}
|
|
479
|
+
// The models render unless --derived-out alone was asked for (that path writes no models).
|
|
480
|
+
if (!derivedOut || flags.out) {
|
|
481
|
+
// With --derived-out, the embedded copy would duplicate every descriptor — the models
|
|
482
|
+
// output carries models only, and the barrel IMPORTS the layer from its own file. It used
|
|
483
|
+
// to carry neither, so the same command that wrote 120 descriptors emitted a
|
|
484
|
+
// `defineModule({ models })` that excluded every one of them, and db:plan then compared
|
|
485
|
+
// against a fraction of the database while printing a confident statement count.
|
|
486
|
+
const embeddedDerived = derivedOut ? undefined : derived;
|
|
487
|
+
const externalDerived = derivedOut && flags.out
|
|
488
|
+
? {
|
|
489
|
+
specifier: derivedImportSpecifier(flags.out, derivedOut),
|
|
490
|
+
sequences: derived.sequenceNames.length > 0,
|
|
491
|
+
materializedTables: derived.materializedTableNames.length > 0,
|
|
492
|
+
derived: derived.names.length > 0,
|
|
493
|
+
}
|
|
494
|
+
: undefined;
|
|
495
|
+
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
496
|
+
const dir = path.resolve(flags.out);
|
|
497
|
+
modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables, lifecycleDeleteWhen })
|
|
498
|
+
.map((f) => ({ ...f, target: containedPath(dir, f.file) }));
|
|
499
|
+
} else {
|
|
500
|
+
modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables, lifecycleDeleteWhen });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
} catch (err: any) {
|
|
504
|
+
fail(err.message);
|
|
505
|
+
process.exit(1);
|
|
506
|
+
}
|
|
398
507
|
for (const w of derived.warnings) caution(w);
|
|
399
508
|
const derivedCount = derived.names.length + derived.sequenceNames.length;
|
|
400
509
|
if (derivedCount > 0) {
|
|
@@ -412,10 +521,8 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
412
521
|
const outPath = path.resolve(derivedOut);
|
|
413
522
|
let fileCount = 1;
|
|
414
523
|
try {
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
// anything is written, rather than shipping a barrel nobody can import.
|
|
418
|
-
const files = renderDerivedDirectory(derived, knownTables, outPath);
|
|
524
|
+
if (derivedFiles) {
|
|
525
|
+
const files = derivedFiles;
|
|
419
526
|
await fs.mkdir(outPath, { recursive: true });
|
|
420
527
|
// The split set SHRINKS: drop the last function in `auth` and this pull no longer
|
|
421
528
|
// writes functions-auth.ts, but the previous pull's copy is still on disk declaring
|
|
@@ -424,7 +531,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
424
531
|
// as the --out <dir> model writer below, which leaves stray files alone and says so.
|
|
425
532
|
const written = new Set(files.map((f) => f.file));
|
|
426
533
|
const stale = (await fs.readdir(outPath)).filter((f) => f.endsWith('.ts') && !written.has(f));
|
|
427
|
-
for (const f of files) await fs.writeFile(
|
|
534
|
+
for (const f of files) await fs.writeFile(f.target, f.source, 'utf8');
|
|
428
535
|
fileCount = files.length;
|
|
429
536
|
if (stale.length) {
|
|
430
537
|
caution(
|
|
@@ -435,7 +542,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
435
542
|
}
|
|
436
543
|
} else {
|
|
437
544
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
438
|
-
await fs.writeFile(outPath,
|
|
545
|
+
await fs.writeFile(outPath, derivedFileSource!, 'utf8');
|
|
439
546
|
}
|
|
440
547
|
} catch (err: any) {
|
|
441
548
|
fail(`Could not write ${derivedOut}: ${err.message}`);
|
|
@@ -451,31 +558,16 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
451
558
|
process.exit(0);
|
|
452
559
|
}
|
|
453
560
|
}
|
|
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
561
|
let source: string;
|
|
470
|
-
if (
|
|
562
|
+
if (modelFiles) {
|
|
471
563
|
// 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 =
|
|
564
|
+
const dir = path.resolve(flags.out!);
|
|
565
|
+
const files = modelFiles;
|
|
474
566
|
try {
|
|
475
567
|
await fs.mkdir(dir, { recursive: true });
|
|
476
568
|
const written = new Set(files.map((f) => f.file));
|
|
477
569
|
const existing = (await fs.readdir(dir)).filter((f) => f.endsWith('.ts') && !written.has(f));
|
|
478
|
-
for (const f of files) await fs.writeFile(
|
|
570
|
+
for (const f of files) await fs.writeFile(f.target, f.source, 'utf8');
|
|
479
571
|
if (existing.length) {
|
|
480
572
|
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
573
|
}
|
|
@@ -487,7 +579,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
487
579
|
source = files.map((f) => f.source).join('\n');
|
|
488
580
|
} else if (flags.out) {
|
|
489
581
|
const outPath = path.resolve(flags.out);
|
|
490
|
-
source =
|
|
582
|
+
source = modelSource!;
|
|
491
583
|
try {
|
|
492
584
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
493
585
|
await fs.writeFile(outPath, source, 'utf8');
|
|
@@ -497,7 +589,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
497
589
|
}
|
|
498
590
|
ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
|
|
499
591
|
} else {
|
|
500
|
-
source =
|
|
592
|
+
source = modelSource!;
|
|
501
593
|
process.stdout.write(source);
|
|
502
594
|
ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
|
|
503
595
|
}
|
|
@@ -32,9 +32,10 @@
|
|
|
32
32
|
import type { QueryRunner } from '../authz-contract';
|
|
33
33
|
import type { SourceObject } from '../derived-source';
|
|
34
34
|
import { introspectDerived } from '../derived-introspect';
|
|
35
|
-
import { planReconcile, objectGrantDelta, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
|
|
35
|
+
import { planReconcile, objectGrantDelta, TRIGGER_ENABLED_STATE, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
|
|
36
36
|
import {
|
|
37
37
|
renderReconcileSql,
|
|
38
|
+
triggerEnableSql,
|
|
38
39
|
renderProvenanceUpsert,
|
|
39
40
|
renderProvenanceMigrate,
|
|
40
41
|
triggerDropSql,
|
|
@@ -47,6 +48,8 @@ import {
|
|
|
47
48
|
ownerPreflightSql,
|
|
48
49
|
ownerPreflightRefusal,
|
|
49
50
|
builderOwnerMode,
|
|
51
|
+
transientExecuteGrantee,
|
|
52
|
+
triggerFunctionOf,
|
|
50
53
|
type OwnerApplyMode,
|
|
51
54
|
ENSURE_RECONCILER_SQL,
|
|
52
55
|
} from '../derived-apply';
|
|
@@ -170,7 +173,8 @@ export async function executeReconcile(
|
|
|
170
173
|
const refusal = ownerPreflightRefusal(rows);
|
|
171
174
|
if (refusal) return { plan, applied: false, statements: rendered.statements, refusal };
|
|
172
175
|
ownerMode = builderOwnerMode(rows);
|
|
173
|
-
|
|
176
|
+
const executeGrantee = transientExecuteGrantee(rows);
|
|
177
|
+
if (ownerMode !== 'set-role' || executeGrantee) rendered = renderReconcileSql(plan, parsed.objects, ownerMode, executeGrantee);
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
const now = options.now ?? Date.now;
|
|
@@ -364,7 +368,15 @@ export async function executeReconcile(
|
|
|
364
368
|
liveById = new Map(after.objects.map((o) => [o.identity, o]));
|
|
365
369
|
}
|
|
366
370
|
|
|
367
|
-
|
|
371
|
+
// A built trigger on an owned function had EXECUTE lent to the builder for its CREATE
|
|
372
|
+
// (withTransientExecute). The function may not be in `built` (a trigger-only rebuild), so it
|
|
373
|
+
// joins the ruler here: the post-DDL catalog proves the lend was taken back, or the batch rolls back.
|
|
374
|
+
const lentFunctions = built
|
|
375
|
+
.map((identity) => srcById.get(identity))
|
|
376
|
+
.map((trigger) => (trigger ? triggerFunctionOf(trigger, parsed.objects) : undefined))
|
|
377
|
+
.filter((fn): fn is SourceObject => Boolean(fn?.owner))
|
|
378
|
+
.map((fn) => fn.identity);
|
|
379
|
+
const grantTouched = new Set([...built, ...plan.regrants.map((r) => r.identity), ...lentFunctions]);
|
|
368
380
|
const unconverged: string[] = [];
|
|
369
381
|
for (const identity of [...grantTouched].sort()) {
|
|
370
382
|
const src = srcById.get(identity);
|
|
@@ -573,6 +585,12 @@ export function buildReconcileReport(plan: ReconcilePlan): string[] {
|
|
|
573
585
|
for (const r of plan.regrants) {
|
|
574
586
|
lines.push(`≠ regrant ${r.identity} — live grants differ from source (${r.statements.join('; ')})`);
|
|
575
587
|
}
|
|
588
|
+
for (const r of plan.reenables ?? []) {
|
|
589
|
+
lines.push(
|
|
590
|
+
`! trigger ${r.identity} — ${TRIGGER_ENABLED_STATE[r.state] ?? 'not enabled as declared'} `
|
|
591
|
+
+ `(tgenabled '${r.state}', declared 'O'); --apply runs ${triggerEnableSql(r.name, r.table)}`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
576
594
|
for (const d of plan.dependencyDrift) {
|
|
577
595
|
const parts = [
|
|
578
596
|
...(d.missing.length ? [`reads ${d.missing.join(', ')} undeclared`] : []),
|
|
@@ -612,6 +630,7 @@ export function checkFails(plan: ReconcilePlan): boolean {
|
|
|
612
630
|
|| plan.needsBaseline.length > 0
|
|
613
631
|
|| plan.blocked.length > 0
|
|
614
632
|
|| plan.regrants.length > 0
|
|
633
|
+
|| (plan.reenables?.length ?? 0) > 0
|
|
615
634
|
|| plan.dependencyDrift.length > 0;
|
|
616
635
|
}
|
|
617
636
|
|
|
@@ -45,7 +45,8 @@ import {
|
|
|
45
45
|
type StateSyncOutcome,
|
|
46
46
|
} from '../state-apply';
|
|
47
47
|
import { fingerprintModels } from '../schema-fingerprint';
|
|
48
|
-
import {
|
|
48
|
+
import { writeGeneratedArtifacts } from '../generated-artifacts';
|
|
49
|
+
import { modelMapPath } from '../model-map';
|
|
49
50
|
import type { SourceFile } from '../derived-source';
|
|
50
51
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
|
|
51
52
|
import { borrowedSessionRunner, type SessionRunner } from '../session';
|
|
@@ -378,10 +379,10 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
378
379
|
models = await loadModels(modelsPath);
|
|
379
380
|
declaredDb = await loadDeclaredDerived(flags.models);
|
|
380
381
|
info(`${models.length} model(s).`);
|
|
381
|
-
// Same contract as db:generate: the drizzle runtime schema
|
|
382
|
-
//
|
|
383
|
-
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
384
|
-
await
|
|
382
|
+
// Same contract as db:generate: the drizzle runtime schema and the model map are
|
|
383
|
+
// derived artifacts, refreshed from the models on every run.
|
|
384
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)} and the model map → ${path.relative(process.cwd(), modelMapPath(schemaOut))}...`);
|
|
385
|
+
await writeGeneratedArtifacts(schemaOut, models, declaredDb?.derived);
|
|
385
386
|
} catch (err: any) {
|
|
386
387
|
fail(err.message);
|
|
387
388
|
process.exit(1);
|