@everystack/cli 0.4.104 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.105] - 2026-10-31
8
+
9
+ ### Fixed
10
+
11
+ - rule 5 judges who can replace a trigger function, not its security mode
12
+ - judge declared trigger-function owners before the derived layer lands
13
+ - re-run the catalog check after the derived layer lands
14
+ - read the reconcile's builder on its own connection
15
+ - everystack --version prints the version
16
+ - run the post-check on the reconcile's own connection
17
+ - say the tables were applied only when this run applied them
18
+
7
19
  ## [0.4.104] - 2026-10-30
8
20
 
9
21
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.104",
3
+ "version": "0.4.105",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -192,7 +192,7 @@
192
192
  "react": "19.2.0",
193
193
  "ts-jest": "29.4.9",
194
194
  "zod": "3.25.67",
195
- "@everystack/server": "0.4.40"
195
+ "@everystack/server": "0.4.41"
196
196
  },
197
197
  "scripts": {
198
198
  "test": "jest",
@@ -77,7 +77,7 @@ import { readStageStateTwice } from '../stage-read-consistency';
77
77
  import { renderApplyReceipt, renderCatchUpFailure } from '../plain-output';
78
78
  import { renderFkOrphanAdvisory, ownershipColumns } from '../fk-orphan-advisory';
79
79
  import { catchUpDerived, type CatchUpVenue } from '../derived-catchup';
80
- import { lifecycleApplyRefusal, lifecycleStateOnlyRefusal } from '../lifecycle-preflight';
80
+ import { lifecycleApplyRefusal, lifecycleStateOnlyRefusal, LifecycleFindingsError } from '../lifecycle-preflight';
81
81
  import { buildReconcileReport } from './db-reconcile';
82
82
  import { step, success, fail, info, warn, line as printLine } from '../output';
83
83
 
@@ -269,6 +269,8 @@ async function runCatchUp(
269
269
  ...(flags.models ? { models: flags.models } : {}),
270
270
  onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
271
271
  onRefusal,
272
+ // A lifecycle refusal says the tables were applied only when this run applied some.
273
+ stateApplied: applied > 0,
272
274
  });
