@everystack/cli 0.3.31 → 0.4.1

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 (43) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/.pdr-tmp-20277-1783706384842.ts +59 -0
  4. package/src/.roundtrip-tmp-20275-1783706385459.ts +121 -0
  5. package/src/cli/authz-compile.ts +5 -2
  6. package/src/cli/aws.ts +12 -3
  7. package/src/cli/backfill.ts +1 -1
  8. package/src/cli/commands/db-authz.ts +3 -17
  9. package/src/cli/commands/db-branch.ts +28 -4
  10. package/src/cli/commands/db-check.ts +84 -33
  11. package/src/cli/commands/db-fingerprint.ts +7 -2
  12. package/src/cli/commands/db-generate.ts +102 -34
  13. package/src/cli/commands/db-pull.ts +82 -6
  14. package/src/cli/commands/db-reconcile.ts +132 -41
  15. package/src/cli/commands/db-sync.ts +54 -19
  16. package/src/cli/commands/db.ts +7 -6
  17. package/src/cli/commands/update.ts +2 -1
  18. package/src/cli/db-build.ts +9 -3
  19. package/src/cli/db-source.ts +5 -1
  20. package/src/cli/declared-derived.ts +173 -0
  21. package/src/cli/derived-apply.ts +40 -6
  22. package/src/cli/derived-compile.ts +377 -0
  23. package/src/cli/derived-grants.ts +96 -0
  24. package/src/cli/derived-introspect.ts +258 -21
  25. package/src/cli/derived-lint.ts +261 -0
  26. package/src/cli/derived-plan.ts +176 -10
  27. package/src/cli/derived-render.ts +366 -0
  28. package/src/cli/derived-source.ts +53 -125
  29. package/src/cli/edge-plan.ts +4 -1
  30. package/src/cli/git-descent.ts +15 -2
  31. package/src/cli/index.ts +6 -6
  32. package/src/cli/migration-compile.ts +38 -7
  33. package/src/cli/migration-generate.ts +43 -4
  34. package/src/cli/model-render.ts +125 -16
  35. package/src/cli/models-path.ts +1 -1
  36. package/src/cli/ops-advice.ts +66 -0
  37. package/src/cli/pipeline-path.ts +1 -1
  38. package/src/cli/schema-compile.ts +41 -10
  39. package/src/cli/schema-diff.ts +123 -17
  40. package/src/cli/schema-fingerprint.ts +28 -18
  41. package/src/cli/schema-introspect.ts +175 -8
  42. package/src/cli/schema-source.ts +75 -6
  43. package/src/cli/state-apply.ts +52 -1
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * db:generate --stage <name> [--name <label>] introspect the live DB → diff the Models
5
5
  * → write the next drizzle migration file
6
+ * db:generate --dry-run the same edge, printed — NOTHING written
7
+ * (no migration, no journal, no schema refresh)
6
8
  *
7
9
  * The one generator — both layers in one ordered migration. It compiles the app's Models,
8
10
  * introspects the deployed database through the ops Lambda `db:query` action (the same
@@ -24,12 +26,14 @@ import type { ModelDescriptor, Module } from '@everystack/model';
24
26
  import { introspectSchema } from '../schema-introspect.js';
25
27
  import { compileDrizzleSource } from '../schema-source.js';
26
28
  import { compileModuleMigration } from '../migration-compile.js';
27
- import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
29
+ import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, resolveSchemaOut, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
28
30
  import { introspectContract, type QueryRunner } from '../authz-contract.js';
29
31
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
30
32
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
31
33
  import { resolveModelsPath } from '../models-path.js';
32
- import { applyStateAndVerify, classifyGeneratedStatements, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
34
+ import { loadDeclaredDerived, assertNoHoles, type DeclaredDerived } from '../declared-derived.js';
35
+ import { asModelComposeError, opsAdviceLines } from '../ops-advice.js';
36
+ import { applyStateAndVerify, classifyGeneratedStatements, renderStatementHistogram, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
33
37
  import { resolveConfig, opsFunction } from '../config.js';
34
38
  import { invokeAction } from '../aws.js';
35
39
  import { step, success, fail, info, warn } from '../output.js';
@@ -46,18 +50,24 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
46
50
  };
47
51
  }
48
52
 
49
- /** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work). */
53
+ /** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work).
54
+ * Every failure is the operator's own barrel — ModelComposeError, never dressed as IAM. */
50
55
  export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
51
- const abs = path.resolve(modelsPath);
52
- let mod: any;
53
56
  try {
54
- mod = await import(pathToFileURL(abs).href);
55
- } catch (err: any) {
56
- throw new Error(`Could not load models from ${modelsPath}: ${err.message}`);
57
+ const abs = path.resolve(modelsPath);
58
+ let mod: any;
59
+ try {
60
+ mod = await import(pathToFileURL(abs).href);
61
+ } catch (err: any) {
62
+ throw new Error(`Could not load models from ${modelsPath}: ${err.message}`);
63
+ }
64
+ const models = mod.models ?? mod.default;
65
+ if (!Array.isArray(models)) throw new Error(`${modelsPath} must export a \`models\` array (got ${typeof models}).`);
66
+ assertNoHoles(modelsPath, 'models', models);
67
+ return models;
68
+ } catch (err) {
69
+ throw asModelComposeError(modelsPath, err);
57
70
  }
58
- const models = mod.models ?? mod.default;
59
- if (!Array.isArray(models)) throw new Error(`${modelsPath} must export a \`models\` array (got ${typeof models}).`);
60
- return models;
61
71
  }
