@everystack/cli 0.4.105 → 0.4.107

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,40 @@ 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.107] - 2026-10-31
8
+
9
+ ### Added
10
+
11
+ - db:plan names db:provision first when a lifecycle plan needs it
12
+ - db:apply prints a wall time per phase
13
+ - a type change under a declared view or matview mints and applies
14
+
15
+ ### Fixed
16
+
17
+ - the lifecycle preflight accepts the grants the reviewed plan revokes
18
+ - a re-created view's apply errors name it as a view
19
+ - a grant the plan's REVOKE cannot remove stays a lifecycle finding
20
+ - a destroy_log grant from another role names db:provision's REVOKE
21
+
22
+ ## [0.4.106] - 2026-10-31
23
+
24
+ ### Added
25
+
26
+ - declare request roles with defineModule({ roles: { request } })
27
+
28
+ ### Fixed
29
+
30
+ - a granted definer role is not a request role in the lifecycle check
31
+ - lifecycle rule 2 judges schema owners and the deploy history tables
32
+ - lifecycle reach counts mixed-option membership chains
33
+ - move the database when an app role owns public through pg_database_owner
34
+ - refuse an empty request-role list on the reconcile wire
35
+
36
+ ### Documentation
37
+
38
+ - say why a granted owner counts, and document roles.request
39
+ - state what an older server skips under the role declaration
40
+
7
41
  ## [0.4.105] - 2026-10-31
8
42
 
9
43
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.105",
3
+ "version": "0.4.107",
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>",
@@ -145,7 +145,7 @@
145
145
  "structured-headers": "1.0.1",
146
146
  "tsx": "4.21.0",
147
147
  "typescript": "5.9.3",
148
- "@everystack/model": "0.4.25"
148
+ "@everystack/model": "0.4.26"
149
149
  },
150
150
  "peerDependencies": {
151
151
  "@everystack/server": ">=0.4.0",
@@ -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.41"
195
+ "@everystack/server": "0.4.42"
196
196
  },
