@everystack/cli 0.4.43 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.43",
3
+ "version": "0.4.44",
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>",
@@ -109,7 +109,7 @@
109
109
  "structured-headers": "1.0.1",
110
110
  "tsx": "4.21.0",
111
111
  "typescript": "5.9.3",
112
- "@everystack/model": "0.4.8"
112
+ "@everystack/model": "0.4.9"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -66,7 +66,11 @@ function claimExpr(claim: string): string {
66
66
  * uuid: (author_id = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))::uuid)
67
67
  * text: (user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))
68
68
  */
69
- function ownerPredicate(sqlColumn: string, type: string, claim: string): string {
69
+ function ownerPredicate(sqlColumn: string, type: string, claim: string, expr: string | null = null): string {
70
+ // A declared `ownerExpr` is the accessor the database already has (`auth.user_id()`),
71
+ // emitted verbatim and uncast: the function returns the owner column's type, and a cast
72
+ // the deparser would not print is a DROP + CREATE on every owner policy in the schema.
73
+ if (expr) return `(${sqlColumn} = ${expr})`;
70
74
  const ce = claimExpr(claim);
71
75
  if (type === 'text') return `(${sqlColumn} = ${ce})`;
72
76
  return `(${sqlColumn} = (${ce})::${castForType(type)})`;
@@ -89,7 +93,7 @@ function softDeleteGuard(sqlColumn: string): string {
89
93
  * WHERE (uploads.user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))))
90
94
  */
91
95
  function viaPredicate(v: ViaRef): string {
92
- const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim);
96
+ const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim, v.parentOwner.expr);
93
97
  return `(${v.fkColumn} IN ( SELECT ${v.parentTable}.${v.parentPk}\n FROM ${v.parentTable}\n WHERE ${parentOwner}))`;
94
98
  }
95
99
 
@@ -103,6 +107,8 @@ interface OwnerRef {
103
107
  type: string;
104
108
  /** JWT claim holding the owner id. */
105
109
  claim: string;
110
+ /** A declared accessor for the caller's id (`auth.user_id()`), or null for the inline claim. */
111
+ expr: string | null;
106
112
  }
107
113
 
108
114
  /**
@@ -135,6 +141,7 @@ function resolveOwner(model: ModelDescriptor): OwnerRef | null {
135
141
  sqlColumn: toSnakeCase(fieldKey),
136
142
  type: model.fields[fieldKey]?.spec.type ?? 'uuid',
137
143
  claim: ownerAbility.condition.userField ?? 'sub',
144
+ expr: rawExpr(ownerAbility.condition.ownerExpr, `${model.table}: can({ ownerExpr })`),
138
145
  };
139
146
  }
140
147
 
@@ -244,6 +251,23 @@ function rawPredicate(value: unknown, where: string): string | null {
244
251
  return parenthesizeOnce(text.trim());
245
252
  }
246
253
 
254
+ /**
255
+ * Unwrap a sql fragment to its text, UNparenthesized — for an expression that is not a
256
+ * predicate. `ownerExpr` is the right-hand side of `<col> = …`; parenthesizing it would
257
+ * emit `(user_id = (auth.user_id()))`, which is not what the deparser prints, and the
258
+ * whole point of declaring it is to match the live text exactly.
259
+ */
260
+ function rawExpr(value: unknown, where: string): string | null {
261
+ if (value == null) return null;
262
+ const text = (value as { sql?: unknown }).sql;
263
+ if (typeof text !== 'string' || !text.trim()) {
264
+ throw new Error(
265
+ `${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — the expression that decides who owns a row is authored, never stringly assembled.`,
266
+ );
267
+ }
268
+ return text.trim();
269
+ }
270
+
247
271
  /** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
248
272
  function andPredicates(...parts: (string | null)[]): string | null {
249
273
  const real = parts.filter((p): p is string => Boolean(p) && p !== '(true)' && p !== 'true');
@@ -270,7 +294,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
270
294
  // parent subquery. Both drive the same owner-gated policies (select/insert/
271
295
  // update/delete_own); only the predicate text differs.
272
296
  const rowPred = owner
273
- ? ownerPredicate(owner.sqlColumn, owner.type, owner.claim)
297
+ ? ownerPredicate(owner.sqlColumn, owner.type, owner.claim, owner.expr)
274
298
  : via
275
299
  ? viaPredicate(via)
276
300
  : null;
@@ -298,6 +322,33 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
298
322
  }
299
323
  return null;
300
324
  };
325
+ /**
326
+ * The predicate on the PUBLIC read — a read ability with neither a role nor a row scope.
327
+ *
328
+ * Read separately from {@link ownerReadPred} because the two are different BRANCHES of
329
+ * the same authenticated policy. Taking both from one ordered lookup made the anon
330
+ * policy depend on the order the abilities happened to be declared in: put the owner
331
+ * read first and `anon` inherited the owner branch's guard as its whole public rule.
332
+ */
333
+ const publicReadPred = (): string | null => {
334
+ for (const a of model.abilities) {
335
+ if (a.action !== 'read' || a.condition.role || rowScoped(a)) continue;
336
+ const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read')`);
337
+ if (p) return p;
338
+ }
339
+ return null;
340
+ };
341
+
342
+ /** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
343
+ const ownerReadPred = (): string | null => {
344
+ for (const a of model.abilities) {
345
+ if (a.action !== 'read' || !rowScoped(a) || isColumnRead(a)) continue;
346
+ const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
347
+ if (p) return p;
348
+ }
349
+ return null;
350
+ };
351
+
301
352
  /** The WRITE half, when the author declared one distinct from the read half. */