62
72
 
63
73
  /**
@@ -76,7 +86,7 @@ async function loadModules(modelsPath: string): Promise<Module[]> {
76
86
  }
77
87
  if (Array.isArray(mod.modules)) return mod.modules;
78
88
  const models = mod.models ?? mod.default;
79
- if (Array.isArray(models)) return [{ models, extensions: [], functions: [] }];
89
+ if (Array.isArray(models)) return [{ models, extensions: [], sequences: [], derived: [], functions: [] }];
80
90
  throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
81
91
  }
82
92
 
@@ -87,14 +97,14 @@ async function loadModules(modelsPath: string): Promise<Module[]> {
87
97
  * `drizzle/`. Roles are excluded by design (they are `db:provision`). Use it to (re)initialize
88
98
  * a clean migration history: clear `drizzle/`, then `db:generate --init`.
89
99
  */
90
- async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaOut: string, name: string): Promise<void> {
100
+ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaOut: string, name: string, schemaOutRecord: string): Promise<void> {
91
101
  step(`Loading modules from ${modelsPath}...`);
92
102
  const modules = await loadModules(modelsPath);
93
103
  const models = modules.flatMap((m) => m.models);
94
104
  info(`${modules.length} module(s), ${models.length} model(s).`);
95
105
 
96
106
  step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
97
- await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
107
+ await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived: modules.flatMap((m) => m.derived) }), 'utf8');
98
108
 
99
109
  const statements = compileModuleMigration(modules);
100
110
 
@@ -106,6 +116,7 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
106
116
  journal = { version: '7', dialect: 'postgresql', entries: [] };
107
117
  }
108
118
  const { filename, journal: nextJournal } = planMigrationFile(journal, name, Date.now());
119
+ nextJournal.schemaOut = schemaOutRecord; // D2 — flag-less later runs reuse it
109
120
  const filePath = path.join(migrationsDir, filename);
110
121
  await fs.mkdir(path.join(migrationsDir, 'meta'), { recursive: true });
111
122
  await fs.writeFile(filePath, formatMigrationFile(statements), 'utf8');
@@ -117,18 +128,43 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
117
128
  process.exit(0);
118
129
  }
119
130
 
