@everystack/cli 0.4.102 → 0.4.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-compile.ts +48 -11
  4. package/src/cli/authz-contract.ts +6 -0
  5. package/src/cli/authz-derive.ts +3 -1
  6. package/src/cli/commands/db-apply.ts +34 -10
  7. package/src/cli/commands/db-backup.ts +62 -26
  8. package/src/cli/commands/db-fork.ts +2 -1
  9. package/src/cli/commands/db-plan.ts +5 -0
  10. package/src/cli/commands/db-pull.ts +48 -12
  11. package/src/cli/commands/db-reconcile.ts +34 -5
  12. package/src/cli/commands/db-sync.ts +32 -3
  13. package/src/cli/commands/db.ts +46 -21
  14. package/src/cli/db-build.ts +11 -1
  15. package/src/cli/declared-diff.ts +3 -2
  16. package/src/cli/derived-apply.ts +5 -1
  17. package/src/cli/derived-catchup.ts +4 -0
  18. package/src/cli/derived-compile.ts +2 -1
  19. package/src/cli/derived-introspect.ts +22 -1
  20. package/src/cli/derived-plan.ts +64 -0
  21. package/src/cli/derived-render.ts +1 -0
  22. package/src/cli/edge-plan.ts +35 -0
  23. package/src/cli/index-lock.ts +65 -0
  24. package/src/cli/index.ts +1 -1
  25. package/src/cli/lifecycle-canon.ts +2 -1
  26. package/src/cli/lifecycle-derived.ts +334 -45
  27. package/src/cli/lifecycle-preflight.ts +498 -0
  28. package/src/cli/lifecycle-pull.ts +119 -11
  29. package/src/cli/migration-compile.ts +4 -3
  30. package/src/cli/migration-generate.ts +3 -2
  31. package/src/cli/model-api.ts +9 -0
  32. package/src/cli/model-render.ts +80 -8
  33. package/src/cli/plain-output.ts +14 -1
  34. package/src/cli/schema-compile.ts +34 -67
  35. package/src/cli/schema-fingerprint.ts +3 -2
  36. package/src/cli/schema-introspect.ts +33 -2
  37. package/src/cli/state-function-deps.ts +2 -1
  38. package/src/cli/lifecycle-verify.ts +0 -232
package/CHANGELOG.md CHANGED
@@ -4,6 +4,68 @@ 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.104] - 2026-10-30
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - destroy is one generated SQL function per lifecycle table; purgeExpired is removed
12
+ - only a lifecycle table may hold a foreign key into a lifecycle table
13
+
14
+ ### Added
15
+
16
+ - db:restore and db:fork name the repair a lifecycle app needs
17
+ - a destroy call removes only rows soft-deleted before its cutoff
18
+ - close destroy_log to the lifecycle role; the marker functions refuse inside a trigger
19
+ - a lifecycle model refuses TRIGGER and REFERENCES in privileges
20
+ - the apply preflight refuses a live catalog that breaks the soft delete
21
+ - a skipped destroy root says why: reason and blocker
22
+ - say where skip reasons are reported
23
+ - db:reconcile runs the live catalog check on every write path
24
+
25
+ ### Fixed
26
+
27
+ - export compileDeclaredState from @everystack/cli/model
28
+ - db:pull only warns about a lifecycle table it cannot fold when it tried to fold
29
+ - db:pull does not render an older CLI's lifecycle_restrict pair as authored code
30
+ - provisioning clears committed destroy_log rows and validates the transaction check
31
+ - a DELETE as the lifecycle role is soft outside a destroy call
32
+ - a destroy call whose marker was removed mid-call fails whole (55000)
33
+ - review nits for lifecycle/testing
34
+ - refuse an out-of-date destroy_log before applying lifecycle objects
35
+ - refuse a destroy_log provisioned before the marker functions
36
+ - <t>_lifecycle_destroy_before() is executable by its owner alone
37
+ - catalog checks pass the RDS shape and see owner, log and settings holes
38
+ - the parent check's 23503 names its table and constraint
39
+ - a function whose result type changes is rebuilt, not replaced
40
+ - cap each blocker name part at 63 characters
41
+ - result types compare without type modifiers; bare time is time without time zone
42
+ - db:sync re-checks the catalog after its state apply; tables match by exact name
43
+ - analytics.post_metrics refers to posts by value, not by key
44
+ - destroy eligibility follows the root
45
+ - review nits for soft-delete-decides
46
+ - destroy_before is internal, so db:check passes on a lifecycle app
47
+
48
+ ### Changed
49
+
50
+ - db:pull no longer refuses on lifecycle drift; the verifier is removed
51
+ - one catalog check over tables and roles, with a models adapter
52
+ - a destroy skip is an alarm, not an operator workflow
53
+
54
+ ## [0.4.103] - 2026-10-23
55
+
56
+ ### BREAKING CHANGES
57
+
58
+ - lifecycle delete and restore hand the batch id to the caller
59
+ - lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
60
+ - refuse dependent + where on a lifecycle parent's hasMany
61
+ - can('read', { role, deleted: 'include' | 'only' }) on a lifecycle model
62
+ - deleteWhen spliced on its own lines; a deleted reader is not granted deleted_by
63
+
64
+ ### Added
65
+
66
+ - index the FK column of every lifecycle dependent
67
+ - db:plan warns when a CREATE INDEX will block writes to a live table
68
+
7
69
  ## [0.4.102] - 2026-10-23
