@everystack/cli 0.4.102 → 0.4.104

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 (38) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-compile.ts +48 -11
  4. package/src/cli/authz-contract.ts +6 -0
  5. package/src/cli/authz-derive.ts +3 -1
  6. package/src/cli/commands/db-apply.ts +34 -10
  7. package/src/cli/commands/db-backup.ts +62 -26
  8. package/src/cli/commands/db-fork.ts +2 -1
  9. package/src/cli/commands/db-plan.ts +5 -0
  10. package/src/cli/commands/db-pull.ts +48 -12
  11. package/src/cli/commands/db-reconcile.ts +34 -5
  12. package/src/cli/commands/db-sync.ts +32 -3
  13. package/src/cli/commands/db.ts +46 -21
  14. package/src/cli/db-build.ts +11 -1
  15. package/src/cli/declared-diff.ts +3 -2
  16. package/src/cli/derived-apply.ts +5 -1
  17. package/src/cli/derived-catchup.ts +4 -0
  18. package/src/cli/derived-compile.ts +2 -1
  19. package/src/cli/derived-introspect.ts +22 -1
  20. package/src/cli/derived-plan.ts +64 -0
  21. package/src/cli/derived-render.ts +1 -0
  22. package/src/cli/edge-plan.ts +35 -0
  23. package/src/cli/index-lock.ts +65 -0
  24. package/src/cli/index.ts +1 -1
  25. package/src/cli/lifecycle-canon.ts +2 -1
  26. package/src/cli/lifecycle-derived.ts +334 -45
  27. package/src/cli/lifecycle-preflight.ts +498 -0
  28. package/src/cli/lifecycle-pull.ts +119 -11
  29. package/src/cli/migration-compile.ts +4 -3
  30. package/src/cli/migration-generate.ts +3 -2
  31. package/src/cli/model-api.ts +9 -0
  32. package/src/cli/model-render.ts +80 -8
  33. package/src/cli/plain-output.ts +14 -1
  34. package/src/cli/schema-compile.ts +34 -67
  35. package/src/cli/schema-fingerprint.ts +3 -2
  36. package/src/cli/schema-introspect.ts +33 -2
  37. package/src/cli/state-function-deps.ts +2 -1
  38. package/src/cli/lifecycle-verify.ts +0 -232
