@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-baseline.ts +8 -4
  4. package/src/cli/authz-compile.ts +91 -14
  5. package/src/cli/authz-contract.ts +29 -14
  6. package/src/cli/authz-derive.ts +284 -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 +32 -13
  11. package/src/cli/commands/db-pull.ts +155 -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 +5 -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 +92 -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-lock.ts +65 -0
  29. package/src/cli/index.ts +5 -5
  30. package/src/cli/lifecycle-canon.ts +37 -0
  31. package/src/cli/lifecycle-derived.ts +787 -0
  32. package/src/cli/lifecycle-pull.ts +252 -0
  33. package/src/cli/lifecycle-verify.ts +238 -0
  34. package/src/cli/migration-compile.ts +11 -5
  35. package/src/cli/migration-generate.ts +24 -3
  36. package/src/cli/model-api.ts +9 -0
  37. package/src/cli/model-map.ts +255 -0
  38. package/src/cli/model-render.ts +232 -76
  39. package/src/cli/plain-output.ts +211 -10
  40. package/src/cli/runbook/model-section.ts +8 -5
  41. package/src/cli/schema-compile.ts +64 -18
  42. package/src/cli/schema-diff.ts +166 -31
  43. package/src/cli/schema-fingerprint.ts +40 -6
  44. package/src/cli/schema-introspect.ts +97 -4
  45. package/src/cli/schema-source.ts +40 -9
  46. package/src/cli/schema-swap.ts +3 -2
  47. package/src/cli/state-apply.ts +10 -4
  48. 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,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 { 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,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 contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
265
- liveContract = contract;
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(', ')}: their grants are transcribed as privileges — the models now OWN them, and a fresh build recreates them.`);
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 transcribe their grants as privileges.`);
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
- pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
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 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]);
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(current, contract).hash;
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) if (pulledNames.has(name)) deriveAbilities(c, { governRoles: govern });
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
- // 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
- });
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 (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);
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(path.join(outPath, f.file), f.source, 'utf8');
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, renderDerivedFile(derived, knownTables), 'utf8');
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 (flags.out && !flags.out.endsWith('.ts')) {
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 = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived, externalDerived });
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(path.join(dir, f.file), f.source, 'utf8');
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 = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived });
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 = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived });
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
- 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);