@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
@@ -2,9 +2,9 @@
2
2
  * db:pull's lifecycle fold — reads a lifecycle table back as `lifecycle: true`.
3
3
  *
4
4
  * `lifecycle: true` generates, per table: four columns, the reserved role's grants and policies,
5
- * a `deleted_at IS NULL` guard on every other role's SELECT policy, and two functions, a restore
6
- * function and two triggers. A pull that transcribed them would render the reserved role as an
7
- * authored ability and the columns as authored fields (defineModel refuses both), and the five
5
+ * a `deleted_at IS NULL` guard on every other role's SELECT policy, generated functions,
6
+ * and triggers. A pull that transcribed them would render the reserved role as an
7
+ * authored ability and the columns as authored fields (defineModel refuses both), and the generated
8
8
  * objects as derived descriptors (they would be generated twice).
9
9
  *
10
10
  * So the fold removes all of it from the pull's inputs, and the renderer writes `lifecycle: true`.
@@ -22,6 +22,8 @@ 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';
26
+ import { functionIdentity } from './pg-argtypes';
25
27
 
26
28
  /** The generated columns as the catalog reports them (`format_type`), nullable, no default. */
27
29
  const GENERATED_COLUMNS: Record<string, string> = {
@@ -32,6 +34,7 @@ const GENERATED_COLUMNS: Record<string, string> = {
32
34
  };
33
35
 
34
36
  const GUARD = '(deleted_at IS NULL)';
37
+ const DELETED_ONLY = '(deleted_at IS NOT NULL)';
35
38
 
36
39
  /** The commands the measured effective matrix reports (`effectivePrivileges`). */
37
40
  const EFFECTIVE_COMMANDS = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
@@ -42,6 +45,8 @@ export interface LifecycleFold {
42
45
  catalog: DerivedCatalog;
43
46
  /** Schema-qualified tables folded to `lifecycle: true`. */
44
47
  tables: Set<string>;
48
+ /** lifecycle: { deleteWhen }'s predicate per folded table, read from its delete_when function's manifest. */
49
+ deleteWhen: Map<string, string>;
45
50
  }
46
51
 
47
52
  const split = (qualified: string): { schema: string; name: string } => {
@@ -79,8 +84,31 @@ function columnsAreGenerated(table: TableSchema): boolean {
79
84
  });
80
85
  }
81
86
 
87
+ /** The lifecycle columns a deleted reader is never granted (compileColumnGrants). */
88
+ const DELETED_READ_HIDDEN = new Set(['deleted_by', 'deleted_batch', 'deleted_xid']);
89
+
90
+ /**
91
+ * can('read', { role, deleted }) with no columns compiles to a column SELECT of every column but
92
+ * deleted_by, deleted_batch and deleted_xid. Fold exactly that grant back to the table SELECT the
93
+ * renderer reads as a plain role read, so the pull renders `deleted` without a columns list; the
94
+ * compile turns it back into the same column grant. Any other column set stays as it is.
95
+ */
96
+ function foldDeletedReadGrants(folded: TableContract, table: TableSchema): void {
97
+ const every = sorted(table.columns.map((c) => c.name).filter((name) => !DELETED_READ_HIDDEN.has(name)));
98
+ for (const p of folded.policies) {
99
+ if (p.deleted === undefined || p.roles.length !== 1) continue;
100
+ const role = p.roles[0];
101
+ const cols = folded.columnGrants?.[role];
102
+ if (!cols || folded.grants[role]?.includes('SELECT') || sorted(cols.SELECT) !== every) continue;
103
+ folded.grants = { ...folded.grants, [role]: [...(folded.grants[role] ?? []), 'SELECT'].sort() };
104
+ const { SELECT: _select, ...rest } = cols;
105
+ const { [role]: _role, ...others } = folded.columnGrants!;
106
+ folded.columnGrants = Object.keys(rest).length ? { ...others, [role]: rest } : others;
107
+ }
108
+ }
109
+
82
110
  /** The folded contract for one table, or undefined when the table is not exactly lifecycle. */
