@everystack/cli 0.4.102 → 0.4.103

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,27 @@ 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]
8
+
9
+ ### Fixed
10
+
11
+ - export compileDeclaredState from @everystack/cli/model
12
+
13
+ ## [0.4.103] - 2026-10-23
14
+
15
+ ### BREAKING CHANGES
16
+
17
+ - lifecycle delete and restore hand the batch id to the caller
18
+ - lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
19
+ - refuse dependent + where on a lifecycle parent's hasMany
20
+ - can('read', { role, deleted: 'include' | 'only' }) on a lifecycle model
21
+ - deleteWhen spliced on its own lines; a deleted reader is not granted deleted_by
22
+
23
+ ### Added
24
+
25
+ - index the FK column of every lifecycle dependent
26
+ - db:plan warns when a CREATE INDEX will block writes to a live table
27
+
7
28
  ## [0.4.102] - 2026-10-23
8
29
 
9
30
  ### 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.103",
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.24"
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.39"
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)) {
@@ -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
  }
@@ -945,8 +967,9 @@ function compileGrants(
945
967
  for (const v of verbs) set.add(v);
946
968
  };
947
969
  for (const a of abilities) {
948
- // A column ability grants only its listed columns, never a table-level privilege.
949
- if (isColumnAbility(a)) continue;
970
+ // A column ability grants only its listed columns, never a table-level privilege; nor does
971
+ // a deleted read (compileColumnGrants grants it every column but the three it may not see).
972
+ if (isColumnAbility(a) || isDeletedReadOfEveryColumn(a)) continue;
950
973
  const verbs = verbsFor(a.action);
951
974
  if (a.condition.role) {
952
975
  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);
@@ -525,6 +525,8 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
525
525
  // Only when the checkout declares objects: db:apply's catch-up does nothing at all for a
526
526
  // checkout that declares none (derived-catchup.ts), so listing drops there would be untrue.
527
527
  derived: declaredDerived && declaredDerived.objects.length > 0 ? derivedPlan : undefined,
528
+ lockWarnings: plan.lockWarnings,
529
+ deletedReads: plan.deletedReads,
528
530
  })) printLine(text);
529
531
 
530
532
  if (out !== '-') {
@@ -249,6 +249,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
249
249
  let derivedCatalog: DerivedCatalog | undefined;
250
250
  /** Tables folded to `lifecycle: true` (lifecycle-pull.ts). */
251
251
  let lifecycleTables: ReadonlySet<string> = new Set();
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. */
@@ -290,6 +291,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
290
291
  current = fold.snapshot;
291
292
  derivedCatalog = fold.catalog;
292
293
  lifecycleTables = fold.tables;
294
+ lifecycleDeleteWhen = fold.deleteWhen;
293
295
  if (lifecycleTables.size) note(`Lifecycle: ${[...lifecycleTables].sort().join(', ')} rendered as lifecycle: true (generated columns, grants and functions folded).`);
294
296
  const contract = fold.contract;
295
297
  liveContract = rawContract;
@@ -492,10 +494,10 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
492
494
  : undefined;
493
495
  if (flags.out && !flags.out.endsWith('.ts')) {
494
496
  const dir = path.resolve(flags.out);
495
- modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables })
497
+ modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables, lifecycleDeleteWhen })
496
498
  .map((f) => ({ ...f, target: containedPath(dir, f.file) }));
497
499
  } else {
498
- modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables });
500
+ modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables, lifecycleDeleteWhen });
499
501
  }
500
502
  }
