@everystack/cli 0.4.99 → 0.4.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/package.json +3 -3
  3. package/src/cli/apply-execute.ts +15 -1
  4. package/src/cli/authz-baseline.ts +8 -4
  5. package/src/cli/authz-compile.ts +61 -7
  6. package/src/cli/authz-contract.ts +23 -14
  7. package/src/cli/authz-derive.ts +282 -30
  8. package/src/cli/authz-reconcile.ts +6 -4
  9. package/src/cli/catalog-rename.ts +115 -0
  10. package/src/cli/commands/db-check.ts +40 -0
  11. package/src/cli/commands/db-generate.ts +10 -9
  12. package/src/cli/commands/db-plan.ts +30 -13
  13. package/src/cli/commands/db-pull.ts +153 -63
  14. package/src/cli/commands/db-reconcile.ts +22 -3
  15. package/src/cli/commands/db-sync.ts +6 -5
  16. package/src/cli/commands/db.ts +91 -8
  17. package/src/cli/commands/update.ts +3 -2
  18. package/src/cli/db-build.ts +1 -0
  19. package/src/cli/declared-derived.ts +3 -1
  20. package/src/cli/declared-diff.ts +4 -1
  21. package/src/cli/derived-apply.ts +116 -10
  22. package/src/cli/derived-grants.ts +4 -2
  23. package/src/cli/derived-introspect.ts +23 -3
  24. package/src/cli/derived-plan.ts +37 -1
  25. package/src/cli/derived-render.ts +33 -34
  26. package/src/cli/edge-plan.ts +57 -9
  27. package/src/cli/generated-artifacts.ts +22 -0
  28. package/src/cli/generated-names.ts +108 -0
  29. package/src/cli/host-url.ts +48 -27
  30. package/src/cli/index.ts +5 -5
  31. package/src/cli/lifecycle-canon.ts +37 -0
  32. package/src/cli/lifecycle-derived.ts +721 -0
  33. package/src/cli/lifecycle-pull.ts +177 -0
  34. package/src/cli/lifecycle-verify.ts +232 -0
  35. package/src/cli/migration-compile.ts +8 -3
  36. package/src/cli/migration-generate.ts +23 -3
  37. package/src/cli/model-map.ts +255 -0
  38. package/src/cli/model-render.ts +243 -77
  39. package/src/cli/plain-output.ts +198 -10
  40. package/src/cli/runbook/model-section.ts +8 -5
  41. package/src/cli/schema-compile.ts +57 -18
  42. package/src/cli/schema-diff.ts +184 -41
  43. package/src/cli/schema-fingerprint.ts +39 -6
  44. package/src/cli/schema-introspect.ts +66 -2
  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
@@ -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);
@@ -346,6 +346,8 @@ export interface ServerProvision {
346
346
  declaredOwners?: DeclaredOwner[];
347
347
  declaredObjects?: Adoptee[];
348
348
  confirm?: boolean;
349
+ /** The Models declare a lifecycle model: provision the reserved everystack_lifecycle role. */
350
+ lifecycleRole?: boolean;
349
351
  },
