@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.
package/CHANGELOG.md CHANGED
@@ -4,11 +4,64 @@ 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
- ## [Unreleased]
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
+
19
+ ## [0.4.104] - 2026-10-30
20
+
21
+ ### BREAKING CHANGES
22
+
23
+ - destroy is one generated SQL function per lifecycle table; purgeExpired is removed
24
+ - only a lifecycle table may hold a foreign key into a lifecycle table
25
+
26
+ ### Added
27
+
28
+ - db:restore and db:fork name the repair a lifecycle app needs
29
+ - a destroy call removes only rows soft-deleted before its cutoff
30
+ - close destroy_log to the lifecycle role; the marker functions refuse inside a trigger
31
+ - a lifecycle model refuses TRIGGER and REFERENCES in privileges
32
+ - the apply preflight refuses a live catalog that breaks the soft delete
33
+ - a skipped destroy root says why: reason and blocker
34
+ - say where skip reasons are reported
35
+ - db:reconcile runs the live catalog check on every write path
8
36
 
9
37
  ### Fixed
10
38
 
11
39
  - export compileDeclaredState from @everystack/cli/model
40
+ - db:pull only warns about a lifecycle table it cannot fold when it tried to fold
41
+ - db:pull does not render an older CLI's lifecycle_restrict pair as authored code
42
+ - provisioning clears committed destroy_log rows and validates the transaction check
43
+ - a DELETE as the lifecycle role is soft outside a destroy call
44
+ - a destroy call whose marker was removed mid-call fails whole (55000)
45
+ - review nits for lifecycle/testing
46
+ - refuse an out-of-date destroy_log before applying lifecycle objects
47
+ - refuse a destroy_log provisioned before the marker functions
48
+ - <t>_lifecycle_destroy_before() is executable by its owner alone
49
+ - catalog checks pass the RDS shape and see owner, log and settings holes
50
+ - the parent check's 23503 names its table and constraint
51
+ - a function whose result type changes is rebuilt, not replaced
52
+ - cap each blocker name part at 63 characters
53
+ - result types compare without type modifiers; bare time is time without time zone
54
+ - db:sync re-checks the catalog after its state apply; tables match by exact name
55
+ - analytics.post_metrics refers to posts by value, not by key
56
+ - destroy eligibility follows the root
57
+ - review nits for soft-delete-decides
58
+ - destroy_before is internal, so db:check passes on a lifecycle app
59
+
60
+ ### Changed
61
+
62
+ - db:pull no longer refuses on lifecycle drift; the verifier is removed
63
+ - one catalog check over tables and roles, with a models adapter
64
+ - a destroy skip is an alarm, not an operator workflow
12
65
 
13
66
  ## [0.4.103] - 2026-10-23
14
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.103",
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>",
@@ -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.24"
148
+ "@everystack/model": "0.4.25"
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.39"
195
+ "@everystack/server": "0.4.41"
196
196
  },