131
+ /** Read the journal if present (null = no history yet). */
132
+ async function readJournal(migrationsDir: string): Promise<Journal | null> {
133
+ try {
134
+ return JSON.parse(await fs.readFile(path.join(migrationsDir, 'meta', '_journal.json'), 'utf8'));
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
120
140
  export async function dbGenerateCommand(flags: Record<string, string>): Promise<void> {
121
141
  const modelsPath = resolveModelsPath(flags.models);
122
142
  const migrationsDir = path.resolve(flags.dir || DEFAULT_MIGRATIONS);
123
- const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
124
143
  const name = flags.name || 'generated';
125
144
  const allowDrops = flags['allow-drops'] === 'true';
145
+ const dryRun = flags['dry-run'] === 'true';
146
+
147
+ // D2 — schema-out resolution: flag > journal-recorded > default. Recording the
148
+ // resolved path in the journal makes the relocated-artifact orphan unmintable:
149
+ // a later run without the flag keeps writing where the project writes.
150
+ const journal = await readJournal(migrationsDir);
151
+ const schemaOutRes = resolveSchemaOut(flags['schema-out'], journal, DEFAULT_SCHEMA_OUT);
152
+ const schemaOut = path.resolve(schemaOutRes.path);
153
+ if (schemaOutRes.source === 'recorded') {
154
+ info(`schema-out: ${schemaOutRes.path} (recorded in the journal; override with --schema-out)`);
155
+ } else if (schemaOutRes.source === 'flag' && schemaOutRes.updateRecord && journal?.schemaOut) {
156
+ warn(`schema-out moves: ${journal.schemaOut} → ${schemaOutRes.path} — the journal record updates with this run; delete the old artifact.`);
157
+ }
126
158
 
127
159
  // --init: greenfield, no live DB — the model emits the whole migration (extensions + schema +
128
160
  // authz + package functions). The brownfield path below introspects the deployed DB and diffs.
129
161
  if (flags.init === 'true') {
162
+ if (dryRun) {
163
+ fail('--dry-run previews the brownfield diff — --init is the greenfield generator (its output IS the whole migration; there is nothing cheaper to preview).');
164
+ process.exit(1);
165
+ }
130
166
  try {
131
- await dbGenerateInit(modelsPath, migrationsDir, schemaOut, flags.name || 'init');
167
+ await dbGenerateInit(modelsPath, migrationsDir, schemaOut, flags.name || 'init', schemaOutRes.path);
132
168
  } catch (err: any) {
133
169
  fail(err.message);
134
170
  process.exit(1);
@@ -137,7 +173,12 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
137
173
  }
138
174
 
139
175
  const apply = flags.apply === 'true';
176
+ if (dryRun && apply) {
177
+ fail('--dry-run and --apply are opposites — a dry run writes and executes nothing. Pick one.');
178
+ process.exit(1);
179
+ }
140
180
  let models: ModelDescriptor[];
181
+ let declaredDb: DeclaredDerived | null = null;
141
182
  let current;
142
183
  let liveAuthz;
143
184
  let runner!: QueryRunner;
@@ -145,12 +186,16 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
145
186
  try {
146
187
  step(`Loading models from ${modelsPath}...`);
147
188
  models = await loadModels(modelsPath);
189
+ declaredDb = await loadDeclaredDerived(flags.models);
148
190
  info(`${models.length} model(s).`);
149
191
  // The drizzle runtime schema is a derived artifact — refresh it from the models
150
192
  // every run (it needs no DB), so a relation-only change with no DDL diff still
151
- // updates it. The drift-guard test asserts this file matches the models.
152
- step(`Writing the drizzle schema ${path.relative(process.cwd(), schemaOut)}...`);
153
- await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
193
+ // updates it. The drift-guard test asserts this file matches the models (and the
194
+ // typed derived relations B6a). A dry run refreshes NOTHING — not even this.
195
+ if (!dryRun) {
196
+ step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
197
+ await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived: declaredDb?.derived }), 'utf8');
198
+ }
154
199
  const dbSource: DbSource = resolveDbSource(flags);
155
200
  if (apply && dbSource.kind !== 'url') {
156
201
  fail('--apply needs a direct connection (--database-url or DATABASE_URL) — the ops Lambda query path is read-only by design.');
@@ -172,21 +217,41 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
172
217
  if (!apply) await end?.();
173
218
  } catch (err: any) {
174
219
  fail(err.message);
175
- info('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
176
- info('Diffing against a local database instead? Pass --database-url or set DATABASE_URL.');
220
+ for (const line of opsAdviceLines(err, [
221
+ 'Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.',
222
+ 'Diffing against a local database instead? Pass --database-url or set DATABASE_URL.',
223
+ ])) info(line);
177
224
  process.exit(1);
178
225
  }
179
226
 
180
227
  // One ordered migration carries both layers: data DDL first, then the authz reconcile
181
228
  // (RLS/policies/grants) the Models' abilities declare, diffed against the live contract.
182
- const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz });
229
+ const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences });
183
230
  const unmodeled = unmodeledTables(models, current);
184
231
  if (unmodeled.length) {
185
232
  info(`${unmodeled.length} table(s) in the database are not declared by any model — left untouched (db:generate manages only declared tables).`);
186
233
  }
187
234
  console.log('');