83
- function foldContract(live: TableContract): TableContract | undefined {
111
+ function foldContract(live: TableContract, table: TableSchema): TableContract | undefined {
84
112
  const role = LIFECYCLE_OWNER_ROLE;
85
113
  const expected = reservedRoleContract(live.table);
86
114
  if (sorted(live.grants[role]) !== sorted(expected.grants[role])) return undefined;
@@ -111,8 +139,20 @@ function foldContract(live: TableContract): TableContract | undefined {
111
139
  continue;
112
140
  }
113
141
  const rest = unguarded(p.using);
114
- if (rest === undefined) return undefined;
115
- policies.push({ ...p, using: rest ?? 'true' });
142
+ if (rest !== undefined) {
143
+ policies.push({ ...p, using: rest ?? 'true' });
144
+ continue;
145
+ }
146
+ // can('read', { role, deleted }): a role's own read policy compiled without the guard
147
+ // ('include') or with (deleted_at IS NOT NULL) in its place ('only'). Any other SELECT policy
148
+ // without the guard leaves the table unfolded, as before.
149
+ const own = p.roles.length === 1 && !['anon', 'authenticated', 'public'].includes(p.roles[0])
150
+ && p.name === `${split(live.table).name}_select_${p.roles[0]}`;
151
+ if (!own) return undefined;
152
+ if (p.using === DELETED_ONLY) policies.push({ ...p, using: 'true', deleted: 'only' });
153
+ else if (p.using?.startsWith(`(${DELETED_ONLY} AND `) && p.using.endsWith(')')) {
154
+ policies.push({ ...p, using: p.using.slice(DELETED_ONLY.length + 6, -1), deleted: 'only' });
155
+ } else policies.push({ ...p, deleted: 'include' });
116
156
  }
117
157
 
118
158
  const { [role]: _grant, ...grants } = live.grants;
@@ -125,11 +165,12 @@ function foldContract(live: TableContract): TableContract | undefined {
125
165
  const { [role]: _eff, ...effectivePrivileges } = live.effectivePrivileges;
126
166
  folded.effectivePrivileges = effectivePrivileges;
127
167
  }
168
+ foldDeletedReadGrants(folded, table);
128
169
  return folded;
129
170
  }
130
171
 
131
172
  /**
132
- * The five generated objects of one lifecycle table, exactly as the derived catalog keys them. The
173
+ * The generated objects of one lifecycle table, and the older CLI's retired restrict pair, exactly as the derived catalog keys them. The
133
174
  * restore takes the primary key, and a function signature carries no type modifier
134
175
  * (`character varying(255)` is `character varying` there). An author's own overload stays.
135
176
  */
@@ -140,31 +181,97 @@ function generatedIdentities(table: TableSchema): string[] {
140
181
  return [
141
182
  `${schema}.${name}_lifecycle_transition()`,
142
183
  `${schema}.${name}_lifecycle_guard()`,
184
+ `${schema}.${name}_lifecycle_destroy_before()`,
185
+ functionIdentity(schema, `${name}_lifecycle_destroy`, [`${arg}[]`, 'timestamptz', 'integer']),
143
186
  `${schema}.${name}_restore(${arg})`,
144
187
  `${table.table}.${name}_lifecycle_transition`,
145
188
  `${table.table}.${name}_lifecycle_guard`,
189
+ `${schema}.${name}_lifecycle_delete_when()`,
190
+ `${table.table}.${name}_lifecycle_delete_when`,
191
+ // CLI 0.4.101 also generated a per-row restrict pair. The transition carries that check now and
192
+ // db:apply removes the pair, so a stage built by 0.4.101 must not pull it back as authored code.
193
+ `${schema}.${name}_lifecycle_restrict()`,
194
+ `${table.table}.${name}_lifecycle_restrict`,
146
195
  ];
147
196
  }
148
197
 