197
197
  "scripts": {
198
198
  "test": "jest",
@@ -13,6 +13,16 @@
13
13
  */
14
14
 
15
15
  import type { QueryRunner } from './authz-contract';
16
+ import type { SessionRunner } from './session';
17
+ import type { SourceObject } from './derived-source';
18
+ import type { StateDependent } from './edge-plan';
19
+ import { introspectDerived } from './derived-introspect';
20
+ import { planReconcile } from './derived-plan';
21
+ import {
22
+ builderOwnerMode, derivedSearchPath, ownerPreflightRefusal, ownerPreflightSql, ownerRequirements,
23
+ renderEnsureObjectSchemas, renderReconcileSql, type OwnerApplyMode,
24
+ } from './derived-apply';
25
+ import { quoteIdent, quoteQualified } from './pg-ident';
16
26
 
17
27
  export interface AlterTypeTarget {
18
28
  table: string;
@@ -94,3 +104,192 @@ export function renderAlterTypeRefusal(blocked: readonly BlockedAlterType[]): st
94
104
  `Route the type change through db:swap (rebuild + atomic rename), or drop and re-create the dependent derived objects around it (db:reconcile) — deliberately, not as an apply-time surprise.`,
95
105
  ].join('\n');
96
106
  }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Declared dependents: dropped and re-created inside the state transaction.
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Every view, matview or rule the targets' type changes force out: the ones that bind a target
114
+ * column, and, transitively, every view or matview whose rule reads one of those (a view on a
115
+ * view). PostgreSQL cannot drop the first without the second.
116
+ */
117
+ export function dependentClosureSql(targets: readonly AlterTypeTarget[]): string {
118
+ const direct = targets.map((t) => {
119
+ const rel = quoteLiteral(t.table);
120
+ return `(d.refobjid = ${rel}::regclass AND d.refobjsubid = (SELECT attnum FROM pg_catalog.pg_attribute
121
+ WHERE attrelid = ${rel}::regclass AND attname = ${quoteLiteral(t.column)}))`;
122
+ }).join(' OR ');
123
+ return `WITH RECURSIVE closure(oid) AS (
124
+ SELECT r.ev_class FROM pg_catalog.pg_depend d JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid
125
+ WHERE d.classid = 'pg_catalog.pg_rewrite'::regclass AND d.refclassid = 'pg_catalog.pg_class'::regclass
126
+ AND r.ev_class <> d.refobjid AND (${direct})
127
+ UNION
128
+ SELECT r.ev_class FROM closure c JOIN pg_catalog.pg_depend d ON d.refobjid = c.oid
129
+ AND d.refclassid = 'pg_catalog.pg_class'::regclass AND d.classid = 'pg_catalog.pg_rewrite'::regclass
130
+ JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid
131
+ WHERE r.ev_class <> c.oid
132
+ )
133
+ SELECT DISTINCT cl.oid::bigint AS oid,
134
+ CASE cl.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'rule' END AS kind,
135
+ n.nspname::text || '.' || cl.relname::text AS identity,
136
+ pg_catalog.pg_total_relation_size(cl.oid)::bigint AS bytes
137
+ FROM closure JOIN pg_catalog.pg_class cl ON cl.oid = closure.oid JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace
138
+ ORDER BY 3`;
139
+ }
140
+
141
+ /**
142
+ * What else depends on the closure, which a plain DROP would refuse over (a function returning
143
+ * the view's row type) or remove silently (a trigger on a view). The closure's own rules and
144
+ * members are not counted: they are dropped and re-created with it.
145
+ */
146
+ export function closureBlockersSql(oids: readonly number[]): string {
147
+ const list = `ARRAY[${oids.map((oid) => String(Math.trunc(oid))).join(', ')}]::oid[]`;
148
+ return `SELECT DISTINCT pg_catalog.pg_describe_object(d.classid, d.objid, d.objsubid) AS what,
149
+ pg_catalog.pg_describe_object(d.refclassid, d.refobjid, 0) AS on_what
150
+ FROM pg_catalog.pg_depend d
151
+ WHERE ((d.refclassid = 'pg_catalog.pg_class'::regclass AND d.refobjid = ANY(${list}))
152
+ OR (d.refclassid = 'pg_catalog.pg_type'::regclass AND d.refobjid IN (SELECT reltype FROM pg_catalog.pg_class WHERE oid = ANY(${list}))))
153
+ AND ((d.deptype = 'n'
154
+ AND NOT (d.classid = 'pg_catalog.pg_rewrite'::regclass AND (SELECT ev_class FROM pg_catalog.pg_rewrite WHERE oid = d.objid) = ANY(${list}))
155
+ AND NOT (d.classid = 'pg_catalog.pg_class'::regclass AND d.objid = ANY(${list})))
156
+ OR d.classid = 'pg_catalog.pg_trigger'::regclass)
157
+ ORDER BY 1`;
158
+ }
159
+
160
+ /** `1.2 MB`, for the plan's matview line. */
161
+ export function formatBytes(bytes: number): string {
162
+ const units = ['B', 'kB', 'MB', 'GB', 'TB'];
163
+ let value = bytes;
164
+ let unit = 0;
165
+ while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
166
+ return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
167
+ }
168
+
169
+ /** The plan's line for one dependent. */
170
+ export function renderStateDependentLine(d: Pick<StateDependent, 'identity' | 'kind' | 'bytes'>): string {
171
+ const cost = d.kind === 'materialized view' ? ` (repopulates ${formatBytes(d.bytes ?? 0)})` : '';
172
+ return `~ ${d.kind} ${d.identity}: dropped before the type change and re-created from the checkout in the same transaction${cost}`;
173
+ }
174
+
175
+ export type StateDependentsMint = { dependents: StateDependent[] } | { refusal: string };
176
+
177
+ /**
178
+ * The declared views and matviews the targets' type changes force out, rendered for the state
179
+ * transaction, or a refusal naming what the platform cannot re-create:
180
+ *
181
+ * - a dependent the models do not declare (nothing can re-create it from the checkout):
182
+ * declare it in the models, which db:pull renders;
183
+ * - a declared one whose live definition the reconciler cannot vouch for (drift, or never
184
+ * reconciled): db:reconcile has the verb for each;
185
+ * - something else depending on the closure (a function over a view's row type, a trigger on a
186
+ * view), which a plain DROP would refuse over or silently remove.
187
+ *
188
+ * Read-only. Each create is the SQL a derived apply renders for that object.
189
+ */
190
+ export async function mintStateDependents(
191
+ runner: QueryRunner,
192
+ session: SessionRunner,
193
+ targets: readonly AlterTypeTarget[],
194
+ declared: readonly SourceObject[],
195
+ ): Promise<StateDependentsMint> {
196
+ if (targets.length === 0) return { dependents: [] };
197
+ const rows = await runner(dependentClosureSql(targets));
198
+ if (rows.length === 0) return { dependents: [] };
199
+ const byIdentity = new Map(declared.map((o) => [o.identity, o]));
200
+ const where = targets.map((t) => `${t.table}."${t.column}"`).join(', ');
201
+ const undeclared = rows.filter((r: any) => r.kind === 'rule' || byIdentity.get(String(r.identity))?.kind !== r.kind);
202
+ if (undeclared.length > 0) {
203
+ return {
204
+ refusal: `This plan changes the type of ${where}, and PostgreSQL refuses that while a view or rule binds the column. `
205
+ + 'db:apply drops and re-creates the declared ones in the same transaction, but these are not declared, so nothing can re-create them:\n'
206
+ + undeclared.map((r: any) => ` ${r.kind} ${r.identity} is not declared`).join('\n')
207
+ + '\nFix: declare it in the models (db:pull renders it), then re-run db:plan.',
208
+ };
209
+ }
210
+ const blockers = await runner(closureBlockersSql(rows.map((r: any) => Number(r.oid))));
211
+ if (blockers.length > 0) {
212
+ return {
213
+ refusal: `This plan changes the type of ${where}. db:apply would drop and re-create ${rows.map((r: any) => `${r.kind} ${r.identity}`).join(', ')} around it, `
214
+ + 'but other objects depend on them, and a plain DROP would refuse over them or remove them:\n'
215
+ + blockers.map((b: any) => ` ${b.what} depends on ${b.on_what}`).join('\n')
216
+ + '\nRoute this type change through db:swap, or change the models so nothing else depends on these views first.',
217
+ };
218
+ }
219
+
220
+ // Creation order is the declared layer's dependency order; the drops run in reverse.
221
+ const order = new Map(declared.map((o, i) => [o.identity, i]));
222
+ const closure = rows
223
+ .map((r: any) => ({ identity: String(r.identity), kind: r.kind as StateDependent['kind'], bytes: Number(r.bytes) }))
224
+ .sort((a, b) => order.get(a.identity)! - order.get(b.identity)!);
225
+ const ids = new Set(closure.map((c) => c.identity));
226
+
227
+ const live = await introspectDerived(session);
228
+ const parsed = { objects: [...declared], warnings: [] as string[] };
229
+ // The reconciler's own verdict first: a hand-edited or never-reconciled view is not ours to
230
+ // re-create silently, and db:reconcile already has the verb for each.
231
+ const verdict = planReconcile(parsed, live, { only: [...ids] });
232
+ const drifted = verdict.drift.filter((d) => ids.has(d.identity)).map((d) => d.identity);
233
+ const unbaselined = verdict.needsBaseline.filter((id) => ids.has(id));
234
+ if (drifted.length + unbaselined.length > 0) {
235
+ return {
236
+ refusal: `This plan changes the type of ${where}, which db:apply handles by re-creating the declared views and matviews that bind it, `
237
+ + 'but the reconciler cannot vouch for some of them:\n'
238
+ + [...drifted.map((id) => ` ${id} differs from its record (hand-edited). Fix: db:reconcile --apply --overwrite-drift, or declare the live definition.`),
239
+ ...unbaselined.map((id) => ` ${id} was never reconciled. Fix: db:reconcile --baseline once you have verified it matches the models.`)].join('\n')
240
+ + '\nThen re-run db:plan.',
241
+ };
242
+ }
243
+
244
+ const liveById = new Map(live.objects.map((o) => [o.identity, o]));
245
+ // Rendered as the creates a derived apply would run against the catalog as it is after the
246
+ // drops: the closure absent.
247
+ const absent = {
248
+ ...live,
249
+ objects: live.objects.filter((o) => !ids.has(o.identity)),
250
+ edges: live.edges.filter((e) => !ids.has(e.dependent) && !ids.has(e.referenced)),
251
+ };
252
+ const combined = planReconcile(parsed, absent, { only: [...ids] });
253
+ const requirements = ownerRequirements(combined, parsed.objects);
254
+ let ownerMode: OwnerApplyMode = 'set-role';
255
+ if (requirements.length > 0) {
256
+ const preflight = await runner(ownerPreflightSql(requirements));
257
+ const refusal = ownerPreflightRefusal(preflight as any[]);
258
+ if (refusal) return { refusal: `could not plan the re-create of the views a type change binds: ${refusal}` };
259
+ ownerMode = builderOwnerMode(preflight as any[]);
260
+ }
261
+ const schemas = derivedSearchPath([...declared]);
262
+ const searchPath = schemas.length === 1 && schemas[0] === 'public' ? null : schemas.map(quoteIdent).join(', ');
263
+
264
+ return {
265
+ dependents: closure.map((c) => {
266
+ const source = byIdentity.get(c.identity)!;
267
+ const rendered = renderReconcileSql(planReconcile(parsed, absent, { only: [c.identity] }), parsed.objects, ownerMode);
268
+ if (rendered.record.length !== 1 || rendered.record[0] !== c.identity) {
269
+ throw new Error(`could not render the re-create of ${c.identity}: the target changed while minting; re-run db:plan.`);
270
+ }
271
+ const drop = `DROP ${c.kind === 'view' ? 'VIEW' : 'MATERIALIZED VIEW'} ${quoteQualified(c.identity)}`;
272
+ const owner = ownerMode === 'set-role' ? source.owner : undefined;
273
+ return {
274
+ identity: c.identity,
275
+ kind: c.kind,
276
+ expectedDefHash: liveById.get(c.identity)!.defHash,
277
+ dropSql: owner ? [`SET ROLE ${owner}`, drop, 'RESET ROLE'].join('\n;\n') : drop,
278
+ sql: [...renderEnsureObjectSchemas([source]), ...rendered.statements].join('\n;\n'),
279
+ searchPath,
280
+ grantContract: {
281
+ attachments: source.attachments.filter((a) => a.kind === 'grant'),
282
+ ...(source.owner ? { owner: source.owner } : {}),
283
+ ownerMode,
284
+ },
285
+ provenance: {
286
+ srcHash: source.hash,
287
+ ...(source.bodyHash ? { bodyHash: source.bodyHash } : {}),
288
+ kind: c.kind,
289
+ ...(source.drop ? { dropSql: source.drop } : {}),
290
+ },
291
+ ...(c.kind === 'materialized view' ? { bytes: c.bytes } : {}),
292
+ };
293
+ }),
294
+ };
295
+ }
@@ -189,6 +189,12 @@ export interface ApplyPlanResult {
189
189
  liveFingerprint: string;
190
190
  reason?: string;
191
191
  logId?: number;
192
+ /**
193
+ * Wall time, in ms, of the two phases this function runs, on the statuses that reached the
194
+ * state transaction: everything before it (the lock, the gates, descent; `verifyMs`), and the
195
+ * transaction plus the landed-fingerprint read (`stateMs`). Optional on the wire.
196
+ */
197
+ phases?: { verifyMs: number; stateMs: number };
192
198
  /**
193
199
  * The keys the row check found orphans on, if any — data, for the caller's advisory (see
194
200
  * fk-orphan-advisory). Present on the paths that got as far as running the check, empty
@@ -465,6 +471,7 @@ export async function executeApplyPlan(
465
471
  plan: EdgePlan,
466
472
  options: ApplyPlanOptions = {},
467
473
  ): Promise<ApplyPlanResult> {
474
+ const started = Date.now();
468
475
  const { hash: live, snapshot: before, contract: beforeAuthz, ledger, ledgerRead } = await liveBaseAndLedger(session);
469
476
 
470
477
  // The endpoint is FULL (it carries the ungoverned live authz through), so it is compared against
@@ -596,16 +603,23 @@ export async function executeApplyPlan(
596
603
  // rewritten — the plan is an artifact under a content hash, and `planHash(plan)` below has to
597
604
  // keep naming the file the operator reviewed.
598
605
  const { statements: toRun, withheld } = withheldValidations(plan.statements, orphans);
606
+ if (options.stateOnly && plan.stateDependents?.length) {
607
+ const reason = `this plan re-creates ${plan.stateDependents.map((d) => `${d.kind} ${d.identity}`).join(', ')} around a column type change, but --state-only skips derived writes. Apply the reviewed plan without --state-only.`;
608
+ await recordRefusal(runner, plan, live, 'state dependents', reason, options);
609
+ return { status: 'refused', liveFingerprint: live, reason };
610
+ }
599
611
  if (options.stateOnly && plan.statePrerequisites?.length) {
600
612
  const reason = 'this plan needs state prerequisites, but --state-only skips function writes. Apply the reviewed plan without --state-only, or satisfy the prerequisites and re-mint.';
601
613
  await recordRefusal(runner, plan, live, 'state prerequisites', reason, options);
602
614
  return { status: 'refused', liveFingerprint: live, reason };
603
615
  }
604
616
 
617
+ const stateStarted = Date.now();
605
618
  let result: StateApplyResult;
606
619
  try {
607
620
  result = await applyGeneratedStatements(runner, toRun, {
608
621
  statePrerequisites: plan.statePrerequisites,
622
+ stateDependents: plan.stateDependents,
609
623
  transaction: options.transaction,
610
624
  ...(withheld.length > 0 ? { skippedValidations: withheld } : {}),
611
625
  actor: options.actor,
@@ -630,6 +644,7 @@ export async function executeApplyPlan(
630
644
  }
631
645
 
632
646
  const { hash: landed } = await liveBaseFingerprint(session);
647
+ const phases = { verifyMs: stateStarted - started, stateMs: Date.now() - stateStarted };
633
648
  if (result.logId !== undefined) {
634
649
  // The log stamps the RAW landed hash — the same flavor as `from` AND as the full `to`, so the
635
650
  // schema_log chain reads as one consistent series of live states.
@@ -645,6 +660,7 @@ export async function executeApplyPlan(
645
660
  return {
646
661
  status: 'validation-pending',
647
662
  liveFingerprint: landed,
663
+ phases,
648
664
  // The next move follows the CAUSE: bad rows are a data fix, a check that was cut off by a
649
665
  // clock is a lane change. See validationPendingReason.
650
666
  reason: validationPendingReason(result.pendingValidations),
@@ -660,6 +676,7 @@ export async function executeApplyPlan(
660
676
  return {
661
677
  status: 'verify-failed',
662
678
  liveFingerprint: landed,
679
+ phases,
663
680
  reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
664
681
  ...(result.logId !== undefined ? { logId: result.logId } : {}),
665
682
  fkOrphans: orphans,
@@ -668,6 +685,7 @@ export async function executeApplyPlan(
668
685
  return {
669
686
  status: 'applied',
670
687
  liveFingerprint: landed,
688
+ phases,
671
689
  ...(result.logId !== undefined ? { logId: result.logId } : {}),
672
690
  fkOrphans: orphans,
673
691
  };
@@ -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, LifecycleFindingsError } from '../lifecycle-preflight';
80
+ import { lifecycleApplyRefusal, lifecycleRolesOf, lifecycleStateOnlyRefusal, LifecycleFindingsError, unclassifiedGranteesWarning } 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
 
@@ -238,6 +238,30 @@ export function catchUpRerunCommand(flags: Record<string, string>): string {
238
238
  return parts.join(' ');
239
239
  }
240
240
 
241
+ /** Wall time, in ms, of each db:apply phase that ran. */
242
+ export interface ApplyPhaseTimings {
243
+ verify?: number;
244
+ state?: number;
245
+ derived?: number;
246
+ postChecks?: number;
247
+ }
248
+
249
+ /**
250
+ * One line: the wall time of each phase that ran, in order, or null when none did. Printed beside
251
+ * the receipt, never inside it, so the receipt's contract is unchanged.
252
+ */
253
+ export function renderPhaseTimings(phases: ApplyPhaseTimings): string | null {
254
+ const named: Array<[string, number | undefined]> = [
255
+ ['descent and fingerprint verify', phases.verify],
256
+ ['state transaction', phases.state],
257
+ ['derived catch-up', phases.derived],
258
+ ['post-checks', phases.postChecks],
259
+ ];
260
+ const ran = named.filter((entry): entry is [string, number] => entry[1] !== undefined);
261
+ if (ran.length === 0) return null;
262
+ return `phases: ${ran.map(([name, ms]) => `${name} ${(ms / 1000).toFixed(2)}s`).join(', ')}`;
263
+ }
264
+
241
265
  /** What the catch-up did, or what to say about the fact that it did not. */
242
266
  type CatchUpReport = { ok: true; statements: string[] } | { ok: false; lines: string[] };
243
267
 
@@ -261,10 +285,12 @@ async function runCatchUp(
261
285
  applied: number,
262
286
  onRefusal: 'exit' | 'throw',
263
287
  declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
288
+ phases?: ApplyPhaseTimings,
264
289
  ): Promise<CatchUpReport> {
265
290
  const verbose = flags.verbose === 'true';
266
291
  try {
267
292
  const outcome = await catchUpDerived(venue, {
293
+ ...(phases ? { onPhase: (phase: 'derived' | 'post-checks', ms: number) => { phases[phase === 'derived' ? 'derived' : 'postChecks'] = ms; } } : {}),
268
294
  ...(declared !== undefined ? { declared } : {}),
269
295
  ...(flags.models ? { models: flags.models } : {}),
270
296
  onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
@@ -336,6 +362,7 @@ async function catchUpAfterApply(
336
362
  venue: CatchUpVenue,
337
363
  flags: Record<string, string>,
338
364
  declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
365
+ phases?: ApplyPhaseTimings,
339
366
  ): Promise<CatchUpReport> {
340
367
  if (!LANDED.has(result.status)) return { ok: true, statements: [] };
341
368
  if (flags['state-only'] === 'true') return { ok: true, statements: [] };
@@ -345,7 +372,7 @@ async function catchUpAfterApply(
345
372
  const applied = result.status === 'already-applied'
346
373
  ? 0
347
374
  : classifyGeneratedStatements((plan.statements ?? []).filter((s) => !unapplied.has(s))).executable.length;
348
- return runCatchUp(venue, flags, applied, 'throw', declared);
375
+ return runCatchUp(venue, flags, applied, 'throw', declared, phases);
349
376
  }
350
377
 
351
378
  /**
@@ -519,7 +546,11 @@ async function applyPlanViaStage(
519
546
  const { region } = config;
520
547
  const fn = opsFunction(config);
521
548
  const declared = await loadDeclaredDerived(flags.models);
522
- const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [])
549
+ const unclassified = unclassifiedGranteesWarning(declared?.models ?? [], lifecycleRolesOf(declared));
550
+ if (unclassified) warn(unclassified);
551
+ // The plan's own REVOKEs resolve the grant findings they remove: the state transaction runs them
552
+ // before the catch-up creates any lifecycle function, and the post-check reads the catalog after.
553
+ const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [], lifecycleRolesOf(declared), plan.statements)
523
554
  ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? []) : null);
524
555
  if (lifecycleRefusal) {
525
556
  fail(lifecycleRefusal);
@@ -535,6 +566,10 @@ async function applyPlanViaStage(
535
566
  // answered by two containers at two moments. Read the stage twice and refuse a
536
567
  // disagreement — the apply never starts. Deliberately OUTSIDE the try below: this
537
568
  // refusal must exit as itself, not as "Apply failed".
569
+ // Wall times as this CLI sees them: the reads, descent and authority before the write; the write
570
+ // invoke (the ops Lambda's own lock, gates and transaction); then the catch-up's two halves.
571
+ const phases: ApplyPhaseTimings = {};
572
+ const verifyStarted = Date.now();
538
573
  step('Reading the stage TWICE (state and authz are separate ops-Lambda invokes)...');
539
574
  const readSession = lambdaSessionRunner(region, fn);
540
575
  const readPair = await readStageStateTwice(readSession).catch((err: any) => {
@@ -626,6 +661,8 @@ async function applyPlanViaStage(
626
661
  }
627
662
 
628
663
  step('Applying in the ops Lambda (credential-free — the operator never holds a database URL)...');
664
+ const stateStarted = Date.now();
665
+ phases.verify = stateStarted - verifyStarted;
629
666
  const result: any = await invokeAction(region, fn, 'db:apply', {
630
667
  plan,
631
668
  // q1-contract-version direction A: the server refuses on exact mismatch, so a payload-key or
@@ -641,6 +678,7 @@ async function applyPlanViaStage(
641
678
  ...(flags['snapshot-ref'] ? { snapshotRef: flags['snapshot-ref'] } : {}),
642
679
  stage: flags.stage,
643
680
  });
681
+ phases.state = Date.now() - stateStarted;
644
682
  if (result?.error) {
645
683
  fail(`Apply failed: ${result.error}`);
646
684
  process.exit(1);
@@ -668,8 +706,10 @@ async function applyPlanViaStage(
668
706
  }
669
707
 
670
708
  // Then the functions, views and triggers — on the same stage, credential-free, always.
671
- const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags, declared);
709
+ const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags, declared, phases);
672
710
  exitOnCatchUpFailure(catchUp);
711
+ const timings = LANDED.has(landedResult!.status) ? renderPhaseTimings(phases) : null;
712
+ if (timings) info(timings);
673
713
  const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
674
714
  const advisoryModels = await modelsForAdvisory(landedResult!, flags);
675
715
  reportApplyResult(landedResult!, plan, {
@@ -956,7 +996,10 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
956
996
  const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
957
997
  declaredDbPromise ??= loadDeclaredDerived(flags.models);
958
998
  // Read-only, before the rollback point: a refused apply must not first take a snapshot, backup or dump.
959
- const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [])
999
+ const unclassified = unclassifiedGranteesWarning((await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()));
1000
+ if (unclassified) warn(unclassified);
1001
+ // As on the stage lane: a finding this plan's own REVOKE removes is not a refusal.
1002
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()), plan.statements)
960
1003
  ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(runner, (await declaredDb())?.models ?? []) : null);
961
1004
  if (lifecycleRefusal) {
962
1005
  fail(lifecycleRefusal);
@@ -1083,6 +1126,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1083
1126
 
1084
1127
  let landed: ApplyPlanResult | undefined;
1085
1128
  let catchUp: CatchUpReport = { ok: true, statements: [] };
1129
+ const phases: ApplyPhaseTimings = {};
1086
1130
  try {
1087
1131
  // The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
1088
1132
  if (flags.verbose === 'true') info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
@@ -1113,7 +1157,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1113
1157
  { verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
1114
1158
  async () => {
1115
1159
  // Re-checked under the lease, where the writes happen.
1116
- const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? []);
1160
+ const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()), plan.statements);
1117
1161
  if (leasedRefusal) {
1118
1162
  fail(leasedRefusal);
1119
1163
  process.exit(1);
@@ -1175,7 +1219,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1175
1219
  // they read, and no second operator can slip in between the two halves. Its failure
1176
1220
  // is carried out as a report, never thrown: the plan landed, and "rolled back" would
1177
1221
  // be a lie.
1178
- const report = await catchUpAfterApply(result, plan, { kind: 'direct', runner, session }, flags);
1222
+ if (result.phases) Object.assign(phases, { verify: result.phases.verifyMs, state: result.phases.stateMs });
1223
+ const report = await catchUpAfterApply(result, plan, { kind: 'direct', runner, session }, flags, undefined, phases);
1179
1224
  return { result, catchUp: report };
1180
1225
  },
1181
1226
  );
@@ -1195,6 +1240,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1195
1240
  }
1196
1241
 
1197
1242
  exitOnCatchUpFailure(catchUp);
1243
+ const timings = LANDED.has(landed!.status) ? renderPhaseTimings(phases) : null;
1244
+ if (timings) info(timings);
1198
1245
  const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
1199
1246
  const advisoryModels = await modelsForAdvisory(landed!, flags);
1200
1247
  reportApplyResult(landed!, plan, {
@@ -41,6 +41,7 @@ import { resolveDbSource, type DbSource } from '../db-source';
41
41
  import { resolveModelsPath } from '../models-path';
42
42
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
43
43
  import { loadModels } from './db-generate';
44
+ import { lifecycleRolesOf } from '../lifecycle-preflight';
44
45
  import { step, success, fail, info, warn } from '../output';
45
46
 
46
47
  function requireUrl(flags: Record<string, string>, verb: string): string {
@@ -134,6 +135,7 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
134
135
  const result = await executeTemplateRefresh(url, models, {
135
136
  declared: declaredDb?.objects,
136
137
  sequences: declaredDb?.sequences,
138
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
137
139
  actor: process.env.USER ?? null,
138
140
  gitRef: currentGitRef(),
139
141
  seed: seedCommand === null ? null : (templateUrl) => {
@@ -26,7 +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
+ import { LifecycleFindingsError, lifecycleRolesOf } from '../lifecycle-preflight';
30
30
  import { createUrlRunner } from '../db-source';
31
31
  import { resolveModelsPath } from '../models-path';
32
32
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
@@ -124,6 +124,7 @@ export async function dbBuildCommand(flags: Record<string, string>): Promise<voi
124
124
  declared: declaredDb?.objects,
125
125
  sequences: declaredDb?.sequences,
126
126
  extensions: declaredDb?.extensions,
127
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
127
128
  actor: process.env.USER ?? null,
128
129
  gitRef: currentGitRef(),
129
130
  });
@@ -44,6 +44,7 @@ import { findUnexportedModels, scanModelDirectory, type ModelFileScan } from '..
44
44
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
45
45
  import type { SequenceDescriptor } from '@everystack/model';
46
46
  import type { SourceObject } from '../derived-source';
47
+ import { lifecycleRolesOf, unclassifiedGranteesWarning, type LifecycleRoleDeclaration } from '../lifecycle-preflight';
47
48
  import { findReadAuthzGaps, findNakedGrants } from '../authz-lint';
48
49
  import { findSelectGrantGaps } from '../select-grant-advisory';
49
50
  import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline';
@@ -125,6 +126,8 @@ export interface StaticCheckInput {
125
126
  * Absent (undefined) = the caller did not read it (older callers): no map finding at all.
126
127
  */
127
128
  modelMapSource?: string | null;
129
+ /** The modules' role declaration (lifecycleRolesOf). Its unclassified grantees are a warning. */
130
+ lifecycleRoles?: LifecycleRoleDeclaration;
128
131
  }
129
132
 
130
133
  export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
@@ -285,6 +288,11 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
285
288
  }
286
289
  }
287
290
 
291
+ // A grantee the role declaration leaves unclassified counts as a request role in the lifecycle
292
+ // catalog check. Visible, never fatal: classifying it is the operator's call.
293
+ const unclassified = unclassifiedGranteesWarning(input.models, input.lifecycleRoles ?? {});
294
+ if (unclassified) findings.push({ level: 'warn', area: 'authz', message: unclassified });
295
+
288
296
  if (input.artifactSource === null && input.artifactOrigin === 'flag') {
289
297
  findings.push({
290
298
  level: 'fail',
@@ -369,7 +377,7 @@ export interface EphemeralComposeResult {
369
377
  export async function executeEphemeralCompose(
370
378
  adminUrl: string,
371
379
  models: ModelDescriptor[],
372
- opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[]; extensions?: string[] } = {},
380
+ opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[]; extensions?: string[]; lifecycleRoles?: LifecycleRoleDeclaration } = {},
373
381
  ): Promise<EphemeralComposeResult> {
374
382
  const database = `escheck_${process.pid}_${Date.now()}`;
375
383
  await createDatabase(adminUrl, database);
@@ -378,6 +386,7 @@ export async function executeEphemeralCompose(
378
386
  declared: opts.declared,
379
387
  sequences: opts.sequences,
380
388
  extensions: opts.extensions,
389
+ lifecycleRoles: opts.lifecycleRoles,
381
390
  actor: opts.actor ?? 'db:check',
382
391
  gitRef: opts.gitRef ?? null,
383
392
  });
@@ -478,6 +487,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
478
487
  moduleModels: declaredDb?.models ?? null,
479
488
  baselineSource,
480
489
  modelMapPath: mapPath, modelMapSource,
490
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
481
491
  });
482
492
  // Per-finding detail is the --verbose report. The quiet default answers the one question a
483
493
  // human asked — is anything pending? — and names the verb that shows it.
@@ -504,6 +514,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
504
514
  declared: declaredDb?.objects,
505
515
  sequences: declaredDb?.sequences,
506
516
  extensions,
517
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
507
518
  });
508
519
  if (verbose) for (const line of ephemeral.report) info(` ${line}`);
509
520
  } catch (err: any) {