501
503
  } catch (err: any) {
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  import type { ModelDescriptor } from '@everystack/model';
30
- import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
30
+ import { assertExternalTargets, lifecycleDependentIndexes, relationDeleteActions } from '@everystack/model';
31
31
  import type { SchemaSnapshot } from './schema-introspect';
32
32
  import type { AuthzContract } from './authz-contract';
33
33
  import { compileTableSchema, compileEnums } from './schema-compile';
@@ -57,10 +57,11 @@ export function compileDeclaredState(
57
57
  ): DeclaredState {
58
58
  assertExternalTargets(models);
59
59
  const dependents = relationDeleteActions(models);
60
+ const dependentIndexes = lifecycleDependentIndexes(models);
60
61
  const schema = opts.schema ?? 'public';
61
62
  return {
62
63
  snapshot: {
63
- tables: models.map((m) => compileTableSchema(m, { schema, dependents })),
64
+ tables: models.map((m) => compileTableSchema(m, { schema, dependents, dependentIndexes })),
64
65
  enums: compileEnums(models),
65
66
  },
66
67
  contract: {
@@ -24,6 +24,7 @@
24
24
  * JSON — recorded as `plan_ref` on the schema_log row the apply writes.
25
25
  */
26
26
 
27
+ import { indexLockWarnings, type LockWarning } from './index-lock';
27
28
  import { createHash } from 'node:crypto';
28
29
  import type { Attachment } from './derived-source';
29
30
  import type { OwnerApplyMode } from './derived-apply';
@@ -253,6 +254,18 @@ export interface EdgePlan {
253
254
  * plans minted before this field existed; the summary falls back to the text-only reading.
254
255
  */
255
256
  dark?: string[];
257
+ /**
258
+ * The plain CREATE INDEX statements that build on a live table with rows, with the catalog's row
259
+ * estimate — db:plan's ⚠ LOCK lines. Read at mint, like `dark`; informational, never a gate, and
260
+ * outside both fingerprints. Absent on plans minted before it, or when there is nothing to warn.
261
+ */
262
+ lockWarnings?: LockWarning[];
263
+ /**
264
+ * The read policies this plan creates or replaces for a can('read', { role, deleted }) ability:
265
+ * db:plan's `⚠ ACCESS — role <r> can read deleted rows of <table>` lines. Informational, outside
266
+ * both fingerprints. Absent when there is none, and on plans minted before it.
267
+ */
268
+ deletedReads?: DeletedRead[];
256
269
  /** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
257
270
  unmodeled: string[];
258
271
  gitRef: string | null;
@@ -564,6 +577,26 @@ function unplannableReason(change: SchemaChange): string | null {
564
577
  }
565
578
  }
566
579
 
580
+ /** A read policy, created or replaced by this plan, that lets a role see deleted lifecycle rows. */
581
+ export interface DeletedRead { table: string; role: string; deleted: 'include' | 'only' }
582
+
583
+ /** The can('read', { role, deleted }) policies whose CREATE POLICY this plan runs. */
584
+ export function deletedReadChanges(models: readonly ModelDescriptor[], statements: readonly string[]): DeletedRead[] {
585
+ const out: DeletedRead[] = [];
586
+ for (const m of models) {
587
+ if (m.lifecycle !== true) continue;
588
+ for (const a of m.abilities) {
589
+ if (a.action !== 'read' || !a.condition.deleted || !a.condition.role) continue;
590
+ const name = `${m.table}_select_${a.condition.role.toLowerCase()}`;
591
+ // The emitter writes the name bare when it is a plain identifier, quoted otherwise.
592
+ const created = new RegExp(`^\\s*CREATE POLICY (?:"${name}"|${name}) `, 'm');
593
+ if (!statements.some((st) => created.test(st))) continue;
594
+ out.push({ table: m.schema && m.schema !== 'public' ? `${m.schema}.${m.table}` : m.table, role: a.condition.role, deleted: a.condition.deleted });
595
+ }
596
+ }
597
+ return out;
598
+ }
599
+
567
600
  export function mintEdgePlan(
568
601
  models: ModelDescriptor[],
569
602
  snapshot: SchemaSnapshot,
@@ -660,6 +693,8 @@ export function mintEdgePlan(
660
693
  // contract. The summary is rendered later from the plan alone, and answering "does a read
661
694
  // survive?" from statement text is what produced three false positives on a real plan.
662
695
  dark: tablesLeftWithoutARead(statements, contract),
696
+ ...(() => { const lockWarnings = indexLockWarnings(statements, snapshot); return lockWarnings.length ? { lockWarnings } : {}; })(),
697
+ ...(() => { const deletedReads = deletedReadChanges(models, statements); return deletedReads.length ? { deletedReads } : {}; })(),
663
698
  unmodeled: unmodeledTables(models, snapshot),
664
699
  gitRef: opts.gitRef ?? null,
665
700
  mintedBy: opts.actor ?? null,
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ⚠ LOCK — which of a plan's statements build an index on a table that already holds rows.
3
+ *
4
+ * db:apply runs a plain `CREATE INDEX` inside its one transaction. PostgreSQL takes a SHARE lock
5
+ * on the table for the whole build, whatever its size: reads go on, INSERT/UPDATE/DELETE wait until
6
+ * the build finishes and the transaction commits. On a large table that is a write outage, so the
7
+ * plan says so, with the catalog's row estimate (`pg_class.reltuples`) to let the reader judge.
8
+ *
9
+ * Informational only: no --confirm, no exit code, and the plan is still "Safe to apply". Applying
10
+ * is unchanged. The estimate rides beside the snapshot (`rowEstimates`), never inside a table, so
11
+ * no fingerprint reads it.
12
+ *
13
+ * A table the same plan creates is not live, so it gets no line; an estimate of 0 is an empty table,
14
+ * no line; -1 is a table with data that was never analyzed, "row count unknown"; a read without
15
+ * estimates (an older ops Lambda) still warns, without a count.
16
+ */
17
+
18
+ import { statementBody } from './sql-statement';
19
+ import type { SchemaSnapshot } from './schema-introspect';
20
+
21
+ export interface LockWarning {
22
+ index: string;
23
+ /** Schema-qualified, as the snapshot names it. */
24
+ table: string;
25
+ /** The estimate; null when the table has data but was never analyzed; absent when not read. */
26
+ rows?: number | null;
27
+ }
28
+
29
+ const IDENT = '"(?:[^"]|"")+"|[A-Za-z_][\\w$]*';
30
+ const CREATE_INDEX = new RegExp(
31
+ `^CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?!CONCURRENTLY\\b)(?:IF\\s+NOT\\s+EXISTS\\s+)?(${IDENT})\\s+ON\\s+(?:ONLY\\s+)?((?:${IDENT})(?:\\.(?:${IDENT}))?)`,
32
+ 'i',
33
+ );
34
+ const unquote = (part: string): string => (part.startsWith('"') ? part.slice(1, -1).replace(/""/g, '"') : part);
35
+ const qualified = (name: string): string => {
36
+ const parts = name.match(new RegExp(IDENT, 'g')) ?? [name];
37
+ const clean = parts.map(unquote);
38
+ return clean.length === 1 ? `public.${clean[0]}` : clean.join('.');
39
+ };
40
+
41
+ export function indexLockWarnings(statements: readonly string[], live: SchemaSnapshot): LockWarning[] {
42
+ const liveTables = new Set(live.tables.map((t) => t.table));
43
+ const out: LockWarning[] = [];
44
+ for (const statement of statements) {
45
+ const m = CREATE_INDEX.exec(statementBody(statement));
46
+ if (!m) continue;
47
+ const table = qualified(m[2]);
48
+ if (!liveTables.has(table)) continue;
49
+ const estimate = live.rowEstimates?.[table];
50
+ if (estimate === 0) continue;
51
+ out.push({ index: unquote(m[1]), table, ...(estimate === undefined ? {} : { rows: estimate < 0 ? null : estimate }) });
52
+ }
53
+ return out;
54
+ }
55
+
56
+ const display = (table: string): string => (table.startsWith('public.') ? table.slice('public.'.length) : table);
57
+
58
+ export function lockLines(warnings: readonly LockWarning[]): string[] {
59
+ return warnings.map((w) => {
60
+ const count = w.rows === undefined ? ''
61
+ : w.rows === null ? ' (row count unknown)'
62
+ : ` (about ${w.rows.toLocaleString('en-US')} ${w.rows === 1 ? 'row' : 'rows'})`;
63
+ return `⚠ LOCK — CREATE INDEX ${w.index} on ${display(w.table)} blocks writes to that table until it is built${count}.`;
64
+ });
65
+ }
@@ -8,7 +8,14 @@
8
8
  * owned by the lifecycle role stamps the four lifecycle columns and skips the delete. A row that
9
9
  * is already deleted keeps its stamps. It refuses to run as a trigger on any other table: a
10
10
  * role holding EXECUTE could otherwise attach it to its own table and soft-delete `<t>` rows by id.
11
+ * It hands the batch to a caller that cannot read it: for a top-level DELETE (trigger depth 1) it
12
+ * appends `<JSON-quoted key> <batch>` as a line to the transaction-local setting
13
+ * `everystack.lifecycle_batch`, one line per stamped root. Informational, never a gate: any role
14
+ * can set that name, and the stamp columns stay the truth.
11
15
  * - `<t>_lifecycle_guard` (BEFORE INSERT OR UPDATE): refuses any direct write to those columns.
16
+ * - `<t>_lifecycle_delete_when` (BEFORE UPDATE OF deleted_at, live -> deleted), only for
17
+ * lifecycle: { deleteWhen }: refuses the stamp (23514) unless the predicate is TRUE for the row,
18
+ * for a direct delete and a cascade alike. Its predicate rides on its own manifest only.
12
19
  * - `<t>_restore(p_id)`: undoes a delete for whoever may delete p_id. A SECURITY DEFINER
13
20
  * function owned by the lifecycle role, EXECUTE to exactly the roles with a delete rule. The
14
21
  * caller is the SET ROLE role (session_user when none); a role branch passes when the caller
@@ -18,7 +25,8 @@
18
25
  * the whole cascade subtree that delete stamped, on p_id's authority alone: the delete took
19
26
  * those rows whatever the deleter's rights over them, so its undo must too, or a user's
20
27
  * restore would leave the likes others put on their posts deleted for good. It never makes a
21
- * live row under a deleted parent (see restoreFunction), and it returns counts, never ids.
28
+ * live row under a deleted parent (see restoreFunction), and it returns counts and the batch it
29
+ * restored (`{ restored, skipped, batch }`; batch null for a legacy row), never ids.
22
30
  *
23
31
  * A lifecycle parent's hasMany `dependent` cannot ride the foreign key: its DELETE never removes the
24
32
  * row, so the FK never fires (relationDeleteActions compiles a lifecycle parent's 'cascade' to
@@ -92,7 +100,7 @@ export interface LifecycleDeclaration {
92
100
  edges: LifecycleEdge[];
93
101
  }
94
102
  export const MANIFEST_VERSION = 1;
95
- export const LIFECYCLE_ROLES = ['transition', 'guard', 'restore', 'cascade', 'parent'] as const;
103
+ export const LIFECYCLE_ROLES = ['transition', 'guard', 'restore', 'cascade', 'parent', 'delete_when'] as const;
96
104
  export type LifecycleRole = typeof LIFECYCLE_ROLES[number];
97
105
  export interface LifecycleManifest {
98
106
  everystack: 'lifecycle';
@@ -100,6 +108,9 @@ export interface LifecycleManifest {
100
108
  role: LifecycleRole;
101
109
  model: string;
102
110
  decl: LifecycleDeclaration;
111
+ /** The `delete_when` function's own input: lifecycle: { deleteWhen }'s predicate text. Carried on
112
+ * that function's manifest only, so a guard added later changes no other function's manifest. */
113
+ deleteWhen?: string;
103
114
  defHash: string;
104
115
  }
105
116
  type TableName = Pick<LifecycleTable, 'schema' | 'table'>;
@@ -179,7 +190,7 @@ function lifecycleDependents(models: readonly ModelDescriptor[]): ModelDependent
179
190
  return out.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
180
191
  }
181
192
 
182
- function lifecycleObjects(model: LifecycleTable, dependents: readonly LifecycleDependent[], decl: LifecycleDeclaration): LifecycleObject[] {
193
+ function lifecycleObjects(model: LifecycleTable, dependents: readonly LifecycleDependent[], decl: LifecycleDeclaration, deleteWhen?: string): LifecycleObject[] {
183
194
  const table = qualified(model);
184
195
  const label = labelOf(model);
185
196
  const pk = pkOf(model);
@@ -200,6 +211,7 @@ function lifecycleObjects(model: LifecycleTable, dependents: readonly LifecycleD
200
211
  DECLARE
201
212
  claims text := pg_catalog.current_setting('request.jwt.claims', true);
202
213
  actor text;
214
+ batch uuid := pg_catalog.gen_random_uuid();
203
215
  ${check ? `${check.declare}\n` : ''}BEGIN
204
216
  IF TG_TABLE_SCHEMA <> ${literal(model.schema)} OR TG_TABLE_NAME <> ${literal(model.table)} THEN
205
217
  RAISE EXCEPTION USING ERRCODE = '42501', MESSAGE = ${literal(
@@ -212,9 +224,16 @@ ${check ? ` ${readCommittedOnly(model)}\n` : ''} IF lc.claims IS NOT NULL AND
212
224
  UPDATE ${table}
213
225
  SET ${at} = pg_catalog.now(),
214
226
  ${by} = lc.actor,
215
- ${batch} = ${check ? 'lc.batch' : 'pg_catalog.gen_random_uuid()'},
227
+ ${batch} = lc.batch,
216
228
  ${xid} = pg_catalog.txid_current()
217
229
  WHERE ${pk} = OLD.${pk} AND ${at} IS NULL;
230
+ -- The batch, for the caller that cannot read it: one line per root of a top-level DELETE,
231
+ -- appended to a transaction-local setting. Informational; the stamp columns are the truth.
232
+ IF FOUND AND pg_catalog.pg_trigger_depth() = 1 THEN
233
+ PERFORM pg_catalog.set_config('everystack.lifecycle_batch',
234
+ pg_catalog.concat_ws(E'\\n', NULLIF(pg_catalog.current_setting('everystack.lifecycle_batch', true), ''),
235
+ pg_catalog.to_jsonb(OLD.${pk}::text)::text || ' ' || lc.batch::text), true);
236
+ END IF;
218
237
  ${check ? `${check.body}\n` : ''} RETURN NULL;
219
238
  END`,
220
239
  });
@@ -254,6 +273,7 @@ END`,
254
273
  }),
255
274
  ...cascadeObjects(model, dependents.filter((d) => d.parent === model && d.dependent === 'cascade')),
256
275
  ...parentLiveObjects(model, dependents.filter((d) => d.child === model)),
276
+ ...deleteWhenObjects(model, deleteWhen),
257
277
  ];
258
278
  }
259
279
 
@@ -439,7 +459,7 @@ ${parentsLive(m, `lc.r${i}`, 'lc.live := false;').replace(/^/gm, ' ')}
439
459
  lc.skipped := lc.skipped + lc.n;`).join('\n');
440
460
 
441
461
  const walk = edges.length === 0
442
- ? ` RETURN pg_catalog.jsonb_build_object('restored', 1, 'skipped', 0);`
462
+ ? ` RETURN pg_catalog.jsonb_build_object('restored', 1, 'skipped', 0, 'batch', lc.root.b);`
443
463
  : ` lc.batch_id := lc.root.b;
444
464
  IF lc.batch_id IS NOT NULL THEN
445
465
  lc.s0 := ARRAY[${id}];
@@ -458,7 +478,7 @@ ${pass}
458
478
  END LOOP;
459
479
  ${left}
460
480
  END IF;
461
- RETURN pg_catalog.jsonb_build_object('restored', lc.restored, 'skipped', lc.skipped);`;
481
+ RETURN pg_catalog.jsonb_build_object('restored', lc.restored, 'skipped', lc.skipped, 'batch', lc.batch_id);`;
462
482
 
463
483
  return defineFunction(`${model.schema}.${model.table}_restore`, {
464
484
  args: [{ name: 'p_id', type: pkType(model) }],
@@ -582,7 +602,6 @@ function restrictCheck(model: LifecycleTable, dependents: readonly LifecycleDepe
582
602
  || ${literal(` live row(s) of ${labelOf(d.child)} still reference it (dependent 'restrict' on ${d.column})`)};
583
603
  END IF;`).join('\n');
584
604
  const declare = [
585
- 'batch uuid := pg_catalog.gen_random_uuid();',
586
605
  'n bigint;',
587
606
  ...(walk ? ['grew boolean;', ...tables.flatMap((m, i) => [
588
607
  `s${i} ${pkType(m)}[] := '{}';`, `f${i} ${pkType(m)}[] := '{}';`, `nf${i} ${pkType(m)}[];`,
@@ -616,6 +635,52 @@ ${checks}`;
616
635
  return { declare, body };
617
636
  }
618
637
 
638
+ /**
639
+ * `<t>_lifecycle_delete_when` — lifecycle: { deleteWhen }'s guard. BEFORE UPDATE OF deleted_at on
640
+ * the live -> deleted stamp, so it fires for the row's own stamp (the transition's UPDATE) and for
641
+ * a parent's cascade stamp alike (a cascaded child's BEFORE DELETE transition never runs). The
642
+ * predicate is read with bare column names against the row as it still stands (a BEFORE trigger:
643
+ * the table holds the old values), one primary-key lookup per stamped row. The predicate sits on
644
+ * lines of its own, so a trailing `--` comment in it ends only its own line. Anything but TRUE
645
+ * refuses with 23514 and the constraint name, naming the table and never a row value; the whole
646
+ * statement rolls back, so one guarded child refuses its parent's delete. No role test: only the
647
+ * lifecycle role stamps (the guard), and restore and purge never make this transition.
648
+ */
649
+ function deleteWhenObjects(model: LifecycleTable, deleteWhen: string | undefined): LifecycleObject[] {
650
+ if (deleteWhen === undefined) return [];
651
+ const name = `${model.schema}.${model.table}_lifecycle_delete_when`;
652
+ const pk = pkOf(model);
653
+ const guard = lifecycleTriggerFunction(name, `
654
+ #variable_conflict use_column
655
+ <<lc>>
656
+ DECLARE
657
+ allowed boolean;
658
+ BEGIN
659
+ ${ownTableOnly(model, name)}
660
+ SELECT (
661
+ ${deleteWhen}
662
+ ) INTO lc.allowed FROM ${qualified(model)} WHERE ${pk} = OLD.${pk};
663
+ IF lc.allowed IS NOT TRUE THEN
664
+ RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = ${literal(`${model.table}_lifecycle_delete_when`)},
665
+ SCHEMA = ${literal(model.schema)}, TABLE = ${literal(model.table)},
666
+ MESSAGE = ${literal(`delete of ${labelOf(model)} refused: the model's deleteWhen does not allow deleting this row`)};
667
+ END IF;
668
+ RETURN NEW;
669
+ END`);
670
+ return [
671
+ guard,
672
+ lifecycleTrigger(`${model.table}_lifecycle_delete_when`, {
673
+ on: model,
674
+ timing: 'before',
675
+ events: ['update'],
676
+ of: [toSnakeCase(LIFECYCLE_COLUMNS[0])],
677
+ forEach: 'row',
678
+ condition: sql`OLD.${AT} IS NULL AND NEW.${AT} IS NOT NULL`,
679
+ execute: guard,
680
+ }),
681
+ ];
682
+ }
683
+
619
684
  /**
620
685
  * `<t>_lifecycle_parent` on a lifecycle child: a live row may not reference a deleted parent of a
621
686
  * 'cascade' or 'restrict' relation. Only a lifecycle parent can be deleted and still be there.
@@ -686,7 +751,7 @@ export function declOf(model: ModelDescriptor, dependents: readonly ModelDepende
686
751
  }
687
752
 
688
753
  /** One generator for model emission and verification of recorded JSON inputs. */
689
- export function regenerateLifecycle(model: string, decl: LifecycleDeclaration): LifecycleObject[] {
754
+ export function regenerateLifecycle(model: string, decl: LifecycleDeclaration, deleteWhen?: string): LifecycleObject[] {
690
755
  const root = decl.tables[model];
691
756
  if (!root) throw new Error(`lifecycle declaration misses ${model}`);
692
757
  const dependents = decl.edges.map(edge => {
@@ -696,7 +761,7 @@ export function regenerateLifecycle(model: string, decl: LifecycleDeclaration):
696
761
  const { schema, name: table } = parseQualified(edge.child);
697
762
  return { parent, child: decl.tables[edge.child] ?? { schema, table }, column: edge.column, dependent: edge.dependent };
698
763
  });
699
- return lifecycleObjects(root, dependents, decl);
764
+ return lifecycleObjects(root, dependents, decl, deleteWhen);
700
765
  }
701
766
 
702
767
  /** The generated lifecycle objects, with comments stamped by the ordinary reconciler. */
@@ -707,12 +772,13 @@ export function lifecycleDerived(models: readonly ModelDescriptor[]): DerivedDes
707
772
  .sort((a, b) => labelOf(a) < labelOf(b) ? -1 : labelOf(a) > labelOf(b) ? 1 : 0)
708
773
  .flatMap(model => {
709
774
  const decl = declOf(model, dependents);
710
- const generated = regenerateLifecycle(labelOf(model), decl);
775
+ const generated = regenerateLifecycle(labelOf(model), decl, model.deleteWhen);
711
776
  for (const object of generated) {
712
777
  if (object.kind !== 'function') continue;
713
778
  const role = lifecycleRole(object.name, labelOf(model));
714
779
  if (!role) throw new Error(`unknown lifecycle function ${object.name}`);
715
- const manifest: LifecycleManifest = { everystack: 'lifecycle', v: MANIFEST_VERSION, role, model: labelOf(model), decl, defHash: canonHash(canonOfFunction(object)) };
780
+ const manifest: LifecycleManifest = { everystack: 'lifecycle', v: MANIFEST_VERSION, role, model: labelOf(model), decl,
781
+ ...(role === 'delete_when' && model.deleteWhen !== undefined ? { deleteWhen: model.deleteWhen } : {}), defHash: canonHash(canonOfFunction(object)) };
716
782
  object.comment = JSON.stringify(manifest);
717
783
  }
718
784
  // Only the emitter binds table references back to Models; the builders never see them.
@@ -22,6 +22,7 @@ import type { SchemaSnapshot, TableSchema } from './schema-introspect';
22
22
  import type { AuthzContract, PolicyContract, TableContract } from './authz-contract';
23
23
  import type { DerivedCatalog } from './derived-introspect';
24
24
  import { compileTableContract } from './authz-compile';
25
+ import { lifecycleFkIndexName } from './schema-compile';
25
26
 
26
27
  /** The generated columns as the catalog reports them (`format_type`), nullable, no default. */
27
28
  const GENERATED_COLUMNS: Record<string, string> = {
@@ -32,6 +33,7 @@ const GENERATED_COLUMNS: Record<string, string> = {
32
33
  };
33
34
 
34
35
  const GUARD = '(deleted_at IS NULL)';
36
+ const DELETED_ONLY = '(deleted_at IS NOT NULL)';
35
37
 
36
38
  /** The commands the measured effective matrix reports (`effectivePrivileges`). */
37
39
  const EFFECTIVE_COMMANDS = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
@@ -42,6 +44,8 @@ export interface LifecycleFold {
42
44
  catalog: DerivedCatalog;
43
45
  /** Schema-qualified tables folded to `lifecycle: true`. */
44
46
  tables: Set<string>;
47
+ /** lifecycle: { deleteWhen }'s predicate per folded table, read from its delete_when function's manifest. */
48
+ deleteWhen: Map<string, string>;
45
49
  }
46
50
 
47
51
  const split = (qualified: string): { schema: string; name: string } => {
@@ -79,8 +83,31 @@ function columnsAreGenerated(table: TableSchema): boolean {
79
83
  });
80
84
  }
81
85
 
86
+ /** The lifecycle columns a deleted reader is never granted (compileColumnGrants). */
87
+ const DELETED_READ_HIDDEN = new Set(['deleted_by', 'deleted_batch', 'deleted_xid']);
88
+
89
+ /**
90
+ * can('read', { role, deleted }) with no columns compiles to a column SELECT of every column but
91
+ * deleted_by, deleted_batch and deleted_xid. Fold exactly that grant back to the table SELECT the
92
+ * renderer reads as a plain role read, so the pull renders `deleted` without a columns list; the
93
+ * compile turns it back into the same column grant. Any other column set stays as it is.
94
+ */
95
+ function foldDeletedReadGrants(folded: TableContract, table: TableSchema): void {
96
+ const every = sorted(table.columns.map((c) => c.name).filter((name) => !DELETED_READ_HIDDEN.has(name)));
97
+ for (const p of folded.policies) {
98
+ if (p.deleted === undefined || p.roles.length !== 1) continue;
99
+ const role = p.roles[0];
100
+ const cols = folded.columnGrants?.[role];
101
+ if (!cols || folded.grants[role]?.includes('SELECT') || sorted(cols.SELECT) !== every) continue;
102
+ folded.grants = { ...folded.grants, [role]: [...(folded.grants[role] ?? []), 'SELECT'].sort() };
103
+ const { SELECT: _select, ...rest } = cols;
104
+ const { [role]: _role, ...others } = folded.columnGrants!;
105
+ folded.columnGrants = Object.keys(rest).length ? { ...others, [role]: rest } : others;
106
+ }
107
+ }
108
+
82
109
  /** The folded contract for one table, or undefined when the table is not exactly lifecycle. */
83
- function foldContract(live: TableContract): TableContract | undefined {
110
+ function foldContract(live: TableContract, table: TableSchema): TableContract | undefined {
84
111
  const role = LIFECYCLE_OWNER_ROLE;
85
112
  const expected = reservedRoleContract(live.table);
86
113
  if (sorted(live.grants[role]) !== sorted(expected.grants[role])) return undefined;
@@ -111,8 +138,20 @@ function foldContract(live: TableContract): TableContract | undefined {
111
138
  continue;
112
139
  }
113
140
  const rest = unguarded(p.using);
114
- if (rest === undefined) return undefined;
115
- policies.push({ ...p, using: rest ?? 'true' });
141
+ if (rest !== undefined) {
142
+ policies.push({ ...p, using: rest ?? 'true' });
143
+ continue;
144
+ }
145
+ // can('read', { role, deleted }): a role's own read policy compiled without the guard
146
+ // ('include') or with (deleted_at IS NOT NULL) in its place ('only'). Any other SELECT policy
147
+ // without the guard leaves the table unfolded, as before.
148
+ const own = p.roles.length === 1 && !['anon', 'authenticated', 'public'].includes(p.roles[0])
149
+ && p.name === `${split(live.table).name}_select_${p.roles[0]}`;
150
+ if (!own) return undefined;
151
+ if (p.using === DELETED_ONLY) policies.push({ ...p, using: 'true', deleted: 'only' });
152
+ else if (p.using?.startsWith(`(${DELETED_ONLY} AND `) && p.using.endsWith(')')) {
153
+ policies.push({ ...p, using: p.using.slice(DELETED_ONLY.length + 6, -1), deleted: 'only' });
154
+ } else policies.push({ ...p, deleted: 'include' });
116
155
  }
117
156
 
118
157
  const { [role]: _grant, ...grants } = live.grants;
@@ -125,6 +164,7 @@ function foldContract(live: TableContract): TableContract | undefined {
125
164
  const { [role]: _eff, ...effectivePrivileges } = live.effectivePrivileges;
126
165
  folded.effectivePrivileges = effectivePrivileges;
127
166
  }
167
+ foldDeletedReadGrants(folded, table);
128
168
  return folded;
129
169
  }
130
170
 
@@ -143,28 +183,62 @@ function generatedIdentities(table: TableSchema): string[] {
143
183
  `${schema}.${name}_restore(${arg})`,
144
184
  `${table.table}.${name}_lifecycle_transition`,
145
185
  `${table.table}.${name}_lifecycle_guard`,
186
+ `${schema}.${name}_lifecycle_delete_when()`,
187
+ `${table.table}.${name}_lifecycle_delete_when`,
146
188
  ];
147
189
  }
148
190
 
191
+ /**
192
+ * The generator's lifecycle FK index (lifecycleDependentIndexes): plain btree, exactly the columns of
193
+ * a foreign key into a folded lifecycle table, under exactly the generated name. Folded like the
194
+ * lifecycle columns, so the pull never renders it as an author index. Anything else stays.
195
+ */
196
+ function withoutGeneratedFkIndexes(t: TableSchema, lifecycleTables: ReadonlySet<string>): TableSchema {
197
+ const name = t.table.slice(t.table.indexOf('.') + 1);
198
+ const indexes = t.indexes.filter((ix) => !(
199
+ !ix.unique && !ix.where && !ix.using && !ix.include?.length
200
+ && ix.name === lifecycleFkIndexName(name, ix.columns)
201
+ && t.foreignKeys.some((fk) => lifecycleTables.has(fk.refTable.includes('.') ? fk.refTable : `public.${fk.refTable}`)
202
+ && JSON.stringify(fk.columns) === JSON.stringify(ix.columns))));
203
+ return indexes.length === t.indexes.length ? t : { ...t, indexes };
204
+ }
205
+
149
206
  export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract, catalog: DerivedCatalog): LifecycleFold {
150
207
  const tables = new Set<string>();
151
208
  const foldedContracts = new Map<string, TableContract>();
152
209
  for (const table of snapshot.tables) {
153
210
  if (table.primaryKey.length !== 1 || !columnsAreGenerated(table)) continue;
154
211
  const live = contract.tables.find((t) => t.table === table.table);
155
- const folded = live && live.rls.enabled ? foldContract(live) : undefined;
212
+ const folded = live && live.rls.enabled ? foldContract(live, table) : undefined;
156
213
  if (!folded) continue;
157
214
  tables.add(table.table);
158
215
  foldedContracts.set(table.table, folded);
159
216
  }
160
- if (tables.size === 0) return { snapshot, contract, catalog, tables };
217
+ if (tables.size === 0) return { snapshot, contract, catalog, tables, deleteWhen: new Map() };
218
+
219
+ // A guarded table's predicate is the delete_when function's manifest input. The pull must render
220
+ // exactly that text, or the next plan would rebuild the guard; without it, refuse rather than drop
221
+ // the guard or render it as an author function.
222
+ const deleteWhen = new Map<string, string>();
223
+ for (const table of tables) {
224
+ const { schema, name } = split(table);
225
+ const fn = catalog.objects.find((o) => o.kind === 'function' && o.identity === `${schema}.${name}_lifecycle_delete_when()`);
226
+ if (!fn) continue;
227
+ let manifest: { role?: unknown; model?: unknown; deleteWhen?: unknown } | undefined;
228
+ try { manifest = typeof fn.comment === 'string' ? JSON.parse(fn.comment) : undefined; } catch { manifest = undefined; }
229
+ if (!manifest || manifest.role !== 'delete_when' || manifest.model !== table || typeof manifest.deleteWhen !== 'string' || manifest.deleteWhen.trim() === '') {
230
+ throw new Error(`db:pull: ${fn.identity} carries no readable deleteWhen in its lifecycle manifest, so ${table}'s lifecycle: { deleteWhen } `
231
+ + `cannot be rendered exactly. Run db:apply from the checkout that declared it (it rewrites the manifest), then pull again.`);
232
+ }
233
+ deleteWhen.set(table, manifest.deleteWhen);
234
+ }
161
235
 
162
236
  const generated = new Set(snapshot.tables.filter((t) => tables.has(t.table)).flatMap(generatedIdentities));
163
237
  return {
164
238
  snapshot: {
165
239
  ...snapshot,
166
- tables: snapshot.tables.map((t) =>
167
- tables.has(t.table) ? { ...t, columns: t.columns.filter((c) => !Object.hasOwn(GENERATED_COLUMNS, c.name)) } : t),
240
+ tables: snapshot.tables.map((t) => withoutGeneratedFkIndexes(
241
+ tables.has(t.table) ? { ...t, columns: t.columns.filter((c) => !Object.hasOwn(GENERATED_COLUMNS, c.name)) } : t, tables)),
168
242
  },
169
243
  contract: { ...contract, tables: contract.tables.map((t) => foldedContracts.get(t.table) ?? t) },
170
244
  catalog: {
@@ -173,5 +247,6 @@ export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract,
173
247
  provenance: catalog.provenance.filter((p) => !generated.has(p.identity)),
174
248
  },
175
249
  tables,
250
+ deleteWhen,
176
251
  };
177
252
  }
@@ -152,6 +152,8 @@ export async function verifyLifecycle(session: SessionRunner, governedRoles: rea
152
152
  && normalizeArgType(live.argTypes[0]) === normalizeArgType(pkTypes[0]);
153
153
  });
154
154
  const manifests = new Map<string, LifecycleManifest>();
155
+ /** lifecycle: { deleteWhen }'s predicate per model, from its delete_when function's own manifest. */
156
+ const deleteWhenOf = new Map<string, string>();
155
157
  const groups = new Map<string, { decl: LifecycleDeclaration; from: string }>();
156
158
  for (const [id, live] of liveFunctions) {
157
159
  let raw: unknown;
@@ -169,6 +171,10 @@ export async function verifyLifecycle(session: SessionRunner, governedRoles: rea
169
171
  || typeof raw.defHash !== 'string' || !/^[a-f0-9]{64}$/.test(raw.defHash) || !raw.decl.tables[raw.model]) { add(id, 'malformed lifecycle manifest'); continue; }
170
172
  if (lifecycleRole(live.name, raw.model) !== raw.role) { add(id, 'manifest role does not match function name'); continue; }
171
173
  const manifest = raw as unknown as LifecycleManifest;
174
+ if (manifest.role === 'delete_when') {
175
+ if (typeof manifest.deleteWhen !== 'string' || manifest.deleteWhen.trim() === '') { add(id, 'malformed lifecycle manifest'); continue; }
176
+ deleteWhenOf.set(manifest.model, manifest.deleteWhen);
177
+ }
172
178
  manifests.set(id, manifest);
173
179
  const prev = groups.get(manifest.model);
174
180
  if (prev && JSON.stringify(prev.decl) !== JSON.stringify(manifest.decl)) add(id, `conflicting declarations for ${manifest.model}`);
@@ -195,7 +201,7 @@ export async function verifyLifecycle(session: SessionRunner, governedRoles: rea
195
201
  && same(strings(actual.references), strings(parent?.pk_columns)) && actual.action === 'r')) add(`${edge.child}.${edge.column}`, 'foreign key differs from catalog');
196
202
  }
197
203
  let objects;
198
- try { objects = regenerateLifecycle(model, decl); } catch { add(from, 'malformed declaration cannot regenerate'); continue; }
204
+ try { objects = regenerateLifecycle(model, decl, deleteWhenOf.get(model)); } catch { add(from, 'malformed declaration cannot regenerate'); continue; }
199
205
  for (const object of objects) {
200
206
  if (object.kind === 'function') expectedFunctions.set(canonOfFunction(object).identity, object);
201
207
  else { const canon = canonOfTrigger(object); expectedTriggers.set(canon.identity, canon); }
@@ -19,9 +19,9 @@
19
19
  * lands with whole-DB introspection. The emission is identical either way.
20
20
  */
21
21
 
22
- import { relationDeleteActions, type ModelDescriptor, type Module, type SequenceDescriptor } from '@everystack/model';
22
+ import { lifecycleDependentIndexes, relationDeleteActions, type ModelDescriptor, type Module, type SequenceDescriptor } from '@everystack/model';
23
23
  import type { AuthzContract, TableContract } from './authz-contract';
24
- import { compileCreateTable, compileEnums, compileSequences, sequenceCreateSql, foreignKeySql, modelConstraintSpecs, qualifiedTable, toSnakeCase, type CompileTableOptions } from './schema-compile';
24
+ import { compileCreateTable, compileEnums, compileSequences, sequenceCreateSql, foreignKeySql, modelConstraintSpecs, dependentIndexSpecs, qualifiedTable, toSnakeCase, type CompileTableOptions } from './schema-compile';
25
25
  import { compileTableContract } from './authz-compile';
26
26
  import { emitReconcileSql } from './authz-reconcile';
27
27
  import { emitSchemaSql, nextvalSequence, type SchemaChange } from './schema-diff';
@@ -48,6 +48,7 @@ export function compileMigration(
48
48
  // A parent's hasMany `dependent` sets a child FK's ON DELETE; resolved over the whole set
49
49
  // from THIS set, before any FK is compiled.
50
50
  const dependents = relationDeleteActions(models);
51
+ const dependentIndexes = lifecycleDependentIndexes(models);
51
52
 
52
53
  // 0. Extensions — the BOOTSTRAP, before any table that might use one of their types.
53
54
  // compileModuleMigration has always emitted these; compileMigration had no way to,
@@ -113,7 +114,7 @@ export function compileMigration(
113
114
  // schema-qualified table, so a non-public index targets the right table.
114
115
  const indexCreates: SchemaChange[] = [];
115
116
  for (const model of models) {
116
- for (const ix of modelConstraintSpecs(model).indexes) {
117
+ for (const ix of [...modelConstraintSpecs(model).indexes, ...dependentIndexSpecs(model, dependentIndexes)]) {
117
118
  indexCreates.push({
118
119
  kind: 'createIndex', table: qualifiedTable(model), name: ix.name, columns: ix.columns, unique: ix.unique,
119
120
  ...(ix.where ? { where: ix.where } : {}),
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
19
- import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
19
+ import { assertExternalTargets, lifecycleDependentIndexes, relationDeleteActions } from '@everystack/model';
20
20
  import type { SchemaSnapshot, ColumnSchema } from './schema-introspect';
21
21
  import type { AuthzContract, SequenceGrantContract } from './authz-contract';
22
22
  import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile';
@@ -144,12 +144,13 @@ export function generateMigration(
144
144
  assertExternalTargets(models);
145
145
  // hasMany `dependent` → the child FK's ON DELETE, derived from THIS set (and checked, for the same reason).
146
146
  const dependents = relationDeleteActions(models);
147
+ const dependentIndexes = lifecycleDependentIndexes(models);
147
148
  const schema = opts.schema ?? 'public';
148
149
  // A table's schema comes from the model first (package tables live in dist/ops/auth),
149
150
  // then the call default — so one run spans schemas.
150
151
  const schemaOf = (m: ModelDescriptor): string => m.schema ?? schema;
151
152
  const desiredEnums = compileEnums(models);
152
- const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema, dependents })), enums: desiredEnums, ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}) };
153
+ const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema, dependents, dependentIndexes })), enums: desiredEnums, ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}) };
153
154
  const declaredTables = new Set(desired.tables.map((t) => t.table));
154
155
  const declaredEnums = new Set(desiredEnums.map((e) => e.name));
155
156
  // Scope both tables and enums to what the Models declare — an undeclared table OR enum
@@ -23,6 +23,15 @@ export * from './model-render';
23
23
  export * from './migration-compile';
24
24
  export * from './migration-generate';
25
25
 
26
+ // --- the DECLARED state of a model set, as introspection would see it -------
27
+ // `compileDeclaredState(models, { schema })` → `{ snapshot, contract }`: the same compilation
28
+ // db:plan, db:check and the fingerprint use. Diff `.snapshot` against `introspectSchema(...)`
29
+ // to verify a database built from the Models. Prefer it to mapping `compileTableSchema` over
30
+ // the set yourself: the set derives table parts no single Model carries (the hasMany ON DELETE,
31
+ // the generated `<table>_<column>_lifecycle_fk_idx` indexes), and a hand-rolled loop that misses
32
+ // one reads the live object as a drop.
33
+ export { compileDeclaredState, type DeclaredState } from './declared-diff';
34
+
26
35
  // --- authz layer: contract compile/introspect/diff/reconcile ---------------
27
36
  export * from './authz-compile';
28
37
  export * from './authz-contract';
@@ -112,6 +112,8 @@ export interface RenderOptions {
112
112
  externalDerived?: ExternalDerived;
113
113
  /** Tables db:pull folded to `lifecycle: true` (lifecycle-pull.ts): rendered with that key, no softDelete line. */
114
114
  lifecycleTables?: ReadonlySet<string>;
115
+ /** lifecycle: { deleteWhen }'s predicate per folded table (lifecycle-pull.ts), rendered verbatim. */
116
+ lifecycleDeleteWhen?: ReadonlyMap<string, string>;
115
117
  }
116
118
 
117
119
  /**
@@ -792,7 +794,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
792
794
  }
793
795
 
794
796
  /** One `export const X = defineModel(...)` block for a table. */
795
- export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>, fkTargets?: FkTargets, lifecycle = false): string {
797
+ export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>, fkTargets?: FkTargets, lifecycle: boolean | string = false): string {
796
798
  // A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
797
799
  // the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
798
800
  const validates = new Map<string, string>();
@@ -818,7 +820,11 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
818
820
  // put it, proving the position is mechanical-edit-friendly).
819
821
  const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
820
822
  const writtenBy = writtenByStanza(table, liveAuthz);
821
- const softDelete = lifecycle ? ' lifecycle: true,\n' : softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
823
+ // A guarded table renders its predicate inside sql`…`, escaped so the template's cooked string
824
+ // is the predicate text exactly (the model stores it verbatim; the generated guard hashes it).
825
+ const softDelete = typeof lifecycle === 'string'
826
+ ? ` lifecycle: { deleteWhen: sql\`${lifecycle.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\` },\n`
827
+ : lifecycle ? ' lifecycle: true,\n' : softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
822
828
 
823
829
  // A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
824
830
  // qualification cannot ride in the first argument (that would make the table literally
@@ -945,7 +951,7 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
945
951
  const known = knownSet(tables.map((t) => t.table));
946
952
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
947
953
 
948
- const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleTables?.has(t.table)));
954
+ const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table)));
949
955
  // The derived layer (B5): sequences + views/matviews/functions, after the models they