198
+ /**
199
+ * The generator's lifecycle FK index (lifecycleDependentIndexes): plain btree, exactly the columns of
200
+ * a foreign key into a folded lifecycle table, under exactly the generated name. Folded like the
201
+ * lifecycle columns, so the pull never renders it as an author index. Anything else stays.
202
+ */
203
+ function withoutGeneratedFkIndexes(t: TableSchema, lifecycleTables: ReadonlySet<string>): TableSchema {
204
+ const name = t.table.slice(t.table.indexOf('.') + 1);
205
+ const indexes = t.indexes.filter((ix) => !(
206
+ !ix.unique && !ix.where && !ix.using && !ix.include?.length
207
+ && ix.name === lifecycleFkIndexName(name, ix.columns)
208
+ && t.foreignKeys.some((fk) => lifecycleTables.has(fk.refTable.includes('.') ? fk.refTable : `public.${fk.refTable}`)
209
+ && JSON.stringify(fk.columns) === JSON.stringify(ix.columns))));
210
+ return indexes.length === t.indexes.length ? t : { ...t, indexes };
211
+ }
212
+
213
+ /** Fold only the exact read capability generated for a plain restricting child. */
214
+ function foldReferenceContract(live: TableContract, table: TableSchema, lifecycleTables: ReadonlySet<string>): TableContract | undefined {
215
+ const columns = [...new Set(table.foreignKeys.filter(fk =>
216
+ lifecycleTables.has(fk.refTable.includes('.') ? fk.refTable : `public.${fk.refTable}`)
217
+ && (!fk.onDelete || fk.onDelete === 'no action' || fk.onDelete === 'restrict')).flatMap(fk => fk.columns))].sort();
218
+ if (!columns.length) return undefined;
219
+ const role = LIFECYCLE_OWNER_ROLE;
220
+ const grants = live.columnGrants?.[role] ?? {};
221
+ if ((live.grants[role]?.length ?? 0) !== 0 || sorted(Object.keys(grants)) !== sorted(['SELECT']) || sorted(grants.SELECT) !== sorted(columns)) return undefined;
222
+ const expected: PolicyContract = { name: `${split(table.table).name}_select_${role}`, command: 'SELECT', roles: [role], permissive: true, using: 'true', check: null };
223
+ const ownPolicies = live.policies.filter(p => p.roles.includes(role));
224
+ if (ownPolicies.length !== 1 || policyKey(ownPolicies[0]) !== policyKey(expected)) return undefined;
225
+ if (live.effectivePrivileges && sorted(live.effectivePrivileges[role]) !== sorted(['SELECT'])) return undefined;
226
+ const { [role]: _grant, ...tableGrants } = live.grants;
227
+ const { [role]: _cols, ...columnGrants } = live.columnGrants ?? {};
228
+ const { [role]: _effective, ...effectivePrivileges } = live.effectivePrivileges ?? {};
229
+ return { ...live, grants: tableGrants, columnGrants, policies: live.policies.filter(p => p !== ownPolicies[0]),
230
+ ...(live.effectivePrivileges ? { effectivePrivileges } : {}) };
231
+ }
232
+
149
233
  export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract, catalog: DerivedCatalog): LifecycleFold {
150
234
  const tables = new Set<string>();
151
235
  const foldedContracts = new Map<string, TableContract>();
152
236
  for (const table of snapshot.tables) {
153
237
  if (table.primaryKey.length !== 1 || !columnsAreGenerated(table)) continue;
154
238
  const live = contract.tables.find((t) => t.table === table.table);
155
- const folded = live && live.rls.enabled ? foldContract(live) : undefined;
239
+ const folded = live && live.rls.enabled ? foldContract(live, table) : undefined;
156
240
  if (!folded) continue;
157
241
  tables.add(table.table);
158
242
  foldedContracts.set(table.table, folded);
159
243
  }
160
- if (tables.size === 0) return { snapshot, contract, catalog, tables };
244
+ if (tables.size === 0) return { snapshot, contract, catalog, tables, deleteWhen: new Map() };
245
+ for (const table of snapshot.tables) {
246
+ if (tables.has(table.table)) continue;
247
+ const live = contract.tables.find(t => t.table === table.table);
248
+ const folded = live && foldReferenceContract(live, table, tables);
249
+ if (folded) foldedContracts.set(table.table, folded);
250
+ }
251
+
252
+ // A guarded table's predicate is the delete_when function's manifest input. The pull must render
253
+ // exactly that text, or the next plan would rebuild the guard; without it, refuse rather than drop
254
+ // the guard or render it as an author function.
255
+ const deleteWhen = new Map<string, string>();
256
+ for (const table of tables) {
257
+ const { schema, name } = split(table);
258
+ const fn = catalog.objects.find((o) => o.kind === 'function' && o.identity === `${schema}.${name}_lifecycle_delete_when()`);
259
+ if (!fn) continue;
260
+ let manifest: { role?: unknown; model?: unknown; deleteWhen?: unknown } | undefined;
261
+ try { manifest = typeof fn.comment === 'string' ? JSON.parse(fn.comment) : undefined; } catch { manifest = undefined; }
262
+ if (!manifest || manifest.role !== 'delete_when' || manifest.model !== table || typeof manifest.deleteWhen !== 'string' || manifest.deleteWhen.trim() === '') {
263
+ throw new Error(`db:pull: ${fn.identity} carries no readable deleteWhen in its lifecycle manifest, so ${table}'s lifecycle: { deleteWhen } `
264
+ + `cannot be rendered exactly. Run db:apply from the checkout that declared it (it rewrites the manifest), then pull again.`);
265
+ }
266
+ deleteWhen.set(table, manifest.deleteWhen);
267
+ }
161
268
 
162
269
  const generated = new Set(snapshot.tables.filter((t) => tables.has(t.table)).flatMap(generatedIdentities));
163
270
  return {
164
271
  snapshot: {
165
272
  ...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),
273
+ tables: snapshot.tables.map((t) => withoutGeneratedFkIndexes(
274
+ tables.has(t.table) ? { ...t, columns: t.columns.filter((c) => !Object.hasOwn(GENERATED_COLUMNS, c.name)) } : t, tables)),
168
275
  },
169
276
  contract: { ...contract, tables: contract.tables.map((t) => foldedContracts.get(t.table) ?? t) },
170
277
  catalog: {
@@ -173,5 +280,6 @@ export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract,
173
280
  provenance: catalog.provenance.filter((p) => !generated.has(p.identity)),
174
281
  },
175
282
  tables,
283
+ deleteWhen,
176
284
  };