188
235
  if (statements.length === 0) {
189
- success('db:generate — the live database already matches the models. No migration written.');
236
+ success(`db:generate — the live database already matches the models. ${dryRun ? 'Nothing to preview.' : 'No migration written.'}`);
237
+ process.exit(0);
238
+ }
239
+
240
+ // D1 — --dry-run: the edge as SQL, nothing else. No migration file, no journal
241
+ // entry, no schema refresh — preview traffic belongs here (and db:diff computes
242
+ // the models-vs-models edge with no database at all).
243
+ if (dryRun) {
244
+ const classified = classifyGeneratedStatements(statements);
245
+ // Summary FIRST — the shape of the edge is the review surface; the SQL wall follows.
246
+ info(`dry run — ${statements.length} statement(s), nothing written:`);
247
+ const histogram = renderStatementHistogram(classified.executable);
248
+ if (histogram) info(`shape: ${histogram}`);
249
+ if (classified.heldDrops.length) warn(`${classified.heldDrops.length} destructive change(s) held back (shown as comments) — --allow-drops includes them.`);
250
+ if (classified.notices.length) info(`${classified.notices.length} notice(s) — manual follow-ups.`);
251
+ console.log('');
252
+ for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
253
+ console.log('');
254
+ info('Nothing was written. Drop --dry-run to mint the migration, or `db:generate --apply` to run it directly on a dev database.');
190
255
  process.exit(0);
191
256
  }
192
257
 
@@ -195,12 +260,15 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
195
260
  // means db:generate is a no-op again, not "a file was written".
196
261
  if (apply) {
197
262
  const classified = classifyGeneratedStatements(statements);
263
+ // Summary FIRST — the shape of the edge is the review surface; the SQL wall follows.
198
264
  info(`Plan — ${statements.length} statement(s):`);
265
+ const histogram = renderStatementHistogram(classified.executable);
266
+ if (histogram) info(`shape: ${histogram}`);
267
+ if (classified.heldDrops.length) warn(`${classified.heldDrops.length} destructive change(s) held back — NOT executed (re-run with --allow-drops to include them).`);
268
+ if (classified.notices.length) info(`${classified.notices.length} notice(s) — manual follow-ups, nothing executed for them.`);
199
269
  console.log('');
200
270
  for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
201
271
  console.log('');
202
- if (classified.heldDrops.length) warn(`${classified.heldDrops.length} destructive change(s) held back — NOT executed (re-run with --allow-drops to include them).`);
203
- if (classified.notices.length) info(`${classified.notices.length} notice(s) — manual follow-ups, nothing executed for them.`);
204
272
  if (classified.executable.length === 0) {
205
273
  success('Nothing executable — only held/notice items differ.');
206
274
  process.exit(0);
@@ -209,7 +277,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
209
277
  let outcome: StateSyncOutcome;
210
278
  try {
211
279
  outcome = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
212
- allowDrops, actor: process.env.USER ?? null, gitRef: currentGitRef(),
280
+ allowDrops, sequences: declaredDb?.sequences, actor: process.env.USER ?? null, gitRef: currentGitRef(),
213
281
  });
214
282
  } catch (err: any) {
215
283
  fail(`Apply failed and rolled back: ${err.message}`);
@@ -228,14 +296,12 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
228
296
  }
229
297
 
230
298
  const journalPath = path.join(migrationsDir, 'meta', '_journal.json');
231
- let journal: Journal;
232
- try {
233
- journal = JSON.parse(await fs.readFile(journalPath, 'utf8'));
234
- } catch {
235
- journal = { version: '7', dialect: 'postgresql', entries: [] };
236
- }
237
-
238
- const { filename, journal: nextJournal } = planMigrationFile(journal, name, Date.now());
299
+ const { filename, journal: nextJournal } = planMigrationFile(
300
+ journal ?? { version: '7', dialect: 'postgresql', entries: [] }, name, Date.now(),
301
+ );
302
+ // D2 — the resolved schema-out path rides the journal, so the next flag-less run
303
+ // keeps writing where this project writes.
304
+ nextJournal.schemaOut = schemaOutRes.path;
239
305
  const filePath = path.join(migrationsDir, filename);
240
306
  try {
241
307
  await fs.mkdir(path.join(migrationsDir, 'meta'), { recursive: true });
@@ -247,6 +313,8 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
247
313
  }
248
314
 
249
315
  success(`Wrote ${path.relative(process.cwd(), filePath)} (${statements.length} statement(s)).`);
316
+ const tapeHistogram = renderStatementHistogram(classifyGeneratedStatements(statements).executable);
317
+ if (tapeHistogram) info(`shape: ${tapeHistogram}`);
250
318
  const warnings = statements.filter((s) => s.includes('-- WARNING')).length;
251
319
  const notices = statements.filter((s) => s.startsWith('-- NOTICE')).length;