950
956
  // reference, before the module wrapper that composes all three.
951
957
  if (opts.derived?.block) blocks.push(opts.derived.block);
@@ -1022,7 +1028,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
1022
1028
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
1023
1029
 
1024
1030
  const files: RenderedModelFile[] = tables.map((t) => {
1025
- const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleTables?.has(t.table));
1031
+ const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table));
1026
1032
  const crossImports = referencedTables(t, known).map(
1027
1033
  (target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
1028
1034
  );
@@ -17,6 +17,9 @@
17
17
  * ⚠ ACCESS db:plan only: one summary line when the plan removes a policy, revokes a
18
18
  * grant, turns row security off, or removes a function, view or trigger. Not destructive (no --confirm), but such a
19
19
  * plan is never called "Safe to apply".
20
+ * ⚠ LOCK db:plan only: one line per plain CREATE INDEX on a live table with rows (it
21
+ * blocks writes while it builds), with the row estimate. Informational: the plan
22
+ * is still "Safe to apply", needs no --confirm and exits the same (index-lock.ts).
20
23
  * ⚠ CONSTRAINT db:plan only: one line per primary key the plan replaces, naming the old key.
21
24
  * Read from the replacement note the emitter writes from the change itself
22
25
  * (schema-diff's parsePrimaryKeyReplacement), never from the DDL. Not
@@ -36,6 +39,7 @@
36
39
 
37
40
  import { statementBody } from './sql-statement';
38
41
  import { parsePrimaryKeyReplacement } from './schema-diff';
42
+ import { lockLines, type LockWarning } from './index-lock';
39
43
  import type { ReconcileAction, ReconcilePlan } from './derived-plan';
40
44
  import type { DerivedKind } from './derived-source';
41
45
 
@@ -440,6 +444,10 @@ export interface PlanReportInput {
440
444
  * Absent, or with nothing that changes an object, the report is byte-identical to one without it.
441
445
  */
442
446
  derived?: Pick<ReconcilePlan, 'actions' | 'regrants'>;
447
+ /** The plan's ⚠ LOCK data (EdgePlan.lockWarnings). Informational; absent or empty prints nothing. */
448
+ lockWarnings?: readonly LockWarning[];
449
+ /** The plan's deleted-row reads (EdgePlan.deletedReads): one ⚠ ACCESS line each. */
450
+ deletedReads?: readonly { table: string; role: string }[];
443
451
  }
444
452
 
445
453
  // ---------------------------------------------------------------------------
@@ -617,12 +625,17 @@ export function renderPlanReport(input: PlanReportInput): string[] {
617
625
 
618
626
  const keys = constraintLines(executable);
619
627
  lines.push(...keys);
628
+ lines.push(...lockLines(input.lockWarnings ?? []));
620
629
  const access = accessLine(accessChanges([...executable, ...derived.statements]), derived.removed);
621
630
  if (access) lines.push(access);
631
+ // can('read', { deleted }): a role that can now read deleted rows. Not destructive (no --confirm);
632
+ // like every other ACCESS line, the plan is then not called "Safe to apply".
633
+ const deletedReads = (input.deletedReads ?? []).map((d) => `${ACCESS_MARK} — role ${d.role} can read deleted rows of ${d.table}.`);
634
+ lines.push(...deletedReads);
622
635
  lines.push(
623
636
  destructive.size > 0
624
637
  ? `Verify the destructive ${plural(destructive.size, 'change', 'changes')}, then run db:backup before applying.`
625
- : access
638
+ : access || deletedReads.length > 0
626
639
  ? 'Non-destructive. Verify the access changes before applying.'
627
640
  : keys.length > 0
628
641
  ? 'Non-destructive. Verify the replaced keys before applying.'
@@ -19,7 +19,8 @@
19
19
  * Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
20
20
  */
21
21
 
22
- import type { ModelDescriptor, FieldSpec, SequenceDescriptor, DependentActions } from '@everystack/model';
22
+ import { createHash } from 'node:crypto';
23
+ import type { ModelDescriptor, FieldSpec, SequenceDescriptor, DependentActions, DependentIndexes } from '@everystack/model';
23
24
  import { positionalCheckName } from '@everystack/model';
24
25
  import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect';
25
26
  import { nextvalSequence, identityClause, primaryKeyConstraintName, type RenameMap } from './schema-diff';
@@ -54,6 +55,33 @@ export function generatedIndexName(table: string, columns: string[]): string {
54
55
  return `${table}_${parts.join('_')}_index`;
55
56
  }
56
57
 
58
+ /**
59
+ * The name of a generated lifecycle FK index: `<table>_<columns>_lifecycle_fk_idx`. The suffix keeps
60
+ * it apart from author names (an author's generated name ends `_index`) and marks it generated, so
61
+ * db:pull folds it. Over PostgreSQL's 63 bytes it keeps 56 and appends `_` and 6 hex of a sha256 of
62
+ * the full name, branch-db's convention, so two long names never truncate to one.
63
+ */
64
+ export function lifecycleFkIndexName(table: string, columns: readonly string[]): string {
65
+ const name = `${table}_${columns.join('_')}_lifecycle_fk_idx`;
66
+ if (Buffer.byteLength(name) <= 63) return name;
67
+ // 56 BYTES, never inside a character: a multi-byte name cut by characters overran 63 bytes, and
68
+ // PostgreSQL then truncated it to a name the generator never planned. ASCII cuts as before.
69
+ let prefix = '';
70
+ for (const ch of name) {
71
+ if (Buffer.byteLength(prefix + ch) > 56) break;
72
+ prefix += ch;
73
+ }
74
+ return `${prefix}_${createHash('sha256').update(name).digest('hex').slice(0, 6)}`;
75
+ }
76
+
77
+ /** The set's generated FK indexes for one model (`lifecycleDependentIndexes(models)`), as IR. */
78
+ export function dependentIndexSpecs(model: ModelDescriptor, dependentIndexes?: DependentIndexes): IndexSchema[] {
79
+ return (dependentIndexes?.get(model) ?? []).map((keys) => {
80
+ const columns = keys.map(toSnakeCase);
81
+ return { name: lifecycleFkIndexName(model.table, columns), columns, unique: false };
82
+ });
83
+ }
84
+
57
85
  export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueConstraint[]; checks: CheckConstraint[]; indexes: IndexSchema[] } {
58
86
  const uniques: UniqueConstraint[] = [];
59
87
  const checks: CheckConstraint[] = [];
@@ -547,7 +575,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
547
575
  * structured form for the brownfield diff. They share the field→type/default/check
548
576
  * helpers, so they never disagree.)
549
577
  */
550
- export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions } = {}): TableSchema {
578
+ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions; dependentIndexes?: DependentIndexes } = {}): TableSchema {
551
579
  const schema = model.schema ?? opts.schema ?? 'public';
552
580
  const entries = Object.entries(model.fields);
553
581
 
@@ -607,7 +635,7 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
607
635
  uniques,
608
636
  checks,
609
637
  foreignKeys: modelForeignKeys(model, opts.dependents),
610
- indexes: tableConstraints.indexes,
638
+ indexes: [...tableConstraints.indexes, ...dependentIndexSpecs(model, opts.dependentIndexes)],
611
639
  };
