@everystack/cli 0.4.41 → 0.4.44

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.
@@ -40,7 +40,7 @@ export interface ProbeResult {
40
40
  }
41
41
 
42
42
  export interface RedTeamFinding {
43
- severity: 'hole' | 'broken' | 'unprobed' | 'inconclusive' | 'inherited';
43
+ severity: 'hole' | 'broken' | 'unprobed' | 'inconclusive' | 'inherited' | 'elevated';
44
44
  role: string;
45
45
  table: string;
46
46
  command: ProbeCommand;
@@ -244,6 +244,9 @@ export interface InheritedGrant {
244
244
  table: string; // schema-qualified
245
245
  command: ProbeCommand;
246
246
  via: string; // the ancestor role(s) supplying it
247
+ /** True when an ancestor is SUPERUSER or BYPASSRLS — the role bypasses RLS entirely,
248
+ * which makes every probe result for it vacuous. Drives the severity split. */
249
+ elevated: boolean;
247
250
  }
248
251
 
249
252
  /**
@@ -273,7 +276,8 @@ WITH RECURSIVE anc AS (
273
276
  SELECT r.rolname AS role,
274
277
  (c.relnamespace::regnamespace::text || '.' || c.relname) AS "table",
275
278
  p.priv AS command,
276
- string_agg(DISTINCT ar.rolname, ', ' ORDER BY ar.rolname) AS via
279
+ string_agg(DISTINCT ar.rolname, ', ' ORDER BY ar.rolname) AS via,
280
+ bool_or(ar.rolsuper OR ar.rolbypassrls) AS elevated
277
281
  FROM pg_class c
278
282
  CROSS JOIN (VALUES ('SELECT'),('INSERT'),('UPDATE'),('DELETE')) AS p(priv)
279
283
  JOIN pg_roles r ON r.rolname = ANY(${pgArrayLiteral(roles)})
@@ -296,6 +300,10 @@ export function toInheritedGrant(row: any): InheritedGrant {
296
300
  table: String(row.table),
297
301
  command: String(row.command).toUpperCase() as ProbeCommand,
298
302
  via: String(row.via),
303
+ // An ancestor that is SUPERUSER or BYPASSRLS makes every probe result for this role
304
+ // vacuous — default-deny cannot be falsified and RLS assertions are void. That is a
305
+ // different finding from inheriting an ordinary role's grants, and it is graded so.
306
+ elevated: row.elevated === true || row.elevated === 't' || row.elevated === 'true',
299
307
  };
300
308
  }
301
309
 
@@ -329,8 +337,8 @@ export function evaluateRedTeam(
329
337
  const findings: RedTeamFinding[] = [];
330
338
  // Privileges explained by role membership rather than a direct grant. Keyed so the
331
339
  // per-result lookup is exact; reported once per role, not once per table × command.
332
- const inheritedBy = new Map(inherited.map((g) => [`${g.role}${g.table}${g.command}`, g]));
333
- const inheritedRoles = new Map<string, { via: string; count: number }>();
340
+ const inheritedBy = new Map(inherited.map((g) => [`${g.role}\u0000${g.table}\u0000${g.command}`, g]));
341
+ const inheritedRoles = new Map<string, { via: string; count: number; elevated: boolean }>();
334
342
 
335
343
  for (const r of results) {
336
344
  const table = tablesByName.get(r.table);
@@ -347,12 +355,12 @@ export function evaluateRedTeam(
347
355
  findings.push({ severity: 'inconclusive', role: r.role, table: r.table, command: r.command,
348
356
  detail: `${r.command} on ${r.table} failed before the privilege check — this probe proves nothing about ${r.role}` });
349
357
  } else if (allowed && !granted) {
350
- const via = inheritedBy.get(`${r.role}${r.table}${r.command}`);
358
+ const via = inheritedBy.get(`${r.role}\u0000${r.table}\u0000${r.command}`);
351
359
  if (via) {
352
360
  // Real, but it is one fact about a role, not N facts about N tables.
353
361
  const seen = inheritedRoles.get(r.role);
354
- if (seen) seen.count += 1;
355
- else inheritedRoles.set(r.role, { via: via.via, count: 1 });
362
+ if (seen) { seen.count += 1; seen.elevated ||= via.elevated; }
363
+ else inheritedRoles.set(r.role, { via: via.via, count: 1, elevated: via.elevated });
356
364
  } else {
357
365
  findings.push({ severity: 'hole', role: r.role, table: r.table, command: r.command,
358
366
  detail: `${r.role} can ${r.command} ${r.table} but the contract grants no such privilege (enforcement exceeds declaration)` });
@@ -366,9 +374,23 @@ export function evaluateRedTeam(
366
374
  // One line per inheriting role. The membership IS the finding — a role that reaches
367
375
  // tables through `GRANT parent TO child` is worth stating out loud once, and worth not
368
376
  // stating N times.
369
- for (const [role, { via, count }] of inheritedRoles) {
370
- findings.push({ severity: 'inherited', role, table: '', command: 'SELECT',
371
- detail: `${role} holds ${count} undeclared privilege(s) INHERITED via membership in ${via} — not a direct grant, so the contract cannot see them. Intentional for a migrator/owner role; a finding if it is an application role` });
377
+ for (const [role, { via, count, elevated }] of inheritedRoles) {
378
+ // The severity axis is WHAT the ancestor is, not that inheritance happened.
379
+ //
380
+ // An ordinary ancestor's grants are finite, enumerable, and still RLS-subject: the
381
+ // database enforces declared ∪ inherited, which is a completeness note.
382
+ //
383
+ // A SUPERUSER/BYPASSRLS ancestor is categorically different. The role bypasses every
384
+ // policy, its effective access is unbounded, and every probe result for it is vacuous —
385
+ // this tool cannot vouch for the role at all. Reporting that at the same level as
386
+ // "could not SET ROLE into this role" is what let an adopter read the run as clean.
387
+ if (elevated) {
388
+ findings.push({ severity: 'elevated', role, table: '', command: 'SELECT',
389
+ detail: `${role} inherits ${via}, which is SUPERUSER or BYPASSRLS — it bypasses every RLS policy and holds privileges no ACL lists (${count} seen here). Every probe result for ${role} is vacuous: this run cannot vouch for it` });
390
+ } else {
391
+ findings.push({ severity: 'inherited', role, table: '', command: 'SELECT',
392
+ detail: `${role} holds ${count} undeclared privilege(s) INHERITED via membership in ${via} — not a direct grant, so the contract cannot see them. Intentional for a migrator/owner role; a finding if it is an application role` });
393
+ }
372
394
  }
373
395
  return findings;
374
396
  }
@@ -252,12 +252,20 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
252
252
  const unprobed = findings.filter((f) => f.severity === 'unprobed');
253
253
  const inconclusive = findings.filter((f) => f.severity === 'inconclusive');
254
254
  const inheritedFindings = findings.filter((f) => f.severity === 'inherited');
255
+ // An ancestor that is SUPERUSER/BYPASSRLS makes every probe for that role vacuous. It is
256
+ // NOT an enforcement hole — the database enforces exactly what the grants say — so it does
257
+ // not fail the gate, which would go red on the many legitimate elevated migration roles
258
+ // and teach adopters to stop running it. But it is warn-grade, and it must qualify the
259
+ // success line: people read the checkmark and stop, which is exactly how the first adopter
260
+ // came away thinking a real finding had been dismissed.
261
+ const elevatedFindings = findings.filter((f) => f.severity === 'elevated');
255
262
  const gaps = gapRows.map(toGrantGap);
256
263
 
257
264
  console.log('');
258
265
  for (const f of holes) fail(`[HOLE] ${f.detail}`);
259
266
  for (const f of broken) warn(`[BROKEN] ${f.detail}`);
260
267
  for (const g of gaps) fail(`[GRANT-GAP] ${g.secdef} calls ${g.helper} but its owner cannot EXECUTE it (42501 in prod once ownership is normalized)`);
268
+ for (const f of elevatedFindings) warn(`[ELEVATED] ${f.detail}`);
261
269
  for (const f of inheritedFindings) info(`[inherited] ${f.detail}`);
262
270
  if (inconclusive.length) {
263
271
  // Never a pass and never a failure — a probe that proved nothing, said out loud so it
@@ -273,7 +281,15 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
273
281
  console.log('');
274
282
 
275
283
  if (holes.length === 0 && broken.length === 0 && gaps.length === 0) {
276
- success(`db:authz:test ${venueLabel} enforces the contract (${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete)`);
284
+ // "default-deny holds" is a FALSE UNIVERSAL when a role bypasses RLS. Any claim this run
285
+ // could not verify for a role is excluded from the claim in the sentence that makes it,
286
+ // so a reader who sees only the green line still learns one role is outside it.
287
+ const exempt = elevatedFindings.map((f) => f.role).sort();
288
+ const scope = exempt.length
289
+ ? `${contract.tables.length} tables probed; default-deny holds EXCEPT for ${exempt.join(', ')} — `
290
+ + `${exempt.length === 1 ? 'that role bypasses RLS by inheritance and this run cannot vouch for it' : 'those roles bypass RLS by inheritance and this run cannot vouch for them'}`
291
+ : `${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete`;
292
+ success(`db:authz:test — ${venueLabel} enforces the contract (${scope})`);
277
293
  process.exit(0);
278
294
  }
279
295
  fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). ${venueLabel} does not enforce the contract.`);
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import fs from 'node:fs/promises';
22
+ import { spawnSync } from 'node:child_process';
22
23
  import type { ModelDescriptor } from '@everystack/model';
23
24
  import { introspectContract, type QueryRunner } from '../authz-contract.js';
24
25
  import { introspectSchema } from '../schema-introspect.js';
@@ -42,6 +43,23 @@ import { step, success, fail, info, warn } from '../output.js';
42
43
 
43
44
  const DEFAULT_OUT = 'db.plan.json';
44
45
 
46
+ /**
47
+ * True when git would ignore `file` — false when it would happily commit it, and ALSO false
48
+ * outside a repo or when git is unavailable, because the warning is only ever advice and a
49
+ * missing git must not turn a plan mint into a failure.
50
+ *
51
+ * `check-ignore` exits 0 for ignored, 1 for not-ignored, 128 for "not a repo".
52
+ */
53
+ function isGitIgnored(file: string): boolean {
54
+ try {
55
+ const r = spawnSync('git', ['check-ignore', '-q', file], { stdio: 'ignore' });
56
+ if (r.error || r.status === 128) return true; // no repo / no git — nothing to warn about
57
+ return r.status === 0;
58
+ } catch {
59
+ return true;
60
+ }
61
+ }
62
+
45
63
  export async function dbPlanCommand(flags: Record<string, string>): Promise<void> {
46
64
  let dbSource: DbSource;
47
65
  try {
@@ -161,6 +179,12 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
161
179
  if (out !== '-') {
162
180
  success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
163
181
  warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
182
+ // "Do NOT commit" is advice; git is what enforces it. The default lands in the app
183
+ // root, so an adopter following the happy path gets an untracked, unignored artifact
184
+ // sitting next to their source with only a log line between it and a `git add .`.
185
+ if (!isGitIgnored(out)) {
186
+ warn(`${out} is NOT gitignored — add it, or the next \`git add .\` commits the plan: echo '${out}' >> .gitignore`);
187
+ }
164
188
  }
165
189
  } finally {
166
190
  await end?.();
@@ -83,6 +83,24 @@ export function keyCandidates(row: Record<string, unknown>, cols: string[]): str
83
83
  return cols.filter((_, i) => Number(row[`c${i}`]) === n && Number(row[`d${i}`]) === n);
84
84
  }
85
85
 
86
+ /**
87
+ * The specifier the generated `index.ts` uses to import the `--derived-out` file.
88
+ *
89
+ * Both paths are resolved against CWD first, so this works whichever way either was written
90
+ * (`db/models` + `db/models/derived.ts`, or absolute, or `./db/models/`). Extension stripped
91
+ * (the barrel's own imports are extensionless), separators normalized for Windows, and a
92
+ * same-directory result gets the explicit `./` a bare `derived` would lack.
93
+ *
94
+ * `--out` may also name a single `.ts` file, in which case the barrel IS that file and the
95
+ * specifier is relative to its directory.
96
+ */
97
+ export function derivedImportSpecifier(out: string, derivedOut: string): string {
98
+ const barrelDir = out.endsWith('.ts') ? path.dirname(path.resolve(out)) : path.resolve(out);
99
+ const target = path.resolve(derivedOut).replace(/\.ts$/, '');
100
+ const rel = path.relative(barrelDir, target).split(path.sep).join('/');
101
+ return rel.startsWith('.') ? rel : `./${rel}`;
102
+ }
103
+
86
104
  /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
87
105
  function lambdaRunner(region: string, fn: string): QueryRunner {
88
106
  return async (sql: string) => {
@@ -269,15 +287,26 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
269
287
  process.exit(0);
270
288
  }
271
289
  }
272
- // With --derived-out, the embedded copy would duplicate every descriptor — the
273
- // models output carries models only.
290
+ // With --derived-out, the embedded copy would duplicate every descriptor — the models
291
+ // output carries models only, and the barrel IMPORTS the layer from its own file. It used
292
+ // to carry neither, so the same command that wrote 120 descriptors emitted a
293
+ // `defineModule({ models })` that excluded every one of them, and db:plan then compared
294
+ // against a fraction of the database while printing a confident statement count.
274
295
  const embeddedDerived = derivedOut ? undefined : derived;
296
+ const externalDerived = derivedOut && flags.out
297
+ ? {
298
+ specifier: derivedImportSpecifier(flags.out, derivedOut),
299
+ sequences: derived.sequenceNames.length > 0,
300
+ materializedTables: derived.materializedTableNames.length > 0,
301
+ derived: derived.names.length > 0,
302
+ }
303
+ : undefined;
275
304
 
276
305
  let source: string;
277
306
  if (flags.out && !flags.out.endsWith('.ts')) {
278
307
  // A directory: one file per model + index.ts — the default shape for a real app.
279
308
  const dir = path.resolve(flags.out);
280
- const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
309
+ const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived, externalDerived });
281
310
  try {
282
311
  await fs.mkdir(dir, { recursive: true });
283
312
  const written = new Set(files.map((f) => f.file));
@@ -30,7 +30,7 @@ import type { SchemaSnapshot } from './schema-introspect.js';
30
30
  import type { AuthzContract } from './authz-contract.js';
31
31
  import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
32
32
  import { compileTableContract } from './authz-compile.js';
33
- import { classifyGeneratedStatements, classifyDestructive, renderStatementHistogram } from './state-apply.js';
33
+ import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply.js';
34
34
  import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
35
35
  import { compileDeclaredState } from './declared-diff.js';
36
36
  import { compileTableRenames, compileTableMoves } from './schema-compile.js';
@@ -39,7 +39,8 @@ export const PLAN_VERSION = 2;
39
39
 
40
40
  /** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
41
41
  export interface PlanClassification {
42
- /** Executable statements that lose no data. */
42
+ /** Statements that positively ADD. Counted from a matcher, never `total - destructive` —
43
+ * computing it by subtraction made "safe" the default for anything unrecognized. */
43
44
  additive: number;
44
45
  /** `DROP TABLE/COLUMN/TYPE` — the data is gone. */
45
46
  drops: number;
@@ -48,6 +49,12 @@ export interface PlanClassification {
48
49
  /** REVOKEs against a grantee no model declares — nothing re-derives that access.
49
50
  * Optional: a plan minted before this classification existed has no field for it. */
50
51
  strips?: number;
52
+ /** `DROP POLICY` / `REVOKE` / `DISABLE RLS` — authorization removed, no row lost. Not
53
+ * destructive (it re-declares from the models) and emphatically not additive. */
54
+ authzRemovals?: number;
55
+ /** Statements the classifier does not recognize. Never additive — a plan carrying these
56
+ * has not been fully described and a human should read them before it is applied. */
57
+ unclassified?: number;
51
58
  }
52
59
 
53
60
  export interface EdgePlan {
@@ -177,7 +184,7 @@ export function mintEdgePlan(
177
184
  for (const g of Object.keys(t.grants)) declaredGrantees.add(g);
178
185
  for (const g of Object.keys(t.columnGrants ?? {})) declaredGrantees.add(g);
179
186
  }
180
- const breakdown = classifyDestructive(classified.executable, { declaredGrantees });
187
+ const breakdown = partitionStatements(classified.executable, { declaredGrantees });
181
188
  const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
182
189
 
183
190
  return {
@@ -189,10 +196,14 @@ export function mintEdgePlan(
189
196
  executable: classified.executable.length,
190
197
  destructive,
191
198
  classification: {
192
- additive: classified.executable.length - destructive,
199
+ // Counted, never inferred by subtraction — see partitionStatements. A statement nobody
200
+ // recognized is `unclassified`, and it must never be able to present as additive.
201
+ additive: breakdown.additive.length,
193
202
  drops: breakdown.drops.length,
194
203
  narrowings: breakdown.narrowings.length,
195
204
  ...(breakdown.strips.length > 0 ? { strips: breakdown.strips.length } : {}),
205
+ ...(breakdown.authzRemovals.length > 0 ? { authzRemovals: breakdown.authzRemovals.length } : {}),
206
+ ...(breakdown.unclassified.length > 0 ? { unclassified: breakdown.unclassified.length } : {}),
196
207
  },
197
208
  notices: classified.notices.length,
198
209
  unmodeled: unmodeledTables(models, snapshot),
@@ -201,6 +212,35 @@ export function mintEdgePlan(
201
212
  };
202
213
  }
203
214
 
215
+ /**
216
+ * Tables this plan leaves with no SELECT-admitting policy — the "goes dark" set.
217
+ *
218
+ * A `DROP POLICY` is cheap to reverse and loses no row, which is why it is not gated. But when
219
+ * a plan drops every policy that admitted a read and creates none in its place, RLS is still
220
+ * enabled and the grant is still there, so the table returns ZERO rows to that role. On a real
221
+ * adoption plan that was 11 tables, including the users table, and it printed no notice at all.
222
+ *
223
+ * Deliberately conservative: it only names a table when the plan drops a read-admitting policy
224
+ * and adds none back for that table. It cannot know what other policies exist live, so it
225
+ * under-reports rather than crying wolf.
226
+ */
227
+ export function tablesLeftWithoutARead(statements: readonly string[]): string[] {
228
+ const dropped = new Map<string, number>();
229
+ const created = new Set<string>();
230
+ for (const statement of statements) {
231
+ const head = (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '').trim();
232
+ let m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?\S+\s+ON\s+(\S+?);?$/i.exec(head);
233
+ if (m) {
234
+ dropped.set(m[1], (dropped.get(m[1]) ?? 0) + 1);
235
+ continue;
236
+ }
237
+ m = /^CREATE\s+POLICY\s+\S+\s+ON\s+(\S+)/i.exec(head);
238
+ // Only a SELECT-admitting policy restores a read; an INSERT-only policy does not.
239
+ if (m && /\bFOR\s+(SELECT|ALL)\b/i.test(head)) created.add(m[1]);
240
+ }
241
+ return [...dropped.keys()].filter((t) => !created.has(t)).sort();
242
+ }
243
+
204
244
  /** The plan's content address — recorded as `plan_ref` on the schema_log row. */
205
245
  export function planHash(plan: EdgePlan): string {
206
246
  return createHash('sha256').update(stableStringify(plan)).digest('hex');
@@ -243,6 +283,24 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
243
283
  lines.push(`! ${ddl.trim()}`);
244
284
  }
245
285
  }
286
+ // Authorization removed loses no row, so it is not gated — but it decides who can SEE the
287
+ // rows, and a plan that drops a table's only read policy leaves that table returning nothing.
288
+ // 11 tables went dark in a real adoption plan that printed "0 notice(s)".
289
+ const { authzRemovals, unclassified } = partitionStatements(classifyGeneratedStatements(plan.statements).executable);
290
+ const dark = tablesLeftWithoutARead(plan.statements);
291
+ if (authzRemovals.length > 0) {
292
+ lines.push(`! ${authzRemovals.length} statement(s) REMOVE authorization (policies, grants, RLS). No data is lost; who can read it changes.`);
293
+ if (dark.length > 0) {
294
+ lines.push(`! ${dark.length} table(s) end this plan with NO read policy and RLS still on — they will return zero rows: ${dark.join(', ')}`);
295
+ }
296
+ }
297
+ if (unclassified.length > 0) {
298
+ lines.push(`! ${unclassified.length} statement(s) could NOT be classified — read them before applying. They are not counted as additive:`);
299
+ for (const statement of unclassified) {
300
+ const ddl = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? statement;
301
+ lines.push(`! ${ddl.trim()}`);
302
+ }
303
+ }
246
304
  if (plan.unmodeled.length > 0) {
247
305
  lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
248
306
  }
@@ -81,6 +81,10 @@ export interface RenderOptions {
81
81
  /** The rendered derived layer (B5) — rides the barrel: block after the models,
82
82
  * sequences/derived arrays on the module wrapper, symbols on the import header. */
83
83
  derived?: DerivedRenderResult;
84
+ /** The `--derived-out` case: the layer lives in its own file, so the barrel imports and
85
+ * wires it instead of embedding it. Mutually exclusive with `derived` in practice —
86
+ * passing neither is what silently produced `defineModule({ models })`. */
87
+ externalDerived?: ExternalDerived;
84
88
  }
85
89
 
86
90
  /**
@@ -88,40 +92,74 @@ export interface RenderOptions {
88
92
  * dominant brownfield shape (public reference data, admin-managed). Anything else is a
89
93
  * per-model edit — the decision belongs in the file, not in a flag grammar.
90
94
  */
91
- export const ABILITY_PRESETS: Record<string, string> = {
92
- 'public-read': `abilities: [can('read'), can('manage', { role: 'admin' })],`,
95
+ export const ABILITY_PRESETS: Record<string, string[]> = {
96
+ 'public-read': [`can('read')`, `can('manage', { role: 'admin' })`],
93
97
  };
94
98
 
95
99
  /**
96
- * The scaffold stanza for one model: the commented decision surface (guide text says the
97
- * same thing the db:check gate failure says one voice, two doorways), or a preset
98
- * stamped uncommented. An unknown preset throws grants are authored, never guessed.
100
+ * A rendered ability that is a PUBLIC read anon-visible, the only shape the soft-delete
101
+ * guard ever applied to. Matches `defineModel`'s own rule: action `read`, with no `role`,
102
+ * `owner`, or `via` narrowing it. Tested per-ability (never against the whole joined stanza)
103
+ * so one ability's `role:` can never mask another's public read.
99
104
  */
100
- function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): string {
105
+ function isPublicReadAbility(expr: string): boolean {
106
+ return /^can\('read'/.test(expr.trim()) && !/\b(role|owner|via)\s*:/.test(expr);
107
+ }
108
+
109
+ /**
110
+ * The scaffold stanza for one model, plus whether it declares a public read — the renderer
111
+ * needs the second fact to decide the `softDelete` line, and it must come from the STRUCTURED
112
+ * abilities, not a regex over the joined text (a live predicate can span lines and carry its
113
+ * own braces). An unknown preset throws — grants are authored, never guessed.
114
+ */
115
+ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): { text: string; publicRead: boolean } {
101
116
  if (mode === 'live') {
102
117
  const contract = table && liveAuthz?.get(table.table);
103
118
  if (!contract) {
104
- return [
105
- ' // no live authorization found for this table — nothing was granted, so nothing is',
106
- ' // rendered. Author the read model deliberately, or leave it internal:',
107
- ' // private: true,',
108
- ].join('\n');
119
+ return {
120
+ text: [
121
+ ' // no live authorization found for this table — nothing was granted, so nothing is',
122
+ ' // rendered. Author the read model deliberately, or leave it internal:',
123
+ ' // private: true,',
124
+ ].join('\n'),
125
+ publicRead: false,
126
+ };
109
127
  }
110
- return renderDerivedAbilities(deriveAbilities(contract));
128
+ const derived = deriveAbilities(contract);
129
+ return { text: renderDerivedAbilities(derived), publicRead: derived.abilities.some(isPublicReadAbility) };
111
130
  }
112
131
  if (mode === 'commented') {
113
- return [
114
- ' // Declare the read model — db:check fails this model until its authz is authored:',
115
- ` // abilities: [can('read')], // public data`,
116
- ` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
117
- ' // private: true, // not part of the data API',
118
- ].join('\n');
132
+ return {
133
+ text: [
134
+ ' // Declare the read model — db:check fails this model until its authz is authored:',
135
+ ` // abilities: [can('read')], // public data`,
136
+ ` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
137
+ ' // private: true, // not part of the data API',
138
+ ].join('\n'),
139
+ // Nothing is stamped uncommented, so the model declares no read at all yet.
140
+ publicRead: false,
141
+ };
119
142
  }
120
143
  const preset = ABILITY_PRESETS[mode];
121
144
  if (!preset) {
122
145
  throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
123
146
  }
124
- return ` ${preset}`;
147
+ return { text: ` abilities: [${preset.join(', ')}],`, publicRead: preset.some(isPublicReadAbility) };
148
+ }
149
+
150
+ /**
151
+ * The `softDelete` line, when the model would otherwise fail to define.
152
+ *
153
+ * `defineModel` refuses to guess for a table that has `deleted_at` AND a public read: the
154
+ * guard decides what anonymous users can see, and neither default is safe. A pull renders
155
+ * `false` — LIVE reality is the truth being transcribed, and no live policy carries a guard
156
+ * nobody wrote. The comment says how to get the other one, so the decision is visible in the
157
+ * file rather than buried in a compiler convention.
158
+ */
159
+ function softDeleteStanza(table: TableSchema, publicRead: boolean): string {
160
+ const hasColumn = table.columns.some((c) => c.name === 'deleted_at');
161
+ if (!hasColumn || !publicRead) return '';
162
+ return ` softDelete: false, // live reality: no policy filters deleted_at. true excludes soft-deleted rows from public reads.\n`;
125
163
  }
126
164
 
127
165
  /** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
@@ -448,8 +486,9 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
448
486
  // reviewer must resolve about a model (and where the field-report consumer's codemod
449
487
  // put it, proving the position is mechanical-edit-friendly).
450
488
  const stanza = abilitiesStanza(abilities, table, liveAuthz);
489
+ const softDelete = softDeleteStanza(table, stanza.publicRead);
451
490
 
452
- return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza}\n fields: {\n${fields}\n },${constraints}\n});`;
491
+ return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
453
492
  }
454
493
 
455
494
  /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
@@ -470,22 +509,42 @@ function importHeader(body: string, extra: string[] = []): string {
470
509
  return imports.join('\n');
471
510
  }
472
511
 
473
- /** The barrel's module wrapper: models always; sequences/derived when the pull found any (B5). */
474
- function moduleFooter(modelNames: string[], derived?: DerivedRenderResult, multiline = false): string {
512
+ /**
513
+ * The barrel's module wrapper: models always; sequences/derived when the pull found any (B5).
514
+ *
515
+ * `external` is the `--derived-out` case — the descriptors live in their own file, so the
516
+ * barrel IMPORTS the arrays rather than re-declaring them. Before this the barrel simply
517
+ * omitted them, which meant the same pull that wrote 120 descriptors emitted a
518
+ * `defineModule({ models })` that excluded every one, and `db:plan` then compared against a
519
+ * fraction of the database while reporting a confident statement count. A plan scoped to
520
+ * something narrower than the reader assumes is the same false-completeness failure as a
521
+ * classification that defaults to safe.
522
+ */
523
+ function moduleFooter(
524
+ modelNames: string[],
525
+ derived?: DerivedRenderResult,
526
+ multiline = false,
527
+ external?: ExternalDerived,
528
+ ): string {
475
529
  // A materialized table (--matviews-as-tables) is a MODEL — its block rides the derived
476
530
  // render (topo-ordered with the objects around it) but its name belongs in `models`.
477
- const allModels = [...modelNames, ...(derived?.materializedTableNames ?? [])];
478
- const models = multiline
479
- ? `export const models = [\n${allModels.map((n) => ` ${n},`).join('\n')}\n];`
480
- : `export const models = [${allModels.join(', ')}];`;
481
- const parts = [models];
531
+ const hasMaterialized = external ? external.materializedTables : Boolean(derived?.materializedTableNames.length);
532
+ const inlineNames = external ? [] : (derived?.materializedTableNames ?? []);
533
+ const allModels = [...modelNames, ...inlineNames];
534
+ const rendered = multiline
535
+ ? `export const models = [\n${allModels.map((n) => ` ${n},`).join('\n')}\n${external && hasMaterialized ? ' ...materializedTables,\n' : ''}];`
536
+ : `export const models = [${[...allModels, ...(external && hasMaterialized ? ['...materializedTables'] : [])].join(', ')}];`;
537
+ const parts = [rendered];
482
538
  const keys = ['models'];
483
- if (derived?.sequenceNames.length) {
484
- parts.push(`export const sequences = [${derived.sequenceNames.join(', ')}];`);
539
+
540
+ const hasSequences = external ? external.sequences : Boolean(derived?.sequenceNames.length);
541
+ const hasDerived = external ? external.derived : Boolean(derived?.names.length);
542
+ if (hasSequences) {
543
+ if (!external) parts.push(`export const sequences = [${derived!.sequenceNames.join(', ')}];`);
485
544
  keys.push('sequences');
486
545
  }
487
- if (derived?.names.length) {
488
- parts.push(`export const derived = [${derived.names.join(', ')}];`);
546
+ if (hasDerived) {
547
+ if (!external) parts.push(`export const derived = [${derived!.names.join(', ')}];`);
489
548
  keys.push('derived');
490
549
  }
491
550
  parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
@@ -493,6 +552,24 @@ function moduleFooter(modelNames: string[], derived?: DerivedRenderResult, multi
493
552
  return parts.join('\n\n');
494
553
  }
495
554
 
555
+ /** What an external (`--derived-out`) derived file exports, so the barrel can wire it. */
556
+ export interface ExternalDerived {
557
+ /** Import specifier as written in the barrel, e.g. `'./derived'`. */
558
+ specifier: string;
559
+ sequences: boolean;
560
+ materializedTables: boolean;
561
+ derived: boolean;
562
+ }
563
+
564
+ /** The named exports to pull out of an external derived file, in a stable order. */
565
+ function externalDerivedNames(e: ExternalDerived): string[] {
566
+ const names: string[] = [];
567
+ if (e.derived) names.push('derived');
568
+ if (e.materializedTables) names.push('materializedTables');
569
+ if (e.sequences) names.push('sequences');
570
+ return names;
571
+ }
572
+
496
573
  /**
497
574
  * Render a whole snapshot as a single Models module: the import, one block per table (scoped
498
575
  * to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
@@ -575,14 +652,21 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
575
652
  // their own model.
576
653
  const names = tables.map((t) => modelVarName(t.table));
577
654
  const modelSymbols = ['defineModule', ...(opts.derived?.imports ?? [])];
655
+ const ext = opts.externalDerived;
656
+ const extNames = ext ? externalDerivedNames(ext) : [];
578
657
  const index = [
579
658
  [
580
659
  `import { ${modelSymbols.join(', ')} } from '@everystack/model';`,
660
+ // The --derived-out layer, imported rather than re-declared — the barrel must WIRE it
661
+ // or db:plan silently compares against models only.
662
+ ...(extNames.length ? [`import { ${extNames.join(', ')} } from '${ext!.specifier}';`] : []),
581
663
  ...names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`),
582
664
  ].join('\n'),
583
665
  `export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
666
+ // Re-exported so the barrel remains the one place that describes the database.
667
+ ...(extNames.length ? [`export { ${extNames.join(', ')} };`] : []),
584
668
  ...(opts.derived?.block ? [opts.derived.block] : []),
585
- moduleFooter(names, opts.derived, true),
669
+ moduleFooter(names, opts.derived, true, ext),
586
670
  ].join('\n\n');
587
671
  files.push({ file: 'index.ts', source: index + '\n' });
588
672
 
@@ -223,12 +223,35 @@ function defaultExpr(spec: FieldSpec): string | null {
223
223
  }
224
224
 
225
225
  /**
226
- * A Postgres array literal for a default `[]` `'{}'`, `['a','b']` `'{"a","b"}'`.
226
+ * True when PostgreSQL's own `array_out` would quote this element: it is empty, it is the
227
+ * literal `NULL` (which unquoted means the SQL null), or it contains a delimiter, a brace, a
228
+ * quote, a backslash, or whitespace.
229
+ */
230
+ function needsArrayQuote(v: string): boolean {
231
+ return v === '' || /^NULL$/i.test(v) || /[{},"\\\s]/.test(v);
232
+ }
233
+
234
+ /**
235
+ * A Postgres array literal for a default — `[]` → `'{}'`, `['User']` → `'{User}'`,
236
+ * `['a b']` → `'{"a b"}'`.
237
+ *
238
+ * QUOTING MATTERS: this must match `array_out` exactly, because the value round-trips
239
+ * through the live catalog. Quoting every element unconditionally produced `'{"User"}'`
240
+ * where PostgreSQL deparses `'{User}'`, so the declared and live defaults never compared
241
+ * equal and `db:generate` re-emitted the same `SET DEFAULT` on every run, forever.
242
+ *
243
+ * Backslashes escape before quotes, or `\` would become `\\"` — the escaping bug the
244
+ * previous version also carried (it escaped `"` and left `\` alone).
245
+ *
227
246
  * The introspected form carries a `::type[]` cast (`'{}'::text[]`) that `normalizeDefault`
228
247
  * strips, so the bare literal round-trips.
229
248
  */
230
249
  function arrayLiteral(arr: unknown[]): string {
231
- const elems = arr.map((v) => (typeof v === 'string' ? `"${v.replace(/"/g, '\\"')}"` : String(v)));
250
+ const elems = arr.map((v) => {
251
+ if (typeof v !== 'string') return String(v);
252
+ if (!needsArrayQuote(v)) return v;
253
+ return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
254
+ });
232
255
  return `'{${elems.join(',')}}'`;
233
256
  }
234
257