252
320
  const held = statements.filter((s) => s.startsWith(HELD_DROP_PREFIX)).length;
@@ -2,6 +2,7 @@
2
2
  * `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
3
3
  *
4
4
  * db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>]
5
+ * [--derived-out <file.ts>] [--abilities public-read]
5
6
  *
6
7
  * The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
7
8
  * database. It introspects the deployed schema through the read-only ops Lambda `db:query`
@@ -18,17 +19,26 @@
18
19
  * `--out` the single module goes to stdout (so `db:pull > models.ts` works). Every
19
20
  * progress/diagnostic line goes to stderr, so the stdout pipe stays pure source. The
20
21
  * renderers (model-render.ts) are pure; this shell is the IO.
22
+ *
23
+ * `--derived-out <file.ts>` is the brownfield splice, first-class: the derived layer
24
+ * (descriptors + sequences) lands as its own self-contained module — model imports from
25
+ * the contract kebab files, `export const derived/sequences` arrays — instead of riding
26
+ * the models render. Alone, it leaves the models untouched (the hand-maintained-barrel
27
+ * case); with `--out`, the models render omits the now-external derived layer.
21
28
  */
22
29
 
23
30
  import fs from 'node:fs/promises';
24
31
  import path from 'node:path';
25
32
  import { introspectSchema } from '../schema-introspect.js';
26
- import { renderModelSource, renderModelFiles, pullableTables } from '../model-render.js';
33
+ import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
34
+ import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
35
+ import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
27
36
  import type { QueryRunner } from '../authz-contract.js';
28
37
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
29
38
  import { resolveConfig, opsFunction } from '../config.js';
30
39
  import { invokeAction } from '../aws.js';
31
40
  import { fail } from '../output.js';
41
+ import { opsAdviceLines } from '../ops-advice.js';
32
42
 
33
43
  // Progress → stderr, so stdout carries only the rendered source (this command is codegen,
34
44
  // not a report — the shared output helpers write to stdout, which would corrupt a redirect).
@@ -49,6 +59,23 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
49
59
  export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
50
60
  const schema = flags.schema || 'public';
51
61
 
62
+ // --derived-out: the brownfield splice, first-class. A consumer with an existing
63
+ // hand-maintained barrel wants the derived layer as its own file — not codemodded
64
+ // out of a rendered monolith. Validated before any introspection.
65
+ const derivedOut = flags['derived-out'];
66
+ if (derivedOut && !derivedOut.endsWith('.ts')) {
67
+ fail(`--derived-out must name a .ts file (got '${derivedOut}') — e.g. --derived-out db/models/derived.ts.`);
68
+ process.exit(1);
69
+ }
70
+
71
+ // The read-model scaffold: default 'commented' surfaces the authz decision in every
72
+ // model; a preset stamps it. Validated up front so a typo fails before introspection.
73
+ const abilities = flags.abilities || 'commented';
74
+ if (abilities !== 'commented' && !ABILITY_PRESETS[abilities]) {
75
+ fail(`Unknown --abilities preset '${abilities}'. Known: ${Object.keys(ABILITY_PRESETS).join(', ')} (omit the flag to scaffold the decision as comments).`);
76
+ process.exit(1);
77
+ }
78
+
52
79
  let dbSource: DbSource;
53
80
  try {
54
81
  dbSource = resolveDbSource(flags);
@@ -58,6 +85,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
58
85
  }
59
86
 
60
87
  let current;
88
+ let derivedCatalog: DerivedCatalog | undefined;
61
89
  try {
62
90
  let runner: QueryRunner;
63
91
  let end: (() => Promise<void>) | undefined;
@@ -72,14 +100,21 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
72
100
  }
73
101
  note(`Introspecting live database (schema: ${schema})...`);
74
102
  current = await introspectSchema(runner);
103
+ // The derived layer rides the same pull (B5) — views/matviews/functions/sequences
104
+ // render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
105
+ derivedCatalog = await introspectDerived(runner);
75
106
  await end?.();
76
107
  } catch (err: any) {
77
108
  fail(err.message);
78
109
  if (dbSource.kind === 'url') {
79
110
  detail('Check the connection string and that the database is accepting connections.');
80
111
  } else {
81
- detail('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
82
- detail('Introspecting a local database instead? Pass --database-url or set DATABASE_URL.');
112
+ // IAM advice only when the AWS transport actually threw — an introspection
113
+ // failure inside a Lambda that RAN is not a permissions problem.
114
+ for (const line of opsAdviceLines(err, [
115
+ 'Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.',
116
+ 'Introspecting a local database instead? Pass --database-url or set DATABASE_URL.',
117
+ ])) detail(line);
83
118
  }
84
119
  process.exit(1);
85
120
  }
@@ -90,11 +125,47 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
90
125
  process.exit(1);
91
126
  }
92
127
 
128
+ // The derived layer (B5): rendered from the live catalog with dependsOn from the real
129
+ // edge graph. Everything inexpressible is a stderr warning AND an inline FIXME.
130
+ const knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
131
+ const derived: DerivedRenderResult = renderDerivedSource(derivedCatalog ?? { objects: [], edges: [], provenance: [] }, {
132
+ knownTables,
133
+ sequences: current.sequences,
134
+ });
135
+ for (const w of derived.warnings) caution(w);
136
+ const derivedCount = derived.names.length + derived.sequenceNames.length;
137
+ if (derivedCount > 0) {
138
+ detail(`Derived layer: ${derived.names.length} descriptor(s) + ${derived.sequenceNames.length} sequence(s) rendered (adopt with db:reconcile --baseline after committing).`);
139
+ }
140
+
141
+ // The standalone derived file. The models output (below) then omits the derived
142
+ // layer — it lives here, and the module wires both: defineModule({ models, sequences, derived }).
143
+ if (derivedOut) {
144
+ const outPath = path.resolve(derivedOut);
145
+ try {
146
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
147
+ await fs.writeFile(outPath, renderDerivedFile(derived, knownTables), 'utf8');
148
+ } catch (err: any) {
149
+ fail(`Could not write ${derivedOut}: ${err.message}`);
150
+ process.exit(1);
151
+ }
152
+ ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${derived.names.length} descriptor(s) + ${derived.sequenceNames.length} sequence(s), self-contained.`);
153
+ note(`Wire it on your module — defineModule({ models${derived.sequenceNames.length ? ', sequences' : ''}, derived }) — then adopt with db:reconcile --apply --baseline (--rebaseline over db/sql-era provenance).`);
154
+ if (!flags.out) {
155
+ // The brownfield case: the barrel is hand-maintained; leave the models alone.
156
+ note('Models were NOT rendered (--derived-out extracts the derived layer only; add --out to render models too).');
157
+ process.exit(0);
158
+ }
159
+ }
160
+ // With --derived-out, the embedded copy would duplicate every descriptor — the
161
+ // models output carries models only.
162
+ const embeddedDerived = derivedOut ? undefined : derived;
163
+
93
164
  let source: string;