8
70
 
9
71
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.102",
3
+ "version": "0.4.104",
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.23"
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.38"
195
+ "@everystack/server": "0.4.40"
196
196
  },
197
197
  "scripts": {
198
198
  "test": "jest",
@@ -278,21 +278,38 @@ const granteeKey = (role: string): string => (isPseudoPublic(role) ? 'PUBLIC' :
278
278
  /** The policy-side spelling of a declared role — what `pg_policies` would report. */
279
279
  const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' : role);
280
280
 
281
+ /**
282
+ * Who deleted a row, in which batch and which transaction: the lifecycle columns a deleted reader
283
+ * never selects. Only a `manage` role sees them; `deletedAt` stays visible to every reader.
284
+ */
285
+ const DELETED_READ_HIDDEN = new Set(['deletedBy', 'deletedBatch', 'deletedXid']);
286
+
287
+ /**
288
+ * `can('read', { role, deleted })` with no `columns`: it reaches deleted rows, whose deleted_by,
289
+ * deleted_batch and deleted_xid are set, so it grants SELECT on every other column instead of the
290
+ * whole table. Grants are a union: defineModel refuses a `manage` for the same role, and the policy
291
+ * compile refuses a second read for it, so nothing re-grants the table.
292
+ */
293
+ const isDeletedReadOfEveryColumn = (a: Ability): boolean =>
294
+ a.action === 'read' && a.condition.deleted !== undefined && !isColumnAbility(a);
295
+
281
296
  /**
282
297
  * Column grants from column abilities. The action determines the PostgreSQL verb; the named role
283
298
  * (or authenticated for an owner-only ability) is the grantee. Field keys are snake-cased and
284
299
  * deduplicated, matching the introspected contract.
285
300
  */
286
- function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
301
+ function compileColumnGrants(abilities: readonly Ability[], fields: readonly string[] = []): Record<string, Record<string, string[]>> | undefined {
287
302
  // Null-prototype maps, as on the introspected side (assembleContract): a role is a key, and
288
303
  // '__proto__' passes the role-name guard, so a plain object would lose it.
289
304
  const out: Record<string, Record<string, Set<string>>> = Object.create(null);
305
+ const add = (role: string, verb: string, columns: readonly string[]): void => {
306
+ const cols = ((out[role] ??= Object.create(null) as Record<string, Set<string>>)[verb] ??= new Set<string>());
307
+ for (const column of columns) cols.add(toSnakeCase(column));
308
+ };
290
309
  for (const a of abilities) {
310
+ if (isDeletedReadOfEveryColumn(a)) add(granteeKey(a.condition.role!), 'SELECT', fields.filter((f) => !DELETED_READ_HIDDEN.has(f)));
291
311
  if (!isColumnAbility(a)) continue;
292
- const role = granteeKey(a.condition.role ?? 'authenticated');
293
- const verb = a.action === 'read' ? 'SELECT' : 'UPDATE';
294
- const cols = ((out[role] ??= Object.create(null) as Record<string, Set<string>>)[verb] ??= new Set<string>());
295
- for (const column of a.condition.columns!) cols.add(toSnakeCase(column));
312
+ add(granteeKey(a.condition.role ?? 'authenticated'), a.action === 'read' ? 'SELECT' : 'UPDATE', a.condition.columns!);
296
313
  }
297
314
  const grants: Record<string, Record<string, string[]>> = Object.create(null);
298
315
  for (const [role, verbs] of Object.entries(out)) {
@@ -443,7 +460,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
443
460
  // A lifecycle model hides deleted rows from every read policy but the lifecycle role's own.
444
461
  // Unlike softDelete there is no owner escape: the guard is AND-ed into each SELECT policy as
445
462
  // compiled. A `manage` role's ALL policy is NOT guarded, on purpose: it sees deleted rows (to
446
- // restore or purge them), exactly as it does under softDelete.
463
+ // restore or destroy them), exactly as it does under softDelete.
447
464
  const lifecycleCol = model.lifecycle ? 'deleted_at' : null;
448
465
  const lifecycleGuard = lifecycleCol ? softDeleteGuard(lifecycleCol) : null;
449
466
 
@@ -541,9 +558,13 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
541
558
  const policy = (
542
559
  name: string, command: PolicyCommand, roles: string[],
543
560
  using: string | null, check: string | null,
561
+ deleted?: 'include' | 'only',
544
562
  ): void => {
545
- if (lifecycleGuard && command === 'SELECT' && !roles.includes(LIFECYCLE_OWNER_ROLE)) {
546
- using = andPredicates(lifecycleGuard, using) ?? lifecycleGuard;
563
+ // can('read', { deleted }): 'include' leaves the guard off this role's policy (its other
564
+ // restrictions stay); 'only' puts `(deleted_at IS NOT NULL)` in its place. Omitted: the guard.
565
+ if (lifecycleGuard && lifecycleCol && command === 'SELECT' && !roles.includes(LIFECYCLE_OWNER_ROLE) && deleted !== 'include') {
566
+ const state = deleted === 'only' ? `(${lifecycleCol} IS NOT NULL)` : lifecycleGuard;
567
+ using = andPredicates(state, using) ?? state;
547
568
  }
548
569
  // Same normalization the live producer applies (authz-contract's `predicate`): a model
549
570
  // pulled on one PostgreSQL major carries that major's deparse spelling in its predicates,
@@ -661,6 +682,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
661
682
  rawPredicate(a.condition.sql ?? a.condition.where, where),
662
683
  ) ?? 'true',
663
684
  null,
685
+ a.condition.deleted,
664
686
  );
665
687
  }
666
688
 
@@ -865,7 +887,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
865
887
  // security downgrade via version skew.
866
888
  rls: { enabled: model.rls !== false, forced: model.rls !== false && model.writtenBy === 'app' },
867
889
  grants: compileGrants(model.abilities, model.privileges),
868
- ...(compileColumnGrants(model.abilities) ? { columnGrants: compileColumnGrants(model.abilities) } : {}),
890
+ ...(compileColumnGrants(model.abilities, Object.keys(model.fields)) ? { columnGrants: compileColumnGrants(model.abilities, Object.keys(model.fields)) } : {}),
869
891
  policies,
870
892
  };
871
893
  }
@@ -877,6 +899,20 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
877
899
  * derivation. The lifecycle role is left out: it is the machinery, not an author's role.
878
900
  * A policy with no USING admits nothing here, so it is skipped (fail closed).
879
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
+
880
916
  export function deleteRulePredicates(model: ModelDescriptor, opts: CompileOptions = {}): Record<string, string> {
881
917
  const byRole = new Map<string, string[]>();
882
918
  for (const p of compileTableContract(model, opts).policies) {
@@ -945,8 +981,9 @@ function compileGrants(
945
981
  for (const v of verbs) set.add(v);
946
982
  };
947
983
  for (const a of abilities) {
948
- // A column ability grants only its listed columns, never a table-level privilege.
949
- if (isColumnAbility(a)) continue;
984
+ // A column ability grants only its listed columns, never a table-level privilege; nor does
985
+ // a deleted read (compileColumnGrants grants it every column but the three it may not see).
986
+ if (isColumnAbility(a) || isDeletedReadOfEveryColumn(a)) continue;
950
987
  const verbs = verbsFor(a.action);
951
988
  if (a.condition.role) {
952
989
  add(granteeKey(a.condition.role), verbs);
@@ -102,6 +102,12 @@ export interface PolicyContract {
102
102
  using: string | null;
103
103
  /** `WITH CHECK` predicate (deparsed), or null when absent. */
104
104
  check: string | null;
105
+ /**
106
+ * Set only by db:pull's lifecycle fold, never by the catalog read or the compiler: this role's
107
+ * read policy on a lifecycle table carries no `(deleted_at IS NULL)` guard ('include') or carries
108
+ * `(deleted_at IS NOT NULL)` instead ('only'), so it renders as can('read', { role, deleted }).
109
+ */
110
+ deleted?: 'include' | 'only';
105
111
  }
106
112
 
107
113
  /**
@@ -587,7 +587,9 @@ function deriveGovernedRole(contract: TableContract, role: string): {
587
587
  continue;
588
588
  }
589
589
  const frag = sqlFragment(p.using);
590
- out.abilities.push(frag ? `can('${DML[priv]}', { role: ${tsString(role)}, sql: ${frag} })` : `can('${DML[priv]}', { role: ${tsString(role)} })`);
590
+ // A lifecycle read without the guard (or with deleted_at IS NOT NULL), marked by the fold.
591
+ const deleted = priv === 'SELECT' && p.deleted ? `, deleted: '${p.deleted}'` : '';
592
+ out.abilities.push(frag ? `can('${DML[priv]}', { role: ${tsString(role)}, sql: ${frag}${deleted} })` : `can('${DML[priv]}', { role: ${tsString(role)}${deleted} })`);
591
593
  }
592
594
  out.asAbility.add(priv);
593
595
  out.usedPolicies.add(p.name);
@@ -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 } 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,10 +260,12 @@ 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,
@@ -328,6 +331,7 @@ async function catchUpAfterApply(
328
331
  plan: EdgePlan,
329
332
  venue: CatchUpVenue,
330
333
  flags: Record<string, string>,
334
+ declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
331
335
  ): Promise<CatchUpReport> {
332
336
  if (!LANDED.has(result.status)) return { ok: true, statements: [] };
333
337
  if (flags['state-only'] === 'true') return { ok: true, statements: [] };
@@ -337,7 +341,7 @@ async function catchUpAfterApply(
337
341
  const applied = result.status === 'already-applied'
338
342
  ? 0
339
343
  : classifyGeneratedStatements((plan.statements ?? []).filter((s) => !unapplied.has(s))).executable.length;
340
- return runCatchUp(venue, flags, applied, 'throw');
344
+ return runCatchUp(venue, flags, applied, 'throw', declared);
341
345
  }
342
346
 
343
347
  /**
@@ -510,6 +514,13 @@ async function applyPlanViaStage(
510
514
  const config = await resolveConfig(flags.stage);
511
515
  const { region } = config;
512
516
  const fn = opsFunction(config);
517
+ const declared = await loadDeclaredDerived(flags.models);
518
+ const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [])
519
+ ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? []) : null);
520
+ if (lifecycleRefusal) {
521
+ fail(lifecycleRefusal);
522
+ process.exit(1);
523
+ }
513
524
  const attestLineageLost = flags['attest-lineage-lost']; // validated (not bare 'true') in dbApplyCommand
514
525
 
515
526
  // The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
@@ -653,7 +664,7 @@ async function applyPlanViaStage(
653
664
  }
654
665
 
655
666
  // 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);
667
+ const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags, declared);
657
668
  exitOnCatchUpFailure(catchUp);
658
669
  const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
659
670
  const advisoryModels = await modelsForAdvisory(landedResult!, flags);
@@ -934,6 +945,21 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
934
945
  }
935
946
  }
936
947
 
948
+ step(connectingVia(dbSource));
949
+ const { runner, session, transaction, end } = await createUrlRunner(dbSource.url);
950
+ // Every gate below needs the declared derived bundle. Never evaluate the models barrel twice.
951
+ let declaredDbPromise: ReturnType<typeof loadDeclaredDerived> | undefined;
952
+ const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
953
+ declaredDbPromise ??= loadDeclaredDerived(flags.models);
954
+ // Read-only, before the rollback point: a refused apply must not first take a snapshot, backup or dump.
955
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [])
956
+ ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(runner, (await declaredDb())?.models ?? []) : null);
957
+ if (lifecycleRefusal) {
958
+ fail(lifecycleRefusal);
959
+ await end();
960
+ process.exit(1);
961
+ }
962
+
937
963
  // Losing data should be hard (decision 11): a destructive plan applies
938
964
  // only confirmed (--confirm, always), with a durable rollback point, and —
939
965
  // when the stage declares an approver set — identity-approved (decision 12).
@@ -1051,16 +1077,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1051
1077
  }
1052
1078
  }
1053
1079
 
1054
- step(connectingVia(dbSource));
1055
- const { runner, session, transaction, end } = await createUrlRunner(dbSource.url);
1056
-
1057
1080
  let landed: ApplyPlanResult | undefined;
1058
1081
  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
1082
  try {
1065
1083
  // The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
1066
1084
  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 +1108,12 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1090
1108
  runner,
1091
1109
  { verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
1092
1110
  async () => {
1111
+ // Re-checked under the lease, where the writes happen.
1112
+ const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? []);
1113
+ if (leasedRefusal) {
1114
+ fail(leasedRefusal);
1115
+ process.exit(1);
1116
+ }
1093
1117
  const result = await executeApplyPlan(runner, session, plan, {
1094
1118
  transaction,
1095
1119
  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. */
@@ -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,8 @@ 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
+ if (lifecycleNote) warn(lifecycleNote);
240
243
  let snapshot: SchemaSnapshot;
241
244
  let contract: AuthzContract;
242
245
  if (dbSource.kind === 'url') {
@@ -525,6 +528,8 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
525
528
  // Only when the checkout declares objects: db:apply's catch-up does nothing at all for a
526
529
  // checkout that declares none (derived-catchup.ts), so listing drops there would be untrue.
527
530
  derived: declaredDerived && declaredDerived.objects.length > 0 ? derivedPlan : undefined,
531
+ lockWarnings: plan.lockWarnings,
532
+ deletedReads: plan.deletedReads,
528
533
  })) printLine(text);
529
534
 
530
535
  if (out !== '-') {
@@ -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,8 @@ 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
+ let lifecycleDeleteWhen: ReadonlyMap<string, string> = new Map();
252
253
  let matviewColumns: Map<string, ColumnSchema[]> | undefined;
253
254
  let candidatesByIdentity: Map<string, string[]> | undefined;
254
255
  /** Each FK's exact target, by confrelid: an external target is declared from these names. */
@@ -273,9 +274,6 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
273
274
  // The derived layer rides the same pull (B5) — views/matviews/functions/sequences
274
275
  // render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
275
276
  derivedCatalog = await introspectDerived(session);
276
- const lifecycle = await verifyLifecycle(session, ['anon', 'authenticated', 'admin', LIFECYCLE_OWNER_ROLE, ...governRoles]);
277
- if (lifecycle.failures.length) throw new LifecycleDriftError(lifecycle.failures);
278
- for (const warning of lifecycle.notes) caution(warning);
279
277
  // --abilities live: the authz half of the on-ramp. Same connection, one more read —
280
278
  // the grants and policies that already exist become the models' declared abilities,
281
279
  // instead of a human transcribing them by hand.
@@ -290,6 +288,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
290
288
  current = fold.snapshot;
291
289
  derivedCatalog = fold.catalog;
292
290
  lifecycleTables = fold.tables;
291
+ lifecycleDeleteWhen = fold.deleteWhen;
293
292
  if (lifecycleTables.size) note(`Lifecycle: ${[...lifecycleTables].sort().join(', ')} rendered as lifecycle: true (generated columns, grants and functions folded).`);
294
293
  const contract = fold.contract;
295
294
  liveContract = rawContract;
@@ -349,6 +348,47 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
349
348
  for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract), governedAtPull)) caution(line);
350
349
  pulledFingerprint = fingerprintLive(rawSnapshot, rawContract).hash;
351
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
+ }
352
392
  // --matviews-as-tables: the flip needs real fields — one extra catalog read for the
353
393
  // matview columns the derived layer (definition-only) doesn't carry.
354
394
  if (matviewsAsTables) {
@@ -370,10 +410,6 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
370
410
  await end?.();
371
411
  } catch (err: any) {
372
412
  await end?.().catch(() => {});
373
- if (err instanceof LifecycleDriftError) {
374
- for (const line of err.lines) console.error(line);
375
- process.exit(1);
376
- }
377
413
  fail(err.message);
378
414
  if (dbSource.kind === 'url') {
379
415
  detail('Check the connection string and that the database is accepting connections.');
@@ -492,10 +528,10 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
492
528
  : undefined;
493
529
  if (flags.out && !flags.out.endsWith('.ts')) {
494
530
  const dir = path.resolve(flags.out);
495
- modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables })
531
+ modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables, lifecycleDeleteWhen, lifecycleForeignKeyTables })
496
532
  .map((f) => ({ ...f, target: containedPath(dir, f.file) }));
497
533
  } else {
498
- modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables });
534
+ modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables, lifecycleDeleteWhen, lifecycleForeignKeyTables });
499
535
  }
500
536
  }
501
537
  } catch (err: any) {