177
285
  }
@@ -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,10 @@ 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 children detected even when the default pull leaves their columns unfolded. */
116
+ lifecycleForeignKeyTables?: ReadonlySet<string>;
117
+ /** lifecycle: { deleteWhen }'s predicate per folded table (lifecycle-pull.ts), rendered verbatim. */
118
+ lifecycleDeleteWhen?: ReadonlyMap<string, string>;
115
119
  }
116
120
 
117
121
  /**
@@ -391,6 +395,48 @@ function knownSet(tables: Iterable<string>): Set<string> {
391
395
  return new Set([...tables].map(qualifiedName));
392
396
  }
393
397
 
398
+ /** A key into a rendered lifecycle parent, declared on that parent as a hasMany dependent. */
399
+ interface RenderedDependent { child: string; column: string; dependent: 'cascade' | 'restrict' | 'nullify' }
400
+ const DEPENDENT_OF: Record<string, RenderedDependent['dependent']> = { cascade: 'cascade', 'set null': 'nullify', restrict: 'restrict' };
401
+
402
+ /**
403
+ * Every one-column foreign key from a table rendered `lifecycle: true` into another, grouped by the
404
+ * parent: the parent declares each one as a dependent (a key into a lifecycle table with none is
405
+ * refused), read from the live ON DELETE: CASCADE -> 'cascade', SET NULL -> 'nullify', RESTRICT or
406
+ * NO ACTION -> 'restrict'. A plain table's key into one has no dependent (only a lifecycle table may
407
+ * hold one): it renders as it is, with a comment, and the model's own refusal names it. So do SET
408
+ * DEFAULT and composite keys.
409
+ */
410
+ function lifecycleDependentsOf(tables: readonly TableSchema[], lifecycleTables: ReadonlySet<string> | undefined): Map<string, RenderedDependent[]> {
411
+ const out = new Map<string, RenderedDependent[]>();
412
+ const rendered = new Set([...(lifecycleTables ?? [])].map(qualifiedName));
413
+ const known = knownSet(tables.map((t) => t.table));
414
+ for (const table of tables) {
415
+ for (const fk of table.foreignKeys) {
416
+ const parent = qualifiedName(fk.refTable);
417
+ const dependent = DEPENDENT_OF[fk.onDelete ?? 'restrict'];
418
+ if (fk.columns.length !== 1 || !dependent || !rendered.has(parent) || !known.has(parent) || !rendered.has(qualifiedName(table.table))) continue;
419
+ out.set(parent, [...(out.get(parent) ?? []), { child: qualifiedName(table.table), column: fk.columns[0], dependent }]);
420
+ }
421
+ }
422
+ return out;
423
+ }
424
+
425
+ /** The parent's `relations: { … }` stanza for its dependents ('' when none). Thunks, like `.references()`: a pulled barrel is not linked. */
426
+ function dependentsStanza(table: TableSchema, dependents: readonly RenderedDependent[]): string {
427
+ if (dependents.length === 0) return '';
428
+ const fieldKeys = new Set(table.columns.map((c) => toCamelCase(c.name)));
429
+ const base = (d: RenderedDependent): string => toCamelCase(schemaOf(d.child) === 'public' ? bareName(d.child) : `${schemaOf(d.child)}_${bareName(d.child)}`);
430
+ const lines = [...dependents].sort((a, b) => (`${a.child}\u0000${a.column}` < `${b.child}\u0000${b.column}` ? -1 : 1)).map((d) => {
431
+ const field = toCamelCase(d.column);
432
+ const plain = base(d);
433
+ const shared = dependents.filter((o) => base(o) === plain).length > 1 || fieldKeys.has(plain);
434
+ const key = shared ? `${plain}By${field[0].toUpperCase()}${field.slice(1)}` : plain;
435
+ return ` ${safeKey(key, `the relation for ${liveObject('table', d.child)}`)}: hasMany(() => ${modelVarName(d.child)}, ${tsLiteral(field)}, { dependent: ${tsLiteral(d.dependent)} }),`;
436
+ });
437
+ return `\n relations: {\n${lines.join('\n')}\n },`;
438
+ }
439
+
394
440
  /**
395
441
  * A table name → its model variable: PascalCase, naively singularized (one trailing
396
442
  * `s` strips, whatever the word). `image_variants` → `ImageVariant`.
@@ -423,7 +469,7 @@ export function modelVarName(table: string): string {
423
469
  * phantom rename for every digit-boundary column and COLLIDED distinct columns
424
470
  * (`a_1` and `a1`) onto one key.
425
471
  */