612
640
  }
613
641
 
@@ -53,7 +53,7 @@
53
53
  import { createHash } from 'node:crypto';
54
54
  import { canonicalAuthz } from './authz-canonical';
55
55
  import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
56
- import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
56
+ import { assertExternalTargets, lifecycleDependentIndexes, relationDeleteActions } from '@everystack/model';
57
57
  import type { EnumType, SchemaSnapshot, TableSchema } from './schema-introspect';
58
58
  import type { AuthzContract, TableContract, SequenceGrantContract } from './authz-contract';
59
59
  import { compileTableSchema, compileEnums, compileSequences } from './schema-compile';
@@ -498,9 +498,10 @@ export function fingerprintModels(
498
498
  assertExternalTargets(models);
499
499
  // Also over the whole set: a parent's hasMany `dependent` sets the ON DELETE of a child's FK.
500
500
  const dependents = relationDeleteActions(models);
501
+ const dependentIndexes = lifecycleDependentIndexes(models);
501
502
  const scoped = opts.schemas ? models.filter((m) => opts.schemas!.includes(m.schema)) : models;
502
503
  const snapshot: SchemaSnapshot = {
503
- tables: scoped.map((m) => compileTableSchema(m, { schema: opts.schema, dependents })),
504
+ tables: scoped.map((m) => compileTableSchema(m, { schema: opts.schema, dependents, dependentIndexes })),
504
505
  enums: compileEnums(scoped),
505
506
  ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}),
