@everystack/cli 0.4.103 → 0.4.105

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.
@@ -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, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError } 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,13 @@ 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);
245
+ // Before anything is written, so before both places a pass creates derived objects (the
246
+ // functions the state batch calls, then the reconcile).
247
+ const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false });
248
+ if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
216
249
  const first = await syncOnce(runner, session, models, options, hooks, 1);
217
250
  // The trigger is the STATE bar, not the combined verdict. The cause above is state-layer
218
251
  // 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, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal } 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
@@ -177,14 +181,25 @@ export async function buildIntoDatabase(
177
181
  // declared state: 20 such pairs, `type "…_row" does not exist`, from-scratch only. Their
178
182
  // explicit `dependsOn` could not have helped — this pass dropped the view from the wave, so
179
183
  // no edge could order it back in.
184
+ // Before the first derived write: the owners the reconcile will give the declared trigger
185
+ // functions on lifecycle tables. This pass runs before executeSync, which judges them again
186
+ // first thing, before its own state apply.
187
+ const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false });
188
+ if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
180
189
  const declaredFunctions = (options.declared ?? [])
181
190
  .filter((o) => o.kind === 'function' && !o.signatureDeps?.length);
182
191
  if (declaredFunctions.length > 0) {
192
+ // The lifecycle catalog check, with the full models, before any lifecycle-owned function
193
+ // lands: this pass declares functions only, so it carries no transition trigger for the
194
+ // reconcile to read the lifecycle tables from. executeSync runs the same check again below.
195
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
196
+ if (lifecycleRefusal) throw new Error(lifecycleRefusal);
183
197
  await runner('SET check_function_bodies = off');
184
198
  try {
185
199
  const pre = await executeReconcile(runner, session, {
186
200
  declared: declaredFunctions,
187
201
  only: declaredFunctions.map(object => object.identity),
202
+ lifecyclePreflighted: true,
188
203
  apply: true,
189
204
  actor: options.actor ?? 'db-build',
190
205
  gitRef: options.gitRef ?? null,
@@ -204,6 +219,10 @@ export async function buildIntoDatabase(
204
219
  actor: options.actor ?? 'db-build',
205
220
  gitRef: options.gitRef ?? null,
206
221
  });
222
+ // Verification: the live catalog check again, over what the derived layer just made live. A
223
+ // throw, so no caller (db:check's compose ring, the dev template, a test database) can miss it.
224
+ const post = await lifecyclePostApplyRefusal(session, models);
225
+ if (post) throw new LifecycleFindingsError(post);
207
226
  return {
208
227
  converged: run.converged,
209
228
  fingerprintMatch: run.fingerprintMatch,
@@ -181,6 +181,16 @@ function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role', re
181
181
  return [`SET ROLE ${obj.owner}`, ...statements, 'RESET ROLE'];
182
182
  }
183
183
 
184
+ /**
185
+ * The role that owns an object once the reconcile has created it: its declared owner (objectSql
186
+ * enacts it), else `builder`, the role the reconcile runs as, which creates it as itself. The
187
+ * lifecycle pre-exposure gate reads owners through this too, so the role it judges is the role the
188
+ * reconcile makes.
189
+ */
190
+ export function reconcileOwner(obj: Pick<SourceObject, 'owner'>, builder: string): string {
191
+ return obj.owner || builder;
192
+ }
193
+
184
194
  const ALTER_OWNER: Record<string, string> = {
185
195
  function: 'ALTER FUNCTION',
186
196
  view: 'ALTER VIEW',
@@ -228,7 +238,8 @@ export function triggerFunctionOf(trigger: SourceObject, source: readonly Source
228
238
  function withTransientExecute(obj: SourceObject, statements: string[], source: readonly SourceObject[], grantee?: string): string[] {
229
239
  if (!grantee || obj.kind !== 'trigger') return statements;
230
240
  const fn = triggerFunctionOf(obj, source);
231
- if (!fn?.owner) return statements;
241
+ // The builder owns a function it creates for itself, and an owner holds EXECUTE.
242
+ if (!fn || reconcileOwner(fn, grantee) === grantee) return statements;
232
243
  const declared = parseGrantAttachments(fn.attachments).grants[grantee] ?? [];
233
244
  if (declared.includes('EXECUTE') || declared.includes('ALL')) return statements;
234
245
  const target = dropTarget('function', fn.identity);
@@ -607,7 +618,11 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
607
618
  // The new kinds carry their drop on the action (triggers: composed from structure;
608
619
  // 'sql' objects: the recorded/declared drop_sql) — the legacy kinds keep the
609
620
  // keyword rendering they always had.
610
- statements.push(action.dropSql ?? `DROP ${DROP_KEYWORD[action.kind]} IF EXISTS ${dropTarget(action.kind, action.identity)}`);
621
+ const dropSql = action.dropSql ?? `DROP ${DROP_KEYWORD[action.kind]} IF EXISTS ${dropTarget(action.kind, action.identity)}`;
622
+ // A rebuilt function with a declared owner drops AS that owner, as its create runs: DROP
623
+ // needs ownership, and an operator holding the owner WITH INHERIT FALSE, SET TRUE has none.
624
+ const owner = action.kind === 'function' && ownerMode === 'set-role' ? srcById.get(action.identity)?.owner : undefined;
625
+ statements.push(...(owner ? [`SET ROLE ${owner}`, dropSql, 'RESET ROLE'] : [dropSql]));
611
626
  // A rebuild's drop is followed by its create; only a true removal loses provenance.
612
627
  if (!plan.actions.some((a) => a.action === 'create' && a.identity === action.identity)) {
613
628
  remove.push(action.identity);
@@ -33,8 +33,9 @@ import { loadDeclaredDerived, type DeclaredDerived } from './declared-derived';
33
33
  import { modelContractPreflight } from './model-wire-lane';
34
34
  import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../wire';
35
35
  import { resolveConfig, opsFunction } from './config';
36
- import { invokeAction } from './aws';
36
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from './aws';
37
37
  import { currentGitRef } from './state-apply';
38
+ import { catalogInputsFromModels, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal } 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 =
@@ -76,6 +77,12 @@ export interface CatchUpOptions {
76
77
  * wrongly handed an exit loses the sentence its operator was owed.
77
78
  */
78
79
  onRefusal: 'exit' | 'throw';
80
+ /**
81
+ * Whether THIS run committed table or column changes before the catch-up: a lifecycle refusal
82
+ * then says so. False for db:apply with no --plan, and for a plan that was already applied.
83
+ * Required for the same reason as `onRefusal`: only the caller knows what it wrote.
84
+ */
85
+ stateApplied: boolean;
79
86
  }
80
87
 
81
88
  export interface CatchUpOutcome {
@@ -108,15 +115,28 @@ export async function catchUpDerived(
108
115
  apply: true,
109
116
  declared: objects,
110
117
  renamedTables: declared?.renamedTables,
118
+ // The lifecycle catalog check's roles; the reconcile, here or in the ops Lambda, refuses
119
+ // lifecycle-owned objects without them.
120
+ applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
111
121
  actor: process.env.USER ?? null,
112
122
  gitRef: currentGitRef(),
113
123
  };
114
124
 
125
+ // Before the first derived write, judge the owners the reconcile will give the declared trigger
126
+ // functions on lifecycle tables.
127
+ const config = venue.kind === 'stage' ? await resolveConfig(venue.stage) : undefined;
128
+ const reader: QueryRunner = (q) => (venue.kind === 'direct' ? venue.runner : lambdaQueryRunner(config!.region, opsFunction(config!)))(q);
129
+ // The builder is read on the reconcile's own connection: on a stage, db:session runs on the
130
+ // operator connection, while db:query may run on a reader.
131
+ const operator: SessionRunner = (statements, opts) =>
132
+ (venue.kind === 'direct' ? venue.session : lambdaSessionRunner(config!.region, opsFunction(config!)))(statements, opts);
133
+ const gate = await lifecycleDeclaredOwnerRefusal(reader, operator, objects, declared?.models ?? [], { stateApplied: opts.stateApplied });
134
+ if (gate) throw new LifecycleFindingsError(gate);
135
+
115
136
  let run: ReconcileRun;
116
137
  if (venue.kind === 'direct') {
117
138
  run = await executeReconcile(venue.runner, venue.session, options);
118
139
  } else {
119
- const config = await resolveConfig(venue.stage);
120
140
  // The ops Lambda CREATES AND REPLACES database objects from these descriptors, so verify the
121
141
  // deployed stage understands their shape before sending them — the same handshake
122
142
  // db:reconcile --apply --stage runs, for the same payload.
@@ -126,14 +146,14 @@ export async function catchUpDerived(
126
146
  // before the process ends. A catch-up that is the whole command has written nothing, and
127
147
  // asks to exit: print the skew, say nothing was sent, exit.
128
148
  await modelContractPreflight({
129
- region: config.region,
130
- fn: opsFunction(config),
149
+ region: config!.region,
150
+ fn: opsFunction(config!),
131
151
  directCommand: `everystack db:apply --stage ${venue.stage} --direct`,
132
152
  onRefusal: opts.onRefusal,
133
153
  })('db:apply');
134
154
  const result: any = await invokeAction(
135
- config.region,
136
- opsFunction(config),
155
+ config!.region,
156
+ opsFunction(config!),
137
157
  'db:reconcile',
138
158
  { ...options, modelContract: MODEL_WIRE_CONTRACT } satisfies ReconcileWirePayload,
139
159
  );
@@ -150,5 +170,10 @@ export async function catchUpDerived(
150
170
  // not land, so db:apply must say so and exit the same way.
151
171
  if (run.refusal) throw new CatchUpError(run.refusal);
152
172
 
173
+ // Verification: the live catalog check again, over what the reconcile just made live. On the
174
+ // operator session, where the commit is: db:query may read a replica that has not caught up.
175
+ const post = await lifecyclePostApplyRefusal(operator, declared?.models ?? []);
176
+ if (post) throw new LifecycleFindingsError(post);
177
+
153
178
  return { declared: objects.length, run, statements: run.applied ? (run.statements ?? []) : [] };
154
179
  }
@@ -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);
package/src/cli/index.ts CHANGED
@@ -44,6 +44,8 @@ import { auditLighthouseCommand } from './commands/lighthouse';
44
44
  import { auditCommand } from './commands/audit';
45
45
  import { uiAuditCommand } from './commands/ui-audit';
46
46
  import { runbookCommand } from './commands/runbook';
47
+ import { readFileSync } from 'node:fs';
48
+ import path from 'node:path';
47
49
  import { fail } from './output';
48
50
  import { parseFlags } from './parse-flags';
49
51
 
@@ -56,6 +58,13 @@ async function main() {
56
58
  process.exit(0);
57
59
  }
58
60
 
61
+ if (command === '--version' || command === '-v') {
62
+ // tsx polyfills __dirname in ESM (see runbook.ts cliVersion).
63
+ const pkg = JSON.parse(readFileSync(path.join(eval('__dirname') as string, '..', '..', 'package.json'), 'utf8'));
64
+ console.log(pkg.version);
65
+ process.exit(0);
66
+ }
67
+
59
68
  const flags = parseFlags(args.slice(1));
60
69
 
61
70
  // `everystack <command> --help` shows help instead of running the command
@@ -312,7 +321,7 @@ Usage:
312
321
  everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
313
322
  everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
314
323
  everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (read-only). Runs as the SELECT-only 'reader' role when the stage has one (db:provision --with-reader + QUERY_DATABASE_URL wired to dbPlugin's queryDatabaseUrl); WITHOUT one it runs on the OPERATOR connection, fenced only by a read-only transaction — see docs/cli.md
315
- everystack db:doctor [--purge-after <days>] [--models <barrel>] [--stage <name>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
324
+ everystack db:doctor [--destroy-after <days>] [--models <barrel>] [--stage <name>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
316
325
  everystack db:provision --stage <name> [--direct | --database-url <url>] [--rotation-pairs] [--with-reader] Create the least-privilege role chain (idempotent). --rotation-pairs creates A/B pairs for live rotation. --with-reader adds the SELECT-only 'reader' login db:query runs as, grants it read on the declared schemas, and writes QUERY_DATABASE_URL (opt-in once, then kept converged on every re-provision)
317
326
  everystack db:provision --rotate --stage <name> [--slot api|ops|both] [--grace 60] Rotate credentials live (A/B role swap, no deploy, no downtime)
318
327
  everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
@@ -405,6 +414,7 @@ Auth:
405
414
 
406
415
  Options:
407
416
  --help, -h Show this help message
417
+ --version, -v Print the @everystack/cli version
408
418
  `);
409
419
  }
410
420
 
@@ -29,7 +29,8 @@ export function canonOfFunction(fn: FunctionDescriptor): FunctionCanon {
29
29
  language: fn.language, secdef: fn.security === 'definer',
30
30
  volatility: { immutable: 'i', stable: 's', volatile: 'v' }[fn.volatility],
31
31
  strict: false, leakproof: false,
32
- config: Array.isArray(fn.searchPath) ? [`search_path=${fn.searchPath.join(', ')}`] : [],
32
+ config: [...(Array.isArray(fn.searchPath) ? [`search_path=${fn.searchPath.join(', ')}`] : []),
33
+ ...(fn.lockTimeout === undefined ? [] : [`lock_timeout=${fn.lockTimeout}`])],
33
34
  owner: fn.owner ?? '(ambient)', execute: [...execute].sort(),
34
35
  // renderFunction adds exactly one newline on each side of the authored body.
35
36
  body: `\n${fn.body}\n`,