197
197
  "scripts": {
198
198
  "test": "jest",
@@ -460,7 +460,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
460
460
  // A lifecycle model hides deleted rows from every read policy but the lifecycle role's own.
461
461
  // Unlike softDelete there is no owner escape: the guard is AND-ed into each SELECT policy as
462
462
  // compiled. A `manage` role's ALL policy is NOT guarded, on purpose: it sees deleted rows (to
463
- // restore or purge them), exactly as it does under softDelete.
463
+ // restore or destroy them), exactly as it does under softDelete.
464
464
  const lifecycleCol = model.lifecycle ? 'deleted_at' : null;
465
465
  const lifecycleGuard = lifecycleCol ? softDeleteGuard(lifecycleCol) : null;
466
466
 
@@ -899,6 +899,20 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
899
899
  * derivation. The lifecycle role is left out: it is the machinery, not an author's role.
900
900
  * A policy with no USING admits nothing here, so it is skipped (fail closed).
901
901
  */
902
+ /**
903
+ * The roles whose `manage` covers every row: each one holds an ALL policy USING true WITH CHECK
904
+ * true. Read back from {@link compileTableContract}, so a `manage` that desugars to manage-own
905
+ * (owner-scoped, or role-less on a row-scoped model) is never in it. The lifecycle role is left out.
906
+ */
907
+ export function allRowsManageRoles(model: ModelDescriptor, opts: CompileOptions = {}): string[] {
908
+ const roles = new Set<string>();
909
+ for (const p of compileTableContract(model, opts).policies) {
910
+ if (p.command !== 'ALL' || p.using !== 'true' || p.check !== 'true') continue;
911
+ for (const role of p.roles) if (role !== LIFECYCLE_OWNER_ROLE) roles.add(role);
912
+ }
913
+ return [...roles].sort();
914
+ }
915
+
902
916
  export function deleteRulePredicates(model: ModelDescriptor, opts: CompileOptions = {}): Record<string, string> {
903
917
  const byRole = new Map<string, string[]>();
904
918
  for (const p of compileTableContract(model, opts).policies) {
@@ -77,6 +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
81
  import { buildReconcileReport } from './db-reconcile';
81
82
  import { step, success, fail, info, warn, line as printLine } from '../output';
82
83
 
@@ -259,13 +260,17 @@ async function runCatchUp(
259
260
  flags: Record<string, string>,
260
261
  applied: number,
261
262
  onRefusal: 'exit' | 'throw',
263
+ declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
262
264
  ): Promise<CatchUpReport> {
263
265
  const verbose = flags.verbose === 'true';
264
266
  try {
265
267
  const outcome = await catchUpDerived(venue, {
268
+ ...(declared !== undefined ? { declared } : {}),
266
269
  ...(flags.models ? { models: flags.models } : {}),
267
270
  onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
268
271
  onRefusal,
272
+ // A lifecycle refusal says the tables were applied only when this run applied some.
273
+ stateApplied: applied > 0,
269
274
  });
270
275
  if (outcome.run) {
271
276
  if (verbose) {
@@ -286,6 +291,8 @@ async function runCatchUp(
286
291
  return { ok: true, statements: outcome.statements };
287
292
  } catch (err: any) {
288
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') };
289
296
  return {
290
297
  ok: false,
291
298
  // The cause is printed ONCE, at every verbosity: its headline on the report line, and
@@ -328,6 +335,7 @@ async function catchUpAfterApply(
328
335
  plan: EdgePlan,
329
336
  venue: CatchUpVenue,
330
337
  flags: Record<string, string>,
338
+ declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
331
339
  ): Promise<CatchUpReport> {
332
340
  if (!LANDED.has(result.status)) return { ok: true, statements: [] };
333
341
  if (flags['state-only'] === 'true') return { ok: true, statements: [] };
@@ -337,7 +345,7 @@ async function catchUpAfterApply(
337
345
  const applied = result.status === 'already-applied'
338
346
  ? 0
339
347
  : classifyGeneratedStatements((plan.statements ?? []).filter((s) => !unapplied.has(s))).executable.length;
340
- return runCatchUp(venue, flags, applied, 'throw');
348
+ return runCatchUp(venue, flags, applied, 'throw', declared);
341
349
  }
342
350
 
343
351
  /**
@@ -510,6 +518,13 @@ async function applyPlanViaStage(
510
518
  const config = await resolveConfig(flags.stage);
511
519
  const { region } = config;
512
520
  const fn = opsFunction(config);
521
+ const declared = await loadDeclaredDerived(flags.models);
522
+ const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [])
523
+ ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? []) : null);
524
+ if (lifecycleRefusal) {
525
+ fail(lifecycleRefusal);
526
+ process.exit(1);
527
+ }
513
528
  const attestLineageLost = flags['attest-lineage-lost']; // validated (not bare 'true') in dbApplyCommand
514
529
 
515
530
  // The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
@@ -653,7 +668,7 @@ async function applyPlanViaStage(
653
668
  }
654
669
 
655
670
  // Then the functions, views and triggers — on the same stage, credential-free, always.
656
- const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags);
671
+ const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags, declared);
657
672
  exitOnCatchUpFailure(catchUp);
658
673
  const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
659
674
  const advisoryModels = await modelsForAdvisory(landedResult!, flags);
@@ -934,6 +949,21 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
934
949
  }
935
950
  }
936
951
 
952
+ step(connectingVia(dbSource));
953
+ const { runner, session, transaction, end } = await createUrlRunner(dbSource.url);
954
+ // Every gate below needs the declared derived bundle. Never evaluate the models barrel twice.
955
+ let declaredDbPromise: ReturnType<typeof loadDeclaredDerived> | undefined;
956
+ const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
957
+ declaredDbPromise ??= loadDeclaredDerived(flags.models);
958
+ // 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 ?? [])
960
+ ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(runner, (await declaredDb())?.models ?? []) : null);
961
+ if (lifecycleRefusal) {
962
+ fail(lifecycleRefusal);
963
+ await end();
964
+ process.exit(1);
965
+ }
966
+
937
967
  // Losing data should be hard (decision 11): a destructive plan applies
938
968
  // only confirmed (--confirm, always), with a durable rollback point, and —
939
969
  // when the stage declares an approver set — identity-approved (decision 12).
@@ -1051,16 +1081,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1051
1081
  }
1052
1082
  }
1053
1083
 
1054
- step(connectingVia(dbSource));
1055
- const { runner, session, transaction, end } = await createUrlRunner(dbSource.url);
1056
-
1057
1084
  let landed: ApplyPlanResult | undefined;
1058
1085
  let catchUp: CatchUpReport = { ok: true, statements: [] };
1059
- // Both gates below need the declared derived bundle. Keep its existing lazy timing — the
1060
- // baseline gate is still the first reader — but never evaluate the models barrel twice.
1061
- let declaredDbPromise: ReturnType<typeof loadDeclaredDerived> | undefined;
1062
- const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
1063
- declaredDbPromise ??= loadDeclaredDerived(flags.models);
1064
1086
  try {
1065
1087
  // The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
1066
1088
  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` : ''})`);
@@ -1090,6 +1112,12 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1090
1112
  runner,
1091
1113
  { verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
1092
1114
  async () => {
1115
+ // Re-checked under the lease, where the writes happen.
1116
+ const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? []);
1117
+ if (leasedRefusal) {
1118
+ fail(leasedRefusal);
1119
+ process.exit(1);
1120
+ }
1093
1121
  const result = await executeApplyPlan(runner, session, plan, {
1094
1122
  transaction,
1095
1123
  actor: process.env.USER ?? null,
@@ -28,7 +28,7 @@ import {
28
28
  backupKey, backupId, metaKey, utcStamp,
29
29
  } from '../backup';
30
30
  import { pgEnvFromUrl, pgKeepaliveConninfo } from './db';
31
- import { pollTaskUntilStopped } from '../task-poll';
31
+ import { pollTaskUntilStopped, type TaskPollResult } from '../task-poll';
32
32
  import { step, success, fail, info, warn } from '../output';
33
33
 
34
34
  /** Which database db:backup dumps. LOCAL requires a target --stage (its bucket receives the upload
@@ -327,29 +327,70 @@ export function printRestoreWitness(
327
327
  * it requires --confirm; restoring production-tier data downward or into a production target raises
328
328
  * a louder warning (the S0 guards). The id encodes the source stage.
329
329
  */
330
- export async function dbRestoreCommand(flags: Record<string, string>): Promise<void> {
330
+ /**
331
+ * The db:restore preflight — the destructive gate, DB-free. A restore DROPS and replaces a
332
+ * database, so this decides who is allowed to proceed before anything connects: the id must be
333
+ * present and well-formed, and `--confirm` is mandatory (with the cross-stage / prod-target
334
+ * warnings escalated). Extracted from the shell so the gate itself is asserted by a test — a
335
+ * refactor that let the write dispatch without --confirm goes red (closing-gate F1 method).
336
+ */
337
+ export type RestorePreflight =
338
+ | { ok: true; fromStage: string; toStage: string }
339
+ | { ok: false; kind: 'usage'; message: string }
340
+ | { ok: false; kind: 'needs-confirm'; warnings: string[]; hint: string };
341
+
342
+ export function restorePreflight(flags: Record<string, string>): RestorePreflight {
331
343
  const id = flags.from;
332
- if (!id) {
333
- fail('db:restore requires --from <backup-id> (see `everystack db:backups`).');
334
- process.exit(1);
335
- }
344
+ if (!id) return { ok: false, kind: 'usage', message: 'db:restore requires --from <backup-id> (see `everystack db:backups`).' };
336
345
  const ref = parseBackupRef(id);
337
- if (!ref || !keyForId(id)) {
338
- fail(`Malformed backup id: ${id}`);
339
- process.exit(1);
340
- }
346
+ if (!ref || !keyForId(id)) return { ok: false, kind: 'usage', message: `Malformed backup id: ${id}` };
341
347
  const fromStage = ref.stage;
342
348
  const toStage = flags.stage ?? fromStage;
343
-
344
- // Destructive — always require --confirm. Production-tier source/target escalate the warning.
345
349
  if (!flags.confirm) {
346
- warn(`Restore is DESTRUCTIVE — it drops and replaces ${toStage}'s database with backup ${id}.`);
350
+ // Destructive — always require --confirm. Production-tier source/target escalate the warning.
351
+ const warnings = [`Restore is DESTRUCTIVE — it drops and replaces ${toStage}'s database with backup ${id}.`];
347
352
  for (const g of [crossStageGuard(fromStage, toStage), restoreTargetGuard(toStage)]) {
348
- if (g.requiresConfirm && g.warning) warn(g.warning);
353
+ if (g.requiresConfirm && g.warning) warnings.push(g.warning);
354
+ }
355
+ return { ok: false, kind: 'needs-confirm', warnings, hint: 'Re-run with --confirm to proceed.' };
356
+ }
357
+ return { ok: true, fromStage, toStage };
358
+ }
359
+
360
+ /**
361
+ * Map the restore task's poll outcome to an exit verdict — DB-free. The one guarantee it exists to
362
+ * hold: a restore that timed out, errored, or exited non-zero NEVER reports success. Only a clean
363
+ * `stopped` with exitCode 0 is a success; every other outcome is exit 1 with the intent to reconcile.
364
+ */
365
+ export type RestoreVerdict = { exitCode: 0 | 1; kind: 'ok' | 'timeout' | 'error' | 'nonzero'; message: string };
366
+
367
+ export function restorePollVerdict(poll: TaskPollResult, ctx: { id: string; toStage: string; intentKey: string }): RestoreVerdict {
368
+ if (poll.outcome === 'timeout')
369
+ return { exitCode: 1, kind: 'timeout', message: `db:restore timed out (last status: ${poll.lastStatus}). Intent ${ctx.intentKey} — reconcile via ECS/the S3 intent.` };
370
+ if (poll.outcome === 'error')
371
+ return { exitCode: 1, kind: 'error', message: `task:status failed repeatedly: ${poll.status.error}. The restore may still be running — reconcile intent ${ctx.intentKey}.` };
372
+ if (poll.status.exitCode !== 0)
373
+ return { exitCode: 1, kind: 'nonzero', message: `Restore failed (exit ${poll.status.exitCode ?? 'unknown'})${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. The target DB may be partially restored — check the S3 intent ${ctx.intentKey} and the task logs.` };
374
+ return { exitCode: 0, kind: 'ok', message: `Restored ${ctx.id} into ${ctx.toStage}.` };
375
+ }
376
+
377
+ /** What a restored lifecycle app needs; shared by db:restore and db:fork. */
378
+ export const LIFECYCLE_RESTORE_REPAIR =
379
+ 'If the app declares lifecycle: true, DELETEs on those tables fail until you run db:apply, then db:reconcile --apply --overwrite-drift (docs/backups.md).';
380
+
381
+ export async function dbRestoreCommand(flags: Record<string, string>): Promise<void> {
382
+ const id = flags.from;
383
+ const pre = restorePreflight(flags);
384
+ if (!pre.ok) {
385
+ if (pre.kind === 'usage') fail(pre.message);
386
+ else {
387
+ for (const w of pre.warnings) warn(w);
388
+ info(pre.hint);
349
389
  }
350
- info('Re-run with --confirm to proceed.');
351
390
  process.exit(1);
391
+ return; // process.exit is mocked to throw in tests; keep the narrowing honest for callers
352
392
  }
393
+ const { toStage } = pre;
353
394
 
354
395
  step('Resolving deployed config...');
355
396
  let config: CliConfig;
@@ -383,19 +424,14 @@ export async function dbRestoreCommand(flags: Record<string, string>): Promise<v
383
424
 
384
425
  step(`Restoring ${id} into ${toStage} (pg_restore in the Task; this may take a while)...`);
385
426
  const poll = await pollTaskUntilStopped(config.region, fn, { runId, taskArn });
386
- if (poll.outcome === 'timeout') {
387
- fail(`db:restore timed out (last status: ${poll.lastStatus}). Intent ${intentKey} — reconcile via ECS/the S3 intent.`);
388
- process.exit(1);
389
- }
390
- if (poll.outcome === 'error') {
391
- fail(`task:status failed repeatedly: ${poll.status.error}. The restore may still be running — reconcile intent ${intentKey}.`);
392
- process.exit(1);
393
- }
394
- if (poll.status.exitCode !== 0) {
395
- fail(`Restore failed (exit ${poll.status.exitCode ?? 'unknown'})${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. The target DB may be partially restored — check the S3 intent ${intentKey} and the task logs.`);
427
+ const verdict = restorePollVerdict(poll, { id, toStage, intentKey });
428
+ if (verdict.exitCode !== 0) {
429
+ fail(verdict.message);
396
430
  process.exit(1);
397
431
  }
398
- success(`Restored ${id} into ${toStage}.`);
432
+ success(verdict.message);
433
+ // The dump carries no owners or grants, so a lifecycle table refuses every DELETE (never hard-deletes) until repaired.
434
+ info(LIFECYCLE_RESTORE_REPAIR);
399
435
  }
400
436
 
401
437
  /** db:backup:download — print (or save) a presigned URL to a backup's dump for local download. */
@@ -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
 
@@ -24,7 +24,7 @@ import { invokeAction, presignGet } from '../aws';
24
24
  import { keyForId, metaKey, parseBackupRef, isProductionTier, crossStageGuard } from '../backup';
25
25
  import { pollTaskUntilStopped } from '../task-poll';
26
26
  import { runStageBackup, stageBackupDeps } from '../stage-backup';
27
- import { printRestoreWitness } from './db-backup';
27
+ import { LIFECYCLE_RESTORE_REPAIR, printRestoreWitness } from './db-backup';
28
28
  import { step, success, fail, info, warn } from '../output';
29
29
 
30
30
  export type ForkGuardVerdict =
@@ -157,5 +157,6 @@ export async function dbForkCommand(flags: Record<string, string>): Promise<void
157
157
  info('Next:');
158
158
  info(` everystack db:fingerprint --stage ${target} # where the fork is (a state some main commit declares)`);
159
159
  info(` everystack db:plan --stage ${target} # your branch's edge on top — descent composes for free`);
160
+ info(` ${LIFECYCLE_RESTORE_REPAIR}`);
160
161
  info(` sst remove --stage ${target} # teardown takes the database with it`);
161
162
  }
@@ -63,6 +63,7 @@ import { resolveModelsPath } from '../models-path';
63
63
  import { resolveConfig, opsFunction } from '../config';
64
64
  import { resolveOperatorUrlViaStage } from '../direct-venue';
65
65
  import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
66
+ import { lifecycleApplyRefusal } from '../lifecycle-preflight';
66
67
  import { loadModels } from './db-generate';
67
68
  import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived';
68
69
  import { introspectDerived } from '../derived-introspect';
@@ -237,6 +238,9 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
237
238
  }
238
239
 
239
240
  try {
241
+ const lifecycleNote = await lifecycleApplyRefusal(runner, models);
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}`);
240
244
  let snapshot: SchemaSnapshot;
241
245
  let contract: AuthzContract;
242
246
  if (dbSource.kind === 'url') {
@@ -43,13 +43,12 @@
43
43
 
44
44
  import fs from 'node:fs/promises';
45
45
  import path from 'node:path';
46
- import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, FK_TARGETS_SQL, foreignKeyTargets, type ColumnRow, type ColumnSchema, type ForeignKeyTargetRow } from '../schema-introspect';
46
+ import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, FK_TARGETS_SQL, foreignKeyTargets, type ColumnRow, type ColumnSchema, type ForeignKeyTargetRow, type TableSchema } from '../schema-introspect';
47
47
  import { fingerprintLive } from '../schema-fingerprint';
48
48
  import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile';
49
49
  import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline';
50
50
  import { introspectDerived, type DerivedCatalog } from '../derived-introspect';
51
51
  import { foldLifecycle } from '../lifecycle-pull';
52
- import { verifyLifecycle, LifecycleDriftError } from '../lifecycle-verify';
53
52
  import { LIFECYCLE_OWNER_ROLE } from '@everystack/model';
54
53
 
55
54
  /**
@@ -86,7 +85,7 @@ import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, typ
86
85
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
87
86
  import { renderDerivedSource, renderDerivedFile, renderDerivedDirectory, type DerivedRenderResult, type RenderedDerivedFile } from '../derived-render';
88
87
  import { containedPath } from '../generated-names';
89
- import { renderModelSource, renderModelFiles, pullableTables, skippedInfrastructureTables, modelVarName, externalForeignKeyWarnings, ABILITY_PRESETS, type RenderedModelFile } from '../model-render';
88
+ import { renderModelSource, renderModelFiles, pullableTables, skippedInfrastructureTables, modelVarName, toCamelCase, externalForeignKeyWarnings, ABILITY_PRESETS, type RenderedModelFile } from '../model-render';
90
89
  import { deriveAbilities } from '../authz-derive';
91
90
  import type { QueryRunner } from '../authz-contract';
92
91
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
@@ -249,6 +248,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
249
248
  let derivedCatalog: DerivedCatalog | undefined;
250
249
  /** Tables folded to `lifecycle: true` (lifecycle-pull.ts). */
251
250
  let lifecycleTables: ReadonlySet<string> = new Set();
251
+ const lifecycleForeignKeyTables = new Set<string>();
252
252
  let lifecycleDeleteWhen: ReadonlyMap<string, string> = new Map();
253
253
  let matviewColumns: Map<string, ColumnSchema[]> | undefined;
254
254
  let candidatesByIdentity: Map<string, string[]> | undefined;
@@ -274,9 +274,6 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
274
274
  // The derived layer rides the same pull (B5) — views/matviews/functions/sequences
275
275
  // render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
276
276
  derivedCatalog = await introspectDerived(session);
277
- const lifecycle = await verifyLifecycle(session, ['anon', 'authenticated', 'admin', LIFECYCLE_OWNER_ROLE, ...governRoles]);
278
- if (lifecycle.failures.length) throw new LifecycleDriftError(lifecycle.failures);
279
- for (const warning of lifecycle.notes) caution(warning);
280
277
  // --abilities live: the authz half of the on-ramp. Same connection, one more read —
281
278
  // the grants and policies that already exist become the models' declared abilities,
282
279
  // instead of a human transcribing them by hand.
@@ -351,6 +348,47 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
351
348
  for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract), governedAtPull)) caution(line);
352
349
  pulledFingerprint = fingerprintLive(rawSnapshot, rawContract).hash;
353
350
  }
351
+ const tables = pullableTables(current, schema);
352
+ const derivedObjects = derivedCatalog.objects;
353
+ const isLifecycleTable = (table: TableSchema): boolean => {
354
+ const name = table.table.slice(table.table.indexOf('.') + 1);
355
+ return lifecycleTables.has(table.table) || (['deleted_at', 'deleted_by', 'deleted_batch', 'deleted_xid'].every(column => table.columns.some(c => c.name === column))
356
+ && derivedObjects.some(object => object.kind === 'trigger' && object.identity === `${table.table}.${name}_lifecycle_transition`));
357
+ };
358
+ for (const table of tables) {
359
+ const name = table.table.slice(table.table.indexOf('.') + 1);
360
+ if (isLifecycleTable(table)) {
361
+ lifecycleForeignKeyTables.add(table.table);
362
+ for (const fk of table.foreignKeys) {
363
+ if (fk.onDelete !== 'cascade') continue;
364
+ const parent = fk.refTable.includes('.') ? fk.refTable : `public.${fk.refTable}`;
365
+ const key = `${table.table}.${fk.columns.join(',')}`;
366
+ const parentTable = tables.find(t => t.table === parent);
367
+ // A one-column key into a parent rendered lifecycle: true renders as its hasMany dependent.
368
+ if (fk.columns.length === 1 && lifecycleTables.has(parent)) continue;
369
+ // Only a one-column key from a lifecycle parent can be declared: hasMany joins on one
370
+ // column, and a lifecycle child cannot cascade from a table without lifecycle. The
371
+ // pulled barrel does not link models, so the advice names the thunk form the pull uses.
372
+ if (fk.columns.length === 1 && parentTable && isLifecycleTable(parentTable)) {
373
+ const child = modelVarName(table.table);
374
+ const field = toCamelCase(fk.columns[0]);
375
+ caution(`${key} cascades from lifecycle table ${parent} in the database; db:pull cannot render ` + '`dependent`'
376
+ + `, so it is rendered as restrict. Declare \`hasMany(() => ${child}, '${field}', { dependent: 'cascade' })\` in ${modelVarName(parent)}'s relations`
377
+ + ` and remove \`onDelete\` from ${child}'s ${field} field; then db:plan shows no foreign-key change.`);
378
+ } else {
379
+ caution(`${key} cascades from ${parent} in the database; db:pull renders it as restrict, and the models cannot declare this cascade, so db:plan shows this key changing to restrict.`);
380
+ }
381
+ }
382
+ }
383
+ if (!lifecycleTables.has(table.table) && derivedCatalog.objects.some(object =>
384
+ object.kind === 'trigger' && object.identity === `${table.table}.${name}_lifecycle_transition`)) {
385
+ if (abilities === 'live') {
386
+ caution(`${table.table} has lifecycle objects but they are not exactly what the compiler writes. Rendered as a plain table. Run db:apply from the checkout that declared it, or review db:plan.`);
387
+ } else {
388
+ note(`${table.table} is a lifecycle table. The default pull renders it as a plain table; pass --abilities live to render lifecycle: true.`);
389
+ }
390
+ }
391
+ }
354
392
  // --matviews-as-tables: the flip needs real fields — one extra catalog read for the
355
393
  // matview columns the derived layer (definition-only) doesn't carry.
356
394
  if (matviewsAsTables) {
@@ -372,10 +410,6 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
372
410
  await end?.();
373
411
  } catch (err: any) {
374
412
  await end?.().catch(() => {});
375
- if (err instanceof LifecycleDriftError) {
376
- for (const line of err.lines) console.error(line);
377
- process.exit(1);
378
- }
379
413
  fail(err.message);
380
414
  if (dbSource.kind === 'url') {
381
415
  detail('Check the connection string and that the database is accepting connections.');
@@ -494,10 +528,10 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
494
528
  : undefined;
495
529
  if (flags.out && !flags.out.endsWith('.ts')) {
496
530
  const dir = path.resolve(flags.out);
497
- modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables, lifecycleDeleteWhen })
531
+ modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables, lifecycleDeleteWhen, lifecycleForeignKeyTables })
498
532
  .map((f) => ({ ...f, target: containedPath(dir, f.file) }));
499
533
  } else {
500
- modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables, lifecycleDeleteWhen });
534
+ modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables, lifecycleDeleteWhen, lifecycleForeignKeyTables });
501
535
  }