506
507
  };
@@ -166,12 +166,35 @@ export interface SchemaSnapshot {
166
166
  sequences?: SequenceSchema[];
167
167
  /** Installed extensions, `plpgsql` excluded. Absent when the read predates this. */
168
168
  extensions?: string[];
169
+ /**
170
+ * The catalog's row estimate per table (`pg_class.reltuples`): > 0 an estimate, 0 empty, -1 a
171
+ * table with data that was never analyzed. For the plan's ⚠ LOCK line only. Beside the tables,
172
+ * never inside one, so no fingerprint or diff reads it. Absent when the read predates it.
173
+ */
174
+ rowEstimates?: Record<string, number>;
169
175
  }
170
176
 
171
177
  // ---------------------------------------------------------------------------
172
178
  // Introspection SQL.
173
179
  // ---------------------------------------------------------------------------
174
180
 
181
+ /**
182
+ * Row estimates for the plan's ⚠ LOCK line — catalog statistics, no table scan. reltuples is -1
183
+ * before the first VACUUM/ANALYZE; a table that has pages then is reported -1 (data, unknown count).
184
+ */
185
+ export const ROW_ESTIMATES_SQL = `
186
+ SELECT n.nspname AS schema, c.relname AS "table",
187
+ CASE WHEN c.reltuples > 0 THEN c.reltuples::bigint
188
+ WHEN pg_catalog.pg_relation_size(c.oid) > 0 THEN -1
189
+ ELSE 0 END AS estimate
190
+ FROM pg_class c
191
+ JOIN pg_namespace n ON n.oid = c.relnamespace
192
+ WHERE c.relkind = 'r'
193
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
194
+ AND n.nspname NOT LIKE 'pg_%'
195
+ ORDER BY 1, 2;
196
+ `.trim();
197
+
175
198
  /**
176
199
  * Every column of every base table, with its precise type (`format_type`),
177
200
  * nullability, and default. Extension-owned tables are excluded via pg_depend,
@@ -711,6 +734,8 @@ export interface SchemaRows {
711
734
  indexes?: IndexRow[];
712
735
  sequences?: SequenceRow[];
713
736
  extensions?: ExtensionRow[];
737
+ /** Absent on a read that predates ROW_ESTIMATES_SQL (an older ops Lambda reply). */
738
+ estimates?: Array<{ schema: string; table: string; estimate: unknown }>;
714
739
  }