426
- function toCamelCase(name: string): string {
472
+ export function toCamelCase(name: string): string {
427
473
  return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
428
474
  }
429
475
 
@@ -792,7 +838,17 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
792
838
  }
793
839
 
794
840
  /** 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 {
841
+ 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, lifecycleForeignKeys = Boolean(lifecycle), dependents: ReadonlyMap<string, readonly RenderedDependent[]> = new Map(), lifecycleTables: ReadonlySet<string> = new Set()): string {
842
+ const self = qualifiedName(table.table);
843
+ const asParent = dependents.get(self) ?? [];
844
+ // A key a lifecycle parent declares as a dependent carries no onDelete of its own: the dependent derives it.
845
+ const declared = new Set([...dependents.values()].flat().filter((d) => d.child === self).map((d) => d.column));
846
+ // Copy for rendering only; the raw catalog still owns the adoption fingerprint.
847
+ if (declared.size) table = { ...table, foreignKeys: table.foreignKeys.map(fk =>
848
+ fk.columns.length === 1 && declared.has(fk.columns[0]) ? { ...fk, onDelete: undefined } : fk) };
849
+ // A pull cannot infer the parent relation that makes any other lifecycle cascade legal.
850
+ if (lifecycleForeignKeys) table = { ...table, foreignKeys: table.foreignKeys.map(fk =>
851
+ fk.onDelete === 'cascade' ? { ...fk, onDelete: 'restrict' } : fk) };
796
852
  // A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
797
853
  // the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
798
854
  const validates = new Map<string, string>();
@@ -811,14 +867,24 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
811
867
  }
812
868
 
813
869
  const pulled = knownSet(known);
814
- const fields = table.columns.map((c) => renderField(table, c, pulled, enums, validates, fkTargets)).join('\n');
870
+ // A plain table's key into a table rendered lifecycle: the models refuse it; say why and the fix.
871
+ const intoLifecycle = new Set(lifecycle ? [] : table.foreignKeys
872
+ .filter((fk) => fk.columns.length === 1 && lifecycleTables.has(qualifiedName(fk.refTable)))
873
+ .map((fk) => fk.columns[0]));
874
+ const fields = table.columns.map((c) => (intoLifecycle.has(c.name)
875
+ ? ` // ${commentText(`${c.name} references a lifecycle table, and only a lifecycle table may hold a key into one: make this model lifecycle: true (and declare the dependent on the parent), or refer to it by value (drop .references())`)}\n`
876
+ : '') + renderField(table, c, pulled, enums, validates, fkTargets)).join('\n');
815
877
  const constraints = renderConstraintsBlock(table, tableChecks, pulled, fkTargets);
816
878
  // The authz decision renders FIRST — before fields — because it is the first thing a
817
879
  // reviewer must resolve about a model (and where the field-report consumer's codemod
818
880
  // put it, proving the position is mechanical-edit-friendly).
819
881
  const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
820
882
  const writtenBy = writtenByStanza(table, liveAuthz);
821
- const softDelete = lifecycle ? ' lifecycle: true,\n' : softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
883
+ // A guarded table renders its predicate inside sql`…`, escaped so the template's cooked string
884
+ // is the predicate text exactly (the model stores it verbatim; the generated guard hashes it).
885
+ const softDelete = typeof lifecycle === 'string'
886
+ ? ` lifecycle: { deleteWhen: sql\`${lifecycle.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\` },\n`
887
+ : lifecycle ? ' lifecycle: true,\n' : softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
822
888
 
823
889
  // A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
824
890
  // qualification cannot ride in the first argument (that would make the table literally
@@ -828,7 +894,7 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
828
894
  // already uses for defineMaterializedTable.
829
895
  const owner = schemaOf(table.table);
830
896
  const schemaProp = owner === 'public' ? '' : ` schema: ${tsLiteral(owner)},\n`;
831
- return `export const ${modelVarName(table.table)} = defineModel(${tsLiteral(bareName(table.table))}, {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
897
+ return `export const ${modelVarName(table.table)} = defineModel(${tsLiteral(bareName(table.table))}, {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}${dependentsStanza(table, asParent)}\n});`;
832
898
  }
833
899
 
834
900
  /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
@@ -841,6 +907,7 @@ function importHeader(body: string, extra: string[] = []): string {
841
907
  if (/\bcheck\(/.test(body)) symbols.push('check');
842
908
  if (/\bindex\(/.test(body)) symbols.push('index');
843
909
  if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
910
+ if (/\bhasMany\(/.test(body)) symbols.push('hasMany');
844
911
  if (/\bdefineModule\(/.test(body)) symbols.push('defineModule');
845
912
  if (/\bsql`/.test(body)) symbols.push('sql');
846
913
  for (const s of extra) if (!symbols.includes(s)) symbols.push(s);
@@ -945,7 +1012,9 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
945
1012
  const known = knownSet(tables.map((t) => t.table));
946
1013
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
947
1014
 
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)));
1015
+ const dependents = lifecycleDependentsOf(tables, opts.lifecycleTables);
1016
+ const rendered = new Set([...(opts.lifecycleTables ?? [])].map(qualifiedName));
1017
+ 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), opts.lifecycleForeignKeyTables?.has(t.table) || opts.lifecycleTables?.has(t.table), dependents, rendered));
949
1018
  // The derived layer (B5): sequences + views/matviews/functions, after the models they
950
1019
  // reference, before the module wrapper that composes all three.
951
1020
  if (opts.derived?.block) blocks.push(opts.derived.block);
@@ -1021,9 +1090,12 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
1021
1090
  const known = knownSet(tables.map((t) => t.table));
1022
1091
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
1023
1092
 
1093
+ const dependents = lifecycleDependentsOf(tables, opts.lifecycleTables);
1094
+ const rendered = new Set([...(opts.lifecycleTables ?? [])].map(qualifiedName));
1024
1095
  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));
1026
- const crossImports = referencedTables(t, known).map(
1096
+ 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), opts.lifecycleForeignKeyTables?.has(t.table) || opts.lifecycleTables?.has(t.table), dependents, rendered);
1097
+ const children = (dependents.get(qualifiedName(t.table)) ?? []).map((d) => d.child).filter((c) => c !== qualifiedName(t.table));
1098
+ const crossImports = [...new Set([...referencedTables(t, known), ...children])].sort().map(
1027
1099
  (target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
1028
1100
  );
1029
1101
  const header = [importHeader(block), ...crossImports].join('\n');
@@ -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,8 +19,9 @@
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';
23
- import { positionalCheckName } from '@everystack/model';
22
+ import { createHash } from 'node:crypto';
23
+ import type { ModelDescriptor, FieldSpec, SequenceDescriptor, DependentActions, DependentIndexes } from '@everystack/model';
24
+ import { pgType, 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';
26
27
  import { normalizeDeparsedExpr } from './deparse-normal';
@@ -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[] = [];
@@ -126,69 +154,8 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
126
154
  return { uniques, checks, indexes };
127
155
  }
128
156
 
129
- /**
130
- * Postgres type for a field, with the precision drizzle-kit would emit — in
131
- * `format_type`'s vocabulary, because this feeds the IR that diffs against
132
- * introspection. A serial field's *column type* is integer/bigint (`serial` is
133
- * DDL shorthand, not a type — `format_type` never reports it); the sequence
134
- * default is carried separately (see compileTableSchema), and the DDL renderers
135
- * spell the shorthand (see SERIAL_DDL).
136
- */
137
- const PG_TYPE: Record<string, string> = {
138
- text: 'text',
139
- uuid: 'uuid',
140
- integer: 'integer',
141
- boolean: 'boolean',
142
- json: 'json',
143
- jsonb: 'jsonb',
144
- bigint: 'bigint',
145
- serial: 'integer',
146
- bigserial: 'bigint',
147
- real: 'real',
148
- doublePrecision: 'double precision',
149
- timestamptz: 'timestamp with time zone',
150
- // `format_type` canonicalizes a bare `timestamp` column to this spelling, and
151
- // introspection compares against it — so the compiler must emit the same, or an
152
- // unchanged `timestamp` column round-trips to a spurious ALTER COLUMN TYPE.
153
- timestamp: 'timestamp without time zone',
154
- date: 'date',
155
- };
156
-
157
- export function pgType(spec: FieldSpec): string {
158
- // An array is the base type with `[]` appended — `text` → `text[]`, exactly as
159
- // `format_type` spells it.
160
- return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
161
- }
162
-
163
- function scalarPgType(spec: FieldSpec): string {
164
- // Parameterized types build their own canonical string — exactly as `format_type`
165
- // spells it (no space after the comma; `varchar` is `character varying`; a
166
- // precision-only numeric carries scale 0), so an unchanged column round-trips clean.
167
- if (spec.type === 'numeric') {
168
- if (spec.precision != null) return `numeric(${spec.precision},${spec.scale ?? 0})`;
169
- return 'numeric';
170
- }
171
- if (spec.type === 'varchar') {
172
- return spec.length != null ? `character varying(${spec.length})` : 'character varying';
173
- }
174
- if (spec.type === 'enum') {
175
- // The column's type is the enum's own type name; `format_type` reports the same bare
176
- // name, so it round-trips. The `CREATE TYPE` is emitted separately, before the table.
177
- if (!spec.enumName) throw new Error('field bridge: an enum field needs a type name — field.enum(name, values)');
178
- return spec.enumName;
179
- }
180
- if (spec.type === 'pgType') {
181
- // A verbatim type: the declared string IS the canonical `format_type` spelling
182
- // (the factory refused anything else), so it round-trips byte-for-byte.
183
- if (!spec.pgTypeName) throw new Error('field bridge: a pgType field needs its type name — field.pgType(name)');
184
- return spec.pgTypeName;
185
- }
186
- const t = PG_TYPE[spec.type];
187
- if (!t) {
188
- throw new Error(`field bridge: no DDL mapping for field type '${spec.type}' yet`);
189
- }
190
- return t;
191
- }
157
+ // Shared with the runtime destroy wrapper: casts and generated signatures must agree.
158
+ export { pgType } from '@everystack/model';
192
159
 
193
160
  /**
194
161
  * The FK referential actions declared on a field or a table-level `foreignKey(...)`, dropping
@@ -547,7 +514,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
547
514
  * structured form for the brownfield diff. They share the field→type/default/check
548
515
  * helpers, so they never disagree.)
549
516
  */
550
- export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions } = {}): TableSchema {
517
+ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions; dependentIndexes?: DependentIndexes } = {}): TableSchema {
551
518
  const schema = model.schema ?? opts.schema ?? 'public';
552
519
  const entries = Object.entries(model.fields);
553
520
 
@@ -607,7 +574,7 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
607
574
  uniques,
608
575
  checks,
609
576
  foreignKeys: modelForeignKeys(model, opts.dependents),
610
- indexes: tableConstraints.indexes,
577
+ indexes: [...tableConstraints.indexes, ...dependentIndexSpecs(model, opts.dependentIndexes)],
611
578
  };
612
579
  }
613
580