502
536
  }
503
537
  } catch (err: any) {
@@ -32,6 +32,7 @@
32
32
  import type { QueryRunner } from '../authz-contract';
33
33
  import type { SourceObject } from '../derived-source';
34
34
  import { introspectDerived } from '../derived-introspect';
35
+ import { catalogInputsFromModels, lifecycleReconcileRefusal } from '../lifecycle-preflight';
35
36
  import { planReconcile, objectGrantDelta, TRIGGER_ENABLED_STATE, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
36
37
  import {
37
38
  renderReconcileSql,
@@ -77,6 +78,18 @@ export interface ExecuteOptions extends ReconcileOptions {
77
78
  planRef?: string | null;
78
79
  /** Descriptor-compiled objects (the declared derived layer) — the ONE source stream. */
79
80
  declared?: SourceObject[];
81
+ /**
82
+ * The application roles for the lifecycle catalog check (catalogInputsFromModels). Required
83
+ * whenever a declared object is lifecycle-owned: absent, the reconcile refuses rather than skip
84
+ * the check. `unknown` because the ops Lambda passes it straight off the wire.
85
+ */
86
+ applicationRoles?: unknown;
87
+ /**
88
+ * The caller already ran lifecycleApplyRefusal with the full models, on this runner, in this
89
+ * process (executeSync does, first thing), so the reconcile does not run the catalog check again.
90
+ * Never set from a wire payload.
91
+ */
92
+ lifecyclePreflighted?: boolean;
80
93
  /** Injectable clock for tests. */
81
94
  now?: () => number;
82
95
  }
@@ -89,6 +102,8 @@ export interface ReconcileRun {
89
102
  statements: string[];
90
103
  /** Why apply was refused, when it was. */
91
104
  refusal?: string;
105
+ /** A plan-only run: what the lifecycle catalog check found (the text an apply would refuse with). */
106
+ lifecycleFindings?: string;
92
107
  /**
93
108
  * Which mechanism enacted the declared owners, when any were enacted. Reported because the two
94
109
  * make different demands of the operator: `set-role` needs membership plus CREATE-on-schema for
@@ -140,13 +155,21 @@ export async function executeReconcile(
140
155
  // catalog and re-renders if this builder can use the cheaper one; a plan that is never applied
141
156
  // (or is refused) shows the conservative form, which is the honest default.
142
157
  let rendered = renderReconcileSql(plan, parsed.objects);
143
-
144
- if (!options.apply) return { plan, applied: false, statements: rendered.statements };
158
+ // Every mode that writes or adopts a lifecycle-owned object (--apply with --baseline,
159
+ // --rebaseline, --only, --overwrite-drift or none) refuses on it below; a plan-only run reports
160
+ // it as a finding. See lifecycleCatalogRefusal for what it defends and from whom.
161
+ const lifecycleGate = (): Promise<string | null> => (options.lifecyclePreflighted ? Promise.resolve(null)
162
+ : lifecycleReconcileRefusal(runner, parsed.objects, { applicationRoles: options.applicationRoles }));
163
+
164
+ if (!options.apply) {
165
+ const lifecycleFindings = await lifecycleGate();
166
+ return { plan, applied: false, statements: rendered.statements, ...(lifecycleFindings ? { lifecycleFindings } : {}) };
167
+ }
145
168
 
146
169
  if (plan.blocked.length > 0) {
147
170
  return {
148
171
  plan, applied: false, statements: rendered.statements,
149
- refusal: `blocked chains: ${plan.blocked.map((b) => b.identity).join(', ')} — resolve the unmanaged dependents first`,
172
+ refusal: `blocked chains: ${plan.blocked.map((b) => `${b.identity} (${b.reason})`).join('; ')} — resolve the dependents first`,
150
173
  };
151
174
  }
152
175
  if (plan.drift.length > 0 && !options.overwriteDrift) {
@@ -155,6 +178,8 @@ export async function executeReconcile(
155
178
  refusal: `drift on ${plan.drift.map((d) => d.identity).join(', ')} — inspect, then re-run with --overwrite-drift to rebuild from source`,
156
179
  };
157
180
  }
181
+ const lifecycleRefusal = await lifecycleGate();
182
+ if (lifecycleRefusal) return { plan, applied: false, statements: rendered.statements, refusal: lifecycleRefusal };
158
183
  if (rendered.statements.length === 0 && rendered.record.length === 0 && rendered.remove.length === 0 && rendered.migrate.length === 0) {
159
184
  return { plan, applied: false, statements: [] };
160
185
  }
@@ -740,6 +765,9 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
740
765
  const reconcileOptions = {
741
766
  apply,
742
767
  declared: declared?.objects,
768
+ // The lifecycle catalog check's roles, from the barrel's models. The tables come from the
769
+ // declared transition triggers inside the reconcile, the same derivation the ops Lambda runs.
770
+ applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
743
771
  renamedTables: declared?.renamedTables,
744
772
  baseline: flags.baseline === 'true',
745
773
  rebaseline: flags.rebaseline === 'true',
@@ -820,9 +848,10 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
820
848
  }
821
849
 
822
850
  if (flags.json === 'true') {
823
- console.log(JSON.stringify({ ...run.plan, applied: run.applied, refusal: run.refusal ?? null }, null, 2));
851
+ console.log(JSON.stringify({ ...run.plan, applied: run.applied, refusal: run.refusal ?? null, lifecycleFindings: run.lifecycleFindings ?? null }, null, 2));
824
852
  } else {
825
853
  for (const line of buildReconcileReport(run.plan)) info(line);
854
+ if (run.lifecycleFindings) warn(`lifecycle: ${run.lifecycleFindings}`);
826
855
  if (run.refusal) {
827
856
  fail(`not applied: ${run.refusal}`);
828
857
  } else if (run.applied) {
@@ -848,7 +877,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
848
877
  }
849
878
 
850
879
  if (run.refusal) process.exit(1);
851
- if (check && checkFails(run.plan)) process.exit(1);
880
+ if (check && (checkFails(run.plan) || run.lifecycleFindings)) process.exit(1);
852
881
  } catch (err: any) {
853
882
  if (err instanceof MutationLeaseError) {
854
883
  // The lease refused before any DDL — nothing was reconciled.