@everystack/cli 0.4.100 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-baseline.ts +8 -4
  4. package/src/cli/authz-compile.ts +61 -7
  5. package/src/cli/authz-contract.ts +23 -14
  6. package/src/cli/authz-derive.ts +282 -30
  7. package/src/cli/authz-reconcile.ts +6 -4
  8. package/src/cli/commands/db-check.ts +40 -0
  9. package/src/cli/commands/db-generate.ts +10 -9
  10. package/src/cli/commands/db-plan.ts +30 -13
  11. package/src/cli/commands/db-pull.ts +153 -63
  12. package/src/cli/commands/db-reconcile.ts +22 -3
  13. package/src/cli/commands/db-sync.ts +6 -5
  14. package/src/cli/commands/db.ts +91 -8
  15. package/src/cli/commands/update.ts +3 -2
  16. package/src/cli/db-build.ts +1 -0
  17. package/src/cli/declared-derived.ts +3 -1
  18. package/src/cli/declared-diff.ts +4 -1
  19. package/src/cli/derived-apply.ts +116 -10
  20. package/src/cli/derived-grants.ts +4 -2
  21. package/src/cli/derived-introspect.ts +23 -3
  22. package/src/cli/derived-plan.ts +37 -1
  23. package/src/cli/derived-render.ts +33 -34
  24. package/src/cli/edge-plan.ts +57 -9
  25. package/src/cli/generated-artifacts.ts +22 -0
  26. package/src/cli/generated-names.ts +108 -0
  27. package/src/cli/host-url.ts +48 -27
  28. package/src/cli/index.ts +5 -5
  29. package/src/cli/lifecycle-canon.ts +37 -0
  30. package/src/cli/lifecycle-derived.ts +721 -0
  31. package/src/cli/lifecycle-pull.ts +177 -0
  32. package/src/cli/lifecycle-verify.ts +232 -0
  33. package/src/cli/migration-compile.ts +8 -3
  34. package/src/cli/migration-generate.ts +23 -3
  35. package/src/cli/model-map.ts +255 -0
  36. package/src/cli/model-render.ts +226 -76
  37. package/src/cli/plain-output.ts +198 -10
  38. package/src/cli/runbook/model-section.ts +8 -5
  39. package/src/cli/schema-compile.ts +35 -17
  40. package/src/cli/schema-diff.ts +166 -31
  41. package/src/cli/schema-fingerprint.ts +39 -6
  42. package/src/cli/schema-introspect.ts +64 -2
  43. package/src/cli/schema-source.ts +40 -9
  44. package/src/cli/schema-swap.ts +3 -2
  45. package/src/cli/state-apply.ts +10 -4
  46. 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 VISIBILITY — db:apply ALWAYS reconciles functions/views/matviews/triggers
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. Without this, an empty table/column/grant edge reads as "the target
468
- // matches the checkout" even when a function body changed underneath it — an operator who
469
- // trusts an empty plan never learns db:apply still has work to do. Read-only: the same pure
470
- // planReconcile() that backs `db:reconcile --check`, applied to nothing here — advisory
471
- // only, like the FK-orphan and backfill checks above, so it can go stale between mint and
472
- // apply without corrupting the plan file (see the file header's rationale for why nothing
473
- // read-time-only belongs in the artifact).
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
- const derivedPlan = planReconcile(
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
- warn('derived layer out of sync (functions/views/matviews/triggers) — this plan does not preview these, only tables/columns/grants:');
484
- for (const line of buildReconcileReport(derivedPlan)) warn(` ${line}`);
485
- warn('db:apply always catches up the derived layer, --plan or not — run it to reconcile the above.');
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 { renderModelSource, renderModelFiles, pullableTables, skippedInfrastructureTables, modelVarName, ABILITY_PRESETS } from '../model-render';
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 transcribe these foreign roles'
197
- // grants into `privileges` — which governs them. Required for migration deletion when
198
- // the migrations created grants to roles outside the vocabulary: a fresh build from
199
- // models must recreate them. Never inferred from the database (a re-pull must not turn
200
- // a rendering decision into an access decision); complete-or-refuse per role — a listed
201
- // role holding a column-scoped grant fails the pull (see deriveAbilities).
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 contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
265
- liveContract = contract;
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(', ')}: their grants are transcribed as privileges — the models now OWN them, and a fresh build recreates them.`);
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 transcribe their grants as privileges.`);
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
- pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
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 was told "not governed, nothing revokes
302
- // it" while the plan minted the REVOKE — closing-gate review, 2026-09-07.)
303
- const governedAtPull = new Set<string>([...ALWAYS_GOVERNED, ...governRoles]);
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(current, contract).hash;
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) if (pulledNames.has(name)) deriveAbilities(c, { governRoles: govern });
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
- // The derived layer (B5): rendered from the live catalog with dependsOn from the real
385
- // edge graph. Everything inexpressible is a stderr warning AND an inline FIXME.
386
- const knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
387
- const derived: DerivedRenderResult = renderDerivedSource(derivedCatalog ?? { objects: [], edges: [], provenance: [] }, {
388
- knownTables,
389
- sequences: current.sequences,
390
- ...(matviewsAsTables ? {
391
- matviewsAsTables: {
392
- columns: matviewColumns ?? new Map(),
393
- enums: new Map((current.enums ?? []).map((e) => [e.name, e.values])),
394
- ...(candidatesByIdentity ? { keyCandidates: candidatesByIdentity } : {}),
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 (derivedOutIsDir) {
416
- // A cycle between split files is fatal at import time (TDZ) — refused here, before
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(path.join(outPath, f.file), f.source, 'utf8');
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, renderDerivedFile(derived, knownTables), 'utf8');
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 (flags.out && !flags.out.endsWith('.ts')) {
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 = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived, externalDerived });
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(path.join(dir, f.file), f.source, 'utf8');
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 = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived });
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 = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived });
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
  }
@@ -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
- if (ownerMode !== 'set-role') rendered = renderReconcileSql(plan, parsed.objects, ownerMode);
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
- const grantTouched = new Set([...built, ...plan.regrants.map((r) => r.identity)]);
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 { compileDrizzleSource } from '../schema-source';
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 is a derived
382
- // artifact, refreshed from the models on every run.
383
- step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
384
- await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived: declaredDb?.derived }), 'utf8');
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);