350
352
  deps: {
351
353
  execute: (sql: string) => Promise<unknown>;
@@ -955,10 +957,14 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
955
957
  // function/view/matview objects to ADOPT, read from the same barrel as the schema set.
956
958
  let declaredOwners: DeclaredOwner[] = [];
957
959
  let declaredObjects: Adoptee[] = [];
960
+ // A `lifecycle: true` model compiles to the reserved role everystack_lifecycle, which nothing
961
+ // else on a stage creates (db:apply never makes roles). Provision makes it, NOLOGIN, no members.
962
+ let lifecycleRole = false;
958
963
  try {
959
964
  const { resolveModelsPath } = await import('../models-path');
960
965
  const { loadModels } = await import('./db-generate');
961
966
  const models = await loadModels(resolveModelsPath(flags.models));
967
+ lifecycleRole = Array.isArray(models) && models.some((m) => m.lifecycle === true);
962
968
  let derivedObjects: Array<{ schema: string }> = [];
963
969
  try {
964
970
  const { loadDeclaredDerived } = await import('../declared-derived');
@@ -1023,14 +1029,16 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1023
1029
  const { runner, end } = await createUrlRunner(url);
1024
1030
  try {
1025
1031
  // Credentials ONLY — ownership convergence runs separately, AFTER the secret is written.
1026
- return await runProvision(credentials, {
1032
+ // Rows from either driver shape, as the ops Lambda's adapter reads them (plugin.ts).
1033
+ const rowsOf = (res: any): any[] => (Array.isArray(res) ? res : (res?.rows ?? []));
1034
+ return await runProvision({ ...credentials, lifecycleRole }, {
1027
1035
  execute: async (statement) => runner(statement),
1036
+ query: async (statement) => rowsOf(await runner(statement)),
1028
1037
  connection: parseUrlConnection(url),
1029
1038
  // Additive-only: an already-live login role keeps its password (never reset on re-provision).
1030
1039
  roleHasLogin: async (role) => {
1031
1040
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(role)) return false;
1032
- const rows = await runner(`SELECT 1 FROM pg_roles WHERE rolname = '${role}' AND rolcanlogin`);
1033
- return Array.isArray(rows) && rows.length > 0;
1041
+ return rowsOf(await runner(`SELECT 1 FROM pg_roles WHERE rolname = '${role}' AND rolcanlogin`)).length > 0;
1034
1042
  },
1035
1043
  // Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
1036
1044
  connectProbe: async (probeUrl) => {
@@ -1068,6 +1076,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1068
1076
  // The credentials half is generated locally and is not part of the wire contract.
1069
1077
  const provisionWire = {
1070
1078
  searchPath,
1079
+ lifecycleRole,
1071
1080
  modelContract: MODEL_WIRE_CONTRACT,
1072
1081
  } satisfies ProvisionWirePayload;
1073
1082
  result = await invokeAction(config.region, opsFunction(config), 'db:provision', {
@@ -1113,6 +1122,35 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1113
1122
  : (result.adminVerified === true ? 'connectivity verified' : 'connectivity NOT verified');
1114
1123
  success(`Operator login ready: ${result.adminRole} (LOGIN, INHERIT owner) — ${adminMsg}`);
1115
1124
  }
1125
+ if (result.lifecycleRole) {
1126
+ const lc = result.lifecycleRole as {
1127
+ role: string; created: boolean | null; attributes?: string[] | null;
1128
+ members: Array<{ role: string; admin: boolean; inherit: boolean; set: boolean }> | null;
1129
+ };
1130
+ const membersMsg = lc.members === null ? 'members not checked' : (lc.members.length ? `${lc.members.length} member(s)` : 'no members');
1131
+ success(`Lifecycle role ${lc.created ? 'created' : 're-asserted'}: ${lc.role} (NOLOGIN, NOINHERIT, no BYPASSRLS, ${membersMsg})`);
1132
+ // The operator is a member by design: ownership convergence grants it SET on the role so it can
1133
+ // create the role's owned functions. It (without ADMIN), and its rotation pair when this provision
1134
+ // configures one, are expected; anyone else is not.
1135
+ const operator = result.adminRole as string | undefined;
1136
+ const operatorRoles = operator ? [operator, ...(credentials.rotationPairs ? [`${operator}_a`, `${operator}_b`] : [])] : [];
1137
+ const isExpected = (m: { role: string; admin: boolean }): boolean => !m.admin && operatorRoles.includes(m.role);
1138
+ const expected = (lc.members ?? []).filter(isExpected);
1139
+ const unexpected = (lc.members ?? []).filter((m) => !isExpected(m));
1140
+ if (expected.length) info(` ${lc.role} members, expected (operator): ${expected.map((m) => m.role).join(', ')}`);
1141
+ if (unexpected.length) {
1142
+ const named = unexpected.map((m) => {
1143
+ const opts = [m.set && 'SET', m.inherit && 'INHERIT', m.admin && 'ADMIN'].filter(Boolean).join(', ');
1144
+ return `${m.role}${opts ? ` (${opts})` : ''}`;
1145
+ }).join(', ');
1146
+ warn(`${lc.role} has unexpected direct member(s): ${named}. A member with SET can SET ROLE ${lc.role} and pass the lifecycle guard. Nothing was revoked; review each.`);
1147
+ }
1148
+ if (lc.attributes?.length) {
1149
+ warn(`${lc.role} carries ${lc.attributes.join(', ')}. The lifecycle role needs none of them, and db:provision does not strip them; remove each with ALTER ROLE ${lc.role} NO<attribute>.`);
1150
+ }
1151
+ } else if (lifecycleRole) {
1152
+ warn('This @everystack/server did not create the lifecycle role everystack_lifecycle the models need. Upgrade the server pin and re-run.');
1153
+ }
1116
1154
  if (result.readerRole) {
1117
1155
  const readerMsg = result.readerPasswordSet === false ? 'existing login preserved' : 'password set';
1118
1156
  success(`Read-only login ready: ${result.readerRole} (LOGIN, NOINHERIT, no BYPASSRLS, SELECT only) — ${readerMsg}`);
@@ -1343,6 +1381,30 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1343
1381
  }
1344
1382
  }
1345
1383
 
1384
+ /** Parse the explicit day window once, before choosing a connection venue. */
1385
+ export function purgeAfterMilliseconds(days: string | undefined): number | undefined {
1386
+ if (days === undefined) return undefined;
1387
+ const milliseconds = Number(days) * 86400000;
1388
+ if (!Number.isFinite(milliseconds) || milliseconds <= 0) throw new Error('--purge-after needs a positive number of days');
1389
+ return milliseconds;
1390
+ }
1391
+
1392
+ async function doctorOverdueRows(
1393
+ after: number | undefined,
1394
+ modelsPath: string | undefined,
1395
+ runner: (query: string) => Promise<any[]>,
1396
+ ): Promise<Record<string, number> | undefined> {
1397
+ if (after === undefined) return undefined;
1398
+ const { loadModels } = await import('./db-generate');
1399
+ const { resolveModelsPath } = await import('../models-path');
1400
+ const spec = '@everystack/server/purge';
1401
+ const { overdueLifecycleRows } = await import(spec) as typeof import('@everystack/server/purge');
1402
+ const { PgDialect } = await import('drizzle-orm/pg-core');
1403
+ const dialect = new PgDialect();
1404
+ const models = await loadModels(resolveModelsPath(modelsPath));
1405
+ return overdueLifecycleRows({ execute: query => runner(dialect.sqlToQuery(query.inlineParams()).sql) }, models, { after });
1406
+ }
1407
+
1346
1408
  /** Render a doctor report — identical output whichever venue produced it. */
1347
1409
  export function printDoctorReport(report: any): void {
1348
1410
  console.log('');
@@ -1357,6 +1419,12 @@ export function printDoctorReport(report: any): void {
1357
1419
  if (c.status === 'fail' && c.remediation) info(` fix: ${c.remediation}`);
1358
1420
  }
1359
1421
 
1422
+ console.log('');
1423
+ if (report.overdueLifecycleRows === undefined) info('Lifecycle purge: skipped; pass --purge-after <days> to count overdue rows');
1424
+ else {
1425
+ info('Lifecycle purge: overdue rows');
1426
+ for (const [table, count] of Object.entries(report.overdueLifecycleRows)) info(` ${table}: ${count}`);
1427
+ }
1360
1428
  console.log('');
1361
1429
  if (report.ok) {
1362
1430
  success('db:doctor — database is least-privilege and RLS-subject');
@@ -1385,7 +1453,7 @@ export function printDoctorReport(report: any): void {
1385
1453
  * forceRlsCarveouts/functionSchemas are unknown to the CLI, so the functions teeth run at
1386
1454
  * their safe default (unpinned SECDEF warns rather than fails). Returns report.ok.
1387
1455
  */
1388
- async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: string): Promise<boolean> {
1456
+ async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: string, purgeAfter: number | undefined, modelsPath: string | undefined): Promise<boolean> {
1389
1457
  const { runDoctorProbe } = await loadServerDoctor();
1390
1458
  const { createUrlRunner } = await import('../db-source');
1391
1459
  step('Probing database connections (api + operator)...');
@@ -1396,7 +1464,8 @@ async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: st
1396
1464
  { api: apiConn.runner, ops: opsConn.runner },
1397
1465
  { apiSourceLabel: sourceLabel },
1398
1466
  );
1399
- printDoctorReport(report);
1467
+ const overdueLifecycleRows = await doctorOverdueRows(purgeAfter, modelsPath, opsConn.runner);
1468
+ printDoctorReport({ ...report, overdueLifecycleRows });
1400
1469
  return report.ok;
1401
1470
  } finally {
1402
1471
  await apiConn.end().catch(() => {});
@@ -1405,6 +1474,13 @@ async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: st
1405
1474
  }
1406
1475
 
1407
1476
  export async function dbDoctorCommand(flags: Record<string, string>): Promise<void> {
1477
+ let purgeAfter: number | undefined;
1478
+ try {
1479
+ purgeAfter = purgeAfterMilliseconds(flags['purge-after']);
1480
+ } catch (error) {
1481
+ fail(error instanceof Error ? error.message : String(error));
1482
+ process.exit(1);
1483
+ }
1408
1484
  // Explicit --database-url / --admin-database-url is a fully LOCAL venue — no AWS, no
1409
1485
  // stage, no secrets. Resolve and run before touching config (mirrors db:apply's
1410
1486
  // local-dev --database-url path).
@@ -1419,7 +1495,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
1419
1495
  info('venue: direct connection (explicit --database-url)');
1420
1496
  let ok = false;
1421
1497
  try {
1422
- ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, '--database-url');
1498
+ ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, '--database-url', purgeAfter, flags.models);
1423
1499
  } catch (err: any) {
1424
1500
  fail(`db:doctor failed: ${err.message}`);
1425
1501
  process.exit(1);
@@ -1459,7 +1535,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
1459
1535
  info(`Region: ${config.region}, venue: direct connection (stage secrets)`);
1460
1536
  let ok = false;
1461
1537
  try {
1462
- ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, 'DATABASE_URL secret');
1538
+ ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, 'DATABASE_URL secret', purgeAfter, flags.models);
1463
1539
  } catch (err: any) {
1464
1540
  fail(`db:doctor failed: ${err.message}`);
1465
1541
  process.exit(1);
@@ -1484,7 +1560,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
1484
1560
  info('No Ops Lambda (the deployed handler has no dbPlugin) — verifying directly over the stage secrets.');
1485
1561
  let ok = false;
1486
1562
  try {
1487
- ok = await runDoctorDirect(venue.apiUrl, venue.adminUrl, 'DATABASE_URL secret');
1563
+ ok = await runDoctorDirect(venue.apiUrl, venue.adminUrl, 'DATABASE_URL secret', purgeAfter, flags.models);
1488
1564
  } catch (err: any) {
1489
1565
  fail(`db:doctor failed: ${err.message}`);
1490
1566
  process.exit(1);
@@ -1512,6 +1588,13 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
1512
1588
  process.exit(1);
1513
1589
  }
1514
1590
 
1591
+ const { lambdaSessionRunner } = await import('../aws');
1592
+ const session = lambdaSessionRunner(config.region, opsFunction(config));
1593
+ report.overdueLifecycleRows = await doctorOverdueRows(purgeAfter, flags.models, async query => {
1594
+ const [rows] = await session([query], { readOnly: true, searchPath: 'pg_catalog' });
1595
+ if (!Array.isArray(rows)) throw new Error('overdue lifecycle count failed');
1596
+ return rows;
1597
+ });
1515
1598
  printDoctorReport(report);
1516
1599
  process.exit(report.ok ? 0 : 1);
1517
1600
  }
@@ -6,7 +6,7 @@ import { pipeline } from 'node:stream/promises';
6
6
  import { createWriteStream } from 'node:fs';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { resolveConfig, probeUrl, type CliConfig } from '../config';
9
- import { missingHostUrlError } from '../host-url';
9
+ import { loadSstOutputs, missingHostUrlError } from '../host-url';
10
10
  import { uploadToS3, headS3, invokeAction, rollingRestart } from '../aws';
11
11
  import { step, success, warn, fail, info } from '../output';
12
12
  import { opsAdviceLines, IAM_ADVICE } from '../ops-advice';
@@ -135,7 +135,8 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
135
135
  // published channel's bundle) is the same either way, and the documented day-to-day flow is
136
136
  // `update --channel production` with no --stage at all. A secret loaded above may supply
137
137
  // HOST_URL, which is why this runs after that step rather than before it.
138
- const noHostUrl = await missingHostUrlError(flags.stage, { siteUrl: resolvedConfig?.siteUrl });
138
+ const trace = await loadSstOutputs(flags.stage);
139
+ const noHostUrl = await missingHostUrlError(flags.stage, { siteUrl: resolvedConfig?.siteUrl, trace });
139
140
  if (noHostUrl) {
140
141
  fail(noHostUrl);
141
142
  process.exit(1);
@@ -184,6 +184,7 @@ export async function buildIntoDatabase(
184
184
  try {
185
185
  const pre = await executeReconcile(runner, session, {
186
186
  declared: declaredFunctions,
187
+ only: declaredFunctions.map(object => object.identity),
187
188
  apply: true,
188
189
  actor: options.actor ?? 'db-build',
189
190
  gitRef: options.gitRef ?? null,
@@ -14,6 +14,7 @@ import { pathToFileURL } from 'node:url';
14
14
  import type { Module, ModelDescriptor, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
15
15
  import { linkModels, ModelLinkError, moduleGovernedRoles } from '@everystack/model';
16
16
  import { compileDerived } from './derived-compile';
17
+ import { lifecycleDerived } from './lifecycle-derived';
17
18
  import { compileTableRenames, compileTableMoves } from './schema-compile';
18
19
  import type { SourceObject } from './derived-source';
19
20
  import { resolveModelsPath } from './models-path';
@@ -181,7 +182,8 @@ export async function loadDeclaredDerived(modelsFlag?: string): Promise<Declared
181
182
  export function composeDeclaredDerived(modules: Module[], modelsPath: string): DeclaredDerived {
182
183
  try {
183
184
  const models = modules.flatMap((m) => m.models);
184
- const derived = modules.flatMap((m) => m.derived);
185
+ // The lifecycle triggers are generated from the models, never declared on a module.
186
+ const derived = [...modules.flatMap((m) => m.derived), ...lifecycleDerived(models)];
185
187
  return {
186
188
  objects: compileDerived(models, derived),
187
189
  derived,
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import type { ModelDescriptor } from '@everystack/model';
30
+ import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
30
31
  import type { SchemaSnapshot } from './schema-introspect';
31
32
  import type { AuthzContract } from './authz-contract';
32
33
  import { compileTableSchema, compileEnums } from './schema-compile';
@@ -54,10 +55,12 @@ export function compileDeclaredState(
54
55
  models: ModelDescriptor[],
55
56
  opts: { schema?: string } = {},
56
57
  ): DeclaredState {
58
+ assertExternalTargets(models);
59
+ const dependents = relationDeleteActions(models);
57
60
  const schema = opts.schema ?? 'public';
58
61
  return {
59
62
  snapshot: {
60
- tables: models.map((m) => compileTableSchema(m, { schema })),
63
+ tables: models.map((m) => compileTableSchema(m, { schema, dependents })),
61
64
  enums: compileEnums(models),
62
65
  },
63
66
  contract: {
@@ -13,6 +13,7 @@
13
13
  * the reconciler writes it and never reads it.
14
14
  */
15
15
 
16
+ import { LIFECYCLE_OWNER_ROLE } from '@everystack/model';
16
17
  import { normalizeSql, parseQualified, type SourceObject } from './derived-source';
17
18
  import type { ReconcilePlan } from './derived-plan';
18
19
  import { parseGrantAttachments } from './derived-grants';
@@ -88,6 +89,12 @@ export function triggerDropSql(name: string, table: string): string {
88
89
  return `DROP TRIGGER IF EXISTS "${name}" ON ${table}`;
89
90
  }
90
91
 
92
+ /** Restore a managed trigger to the declared `tgenabled = 'O'`. `table` is the qualified
93
+ * `schema.table` (the live ownerIdentity), so no search_path decides which table it hits. */
94
+ export function triggerEnableSql(name: string, table: string): string {
95
+ return `ALTER TABLE ${quoteQualified(table)} ENABLE TRIGGER "${name.replace(/"/g, '""')}"`;
96
+ }
97
+
91
98
  const DROP_KEYWORD: Record<string, string> = {
92
99
  'materialized view': 'MATERIALIZED VIEW',
93
100
  view: 'VIEW',
@@ -185,6 +192,54 @@ function ownerTarget(obj: SourceObject): string {
185
192
  return obj.kind === 'function' ? dropTarget('function', obj.identity) : quoteQualified(obj.identity);
186
193
  }
187
194
 
195
+ /** Parse the SQL spelling, then compare stored identifiers rather than quoted text. */
196
+ const TRIGGER_IDENT = String.raw`(?:"(?:[^"]|"")*"|[a-z_\u0080-\uffff][a-z0-9_$\u0080-\uffff]*)`;
197
+ const TRIGGER_EXECUTE_RE = new RegExp(
198
+ String.raw`\bEXECUTE\s+(?:FUNCTION|PROCEDURE)\s+(${TRIGGER_IDENT})(?:\s*\.\s*(${TRIGGER_IDENT}))?\s*\(\s*\)\s*;?\s*$`, 'i',
199
+ );
200
+ const storedIdentifier = (token: string): string => token.startsWith('"')
201
+ ? token.slice(1, -1).replace(/""/g, '"') : token.toLowerCase();
202
+
203
+ /** The declared function a trigger executes, or undefined when the source set does not declare it. */
204
+ export function triggerFunctionOf(trigger: SourceObject, source: readonly SourceObject[]): SourceObject | undefined {
205
+ if (trigger.kind !== 'trigger') return undefined;
206
+ const target = TRIGGER_EXECUTE_RE.exec(trigger.sql);
207
+ if (!target) return undefined;
208
+ const schema = target[2] ? storedIdentifier(target[1]) : 'public';
209
+ const name = storedIdentifier(target[2] ?? target[1]);
210
+ return source.find((o) => o.kind === 'function' && o.schema === schema && o.name === name
211
+ && splitFunctionIdentity(o.identity).args === '');
212
+ }
213
+
214
+ /**
215
+ * CREATE TRIGGER needs EXECUTE on the trigger function, and a trigger has no owner: the builder
216
+ * creates it as itself. When the function belongs to a declared owner with PUBLIC revoked, the
217
+ * builder holds no EXECUTE — in set-role mode it is a member WITH INHERIT FALSE, and in alter-owner
218
+ * mode it gave the function away before the trigger. So the owner lends EXECUTE for the one
219
+ * statement and takes it back, in the same transaction: nothing persists, nothing drifts.
220
+ *
221
+ * `grantee` is the builder, and only when it is not a true superuser (a superuser needs no EXECUTE).
222
+ * Skipped when the function's own declaration grants the builder EXECUTE — the REVOKE would strip
223
+ * a declared grant.
224
+ *
225
+ * On a non-atomic batch (CONCURRENTLY/VACUUM) a failure between the GRANT and the REVOKE leaves the
226
+ * grant behind; the builder already holds SET on the owner, so that is drift, not escalation.
227
+ */
228
+ function withTransientExecute(obj: SourceObject, statements: string[], source: readonly SourceObject[], grantee?: string): string[] {
229
+ if (!grantee || obj.kind !== 'trigger') return statements;
230
+ const fn = triggerFunctionOf(obj, source);
231
+ if (!fn?.owner) return statements;
232
+ const declared = parseGrantAttachments(fn.attachments).grants[grantee] ?? [];
233
+ if (declared.includes('EXECUTE') || declared.includes('ALL')) return statements;
234
+ const target = dropTarget('function', fn.identity);
235
+ const to = quoteIdent(grantee);
236
+ return [
237
+ `SET ROLE ${fn.owner}`, `GRANT EXECUTE ON FUNCTION ${target} TO ${to}`, 'RESET ROLE',
238
+ ...statements,
239
+ `SET ROLE ${fn.owner}`, `REVOKE EXECUTE ON FUNCTION ${target} FROM ${to}`, 'RESET ROLE',
240
+ ];
241
+ }
242
+
188
243
  /**
189
244
  * The schemas a from-scratch derived apply must put on the search_path before it
190
245
  * creates anything. Derived bodies carry BARE table refs — db:pull deparsed them
@@ -266,7 +321,7 @@ export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
266
321
  * backup's authz manifest — reads the SAME answer instead of re-deriving it.
267
322
  */
268
323
  export function declaredObjectSchemaUsage(objects: SourceObject[]): Record<string, string[]> {
269
- const usage: Record<string, string[]> = {};
324
+ const usage: Record<string, string[]> = Object.create(null); // keyed by schema name, which may be '__proto__'
270
325
  const schemas = [...new Set(objects.map((o) => o.schema))].filter((s) => s !== 'public').sort();
271
326
  for (const schema of schemas) {
272
327
  const roles = new Set<string>();
@@ -305,6 +360,12 @@ export interface OwnerRequirement {
305
360
  * owner be assumable would refuse work the batch was never going to attempt. On a from-scratch
306
361
  * build every object is created, so this set is every declared owner anyway.
307
362
  */
363
+ /** A baseline may stamp the generated manifest, never rewrite an adopted function body. */
364
+ function lifecycleBaselineComments(object: SourceObject | undefined): string[] {
365
+ if (object?.kind !== 'function' || object.owner !== LIFECYCLE_OWNER_ROLE) return [];
366
+ return object.attachments.filter(a => a.kind === 'comment' && a.sql.includes('"everystack":"lifecycle"')).map(a => a.sql);
367
+ }
368
+
308
369
  export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]): OwnerRequirement[] {
309
370
  const srcById = new Map(source.map((o) => [o.identity, o]));
310
371
  const byOwner = new Map<string, Set<string>>();
@@ -315,6 +376,20 @@ export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]):
315
376
  (byOwner.get(obj.owner) ?? byOwner.set(obj.owner, new Set()).get(obj.owner)!).add(obj.schema);
316
377
  }
317
378
  const grantOnly = new Map<string, Set<string>>();
379
+ for (const action of plan.actions) {
380
+ // A trigger on an owned function: the owner lends the builder EXECUTE for the CREATE TRIGGER
381
+ // (withTransientExecute), so the owner must be assumable and must USE the function's schema.
382
+ if (action.action !== 'create' && action.action !== 'replace') continue;
383
+ const trigger = srcById.get(action.identity);
384
+ const fn = trigger ? triggerFunctionOf(trigger, source) : undefined;
385
+ if (!fn?.owner) continue;
386
+ (grantOnly.get(fn.owner) ?? grantOnly.set(fn.owner, new Set()).get(fn.owner)!).add(fn.schema);
387
+ }
388
+ for (const action of plan.actions) {
389
+ const object = srcById.get(action.identity);
390
+ if (action.action !== 'baseline' || !object?.owner || !lifecycleBaselineComments(object).length) continue;
391
+ (grantOnly.get(object.owner) ?? grantOnly.set(object.owner, new Set()).get(object.owner)!).add(object.schema);
392
+ }
318
393
  for (const r of plan.regrants) {
319
394
  const schema = srcById.get(r.identity)?.schema;
320
395
  if (!r.owner || !schema) continue;
@@ -357,6 +432,8 @@ SELECT
357
432
  )
358
433
  FROM pg_roles b WHERE b.rolname = CURRENT_USER
359
434
  ) AS builder_is_superuser,
435
+ -- A TRUE superuser only. It needs no EXECUTE to create a trigger; an rds_superuser member does.
436
+ (SELECT b.rolsuper FROM pg_roles b WHERE b.rolname = CURRENT_USER) AS builder_rolsuper,
360
437
  d.owner_name,
361
438
  d.schema_name,
362
439
  d.needs_create,
@@ -378,6 +455,7 @@ ORDER BY d.owner_name, d.schema_name
378
455
  export interface OwnerPreflightRow {
379
456
  builder?: unknown;
380
457
  builder_is_superuser?: unknown;
458
+ builder_rolsuper?: unknown;
381
459
  owner_name: string;
382
460
  schema_name: string;
383
461
  /** False for a schema the owner only GRANTs/REVOKEs in — USAGE is checked, CREATE is not. */
@@ -403,6 +481,17 @@ export function builderOwnerMode(rows: OwnerPreflightRow[]): OwnerApplyMode {
403
481
  return rows.some((r) => truthy(r.builder_is_superuser)) ? 'alter-owner' : 'set-role';
404
482
  }
405
483
 
484
+ /**
485
+ * The role an owned trigger function lends EXECUTE to for its CREATE TRIGGER (see
486
+ * withTransientExecute): the builder, unless it is a true superuser. Undefined when the rows do
487
+ * not name the builder — an older caller or a fixture — which renders no transient grant.
488
+ */
489
+ export function transientExecuteGrantee(rows: OwnerPreflightRow[]): string | undefined {
490
+ const builder = rows.find((r) => r.builder != null)?.builder;
491
+ if (builder == null || rows.some((r) => truthy(r.builder_rolsuper))) return undefined;
492
+ return String(builder);
493
+ }
494
+
406
495
  /**
407
496
  * The refusal text, or null when every declared owner is usable.
408
497
  *
@@ -430,16 +519,22 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
430
519
  const noCreate: Array<{ owner: string; schema: string; exists: boolean }> = [];
431
520
  const noUsage: Array<{ owner: string; schema: string }> = [];
432
521
 
433
- // A superuser builder creates as itself and ALTERs the owner, so it needs neither membership in
434
- // the owner nor CREATE on the owner's behalf. Refusing on either would be a false refusal that
435
- // demands a privilege expansion onto a role designed to hold none. Only "the role must exist"
436
- // survives, because no mechanism can hand an object to a principal that is not there.
522
+ // A TRUE superuser builder creates as itself and ALTERs the owner, and PostgreSQL checks nothing
523
+ // of the owner for it. Refusing on CREATE or USAGE would be a false refusal that demands a
524
+ // privilege expansion onto a role designed to hold none. "The role must exist" survives, because
525
+ // no mechanism can hand an object to a principal that is not there.
526
+ //
527
+ // An rds_superuser member also takes alter-owner, but it is not a superuser to PostgreSQL 16:
528
+ // its ALTER … OWNER TO needs SET on the new owner and CREATE for that owner on the schema, and
529
+ // an owned trigger function's transient EXECUTE grant runs as the owner and needs its USAGE. So
530
+ // it is checked exactly as a set-role builder is.
437
531
  const alterOwner = builderOwnerMode(rows) === 'alter-owner';
532
+ const trueSuperuser = rows.some((r) => truthy(r.builder_rolsuper));
438
533
 
439
534
  for (const row of rows) {
440
535
  const owner = String(row.owner_name);
441
536
  if (!truthy(row.role_exists)) { missing.add(owner); continue; }
442
- if (alterOwner) continue;
537
+ if (trueSuperuser) continue;
443
538
  if (!truthy(row.can_set_role)) unassumable.add(owner);
444
539
  // A superuser owner holds CREATE everywhere, including in a schema this build is about to
445
540
  // make — there is nothing to refuse.
@@ -478,7 +573,9 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
478
573
  }
479
574
  for (const owner of [...unassumable].sort()) {
480
575
  lines.push(
481
- ` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. The CREATE and every GRANT/REVOKE on its objects run as the owner:`,
576
+ alterOwner
577
+ ? ` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. ALTER … OWNER TO it and a trigger's transient EXECUTE grant on its functions run as the owner:`
578
+ : ` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. The CREATE and every GRANT/REVOKE on its objects run as the owner:`,
482
579
  ` GRANT ${owner} TO ${builderName} WITH INHERIT FALSE, SET TRUE;`,
483
580
  );
484
581
  }
@@ -497,7 +594,8 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
497
594
  return lines.join('\n');
498
595
  }
499
596
 
500
- export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[], ownerMode: OwnerApplyMode = 'set-role'): RenderedReconcile {
597
+ /** `executeGrantee`: the builder an owned trigger function lends EXECUTE to ({@link transientExecuteGrantee}). */
598
+ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[], ownerMode: OwnerApplyMode = 'set-role', executeGrantee?: string): RenderedReconcile {
501
599
  const srcById = new Map(source.map((o) => [o.identity, o]));
502
600
  const statements: string[] = [];
503
601
  const record: string[] = [];
@@ -519,14 +617,14 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
519
617
  case 'replace': {
520
618
  const obj = srcById.get(action.identity);
521
619
  if (!obj) throw new Error(`replace action for ${action.identity} has no source object`);
522
- statements.push(...objectSql(obj, ownerMode, action.revokes).map(ensureOrReplace));
620
+ statements.push(...withTransientExecute(obj, objectSql(obj, ownerMode, action.revokes).map(ensureOrReplace), source, executeGrantee));
523
621
  record.push(action.identity);
524
622
  break;
525
623
  }
526
624
  case 'create': {
527
625
  const obj = srcById.get(action.identity);
528
626
  if (!obj) throw new Error(`create action for ${action.identity} has no source object`);
529
- statements.push(...objectSql(obj, ownerMode));
627
+ statements.push(...withTransientExecute(obj, objectSql(obj, ownerMode), source, executeGrantee));
530
628
  record.push(action.identity);
531
629
  break;
532
630
  }
@@ -535,6 +633,10 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
535
633
  break;
536
634
  }
537
635
  case 'baseline': {
636
+ const object = srcById.get(action.identity);
637
+ const comments = lifecycleBaselineComments(object);
638
+ if (comments.length && object?.owner && ownerMode === 'set-role') statements.push(`SET ROLE ${object.owner}`, ...comments, 'RESET ROLE');
639
+ else statements.push(...comments);
538
640
  record.push(action.identity);
539
641
  break;
540
642
  }
@@ -567,6 +669,10 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
567
669
  else statements.push(...r.statements);
568
670
  }
569
671
 
672
+ // A managed trigger that is disabled (or replica/always) goes back to the declared 'O' in the
673
+ // same batch. No provenance to record: tgenabled is in no hash.
674
+ for (const r of plan.reenables ?? []) statements.push(triggerEnableSql(r.name, r.table));
675
+
570
676
  // NOTE — a `remove` entry that is also a migration's TARGET is CORRECT, not a contradiction.
571
677
  // A legacy-keyed function the source has removed migrates onto its signature identity and is
572
678
  // then dropped through it: re-key, drop the object, delete the row. That is the documented path
@@ -70,7 +70,9 @@ export function revokesPublic(attachments: readonly Attachment[]): boolean {
70
70
 
71
71
  /** The declared grant contract, read back from an object's own grant attachments. */
72
72
  export function parseGrantAttachments(attachments: readonly Attachment[]): ParsedGrants {
73
- const grants: Record<string, Set<string>> = {};
73
+ // Null-prototype: a grantee is a role name and '__proto__' is a legal one. The live side
74
+ // (derived-introspect) keys it the same way, so the two sides diff on equal terms.
75
+ const grants: Record<string, Set<string>> = Object.create(null);
74
76
  let target: string | null = null;
75
77
  let hasColumnGrants = false;
76
78
 
@@ -101,7 +103,7 @@ export function parseGrantAttachments(attachments: readonly Attachment[]): Parse
101
103
  }
102
104
  }
103
105
 
104
- const out: Record<string, string[]> = {};
106
+ const out: Record<string, string[]> = Object.create(null);
105
107
  for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
106
108
  return { grants: out, target, hasColumnGrants };
107
109
  }