@@ -32,6 +32,7 @@
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 { catalogInputsFromModels, lifecycleReconcileRefusal } from '../lifecycle-preflight';
35
36
  import { planReconcile, objectGrantDelta, TRIGGER_ENABLED_STATE, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
36
37
  import {
37
38
  renderReconcileSql,
@@ -77,6 +78,18 @@ export interface ExecuteOptions extends ReconcileOptions {
77
78
  planRef?: string | null;
78
79
  /** Descriptor-compiled objects (the declared derived layer) — the ONE source stream. */
79
80
  declared?: SourceObject[];
81
+ /**
82
+ * The application roles for the lifecycle catalog check (catalogInputsFromModels). Required
83
+ * whenever a declared object is lifecycle-owned: absent, the reconcile refuses rather than skip
84
+ * the check. `unknown` because the ops Lambda passes it straight off the wire.
85
+ */
86
+ applicationRoles?: unknown;
87
+ /**
88
+ * The caller already ran lifecycleApplyRefusal with the full models, on this runner, in this
89
+ * process (executeSync does, first thing), so the reconcile does not run the catalog check again.
90
+ * Never set from a wire payload.
91
+ */
92
+ lifecyclePreflighted?: boolean;
80
93
  /** Injectable clock for tests. */
81
94
  now?: () => number;
82
95
  }
@@ -89,6 +102,8 @@ export interface ReconcileRun {
89
102
  statements: string[];
90
103
  /** Why apply was refused, when it was. */
91
104
  refusal?: string;
105
+ /** A plan-only run: what the lifecycle catalog check found (the text an apply would refuse with). */
106
+ lifecycleFindings?: string;
92
107
  /**
93
108
  * Which mechanism enacted the declared owners, when any were enacted. Reported because the two
94
109
  * make different demands of the operator: `set-role` needs membership plus CREATE-on-schema for
@@ -140,13 +155,21 @@ export async function executeReconcile(
140
155
  // catalog and re-renders if this builder can use the cheaper one; a plan that is never applied
141
156
  // (or is refused) shows the conservative form, which is the honest default.
142
157
  let rendered = renderReconcileSql(plan, parsed.objects);
143
-
144
- if (!options.apply) return { plan, applied: false, statements: rendered.statements };
158
+ // Every mode that writes or adopts a lifecycle-owned object (--apply with --baseline,
159
+ // --rebaseline, --only, --overwrite-drift or none) refuses on it below; a plan-only run reports
160
+ // it as a finding. See lifecycleCatalogRefusal for what it defends and from whom.
161
+ const lifecycleGate = (): Promise<string | null> => (options.lifecyclePreflighted ? Promise.resolve(null)
162
+ : lifecycleReconcileRefusal(runner, parsed.objects, { applicationRoles: options.applicationRoles }));
163
+
164
+ if (!options.apply) {
165
+ const lifecycleFindings = await lifecycleGate();
166
+ return { plan, applied: false, statements: rendered.statements, ...(lifecycleFindings ? { lifecycleFindings } : {}) };
167
+ }
145
168
 
146
169
  if (plan.blocked.length > 0) {
147
170
  return {
148
171
  plan, applied: false, statements: rendered.statements,
149
- refusal: `blocked chains: ${plan.blocked.map((b) => b.identity).join(', ')} — resolve the unmanaged dependents first`,
172
+ refusal: `blocked chains: ${plan.blocked.map((b) => `${b.identity} (${b.reason})`).join('; ')} — resolve the dependents first`,
150
173
  };
151
174
  }
152
175
  if (plan.drift.length > 0 && !options.overwriteDrift) {
@@ -155,6 +178,8 @@ export async function executeReconcile(
155
178
  refusal: `drift on ${plan.drift.map((d) => d.identity).join(', ')} — inspect, then re-run with --overwrite-drift to rebuild from source`,
156
179
  };
157
180
  }
181
+ const lifecycleRefusal = await lifecycleGate();
182
+ if (lifecycleRefusal) return { plan, applied: false, statements: rendered.statements, refusal: lifecycleRefusal };
158
183
  if (rendered.statements.length === 0 && rendered.record.length === 0 && rendered.remove.length === 0 && rendered.migrate.length === 0) {
159
184
  return { plan, applied: false, statements: [] };
160
185
  }
@@ -740,6 +765,9 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
740
765
  const reconcileOptions = {
741
766
  apply,
742
767
  declared: declared?.objects,
768
+ // The lifecycle catalog check's roles, from the barrel's models. The tables come from the
769
+ // declared transition triggers inside the reconcile, the same derivation the ops Lambda runs.
770
+ applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
743
771
  renamedTables: declared?.renamedTables,
744
772
  baseline: flags.baseline === 'true',
745
773
  rebaseline: flags.rebaseline === 'true',
@@ -820,9 +848,10 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
820
848
  }
821
849
 
822
850
  if (flags.json === 'true') {
823
- console.log(JSON.stringify({ ...run.plan, applied: run.applied, refusal: run.refusal ?? null }, null, 2));
851
+ console.log(JSON.stringify({ ...run.plan, applied: run.applied, refusal: run.refusal ?? null, lifecycleFindings: run.lifecycleFindings ?? null }, null, 2));
824
852
  } else {
825
853
  for (const line of buildReconcileReport(run.plan)) info(line);
854
+ if (run.lifecycleFindings) warn(`lifecycle: ${run.lifecycleFindings}`);
826
855
  if (run.refusal) {
827
856
  fail(`not applied: ${run.refusal}`);
828
857
  } else if (run.applied) {
@@ -848,7 +877,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
848
877
  }
849
878
 
850
879
  if (run.refusal) process.exit(1);
851
- if (check && checkFails(run.plan)) process.exit(1);
880
+ if (check && (checkFails(run.plan) || run.lifecycleFindings)) process.exit(1);
852
881
  } catch (err: any) {
853
882
  if (err instanceof MutationLeaseError) {
854
883
  // The lease refused before any DDL — nothing was reconciled.
@@ -31,7 +31,8 @@ import fs from 'node:fs/promises';
31
31
  import path from 'node:path';
32
32
  import type { ModelDescriptor } from '@everystack/model';
33
33
  import { introspectContract, type QueryRunner } from '../authz-contract';
34
- import { introspectSchema } from '../schema-introspect';
34
+ import { catalogInputsFromModels, lifecycleApplyRefusal } from '../lifecycle-preflight';
35
+ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
35
36
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
36
37
  import { generateMigrationSql, unmodeledTables } from '../migration-generate';
37
38
  import type { SequenceDescriptor } from '@everystack/model';
@@ -112,6 +113,18 @@ export interface SyncRun {
112
113
  converged: boolean;
113
114
  }
114
115
 
116
+ /** The live keys into a lifecycle model whose drop the generated statements hold back, as `<constraint> on <table> references lifecycle table <parent>`. */
117
+ function heldLifecycleKeyDrops(heldDrops: readonly string[], current: SchemaSnapshot, models: readonly ModelDescriptor[]): string[] {
118
+ if (heldDrops.length === 0) return [];
119
+ const lifecycle = new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`));
120
+ const qualify = (name: string): string => (name.includes('.') ? name : `public.${name}`);
121
+ const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
122
+ return current.tables.flatMap(table => table.foreignKeys
123
+ .filter(fk => fk.name && lifecycle.has(qualify(fk.refTable))
124
+ && heldDrops.some(held => new RegExp(`DROP CONSTRAINT (IF EXISTS )?"?${escape(fk.name!)}"?;`).test(held)))
125
+ .map(fk => `${fk.name} on ${qualify(table.table)} references lifecycle table ${qualify(fk.refTable)}`));
126
+ }
127
+
115
128
  /**
116
129
  * One sync: state first (derived objects may depend on new columns), then
117
130
  * compute, then the verdict. The exception is the declared functions the
@@ -132,13 +145,23 @@ async function syncOnce(
132
145
  allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
133
146
  governedRoles: options.governedRoles, extensions: options.extensions,
134
147
  });
135
- hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements), pass);
148
+ const classified = classifyGeneratedStatements(statements);
149
+ hooks.onStatePlan?.(statements, classified, pass);
150
+ // A held drop of a key into a lifecycle table leaves a key the after-state catalog check refuses,
151
+ // after the rest of the state has landed and before any lifecycle function is installed. Refuse
152
+ // here instead, before anything is written.
153
+ const heldKeys = heldLifecycleKeyDrops(classified.heldDrops, current, models);
154
+ if (heldKeys.length > 0) {
155
+ throw new Error(`db:sync would hold back the drop of ${heldKeys.length === 1 ? 'a foreign key' : 'foreign keys'} into a lifecycle table, `
156
+ + `and the lifecycle catalog check refuses ${heldKeys.length === 1 ? 'it' : 'them'} after the state apply: ${heldKeys.join('; ')}. `
157
+ + 'Nothing was written. Only a lifecycle table may hold a key into one; re-run with --allow-drops to drop it.');
158
+ }
136
159
 
137
160
  // The one derived → state edge: a declared function a pending policy (or default, or check)
138
161
  // calls must exist before that statement is created. Only those functions, only when absent.
139
162
  if (options.declared?.length) {
140
163
  await createStateReferencedFunctions(runner, session, statements, options.declared, {
141
- actor: options.actor, gitRef: options.gitRef, now: options.now,
164
+ actor: options.actor, gitRef: options.gitRef, now: options.now, lifecyclePreflighted: true,
142
165
  });
143
166
  }
144
167
 
@@ -157,6 +180,9 @@ async function syncOnce(
157
180
  declared: options.declared,
158
181
  renamedTables: options.renamedTables,
159
182
  actor: options.actor, gitRef: options.gitRef, now: options.now,
183
+ // The catalog check runs again here, after the state apply: a table this sync just created can
184
+ // carry a finding (a default privilege, its owner) the check before the state apply could not see.
185
+ applicationRoles: catalogInputsFromModels(models).applicationRoles,
160
186
  });
161
187
 
162
188
  const declaredFingerprint = fingerprintModels(models, { sequences: options.sequences }).hash;
@@ -213,6 +239,9 @@ export async function executeSync(
213
239
  options: SyncOptions = {},
214
240
  hooks: SyncHooks = {},
215
241
  ): Promise<SyncRun> {
242
+ // State runs before compute, so without this a lifecycle table would land with no transition trigger.
243
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
244
+ if (lifecycleRefusal) throw new Error(lifecycleRefusal);
216
245
  const first = await syncOnce(runner, session, models, options, hooks, 1);
217
246
  // The trigger is the STATE bar, not the combined verdict. The cause above is state-layer
218
247
  // only, so a compute refusal or an unadopted object cannot be settled by looking again —
@@ -98,6 +98,28 @@ export function pgKeepaliveConninfo(dbName?: string): string {
98
98
  return parts.join(' ');
99
99
  }
100
100
 
101
+ /**
102
+ * The verdict for a fire-and-forget ops-Lambda action (migrate, reset, seed) — DB-free. The one
103
+ * guarantee it holds: a result carrying `error` is a FAILURE and never reports success. Extracted
104
+ * from the dispatcher shells so the "error never reports success" boundary is asserted by a test
105
+ * (closing-gate F1 method); the shell still owns the IO, the exit code, and the thrown-error path.
106
+ */
107
+ export type OpsActionVerdict =
108
+ | { ok: true; message: string; warnings: string[] }
109
+ | { ok: false; message: string };
110
+
111
+ export function opsActionResultVerdict(
112
+ result: { error?: unknown; message?: unknown; warnings?: unknown } | null | undefined,
113
+ opts: { failPrefix: string; successFallback: string },
114
+ ): OpsActionVerdict {
115
+ if (result?.error) return { ok: false, message: `${opts.failPrefix}: ${result.error}` };
116
+ return {
117
+ ok: true,
118
+ message: typeof result?.message === 'string' && result.message ? result.message : opts.successFallback,
119
+ warnings: Array.isArray(result?.warnings) ? (result!.warnings as string[]) : [],
120
+ };
121
+ }
122
+
101
123
  export async function dbMigrateCommand(flags: Record<string, string>): Promise<void> {
102
124
  step('Resolving deployed config...');
103
125
  let config;
@@ -118,14 +140,15 @@ export async function dbMigrateCommand(flags: Record<string, string>): Promise<v
118
140
  'migrate',
119
141
  {},
120
142
  );
121
- if (result?.error) {
122
- fail(`Migration failed: ${result.error}`);
143
+ const verdict = opsActionResultVerdict(result, { failPrefix: 'Migration failed', successFallback: 'Migrations applied' });
144
+ if (!verdict.ok) {
145
+ fail(verdict.message);
123
146
  process.exit(1);
124
147
  }
125
- success(result?.message || 'Migrations applied');
148
+ success(verdict.message);
126
149
  // Journal drift warnings — a migration file the journal doesn't list
127
150
  // will silently never apply. Never swallow these.
128
- for (const warning of result?.warnings ?? []) {
151
+ for (const warning of verdict.warnings) {
129
152
  warn(warning);
130
153
  }
131
154
  } catch (err: any) {
@@ -192,11 +215,12 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
192
215
  'db:reset',
193
216
  {},
194
217
  );
195
- if (result?.error) {
196
- fail(`Reset failed: ${result.error}`);
218
+ const verdict = opsActionResultVerdict(result, { failPrefix: 'Reset failed', successFallback: 'Database reset and migrations applied' });
219
+ if (!verdict.ok) {
220
+ fail(verdict.message);
197
221
  process.exit(1);
198
222
  }
199
- success(result?.message || 'Database reset and migrations applied');
223
+ success(verdict.message);
200
224
  } catch (err: any) {
201
225
  fail(`Reset failed: ${err.message}`);
202
226
  for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
@@ -1382,10 +1406,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1382
1406
  }
1383
1407
 
1384
1408
  /** Parse the explicit day window once, before choosing a connection venue. */
1385
- export function purgeAfterMilliseconds(days: string | undefined): number | undefined {
1409
+ export function destroyAfterMilliseconds(days: string | undefined): number | undefined {
1386
1410
  if (days === undefined) return undefined;
1387
1411
  const milliseconds = Number(days) * 86400000;
1388
- if (!Number.isFinite(milliseconds) || milliseconds <= 0) throw new Error('--purge-after needs a positive number of days');
1412
+ if (!Number.isFinite(milliseconds) || milliseconds <= 0) throw new Error('--destroy-after needs a positive number of days');
1389
1413
  return milliseconds;
1390
1414
  }
1391
1415
 
@@ -1397,8 +1421,8 @@ async function doctorOverdueRows(
1397
1421
  if (after === undefined) return undefined;
1398
1422
  const { loadModels } = await import('./db-generate');
1399
1423
  const { resolveModelsPath } = await import('../models-path');
1400
- const spec = '@everystack/server/purge';
1401
- const { overdueLifecycleRows } = await import(spec) as typeof import('@everystack/server/purge');
1424
+ const spec = '@everystack/server/destroy';
1425
+ const { overdueLifecycleRows } = await import(spec) as typeof import('@everystack/server/destroy');
1402
1426
  const { PgDialect } = await import('drizzle-orm/pg-core');
1403
1427
  const dialect = new PgDialect();
1404
1428
  const models = await loadModels(resolveModelsPath(modelsPath));
@@ -1420,10 +1444,11 @@ export function printDoctorReport(report: any): void {
1420
1444
  }
1421
1445
 
1422
1446
  console.log('');
1423
- if (report.overdueLifecycleRows === undefined) info('Lifecycle purge: skipped; pass --purge-after <days> to count overdue rows');
1447
+ if (report.overdueLifecycleRows === undefined) info('Lifecycle destroy: skipped; pass --destroy-after <days> to count overdue rows');
1424
1448
  else {
1425
- info('Lifecycle purge: overdue rows');
1449
+ info('Lifecycle destroy: overdue rows');
1426
1450
  for (const [table, count] of Object.entries(report.overdueLifecycleRows)) info(` ${table}: ${count}`);
1451
+ info(' Why roots are skipped is reported by destroyExpired (see the destroy cron log); doctor counts overdue rows only.');
1427
1452
  }
1428
1453
  console.log('');
1429
1454
  if (report.ok) {
@@ -1453,7 +1478,7 @@ export function printDoctorReport(report: any): void {
1453
1478
  * forceRlsCarveouts/functionSchemas are unknown to the CLI, so the functions teeth run at
1454
1479
  * their safe default (unpinned SECDEF warns rather than fails). Returns report.ok.
1455
1480
  */
1456
- async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: string, purgeAfter: number | undefined, modelsPath: string | undefined): Promise<boolean> {
1481
+ async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: string, destroyAfter: number | undefined, modelsPath: string | undefined): Promise<boolean> {
1457
1482
  const { runDoctorProbe } = await loadServerDoctor();
1458
1483
  const { createUrlRunner } = await import('../db-source');
1459
1484
  step('Probing database connections (api + operator)...');
@@ -1464,7 +1489,7 @@ async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: st
1464
1489
  { api: apiConn.runner, ops: opsConn.runner },
1465
1490
  { apiSourceLabel: sourceLabel },
1466
1491
  );
1467
- const overdueLifecycleRows = await doctorOverdueRows(purgeAfter, modelsPath, opsConn.runner);
1492
+ const overdueLifecycleRows = await doctorOverdueRows(destroyAfter, modelsPath, opsConn.runner);
1468
1493
  printDoctorReport({ ...report, overdueLifecycleRows });
1469
1494
  return report.ok;
1470
1495
  } finally {
@@ -1474,9 +1499,9 @@ async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: st
1474
1499
  }
1475
1500
 
1476
1501
  export async function dbDoctorCommand(flags: Record<string, string>): Promise<void> {
1477
- let purgeAfter: number | undefined;
1502
+ let destroyAfter: number | undefined;
1478
1503
  try {
1479
- purgeAfter = purgeAfterMilliseconds(flags['purge-after']);
1504
+ destroyAfter = destroyAfterMilliseconds(flags['destroy-after']);
1480
1505
  } catch (error) {
1481
1506
  fail(error instanceof Error ? error.message : String(error));
1482
1507
  process.exit(1);
@@ -1495,7 +1520,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
1495
1520
  info('venue: direct connection (explicit --database-url)');
1496
1521
  let ok = false;
1497
1522
  try {
1498
- ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, '--database-url', purgeAfter, flags.models);
1523
+ ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, '--database-url', destroyAfter, flags.models);
1499
1524
  } catch (err: any) {
1500
1525
  fail(`db:doctor failed: ${err.message}`);
1501
1526
  process.exit(1);
@@ -1535,7 +1560,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
1535
1560
  info(`Region: ${config.region}, venue: direct connection (stage secrets)`);
1536
1561
  let ok = false;
1537
1562
  try {
1538
- ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, 'DATABASE_URL secret', purgeAfter, flags.models);
1563
+ ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, 'DATABASE_URL secret', destroyAfter, flags.models);
1539
1564
  } catch (err: any) {
1540
1565
  fail(`db:doctor failed: ${err.message}`);
1541
1566
  process.exit(1);
@@ -1560,7 +1585,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
1560
1585
  info('No Ops Lambda (the deployed handler has no dbPlugin) — verifying directly over the stage secrets.');
1561
1586
  let ok = false;
1562
1587
  try {
1563
- ok = await runDoctorDirect(venue.apiUrl, venue.adminUrl, 'DATABASE_URL secret', purgeAfter, flags.models);
1588
+ ok = await runDoctorDirect(venue.apiUrl, venue.adminUrl, 'DATABASE_URL secret', destroyAfter, flags.models);
1564
1589
  } catch (err: any) {
1565
1590
  fail(`db:doctor failed: ${err.message}`);
1566
1591
  process.exit(1);
@@ -1590,7 +1615,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
1590
1615
 
1591
1616
  const { lambdaSessionRunner } = await import('../aws');
1592
1617
  const session = lambdaSessionRunner(config.region, opsFunction(config));
1593
- report.overdueLifecycleRows = await doctorOverdueRows(purgeAfter, flags.models, async query => {
1618
+ report.overdueLifecycleRows = await doctorOverdueRows(destroyAfter, flags.models, async query => {
1594
1619
  const [rows] = await session([query], { readOnly: true, searchPath: 'pg_catalog' });
1595
1620
  if (!Array.isArray(rows)) throw new Error('overdue lifecycle count failed');
1596
1621
  return rows;
@@ -10,7 +10,7 @@
10
10
  * state lands EXACTLY on the models' fingerprint.
11
11
  */
12
12
 
13
- import type { ModelDescriptor } from '@everystack/model';
13
+ import { ENSURE_DESTROY_LOG_SQL, type ModelDescriptor } from '@everystack/model';
14
14
  import type { TableContract, QueryRunner } from './authz-contract';
15
15
  import type { SourceObject } from './derived-source';
16
16
  import type { SequenceDescriptor } from '@everystack/model';
@@ -18,6 +18,7 @@ import { compileDeclaredState } from './declared-diff';
18
18
  import { createUrlRunner } from './db-source';
19
19
  import { executeSync, buildSyncReport } from './commands/db-sync';
20
20
  import { executeReconcile } from './commands/db-reconcile';
21
+ import { lifecycleApplyRefusal } from './lifecycle-preflight';
21
22
 
22
23
  const SAFE_NAME = /^[a-z_][a-z0-9_$]*$/;
23
24
 
@@ -143,6 +144,9 @@ export async function buildIntoDatabase(
143
144
  // legacy apps do create objects in `public` at runtime.
144
145
  await runner('REVOKE CREATE ON SCHEMA public FROM PUBLIC');
145
146
  const createdRoles = await ensureContractRoles(runner, models);
147
+ if (models.some(model => model.lifecycle)) {
148
+ for (const statement of ENSURE_DESTROY_LOG_SQL) await runner(statement);
149
+ }
146
150
 
147
151
  // FUNCTIONS BEFORE STATE. An RLS policy's predicate is resolved when the policy is
148
152
  // created, so `USING (user_id = auth.user_id())` cannot be created before
@@ -180,11 +184,17 @@ export async function buildIntoDatabase(
180
184
  const declaredFunctions = (options.declared ?? [])
181
185
  .filter((o) => o.kind === 'function' && !o.signatureDeps?.length);
182
186
  if (declaredFunctions.length > 0) {
187
+ // The lifecycle catalog check, with the full models, before any lifecycle-owned function
188
+ // lands: this pass declares functions only, so it carries no transition trigger for the
189
+ // reconcile to read the lifecycle tables from. executeSync runs the same check again below.
190
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
191
+ if (lifecycleRefusal) throw new Error(lifecycleRefusal);
183
192
  await runner('SET check_function_bodies = off');
184
193
  try {
185
194
  const pre = await executeReconcile(runner, session, {
186
195
  declared: declaredFunctions,
187
196
  only: declaredFunctions.map(object => object.identity),
197
+ lifecyclePreflighted: true,
188
198
  apply: true,
189
199
  actor: options.actor ?? 'db-build',
190
200
  gitRef: options.gitRef ?? null,
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  import type { ModelDescriptor } from '@everystack/model';
30
- import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
30
+ import { assertExternalTargets, lifecycleDependentIndexes, relationDeleteActions } from '@everystack/model';
31
31
  import type { SchemaSnapshot } from './schema-introspect';
32
32
  import type { AuthzContract } from './authz-contract';
33
33
  import { compileTableSchema, compileEnums } from './schema-compile';
@@ -57,10 +57,11 @@ export function compileDeclaredState(
57
57
  ): DeclaredState {
58
58
  assertExternalTargets(models);
59
59
  const dependents = relationDeleteActions(models);
60
+ const dependentIndexes = lifecycleDependentIndexes(models);
60
61
  const schema = opts.schema ?? 'public';
61
62
  return {
62
63
  snapshot: {
63
- tables: models.map((m) => compileTableSchema(m, { schema, dependents })),
64
+ tables: models.map((m) => compileTableSchema(m, { schema, dependents, dependentIndexes })),
64
65
  enums: compileEnums(models),
65
66
  },
66
67
  contract: {
@@ -607,7 +607,11 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
607
607
  // The new kinds carry their drop on the action (triggers: composed from structure;
608
608
  // 'sql' objects: the recorded/declared drop_sql) — the legacy kinds keep the
609
609
  // keyword rendering they always had.
610
- statements.push(action.dropSql ?? `DROP ${DROP_KEYWORD[action.kind]} IF EXISTS ${dropTarget(action.kind, action.identity)}`);
610
+ const dropSql = action.dropSql ?? `DROP ${DROP_KEYWORD[action.kind]} IF EXISTS ${dropTarget(action.kind, action.identity)}`;
611
+ // A rebuilt function with a declared owner drops AS that owner, as its create runs: DROP
612
+ // needs ownership, and an operator holding the owner WITH INHERIT FALSE, SET TRUE has none.
613
+ const owner = action.kind === 'function' && ownerMode === 'set-role' ? srcById.get(action.identity)?.owner : undefined;
614
+ statements.push(...(owner ? [`SET ROLE ${owner}`, dropSql, 'RESET ROLE'] : [dropSql]));
611
615
  // A rebuild's drop is followed by its create; only a true removal loses provenance.
612
616
  if (!plan.actions.some((a) => a.action === 'create' && a.identity === action.identity)) {
613
617
  remove.push(action.identity);
@@ -35,6 +35,7 @@ import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../wire';
35
35
  import { resolveConfig, opsFunction } from './config';
36
36
  import { invokeAction } from './aws';
37
37
  import { currentGitRef } from './state-apply';
38
+ import { catalogInputsFromModels } from './lifecycle-preflight';
38
39
 
39
40
  /** Where the catch-up runs. The two lanes db:apply itself already has. */
40
41
  export type CatchUpVenue =
@@ -108,6 +109,9 @@ export async function catchUpDerived(
108
109
  apply: true,
109
110
  declared: objects,
110
111
  renamedTables: declared?.renamedTables,
112
+ // The lifecycle catalog check's roles; the reconcile, here or in the ops Lambda, refuses
113
+ // lifecycle-owned objects without them.
114
+ applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
111
115
  actor: process.env.USER ?? null,
112
116
  gitRef: currentGitRef(),
113
117
  };
@@ -207,7 +207,8 @@ function renderFunction(fn: FunctionDescriptor): { sql: string; attachments: Att
207
207
  const pin = Array.isArray(fn.searchPath) && fn.searchPath.length > 0
208
208
  ? ` SET search_path = ${fn.searchPath.join(', ')}`
209
209
  : '';
210
- const security = `${definer}${pin}`;
210
+ const lockTimeout = fn.lockTimeout === undefined ? '' : ` SET lock_timeout = '${escapeLiteral(fn.lockTimeout)}'`;
211
+ const security = `${definer}${pin}${lockTimeout}`;
211
212
  const tag = dollarTag(fn.body);
212
213
  const signature = functionSignature(target, fn);
213
214
  return {
@@ -112,8 +112,17 @@ export interface LiveObject {
112
112
  volatility: string;
113
113
  /** The pinned search_path value, when set ('pg_catalog, public'). */
114
114
  searchPath?: string;
115
+ /** Function-scoped lock_timeout, when set. */
116
+ lockTimeout?: string;
115
117
  /** prosrc — the raw body. */
116
118
  src: string;
119
+ /**
120
+ * Every object pg_depend records as depending on this function (normal and auto edges), as
121
+ * pg_describe_object prints it: a view's rule, a trigger, another function's argument default,
122
+ * a column default, a policy. What a DROP FUNCTION without CASCADE would refuse over. Absent
123
+ * when not measured (an older read or fixture).
124
+ */
125
+ dependents?: string[];
117
126
  };
118
127
  }
119
128
 
@@ -241,7 +250,14 @@ SELECT
241
250
  (pg_get_userbyid(p.proowner) IN (CURRENT_USER, SESSION_USER)) AS owner_is_connected_role,
242
251
  (SELECT split_part(cfg, '=', 2) FROM unnest(COALESCE(p.proconfig, '{}'::text[])) cfg
243
252
  WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path,
244
- p.prosrc AS src
253
+ (SELECT split_part(cfg, '=', 2) FROM unnest(COALESCE(p.proconfig, '{}'::text[])) cfg
254
+ WHERE cfg LIKE 'lock_timeout=%' LIMIT 1) AS lock_timeout,
255
+ p.prosrc AS src,
256
+ ARRAY(
257
+ SELECT pg_describe_object(d.classid, d.objid, d.objsubid) FROM pg_depend d
258
+ WHERE d.refclassid = 'pg_proc'::regclass AND d.refobjid = p.oid AND d.deptype IN ('n', 'a')
259
+ ORDER BY 1
260
+ ) AS dependents
245
261
  FROM pg_proc p
246
262
  JOIN pg_namespace n ON n.oid = p.pronamespace
247
263
  JOIN pg_language l ON l.oid = p.prolang
@@ -550,6 +566,9 @@ export interface FunctionRow {
550
566
  secdef?: unknown;
551
567
  volatility?: unknown;
552
568
  search_path?: unknown;
569
+ lock_timeout?: unknown;
570
+ /** See LiveObject.fn.dependents. Optional: absent means not measured. */
571
+ dependents?: unknown;
553
572
  /** `pg_get_userbyid(proowner)` — see LiveObject.owner. Optional: pre-owner fixtures omit it. */
554
573
  owner?: unknown;
555
574
  /** See LiveObject.ownerIsSuperuser. Optional: absent means not measured. */
@@ -743,7 +762,9 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
743
762
  secdef: row.secdef === true || row.secdef === 't' || row.secdef === 'true',
744
763
  volatility: String(row.volatility ?? 'v'),
745
764
  ...(row.search_path != null && String(row.search_path) !== '' ? { searchPath: String(row.search_path) } : {}),
765
+ ...(row.lock_timeout != null ? { lockTimeout: String(row.lock_timeout) } : {}),
746
766
  src: String(row.src),
767
+ ...(row.dependents !== undefined ? { dependents: pgTextArray(row.dependents) } : {}),
747
768
  },
748
769
  } : {}),
749
770
  });
@@ -647,6 +647,29 @@ export function planReconcile(
647
647
  }
648
648
  }
649
649
 
650
+ // A replace whose RESULT type changes cannot be a CREATE OR REPLACE: PostgreSQL refuses it
651
+ // (42P13). Decided here, before any DDL: it becomes DROP + CREATE (the rebuild lane, so the
652
+ // create runs its owner block and attachments like a fresh build), and is refused, naming
653
+ // them, while anything depends on the function. The drop never cascades.
654
+ for (const id of [...fnReplace.keys()]) {
655
+ const src = srcById.get(id);
656
+ const liveObj = liveById.get(id);
657
+ const declared = src ? declaredResult(src.sql) : undefined;
658
+ const current = liveObj?.fn?.returns;
659
+ if (declared === undefined || current === undefined || sameResult(declared, current)) continue;
660
+ fnReplace.delete(id);
661
+ const change = `result type changes (live ${current}, declared ${declared})`;
662
+ const dependentsOf = [...new Set([...closureOf(id), ...(liveObj?.fn?.dependents ?? [])])].sort();
663
+ if (dependentsOf.length > 0) {
664
+ blocked.push({
665
+ identity: id,
666
+ reason: `${change}, which needs DROP + CREATE; refused while other objects depend on it: ${dependentsOf.join(', ')}. Drop or change them first; nothing is dropped with CASCADE`,
667
+ });
668
+ continue;
669
+ }
670
+ rebuild.set(id, `${change}: replaced by DROP + CREATE`);
671
+ }
672
+
650
673
  // Matviews that call a replaced function hold stale rows — refresh them,
651
674
  // unless the rebuild already recreates them fresh.
652
675
  const refresh = new Map<string, string>();
@@ -773,3 +796,44 @@ export function planReconcile(
773
796
  ...(reenables.length ? { reenables: reenables.sort((a, b) => a.identity.localeCompare(b.identity)) } : {}),
774
797
  };
775
798
  }
799
+
800
+ /** The RETURNS clause of a function's CREATE statement, or undefined when it cannot be read. */
801
+ export function declaredResult(createSql: string): string | undefined {
802
+ const header = createSql.split(/\bAS\s+\$/i)[0];
803
+ const m = /\bRETURNS\s+([\s\S]+?)\s+LANGUAGE\s/i.exec(header);
804
+ return m ? m[1].trim() : undefined;
805
+ }
806
+
807
+ const TYPE_ALIASES: Array<[RegExp, string]> = [
808
+ [/\btimestamptz\b/g, 'timestamp with time zone'],
809
+ [/\btimestamp\b(?!\s*\(?\d*\)?\s*with(out)?\s+time\s+zone)/g, 'timestamp without time zone'],
810
+ [/\btimetz\b/g, 'time with time zone'],
811
+ [/(?<!with |without )\btime\b(?!\s+with(out)?\s+time\s+zone)/g, 'time without time zone'],
812
+ [/\bbpchar\b|\bchar\b/g, 'character'],
813
+ [/\bint4\b|\bint\b/g, 'integer'],
814
+ [/\bint8\b/g, 'bigint'],
815
+ [/\bint2\b/g, 'smallint'],
816
+ [/\bbool\b/g, 'boolean'],
817
+ [/\bfloat8\b/g, 'double precision'],
818
+ [/\bfloat4\b/g, 'real'],
819
+ [/\bvarchar\b/g, 'character varying'],
820
+ [/\bdecimal\b/g, 'numeric'],
821
+ ];
822
+
823
+ /**
824
+ * Whether a declared RETURNS clause and pg_get_function_result name the same result: case,
825
+ * quotes, whitespace, schema qualifiers, type modifiers (which the catalog does not keep on a
826
+ * result) and the common type aliases are not differences. A
827
+ * spelling this misses only turns an already-planned replace into DROP + CREATE.
828
+ */
829
+ export function sameResult(declared: string, live: string): boolean {
830
+ const norm = (text: string): string => {
831
+ let out = text.toLowerCase().replace(/"/g, '').replace(/\s+/g, ' ').trim();
832
+ out = out.replace(/\b[a-z_][a-z0-9_$]*\.(?=[a-z_])/g, '');
833
+ // pg_get_function_result drops type modifiers: numeric(10,2) prints as numeric.
834
+ out = out.replace(/\s*\(\s*\d+\s*(,\s*\d+\s*)?\)/g, '');
835
+ for (const [pattern, to] of TYPE_ALIASES) out = out.replace(pattern, to);
836
+ return out.replace(/\s*([(),])\s*/g, '$1');
837
+ };
838
+ return norm(declared) === norm(live);
839
+ }
@@ -754,6 +754,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
754
754
  } else if (f.secdef) {
755
755
  props.push(`searchPath: 'unpinned',`);
756
756
  }
757
+ if (f.lockTimeout !== undefined) props.push(`lockTimeout: ${tsString(f.lockTimeout)},`);
757
758
  // Ownership sits beside security because it IS the other half of it: `security: 'definer'`
758
759
  // says the function runs as its owner, and this says who that is.
759
760
  if (ownership.prop) props.push(ownership.prop);
@@ -24,6 +24,7 @@
24
24
  * JSON — recorded as `plan_ref` on the schema_log row the apply writes.
25
25
  */
26
26
 
27
+ import { indexLockWarnings, type LockWarning } from './index-lock';
27
28
  import { createHash } from 'node:crypto';
28
29
  import type { Attachment } from './derived-source';
29
30
  import type { OwnerApplyMode } from './derived-apply';
@@ -253,6 +254,18 @@ export interface EdgePlan {
253
254
  * plans minted before this field existed; the summary falls back to the text-only reading.
254
255
  */
255
256
  dark?: string[];
257
+ /**
258
+ * The plain CREATE INDEX statements that build on a live table with rows, with the catalog's row
259
+ * estimate — db:plan's ⚠ LOCK lines. Read at mint, like `dark`; informational, never a gate, and
260
+ * outside both fingerprints. Absent on plans minted before it, or when there is nothing to warn.
261
+ */
262
+ lockWarnings?: LockWarning[];
263
+ /**
264
+ * The read policies this plan creates or replaces for a can('read', { role, deleted }) ability:
265
+ * db:plan's `⚠ ACCESS — role <r> can read deleted rows of <table>` lines. Informational, outside
266
+ * both fingerprints. Absent when there is none, and on plans minted before it.
267
+ */
268
+ deletedReads?: DeletedRead[];
256
269
  /** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
257
270
  unmodeled: string[];
258
271
  gitRef: string | null;
@@ -564,6 +577,26 @@ function unplannableReason(change: SchemaChange): string | null {
564
577
  }
565
578
  }
566
579
 
580
+ /** A read policy, created or replaced by this plan, that lets a role see deleted lifecycle rows. */
581
+ export interface DeletedRead { table: string; role: string; deleted: 'include' | 'only' }
582
+
583
+ /** The can('read', { role, deleted }) policies whose CREATE POLICY this plan runs. */
584
+ export function deletedReadChanges(models: readonly ModelDescriptor[], statements: readonly string[]): DeletedRead[] {
585
+ const out: DeletedRead[] = [];
586
+ for (const m of models) {
587
+ if (m.lifecycle !== true) continue;
588
+ for (const a of m.abilities) {
589
+ if (a.action !== 'read' || !a.condition.deleted || !a.condition.role) continue;
590
+ const name = `${m.table}_select_${a.condition.role.toLowerCase()}`;
591
+ // The emitter writes the name bare when it is a plain identifier, quoted otherwise.
592
+ const created = new RegExp(`^\\s*CREATE POLICY (?:"${name}"|${name}) `, 'm');
593
+ if (!statements.some((st) => created.test(st))) continue;
594
+ out.push({ table: m.schema && m.schema !== 'public' ? `${m.schema}.${m.table}` : m.table, role: a.condition.role, deleted: a.condition.deleted });
595
+ }
596
+ }
597
+ return out;
598
+ }
599
+
567
600
  export function mintEdgePlan(
568
601
  models: ModelDescriptor[],
569
602
  snapshot: SchemaSnapshot,
@@ -660,6 +693,8 @@ export function mintEdgePlan(
660
693
  // contract. The summary is rendered later from the plan alone, and answering "does a read
661
694
  // survive?" from statement text is what produced three false positives on a real plan.
662
695
  dark: tablesLeftWithoutARead(statements, contract),
696
+ ...(() => { const lockWarnings = indexLockWarnings(statements, snapshot); return lockWarnings.length ? { lockWarnings } : {}; })(),
697
+ ...(() => { const deletedReads = deletedReadChanges(models, statements); return deletedReads.length ? { deletedReads } : {}; })(),
663
698
  unmodeled: unmodeledTables(models, snapshot),
664
699
  gitRef: opts.gitRef ?? null,
665
700
  mintedBy: opts.actor ?? null,