302
353
  const checkFor = (action: Ability['action']): string | null => {
303
354
  for (const a of model.abilities) {
@@ -334,18 +385,37 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
334
385
  if (hasPublicRead) {
335
386
  // public read on a soft-delete table — split per role; authenticated owners
336
387
  // also see their own (soft-deleted) rows, hence the OR'd owner check.
337
- const readPred = predFor('read');
388
+ const readPred = publicReadPred();
338
389
  const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
339
390
  policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
340
391
 
341
- // The owner predicate may only ever WIDEN a public read (the soft-delete OR:
342
- // owners also see their own deleted rows). It must never REPLACE it — an owner
343
- // condition on a WRITE ability used to narrow the authenticated SELECT to
344
- // own-rows-only, so anon saw the whole table and a signed-in user lost it.
345
- // Without a soft-delete guard, `true OR owner` is just `true`.
346
- const authedUsing = sdGuard && rowPred
347
- ? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
348
- : anonUsing;
392
+ let authedUsing: string;
393
+ if (hasOwnerRead && rowPred && anonUsing !== 'true') {
394
+ // PUBLIC **OR** MINE. Two read abilities on one model a public read and an
395
+ // owner read are two branches of one authenticated policy, not a contest the
396
+ // public one wins. They used to be exactly that contest: `hasPublicRead` took
397
+ // the branch and the owner read was computed and discarded, so a signed-in
398
+ // author could not see their own non-public rows. On a real brownfield schema
399
+ // that hid ~13k legacy `pending` rows from the people who wrote them.
400
+ //
401
+ // The owner branch escapes the public predicate (that is the whole point) and
402
+ // carries its OWN guard — `can('read', { owner: 'userId', sql: … })` — so the
403
+ // author says what "mine" means without loosening what "public" means.
404
+ const ownBranch = andPredicates(rowPred, ownerReadPred())!;
405
+ authedUsing = `(${anonUsing} OR ${ownBranch})`;
406
+ } else {
407
+ // The owner predicate may only ever WIDEN a public read (the soft-delete OR:
408
+ // owners also see their own deleted rows). It must never REPLACE it — an owner
409
+ // condition on a WRITE ability used to narrow the authenticated SELECT to
410
+ // own-rows-only, so anon saw the whole table and a signed-in user lost it.
411
+ // Without a soft-delete guard, `true OR owner` is just `true` — and an
412
+ // unfiltered public read already contains every row an owner could add, which
413
+ // is why `anonUsing === 'true'` skips the disjunction rather than emitting a
414
+ // branch that can never change the answer.
415
+ authedUsing = sdGuard && rowPred
416
+ ? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
417
+ : anonUsing;
418
+ }
349
419
  policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
350
420
  } else if (hasOwnerRead && rowPred) {
351
421
  // owner-scoped read — no anon visibility; you see only the rows you own
@@ -65,11 +65,120 @@ const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
65
65
  const OWNER_RE =
66
66
  /^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'::text,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'::text\)*(?:::[a-z]+)?\)?$/i;
67
67
 
68
- /** Does this predicate say "the row is mine"? Returns the column and the claim if so. */
69
- export function parseOwnerPredicate(pred: string | null): { column: string; claim: string } | null {
68
+ /**
69
+ * The OTHER owner shape: the database's own accessor, `(<col> = auth.user_id())`.
70
+ *
71
+ * A brownfield schema almost always has one — a STABLE SECURITY DEFINER function wrapping
72
+ * the same claim read — and every owner policy in it is written against that function, not
73
+ * against the claim inline. Rendering those as the inline claim would be a semantically
74
+ * similar but TEXTUALLY different predicate, and predicates are diffed as text: it would
75
+ * plan a DROP + CREATE on every owner policy in the schema.
76
+ *
77
+ * Deliberately narrow: the right side must be a zero-argument function call. That is what
78
+ * an "who am I" accessor looks like, and it is what a column-to-column comparison
79
+ * (`(user_id = reviewer_id)`) is not — so the matcher cannot mistake a join predicate for
80
+ * a statement of ownership.
81
+ */
82
+ const OWNER_FN_RE = /^\(?([a-z_][a-z0-9_]*)\s*=\s*((?:[a-z_][a-z0-9_]*\.)?[a-z_][a-z0-9_]*\(\))\)?$/i;
83
+
84
+ /**
85
+ * Does this predicate say "the row is mine"? Returns the column, the claim, and the
86
+ * accessor expression when the schema uses one (null = the claim read inline).
87
+ */
88
+ export function parseOwnerPredicate(
89
+ pred: string | null,
90
+ ): { column: string; claim: string; expr: string | null } | null {
70
91
  if (!pred) return null;
71
- const m = OWNER_RE.exec(pred.trim());
72
- return m ? { column: m[1], claim: m[2] } : null;
92
+ const t = pred.trim();
93
+ const m = OWNER_RE.exec(t);
94
+ if (m) return { column: m[1], claim: m[2], expr: null };
95
+ const f = OWNER_FN_RE.exec(t);
96
+ return f ? { column: f[1], claim: 'sub', expr: f[2] } : null;
97
+ }
98
+
99
+ /**
100
+ * Split a predicate on a top-level operator, respecting parens and string literals.
101
+ *
102
+ * Needed because the shapes worth recognising are compositional — `(public) OR (mine AND
103
+ * guard)` — and a naive `.split(' OR ')` cuts inside a nested disjunction, which is how a
104
+ * template matcher silently mis-reads `((state <> 'deleted') AND ((user_id = …) OR (…)))`
105
+ * as a top-level OR it is not.
106
+ */
107
+ function splitTopLevel(pred: string, op: 'OR' | 'AND'): string[] {
108
+ const needle = ` ${op} `;
109
+ const parts: string[] = [];
110
+ let depth = 0;
111
+ let quoted = false;
112
+ let start = 0;
113
+ // Strip one enclosing paren pair so the operator we want is genuinely at depth 0.
114
+ const s = unwrapOnce(pred.trim());
115
+ for (let i = 0; i < s.length; i++) {
116
+ const c = s[i];
117
+ if (c === "'") { quoted = !quoted; continue; }
118
+ if (quoted) continue;
119
+ if (c === '(') depth++;
120
+ else if (c === ')') depth--;
121
+ else if (depth === 0 && s.startsWith(needle, i)) {
122
+ parts.push(s.slice(start, i));
123
+ i += needle.length - 1;
124
+ start = i + 1;
125
+ }
126
+ }
127
+ parts.push(s.slice(start));
128
+ return parts.map((p) => p.trim()).filter(Boolean);
129
+ }
130
+
131
+ /** Drop one enclosing paren pair, but only when it spans the WHOLE expression. */
132
+ function unwrapOnce(s: string): string {
133
+ if (!s.startsWith('(') || !s.endsWith(')')) return s;
134
+ let depth = 0;
135
+ let quoted = false;
136
+ for (let i = 0; i < s.length; i++) {
137
+ const c = s[i];
138
+ if (c === "'") { quoted = !quoted; continue; }
139
+ if (quoted) continue;
140
+ if (c === '(') depth++;
141
+ else if (c === ')') {
142
+ depth--;
143
+ if (depth === 0 && i < s.length - 1) return s; // the pair closed early — not enclosing
144
+ }
145
+ }
146
+ return depth === 0 ? s.slice(1, -1).trim() : s;
147
+ }
148
+
149
+ /**
150
+ * Decompose an authenticated read of the form `(public) OR (mine [AND guard])`, given the
151
+ * public predicate the anon policy already states.
152
+ *
153
+ * Keyed on the ANON PREDICATE APPEARING VERBATIM as one of two top-level disjuncts, and an
154
+ * owner predicate appearing in the other — not on a shape template. The alternative,
155
+ * matching a `X OR (Y AND Z)` template, both over- and under-fires: it claims predicates it
156
+ * has not proven are a public branch, and it misses the schema whose OR is nested inside an
157
+ * AND. Anything that does not decompose EXACTLY returns null, and the caller says so.
158
+ */
159
+ export function parsePublicOrOwn(
160
+ authedPred: string | null,
161
+ anonPred: string | null,
162
+ ): { owner: { column: string; claim: string; expr: string | null }; ownerSql: string | null } | null {
163
+ if (!authedPred || !anonPred) return null;
164
+ const branches = splitTopLevel(authedPred, 'OR');
165
+ if (branches.length !== 2) return null;
166
+
167
+ const anonNorm = unwrapOnce(anonPred.trim());
168
+ const publicIdx = branches.findIndex((b) => unwrapOnce(b) === anonNorm);
169
+ if (publicIdx === -1) return null;
170
+
171
+ const ownBranch = branches[1 - publicIdx];
172
+ const conjuncts = splitTopLevel(ownBranch, 'AND');
173
+ const ownerIdx = conjuncts.findIndex((c) => parseOwnerPredicate(c));
174
+ if (ownerIdx === -1) return null;
175
+
176
+ const owner = parseOwnerPredicate(conjuncts[ownerIdx])!;
177
+ const rest = conjuncts.filter((_, i) => i !== ownerIdx);
178
+ // More than one leftover conjunct would have to be re-ANDed, and the text the compiler
179
+ // then emits is not guaranteed to be the text that was read. Refuse rather than guess.
180
+ if (rest.length > 1) return null;
181
+ return { owner, ownerSql: rest[0] ?? null };
73
182
  }
74
183
 
75
184
  /** Is a privilege EFFECTIVE for a role — i.e. actually granted, not merely policed? */
@@ -135,15 +244,48 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
135
244
  if (anonRead) {
136
245
  // Public read. If the live policy narrows it (a soft-delete guard, a published flag),
137
246
  // carry that predicate — a public read with a filter is still a public read.
138
- const pol = policiesFor(contract, 'anon', 'SELECT')[0];
139
- const frag = sqlFragment(pol?.using ?? null);
247
+ const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
248
+ const anonPred = anonPol?.using ?? null;
249
+ const frag = sqlFragment(anonPred);
140
250
  abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
251
+
252
+ // The authenticated SELECT is a SEPARATE policy and routinely says MORE than the anon
253
+ // one — `(public) OR (mine)`, so an author sees their own not-yet-public rows. Reading
254
+ // only the anon policy dropped that branch and rendered a model that meant strictly
255
+ // less than the database did, with nothing said. On a real schema it hid ~13,184
256
+ // legacy rows from the people who wrote them.
257
+ const authedPol = authedRead
258
+ ? policiesFor(contract, 'authenticated', 'SELECT').find((p) => p.name !== anonPol?.name)
259
+ : undefined;
260
+ const authedPred = authedPol?.using ?? null;
261
+ if (authedPol && (authedPred ?? 'true') !== (anonPred ?? 'true')) {
262
+ const own = parsePublicOrOwn(authedPred, anonPred);
263
+ if (own) {
264
+ const parts = [`owner: '${own.owner.column}'`];
265
+ if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
266
+ if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
267
+ const guard = sqlFragment(own.ownerSql);
268
+ if (guard) parts.push(`sql: ${guard}`);
269
+ abilities.push(`can('read', { ${parts.join(', ')} })`);
270
+ } else {
271
+ // P6: it must SAY SO. An unexpressible predicate becomes a comment naming the
272
+ // policy and its text, never a model that quietly means less.
273
+ notes.push(
274
+ `policy "${authedPol.name}" (SELECT) grants authenticated MORE than the anon read,`,
275
+ );
276
+ notes.push(` and its shape is not expressible as abilities. Live USING:`);
277
+ notes.push(` ${authedPred}`);
278
+ notes.push(` Declare it by hand, or leave the table adopted. NOT rendered above.`);
279
+ }
280
+ }
141
281
  } else if (authedRead) {
142
282
  const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
143
283
  const owner = parseOwnerPredicate(pol?.using ?? null);
144
284
  if (owner) {
145
- const claim = owner.claim === 'sub' ? '' : `, userField: '${owner.claim}'`;
146
- abilities.push(`can('read', { owner: '${owner.column}'${claim} })`);
285
+ const parts = [`owner: '${owner.column}'`];
286
+ if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
287
+ if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
288
+ abilities.push(`can('read', { ${parts.join(', ')} })`);
147
289
  } else {
148
290
  const frag = sqlFragment(pol?.using ?? null);
149
291
  abilities.push(frag ? `can('read', { role: 'authenticated', sql: ${frag} })` : `can('read', { role: 'authenticated' })`);
@@ -165,6 +307,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
165
307
  if (owner) {
166
308
  parts.push(`owner: '${owner.column}'`);
167
309
  if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
310
+ if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
168
311
  }
169
312
  // A predicate beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
170
313
  const usingFrag = owner && parseOwnerPredicate(using) ? null : sqlFragment(using);
@@ -200,7 +343,11 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
200
343
  /** Render one table's derived stanza as the lines that sit inside a model literal. */
201
344
  export function renderDerivedAbilities(d: DerivedAbilities): string {
202
345
  const out: string[] = [];
203
- for (const n of d.notes) out.push(` // ${n}`);
346
+ // Split on newlines: a note may carry a live predicate verbatim, and `pg_get_expr`
347
+ // deparses a subquery across several lines. One `//` for the first line left the rest
348
+ // as bare SQL in the middle of a TypeScript object — a generated file that would not
349
+ // parse. Commenting per line here means no note, present or future, can do that.
350
+ for (const n of d.notes) for (const line of String(n).split('\n')) out.push(` // ${line}`);
204
351
  if (d.abilities.length) {
205
352
  out.push(` abilities: [${d.abilities.join(', ')}],`);
206
353
  } else {
@@ -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));
@@ -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
  /**
@@ -505,22 +509,42 @@ function importHeader(body: string, extra: string[] = []): string {
505
509
  return imports.join('\n');
506
510
  }
507
511
 
508
- /** The barrel's module wrapper: models always; sequences/derived when the pull found any (B5). */
509
- 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 {
510
529
  // A materialized table (--matviews-as-tables) is a MODEL — its block rides the derived
511
530
  // render (topo-ordered with the objects around it) but its name belongs in `models`.
512
- const allModels = [...modelNames, ...(derived?.materializedTableNames ?? [])];
513
- const models = multiline
514
- ? `export const models = [\n${allModels.map((n) => ` ${n},`).join('\n')}\n];`
515
- : `export const models = [${allModels.join(', ')}];`;
516
- 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];
517
538
  const keys = ['models'];
518
- if (derived?.sequenceNames.length) {
519
- 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(', ')}];`);
520
544
  keys.push('sequences');
521
545
  }
522
- if (derived?.names.length) {
523
- parts.push(`export const derived = [${derived.names.join(', ')}];`);
546
+ if (hasDerived) {
547
+ if (!external) parts.push(`export const derived = [${derived!.names.join(', ')}];`);
524
548
  keys.push('derived');
525
549
  }
526
550
  parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
@@ -528,6 +552,24 @@ function moduleFooter(modelNames: string[], derived?: DerivedRenderResult, multi
528
552
  return parts.join('\n\n');
529
553
  }
530
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
+
531
573
  /**
532
574
  * Render a whole snapshot as a single Models module: the import, one block per table (scoped
533
575
  * to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
@@ -610,14 +652,21 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
610
652
  // their own model.
611
653
  const names = tables.map((t) => modelVarName(t.table));
612
654
  const modelSymbols = ['defineModule', ...(opts.derived?.imports ?? [])];
655
+ const ext = opts.externalDerived;
656
+ const extNames = ext ? externalDerivedNames(ext) : [];
613
657
  const index = [
614
658
  [
615
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}';`] : []),
616
663
  ...names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`),
617
664
  ].join('\n'),
618
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(', ')} };`] : []),
619
668
  ...(opts.derived?.block ? [opts.derived.block] : []),
620
- moduleFooter(names, opts.derived, true),
669
+ moduleFooter(names, opts.derived, true, ext),
621
670
  ].join('\n\n');
622
671
  files.push({ file: 'index.ts', source: index + '\n' });
623
672