715
740
 
716
741
  /**
@@ -804,6 +829,9 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
804
829
  enums,
805
830
  ...(sequences.length ? { sequences } : {}),
806
831
  ...(extensions !== undefined ? { extensions } : {}),
832
+ ...(rows.estimates !== undefined
833
+ ? { rowEstimates: Object.fromEntries(rows.estimates.map((r) => [`${r.schema}.${r.table}`, Number(r.estimate)])) }
834
+ : {}),
807
835
  };
808
836
  }
809
837
 
@@ -820,8 +848,10 @@ export async function introspectSchema(session: SessionRunner): Promise<SchemaSn
820
848
  // search_path. Column defaults, CHECK constraints and index expressions all deparse
821
849
  // relative to that path — spread across separate connections these five rows can render
822
850
  // the same schema two ways, and a fingerprint minted from the mix describes nothing.
823
- const [columns, constraints, enums, indexes, sequences, extensions] = await session(
824
- [COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL],
851
+ // The row estimates ride the same session and snapshot; a reply without them (fewer result
852
+ // sets) assembles without `rowEstimates`, and the plan then warns without a count.
853
+ const [columns, constraints, enums, indexes, sequences, extensions, estimates] = await session(
854
+ [COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL, ROW_ESTIMATES_SQL],
825
855
  INTROSPECTION_SESSION,
826
856
  );
827
857
  return assembleSchema({
@@ -831,5 +861,6 @@ export async function introspectSchema(session: SessionRunner): Promise<SchemaSn
831
861
  indexes: indexes as IndexRow[],
832
862
  sequences: sequences as SequenceRow[],
833
863
  extensions: extensions as ExtensionRow[],
864
+ ...(Array.isArray(estimates) ? { estimates: estimates as SchemaRows['estimates'] } : {}),
834
865
  });
835
866
  }