273
275
  if (outcome.run) {
274
276
  if (verbose) {
@@ -289,6 +291,8 @@ async function runCatchUp(
289
291
  return { ok: true, statements: outcome.statements };
290
292
  } catch (err: any) {
291
293
  const cause = String(err?.message ?? err);
294
+ // A lifecycle finding's report already says what committed and what did not, one line each.
295
+ if (err instanceof LifecycleFindingsError) return { ok: false, lines: cause.split('\n') };
292
296
  return {
293
297
  ok: false,
294
298
  // The cause is printed ONCE, at every verbosity: its headline on the report line, and
@@ -26,6 +26,7 @@
26
26
 
27
27
  import type { ModelDescriptor } from '@everystack/model';
28
28
  import { buildIntoDatabase } from '../db-build';
29
+ import { LifecycleFindingsError } from '../lifecycle-preflight';
29
30
  import { createUrlRunner } from '../db-source';
30
31
  import { resolveModelsPath } from '../models-path';
31
32
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
@@ -127,7 +128,8 @@ export async function dbBuildCommand(flags: Record<string, string>): Promise<voi
127
128
  gitRef: currentGitRef(),
128
129
  });
129
130
  } catch (err: any) {
130
- fail(`Build failed: ${err.message}`);
131
+ // A lifecycle finding says itself what was and was not written.
132
+ fail(err instanceof LifecycleFindingsError ? err.message : `Build failed: ${err.message}`);
131
133
  process.exit(1);
132
134
  }
133
135
 
@@ -239,7 +239,8 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
239
239
 
240
240
  try {
241
241
  const lifecycleNote = await lifecycleApplyRefusal(runner, models);
242
- if (lifecycleNote) warn(lifecycleNote);
242
+ // db:plan only reads, so it mints the plan anyway; db:apply runs the same check and refuses.
243
+ if (lifecycleNote) warn(`db:apply will refuse this plan until each lifecycle finding below is fixed.\n${lifecycleNote}`);
243
244
  let snapshot: SchemaSnapshot;
244
245
  let contract: AuthzContract;
245
246
  if (dbSource.kind === 'url') {
@@ -31,7 +31,7 @@ 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 { catalogInputsFromModels, lifecycleApplyRefusal } from '../lifecycle-preflight';
34
+ import { catalogInputsFromModels, lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError } from '../lifecycle-preflight';
35
35
  import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
36
36
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
37
37
  import { generateMigrationSql, unmodeledTables } from '../migration-generate';
@@ -242,6 +242,10 @@ export async function executeSync(
242
242
  // State runs before compute, so without this a lifecycle table would land with no transition trigger.
243
243
  const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
244
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);
245
249
  const first = await syncOnce(runner, session, models, options, hooks, 1);
246
250
  // The trigger is the STATE bar, not the combined verdict. The cause above is state-layer
247
251
  // only, so a compute refusal or an unadopted object cannot be settled by looking again —
@@ -18,7 +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
+ import { lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal } from './lifecycle-preflight';
22
22
 
23
23
  const SAFE_NAME = /^[a-z_][a-z0-9_$]*$/;
24
24
 
@@ -181,6 +181,11 @@ export async function buildIntoDatabase(
181
181
  // declared state: 20 such pairs, `type "…_row" does not exist`, from-scratch only. Their
182
182
  // explicit `dependsOn` could not have helped — this pass dropped the view from the wave, so
183
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);
184
189
  const declaredFunctions = (options.declared ?? [])
185
190
  .filter((o) => o.kind === 'function' && !o.signatureDeps?.length);
186
191
  if (declaredFunctions.length > 0) {
@@ -214,6 +219,10 @@ export async function buildIntoDatabase(
214
219
  actor: options.actor ?? 'db-build',
215
220
  gitRef: options.gitRef ?? null,
216
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);
217
226
  return {
218
227
  converged: run.converged,
219
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);
@@ -33,9 +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 } from './lifecycle-preflight';
38
+ import { catalogInputsFromModels, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal } from './lifecycle-preflight';
39
39
 
40
40
  /** Where the catch-up runs. The two lanes db:apply itself already has. */
41
41
  export type CatchUpVenue =
@@ -77,6 +77,12 @@ export interface CatchUpOptions {
77
77
  * wrongly handed an exit loses the sentence its operator was owed.
78
78
  */
79
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;
80
86
  }
81
87
 
82
88
  export interface CatchUpOutcome {
@@ -116,11 +122,21 @@ export async function catchUpDerived(
116
122
  gitRef: currentGitRef(),
117
123
  };
118
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
+
119
136
  let run: ReconcileRun;
120
137
  if (venue.kind === 'direct') {
121
138
  run = await executeReconcile(venue.runner, venue.session, options);
122
139
  } else {
123
- const config = await resolveConfig(venue.stage);
124
140
  // The ops Lambda CREATES AND REPLACES database objects from these descriptors, so verify the
125
141
  // deployed stage understands their shape before sending them — the same handshake
126
142
  // db:reconcile --apply --stage runs, for the same payload.
@@ -130,14 +146,14 @@ export async function catchUpDerived(
130
146
  // before the process ends. A catch-up that is the whole command has written nothing, and
131
147
  // asks to exit: print the skew, say nothing was sent, exit.
132
148
  await modelContractPreflight({
133
- region: config.region,
134
- fn: opsFunction(config),
149
+ region: config!.region,
150
+ fn: opsFunction(config!),
135
151
  directCommand: `everystack db:apply --stage ${venue.stage} --direct`,
136
152
  onRefusal: opts.onRefusal,
137
153
  })('db:apply');
138
154
  const result: any = await invokeAction(
139
- config.region,
140
- opsFunction(config),
155
+ config!.region,
156
+ opsFunction(config!),
141
157
  'db:reconcile',
142
158
  { ...options, modelContract: MODEL_WIRE_CONTRACT } satisfies ReconcileWirePayload,
143
159
  );
@@ -154,5 +170,10 @@ export async function catchUpDerived(
154
170
  // not land, so db:apply must say so and exit the same way.
155
171
  if (run.refusal) throw new CatchUpError(run.refusal);
156
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
+
157
178
  return { declared: objects.length, run, statements: run.applied ? (run.statements ?? []) : [] };
158
179
  }
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
@@ -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
 
@@ -3,6 +3,8 @@ import { createHash } from 'node:crypto';
3
3
  import { DESTROY_LOG_MARKER_BODIES, DESTROY_LOG_MARKER_LANGUAGES, DESTROY_LOG_MARKER_SEARCH_PATH, LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from '@everystack/model';
4
4
  import type { QueryRunner } from './authz-contract';
5
5
  import { compileTableContract } from './authz-compile';
6
+ import { reconcileOwner, triggerFunctionOf } from './derived-apply';
7
+ import { isSessionError, type SessionRunner } from './session';
6
8
  import { parseQualified, type SourceObject } from './derived-source';
7
9
  import { lifecycleDerived, lifecycleManifestOf, lifecycleRole } from './lifecycle-derived';
8
10
 
@@ -248,20 +250,43 @@ const markerSql = (lc: string): string => `SELECT s.sig, p.oid IS NOT NULL AS pr
248
250
  ORDER BY s.i`;
249
251
 
250
252
  /**
251
- * Rule 5. A trigger on a lifecycle table whose function is SECURITY DEFINER and owned by neither
252
- * the table's owner nor the lifecycle role: it runs as that third role inside every delete.
253
+ * Rule 5. A trigger on a lifecycle table whose function's owner is an application-side role, or
254
+ * one an application-side role reaches. The soft delete's stamp and destroy's DELETE run as the
255
+ * lifecycle role, so an INVOKER trigger function runs as it and a DEFINER one as its owner; either
256
+ * way whoever can replace the body acts inside every soft delete and destroy. So the security
257
+ * mode does not matter, and neither the table's owner nor the lifecycle role is exempt.
253
258
  */
254
- const definerTriggerSql = (tables: string[], lc: string): string => `SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
259
+ const triggerOwnerSql = (tables: string[], apps: string[]): string => `WITH ${sideCte(apps)}
260
+ SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
255
261
  pg_catalog.quote_ident(fn.nspname) || '.' || pg_catalog.quote_ident(p.proname) || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS function,
256
- pg_catalog.pg_get_userbyid(p.proowner)::text AS owner, pg_catalog.pg_get_userbyid(c.relowner)::text AS table_owner
262
+ pg_catalog.pg_get_userbyid(p.proowner)::text AS owner,
263
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')} ORDER BY 1) AS via
257
264
  FROM pg_catalog.pg_trigger g JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid
258
265
  JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
259
266
  JOIN pg_catalog.pg_namespace fn ON fn.oid = p.pronamespace
260
267
  WHERE NOT g.tgisinternal AND (n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)})
261
- AND p.prosecdef AND p.proowner <> c.relowner
262
- AND p.proowner IS DISTINCT FROM (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = ${literal(lc)})
268
+ AND EXISTS (SELECT 1 FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')})
263
269
  ORDER BY 1, 2`;
264
270
 
271
+ /**
272
+ * One rule-5 finding. `via` holds the application-side roles that are, or reach, `owner`. When the
273
+ * owner is one of them the fix is a new owner; otherwise it is to revoke the path to the owner.
274
+ * `would`: the pre-exposure gate, which judges a declared trigger before the reconcile creates it.
275
+ */
276
+ export function triggerOwnerFinding(row: { trigger: string; table: string; function: string; owner: string; via: readonly string[] }, would = false): string {
277
+ const { owner, via } = row;
278
+ const sideOwned = via.includes(owner);
279
+ const who = sideOwned ? `${owner}, an application-side role`
280
+ : `${owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
281
+ const fix = sideOwned
282
+ ? `declare ${row.function}'s owner in db/models as a role no application role can act as, and apply `
283
+ + `(the apply re-creates a declared function with its declared owner, so a live ALTER alone is undone); `
284
+ + `for a function not declared in db/models: ALTER FUNCTION ${row.function} OWNER TO <that role>;`
285
+ : `${via.map(r => `REVOKE ${ident(owner)} FROM ${ident(r)};`).join(' ')} (or from the role each reaches ${owner} through)`;
286
+ return `trigger ${row.trigger} on ${row.table} ${would ? 'would run' : 'runs'} ${row.function}, owned by ${who}; `
287
+ + `${via.join(', ')} can replace its body, and it runs inside every soft delete and destroy. Fix: ${fix}`;
288
+ }
289
+
265
290
  /**
266
291
  * Rule 6. Every foreign key into a lifecycle table (a partition's clone excluded), with its columns
267
292
  * in key order and its ON DELETE action. Which ones a model declares is decided by the caller.
@@ -298,16 +323,32 @@ const OWNER_HAZARD = {
298
323
  *
299
324
  * The one implementation: db:apply, db:sync and db:plan reach it through lifecycleApplyRefusal
300
325
  * (inputs from the models); db:reconcile, local and in the ops Lambda, through
301
- * lifecycleReconcileRefusal (tables from the declared transition triggers).
326
+ * lifecycleReconcileRefusal (tables from the declared transition triggers); db:apply and db:build
327
+ * again after the derived layer lands, through lifecyclePostApplyRefusal.
302
328
  */
303
329
  export async function lifecycleCatalogRefusal(
304
330
  runner: QueryRunner,
305
331
  inputs: LifecycleCatalogInputs,
306
332
  options: { lifecycleRole?: string } = {},
307
333
  ): Promise<string | null> {
334
+ const findings = await lifecycleCatalogFindings(runner, inputs, options);
335
+ if (findings.length === 0) return null;
336
+ return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'}); `
337
+ + `fix each, then re-run:\n${findingLines(findings)}`;
338
+ }
339
+
340
+ const findingLines = (findings: readonly string[]): string => findings.map(finding => ` - ${finding}`).join('\n');
341
+ const countFindings = (n: number): string => `${n} lifecycle finding${n === 1 ? '' : 's'}`;
342
+
343
+ /** lifecycleCatalogRefusal's findings, one string each, without the heading. */
344
+ async function lifecycleCatalogFindings(
345
+ runner: QueryRunner,
346
+ inputs: LifecycleCatalogInputs,
347
+ options: { lifecycleRole?: string } = {},
348
+ ): Promise<string[]> {
308
349
  const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
309
350
  const tables = [...new Set(inputs.lifecycleTables)].sort();
310
- if (tables.length === 0) return null;
351
+ if (tables.length === 0) return [];
311
352
  const apps = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
312
353
  const findings: string[] = [];
313
354
 
@@ -371,10 +412,9 @@ export async function lifecycleCatalogRefusal(
371
412
  }
372
413
  }
373
414
 
374
- for (const row of await runner(definerTriggerSql(tables, lc))) {
375
- findings.push(`trigger ${row.trigger} on ${row.table} runs ${row.function}, SECURITY DEFINER and owned by ${row.owner}, `
376
- + `neither the table's owner (${row.table_owner}) nor ${lc}; it runs as ${row.owner} inside every delete. `
377
- + `Fix: ALTER FUNCTION ${row.function} SECURITY INVOKER; or ALTER FUNCTION ${row.function} OWNER TO ${ident(String(row.table_owner))};`);
415
+ for (const row of await runner(triggerOwnerSql(tables, apps))) {
416
+ findings.push(triggerOwnerFinding({ trigger: String(row.trigger), table: String(row.table), function: String(row.function),
417
+ owner: String(row.owner), via: textList(row.via) }));
378
418
  }
379
419
 
380
420
  const declared = new Set(inputs.dependentKeys.map(key => JSON.stringify([key.child, key.column, key.parent])));
@@ -403,9 +443,88 @@ export async function lifecycleCatalogRefusal(
403
443
  + 'if the models declare this key nullify, upgrade the CLI (@everystack/cli) and re-run.' : ''));
404
444
  }
405
445
 
446
+ return findings;
447
+ }
448
+
449
+ /**
450
+ * A lifecycle finding raised around the derived layer: by the pre-exposure gate (nothing derived
451
+ * was written) or by the post-check (the objects are live). Its message is the whole report, one
452
+ * line of head and one line per finding, so a caller prints it as is.
453
+ */
454
+ export class LifecycleFindingsError extends Error {}
455
+
456
+ /**
457
+ * The pre-exposure gate: rule 5 over the DECLARED owners, before the reconcile creates anything.
458
+ * Each declared trigger on a lifecycle table whose function is declared is judged by the owner the
459
+ * reconcile will give that function (reconcileOwner: the declared owner, else the role the
460
+ * reconcile runs as). A declared owner that does not exist live is a finding. A trigger whose
461
+ * function is not declared is left to the post-check, which reads the live owner.
462
+ *
463
+ * `session` reads the builder: it runs on the connection the reconcile writes on. On a stage that
464
+ * is db:session, the operator connection; db:query (`runner`) may run on a reader
465
+ * (QUERY_DATABASE_URL), whose CURRENT_USER is not the role that will own the function. `runner`
466
+ * reads only pg_roles, which is the same on any connection.
467
+ *
468
+ * `stateApplied`: the table and column changes have committed, so the refusal says so.
469
+ */
470
+ export async function lifecycleDeclaredOwnerRefusal(
471
+ runner: QueryRunner,
472
+ session: SessionRunner,
473
+ declared: readonly SourceObject[],
474
+ models: readonly ModelDescriptor[],
475
+ options: { stateApplied: boolean },
476
+ ): Promise<string | null> {
477
+ const { lifecycleTables, applicationRoles } = catalogInputsFromModels(models);
478
+ const tables = new Set(lifecycleTables);
479
+ const triggers = declared.flatMap((object) => {
480
+ if (object.kind !== 'trigger' || !object.table) return [];
481
+ const { schema, name } = parseQualified(object.table);
482
+ const fn = tables.has(`${schema}.${name}`) ? triggerFunctionOf(object, declared) : undefined;
483
+ return fn ? [{ trigger: object.name, table: `${schema}.${name}`, fn }] : [];
484
+ });
485
+ if (triggers.length === 0) return null;
486
+ const [slot] = await session(['SELECT CURRENT_USER::text AS builder']);
487
+ if (isSessionError(slot) || slot?.[0]?.builder == null) throw new Error('could not read the role the reconcile runs as (CURRENT_USER)');
488
+ const builder = String(slot[0].builder);
489
+ const judged = triggers.map(t => ({ ...t, owner: reconcileOwner(t.fn, builder) }));
490
+ const owners = [...new Set(judged.map(t => t.owner))].sort();
491
+ const rows = await runner(`WITH ${sideCte(applicationRoles)}
492
+ SELECT d.name AS owner, o.oid IS NOT NULL AS present,
493
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.oid OR ${reaches('s.oid', 'o.oid')} ORDER BY 1) AS via
494
+ FROM pg_catalog.unnest(${textArray(owners)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name`);
495
+ const byOwner = new Map(rows.map(row => [String(row.owner), { present: truthy(row.present), via: textList(row.via) }]));
496
+ const findings = judged.flatMap((t) => {
497
+ const owner = byOwner.get(t.owner);
498
+ if (!owner?.present) {
499
+ return [`trigger ${t.trigger} on ${t.table} would run ${t.fn.identity}, owned by ${t.owner}, which does not exist. `
500
+ + `Fix: create ${t.owner} as a role no application role can act as, or declare an existing such role as ${t.fn.identity}'s owner in db/models.`];
501
+ }
502
+ if (owner.via.length === 0) return [];
503
+ return [triggerOwnerFinding({ trigger: t.trigger, table: t.table, function: t.fn.identity, owner: t.owner, via: owner.via }, true)];
504
+ });
505
+ if (findings.length === 0) return null;
506
+ return `${options.stateApplied ? 'tables and columns applied; ' : ''}derived layer NOT applied: ${countFindings(findings.length)}; `
507
+ + `fix each, then re-run:\n${findingLines(findings)}`;
508
+ }
509
+
510
+ /**
511
+ * The post-check: the live catalog check again, after the derived layer landed. The reconcile
512
+ * commits what it created, so the report never says refused or rolled back: the objects are live,
513
+ * and the next apply's preflight refuses until each finding is fixed.
514
+ *
515
+ * It reads on `session`, the reconcile's own connection: on a stage, db:session (the operator
516
+ * connection). db:query may read a replica the reconcile's commit has not reached yet, and pass.
517
+ */
518
+ export async function lifecyclePostApplyRefusal(session: SessionRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
519
+ if (!models.some(model => model.lifecycle === true)) return null;
520
+ const read: QueryRunner = async (q) => {
521
+ const [slot] = await session([q]);
522
+ if (isSessionError(slot)) throw new Error(slot.message);
523
+ return slot ?? [];
524
+ };
525
+ const findings = await lifecycleCatalogFindings(read, catalogInputsFromModels(models));
406
526
  if (findings.length === 0) return null;
407
- return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'}); `
408
- + `fix each, then re-run:\n${findings.map(finding => ` - ${finding}`).join('\n')}`;
527
+ return `${countFindings(findings.length)} after reconcile; the objects are live; the next apply refuses until fixed:\n${findingLines(findings)}`;
409
528
  }
410
529
 
411
530
  /** An older CLI sends no applicationRoles, so the catalog check cannot run: fail closed. */