94
165
  if (flags.out && !flags.out.endsWith('.ts')) {
95
166
  // A directory: one file per model + index.ts — the default shape for a real app.
96
167
  const dir = path.resolve(flags.out);
97
- const files = renderModelFiles(current, { schema });
168
+ const files = renderModelFiles(current, { schema, abilities, derived: embeddedDerived });
98
169
  try {
99
170
  await fs.mkdir(dir, { recursive: true });
100
171
  const written = new Set(files.map((f) => f.file));
@@ -111,7 +182,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
111
182
  source = files.map((f) => f.source).join('\n');
112
183
  } else if (flags.out) {
113
184
  const outPath = path.resolve(flags.out);
114
- source = renderModelSource(current, { schema });
185
+ source = renderModelSource(current, { schema, abilities, derived: embeddedDerived });
115
186
  try {
116
187
  await fs.mkdir(path.dirname(outPath), { recursive: true });
117
188
  await fs.writeFile(outPath, source, 'utf8');
@@ -121,12 +192,17 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
121
192
  }
122
193
  ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
123
194
  } else {
124
- source = renderModelSource(current, { schema });
195
+ source = renderModelSource(current, { schema, abilities, derived: embeddedDerived });
125
196
  process.stdout.write(source);
126
197
  ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
127
198
  }
128
199
 
129
200
  const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →)/g) ?? []).length;
130
201
  if (flagged) caution(`${flagged} inline comment(s) flag things to review (unmapped types, checks, cross-schema FKs).`);
202
+ if (abilities === 'commented') {
203
+ note(`Each model scaffolds its authz decision as comments — author them (db:check fails until every model declares), or stamp the common case: db:pull --abilities public-read.`);
204
+ } else {
205
+ note(`Stamped '${abilities}' abilities into every model — review the generated stanzas; they are code, not defaults.`);
206
+ }
131
207
  process.exit(0);
132
208
  }