@everystack/cli 0.3.31 → 0.4.0

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/README.md CHANGED
@@ -216,7 +216,7 @@ everystack db:psql --stage dev [-c SQL] # interactive psql / one-off query via L
216
216
  # Model-driven schema (@everystack/model)
217
217
  everystack db:generate # diff Models vs the live DB → next migration
218
218
  everystack db:pull # introspect a live DB → field() Models (brownfield on-ramp)
219
- everystack db:reconcile # deploy the derived layer (functions/views/matviews) from db/sql — no migrations
219
+ everystack db:reconcile # deploy the derived layer (functions/views/matviews/triggers) from the declared descriptors in db/models — no migrations
220
220
  everystack db:fingerprint # content-address the live base schema vs the Models — MATCH/MISMATCH
221
221
  everystack db:sync # make the database match your checkout — state + compute, one verb (dev DBs)
222
222
  everystack db:diff # the state edge between two declared states — no DB, CI-pure; reverse = computed rollback
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.31",
3
+ "version": "0.4.0",
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>",
@@ -82,7 +82,7 @@
82
82
  "structured-headers": "1.0.1",
83
83
  "tsx": "4.21.0",
84
84
  "typescript": "5.9.3",
85
- "@everystack/model": "0.3.6"
85
+ "@everystack/model": "0.4.0"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "@everystack/server": ">=0.1.0",
@@ -308,8 +308,10 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
308
308
  /** The four DML privileges, sorted — the expansion of a `manage` ability. */
309
309
  const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
310
310
 
311
- /** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD. */
312
- const VERB: Record<Exclude<Ability['action'], 'manage'>, string> = {
311
+ /** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD.
312
+ * `execute` is a FUNCTION ability — defineModel rejects it on tables, so it never
313
+ * reaches the table grant compiler (the throw below keeps that contract loud). */
314
+ const VERB: Record<Exclude<Ability['action'], 'manage' | 'execute'>, string> = {
313
315
  read: 'SELECT',
314
316
  create: 'INSERT',
315
317
  update: 'UPDATE',
@@ -318,6 +320,7 @@ const VERB: Record<Exclude<Ability['action'], 'manage'>, string> = {
318
320
 
319
321
  /** The privileges an ability's action grants (a single verb, or all of CRUD for `manage`). */
320
322
  function verbsFor(action: Ability['action']): string[] {
323
+ if (action === 'execute') throw new Error(`can('execute') is a function ability — it has no table privilege (defineModel rejects it; this is unreachable).`);
321
324
  return action === 'manage' ? [...CRUD] : [VERB[action]];
322
325
  }
323
326
 
@@ -8,7 +8,7 @@
8
8
  * unlike `schema_log`, which stays a write-only memoir. The distrust that
9
9
  * killed the migration tape doesn't transfer: nothing is numbered, nothing
10
10
  * is watermarked, and identity is the NORMALIZED CONTENT HASH (the same
11
- * comment-insensitive, dollar-quote-aware lexing as db/sql) — renaming a
11
+ * comment-insensitive, dollar-quote-aware lexing as the reconciler) — renaming a
12
12
  * file or editing a comment is a no-op, never a re-run.
13
13
  *
14
14
  * One-shot jobs are IMMUTABLE. A file whose name already ran on this
@@ -4,7 +4,7 @@
4
4
  * decision 14).
5
5
  *
6
6
  * db:template:refresh [--database-url <url>] [--models <barrel>]
7
- * [--sql-dir db/sql] [--seed "<cmd>"] [--no-seed]
7
+ * [--seed "<cmd>"] [--no-seed]
8
8
  * db:branch # mint (or find) the current branch's DB
9
9
  * db:branch --list # every branch DB, mapped to its branch
10
10
  * db:branch --drop --confirm # drop the current branch's DB
@@ -39,7 +39,7 @@ import { currentGitRef } from '../state-apply.js';
39
39
  import { fingerprintModels } from '../schema-fingerprint.js';
40
40
  import { resolveDbSource, type DbSource } from '../db-source.js';
41
41
  import { resolveModelsPath } from '../models-path.js';
42
- import { readSqlDirIfPresent } from './db-sync.js';
42
+ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
43
43
  import { loadModels } from './db-generate.js';
44
44
  import { step, success, fail, info, warn } from '../output.js';
45
45
 
@@ -92,7 +92,30 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
92
92
  fail(err.message);
93
93
  process.exit(1);
94
94
  }
95
- const sources = await readSqlDirIfPresent(flags['sql-dir'] || 'db/sql');
95
+ // B7 tombstone db/sql is retired and no longer read.
96
+ try {
97
+ if (flags['sql-dir']) {
98
+ fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
99
+ process.exit(1);
100
+ }
101
+ const retired = await retiredSqlDirAnywhere(flags.models);
102
+ if (retired) {
103
+ fail(retired);
104
+ process.exit(1);
105
+ }
106
+ } catch (err: any) {
107
+ fail(err.message);
108
+ process.exit(1);
109
+ }
110
+ // The declared derived layer + sequences — branch/template DBs carry the SAME stream
111
+ // every other verb deploys, or a minted branch is silently missing its compute.
112
+ let declaredDb: DeclaredDerived | null = null;
113
+ try {
114
+ declaredDb = await loadDeclaredDerived(flags.models);
115
+ } catch (err: any) {
116
+ fail(err.message);
117
+ process.exit(1);
118
+ }
96
119
 
97
120
  let seedCommand: string | null = null;
98
121
  if (flags['no-seed'] === 'true') {
@@ -109,7 +132,8 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
109
132
  step(`Rebuilding ${template} from the declared state (drop → create → sync both layers → seed)...`);
110
133
  try {
111
134
  const result = await executeTemplateRefresh(url, models, {
112
- sources,
135
+ declared: declaredDb?.objects,
136
+ sequences: declaredDb?.sequences,
113
137
  actor: process.env.USER ?? null,
114
138
  gitRef: currentGitRef(),
115
139
  seed: seedCommand === null ? null : (templateUrl) => {
@@ -3,7 +3,7 @@
3
3
  * invariant: the merged declared state must compose, and generated
4
4
  * artifacts must match regeneration).
5
5
  *
6
- * db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>]
6
+ * db:check [--models <barrel>] [--schema-out <file.ts>]
7
7
  * [--database-url <url>] [--json]
8
8
  *
9
9
  * Two rings, both from the checkout alone (no target database is ever
@@ -13,7 +13,8 @@
13
13
  * git can't see, like two branches declaring the same table, fails
14
14
  * here); the generated drizzle schema matches a byte-for-byte
15
15
  * regeneration (hand-edited artifacts and forgotten `db:generate` runs
16
- * fail loudly); the derived sources in db/sql parse.
16
+ * fail loudly); a leftover db/sql directory fails with the migration
17
+ * path (B7 — descriptors are the single home).
17
18
  *
18
19
  * EPHEMERAL COMPOSE — with a scratch PostgreSQL (`--database-url` /
19
20
  * DATABASE_URL), build the merged declared state FROM SCRATCH on an
@@ -28,24 +29,25 @@
28
29
  */
29
30
 
30
31
  import fs from 'node:fs/promises';
31
- import type { ModelDescriptor } from '@everystack/model';
32
+ import type { ModelDescriptor, DerivedDescriptor } from '@everystack/model';
32
33
  import { compileDeclaredState } from '../declared-diff.js';
33
34
  import { compileMigration } from '../migration-compile.js';
34
35
  import { compileDrizzleSource } from '../schema-source.js';
35
- import { parseSqlSources, type SourceFile } from '../derived-source.js';
36
36
  import { currentGitRef } from '../state-apply.js';
37
37
  import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build.js';
38
38
  import { resolveModelsPath } from '../models-path.js';
39
- import { readSqlDirIfPresent } from './db-sync.js';
40
39
  import { loadModels } from './db-generate.js';
40
+ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
41
+ import type { SequenceDescriptor } from '@everystack/model';
42
+ import type { SourceObject } from '../derived-source.js';
41
43
  import { findReadAuthzGaps } from '../authz-lint.js';
44
+ import { findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings } from '../derived-lint.js';
42
45
  import { step, success, fail, info, warn } from '../output.js';
43
46
 
44
47
  // The role derivation moved to the shared builder (db-build) with brick
45
48
  // 5-remainder; re-exported here because db:check is where it grew up.
46
49
  export { rolesFromContract } from '../db-build.js';
47
50
 
48
- const DEFAULT_SQL_DIR = 'db/sql';
49
51
  const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
50
52
 
51
53
  // ---------------------------------------------------------------------------
@@ -53,7 +55,8 @@ const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
53
55
  // ---------------------------------------------------------------------------
54
56
 
55
57
  export interface CheckFinding {
56
- level: 'ok' | 'note' | 'fail';
58
+ /** `warn` is visible-but-never-fatal — the matview snapshot gate's channel. */
59
+ level: 'ok' | 'note' | 'warn' | 'fail';
57
60
  area: 'models' | 'compose' | 'artifact' | 'compute' | 'authz';
58
61
  message: string;
59
62
  }
@@ -66,8 +69,15 @@ export interface StaticCheckInput {
66
69
  artifactPath: string;
67
70
  /** null = no generated schema artifact on disk. */
68
71
  artifactSource: string | null;
69
- /** null = no db/sql directory. */
70
- sources: SourceFile[] | null;
72
+ /** Set when a retired db/sql directory still carries .sql files — the tombstone
73
+ * message (with the migration path); a hard fail, never a shrug. */
74
+ sqlDirRetired?: string | null;
75
+ /** Standalone sequences the modules declare (state — in the from-scratch compose). */
76
+ sequences?: SequenceDescriptor[];
77
+ /** The declared derived descriptors (raw — the B2 gates read abilities, not SQL). */
78
+ derived?: DerivedDescriptor[] | null;
79
+ /** Set when the derived layer failed to COMPILE (e.g. reachability) — a hard fail. */
80
+ derivedError?: string;
71
81
  }
72
82
 
73
83
  export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
@@ -98,7 +108,7 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
98
108
  }
99
109
  try {
100
110
  compileDeclaredState(input.models);
101
- const statements = compileMigration(input.models);
111
+ const statements = compileMigration(input.models, { sequences: input.sequences });
102
112
  findings.push({ level: 'ok', area: 'compose', message: `declared state composes — ${statements.length} statement(s) from scratch` });
103
113
  } catch (err: any) {
104
114
  findings.push({ level: 'fail', area: 'compose', message: `the merged declared state does NOT compose: ${err.message}` });
@@ -116,13 +126,43 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
116
126
  }
117
127
  }
118
128
 
129
+ // The derived layer failing to COMPILE (reachability, cycles, undeclared trigger
130
+ // functions) is a hard fail — CI cannot shrug at a compile error.
131
+ if (input.derivedError) {
132
+ findings.push({
133
+ level: 'fail',
134
+ area: 'compute',
135
+ message: `the declared derived layer does NOT compile: ${input.derivedError}`,
136
+ });
137
+ }
138
+
139
+ // The B2 gates over the declared derived layer: every relation makes its read decision
140
+ // (fail), every SECURITY DEFINER declares its callers (fail), and a matview over
141
+ // row-scoped sources is named for what it is — a snapshot with the refresher's eyes (warn).
142
+ if (input.derived?.length) {
143
+ const derivedGaps = [...findDerivedReadGaps(input.derived), ...findSecdefExecuteGaps(input.derived)];
144
+ for (const gap of derivedGaps) {
145
+ findings.push({ level: 'fail', area: 'authz', message: gap.message });
146
+ }
147
+ for (const warning of findMatviewSnapshotWarnings(input.derived)) {
148
+ findings.push({ level: 'warn', area: 'authz', message: warning.message });
149
+ }
150
+ if (derivedGaps.length === 0) {
151
+ findings.push({
152
+ level: 'ok',
153
+ area: 'authz',
154
+ message: `every derived relation declares its read surface and every SECURITY DEFINER its callers (${input.derived.length} descriptor(s))`,
155
+ });
156
+ }
157
+ }
158
+
119
159
  if (input.artifactSource === null) {
120
160
  findings.push({
121
161
  level: 'note',
122
162
  area: 'artifact',
123
163
  message: `no generated schema at ${input.artifactPath} — nothing to match (db:generate writes it)`,
124
164
  });
125
- } else if (input.artifactSource === compileDrizzleSource(input.models)) {
165
+ } else if (input.artifactSource === compileDrizzleSource(input.models, { derived: input.derived ?? undefined })) {
126
166
  findings.push({ level: 'ok', area: 'artifact', message: `${input.artifactPath} matches regeneration byte-for-byte` });
127
167
  } else {
128
168
  findings.push({
@@ -132,20 +172,10 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
132
172
  });
133
173
  }
134
174
 
135
- if (input.sources === null) {
136
- findings.push({ level: 'note', area: 'compute', message: 'no db/sql directory derived layer not declared, nothing to parse' });
137
- } else {
138
- try {
139
- const parsed = parseSqlSources(input.sources);
140
- findings.push({
141
- level: 'ok',
142
- area: 'compute',
143
- message: `${parsed.objects.length} derived object(s) parsed from ${input.sources.length} db/sql file(s)`,
144
- });
145
- for (const w of parsed.warnings) findings.push({ level: 'note', area: 'compute', message: w });
146
- } catch (err: any) {
147
- findings.push({ level: 'fail', area: 'compute', message: `db/sql does not parse: ${err.message}` });
148
- }
175
+ // B7 — db/sql is retired. Files still present would be silently unmanaged: fail
176
+ // with the migration path. (Absence is the normal state; nothing to report.)
177
+ if (input.sqlDirRetired) {
178
+ findings.push({ level: 'fail', area: 'compute', message: input.sqlDirRetired });
149
179
  }
150
180
 
151
181
  return findings;
@@ -177,14 +207,14 @@ export interface EphemeralComposeResult {
177
207
  export async function executeEphemeralCompose(
178
208
  adminUrl: string,
179
209
  models: ModelDescriptor[],
180
- sources: SourceFile[] | null,
181
- opts: { actor?: string | null; gitRef?: string | null } = {},
210
+ opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[] } = {},
182
211
  ): Promise<EphemeralComposeResult> {
183
212
  const database = `escheck_${process.pid}_${Date.now()}`;
184
213
  await createDatabase(adminUrl, database);
185
214
  try {
186
215
  const built = await buildIntoDatabase(withDatabase(adminUrl, database), models, {
187
- sources,
216
+ declared: opts.declared,
217
+ sequences: opts.sequences,
188
218
  actor: opts.actor ?? 'db:check',
189
219
  gitRef: opts.gitRef ?? null,
190
220
  });
@@ -198,7 +228,7 @@ export async function executeEphemeralCompose(
198
228
  // The CLI shell.
199
229
  // ---------------------------------------------------------------------------
200
230
 
201
- const MARK: Record<CheckFinding['level'], string> = { ok: '=', note: '·', fail: '!' };
231
+ const MARK: Record<CheckFinding['level'], string> = { ok: '=', note: '·', warn: '~', fail: '!' };
202
232
 
203
233
  export async function dbCheckCommand(flags: Record<string, string>): Promise<void> {
204
234
  const modelsPath = resolveModelsPath(flags.models);
@@ -218,11 +248,30 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
218
248
  } catch {
219
249
  artifactSource = null;
220
250
  }
221
- const sources = await readSqlDirIfPresent(flags['sql-dir'] || DEFAULT_SQL_DIR);
251
+ // B7 tombstone a retired db/sql directory still carrying .sql files is a FAIL
252
+ // finding (the message carries the migration path), never a silent skip. The retired
253
+ // --sql-dir flag is itself a FAIL finding here (the sibling verbs refuse it outright);
254
+ // detector errors (an unreadable directory) surface as the finding, never disarm it.
255
+ const sqlDirRetired = flags['sql-dir']
256
+ ? await retiredSqlDirFlagRefusal(flags['sql-dir'])
257
+ : await retiredSqlDirAnywhere(flags.models).catch((err: any) => String(err?.message ?? err));
258
+
259
+ // The declared derived layer + sequences — the same stream every other verb consumes.
260
+ // A compile failure here (reachability, cycles) is a FAIL finding, not a shrug.
261
+ let declaredDb: DeclaredDerived | null = null;
262
+ let derivedError: string | undefined;
263
+ try {
264
+ declaredDb = await loadDeclaredDerived(flags.models);
265
+ } catch (err: any) {
266
+ derivedError = err.message;
267
+ }
222
268
 
223
- const findings = runStaticChecks({ modelsPath, models, modelsError, artifactPath, artifactSource, sources });
269
+ const findings = runStaticChecks({
270
+ modelsPath, models, modelsError, artifactPath, artifactSource, sqlDirRetired,
271
+ sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
272
+ });
224
273
  for (const f of findings) {
225
- (f.level === 'fail' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
274
+ (f.level === 'fail' || f.level === 'warn' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
226
275
  }
227
276
 
228
277
  let ephemeral: EphemeralComposeResult | null = null;
@@ -236,9 +285,11 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
236
285
  } else {
237
286
  step('Composing the declared state from scratch on an ephemeral database...');
238
287
  try {
239
- ephemeral = await executeEphemeralCompose(url, models!, sources, {
288
+ ephemeral = await executeEphemeralCompose(url, models!, {
240
289
  actor: process.env.USER ?? 'db:check',
241
290
  gitRef: currentGitRef(),
291
+ declared: declaredDb?.objects,
292
+ sequences: declaredDb?.sequences,
242
293
  });
243
294
  for (const line of ephemeral.report) info(` ${line}`);
244
295
  } catch (err: any) {
@@ -30,6 +30,8 @@ import { resolveConfig, opsFunction } from '../config.js';
30
30
  import { invokeAction } from '../aws.js';
31
31
  import { step, success, fail, info, warn } from '../output.js';
32
32
  import { loadModels } from './db-generate.js';
33
+ import { loadDeclaredDerived } from '../declared-derived.js';
34
+ import type { SequenceDescriptor } from '@everystack/model';
33
35
 
34
36
  export interface FingerprintStatus {
35
37
  live: string;
@@ -42,11 +44,12 @@ export interface FingerprintStatus {
42
44
  export async function computeFingerprintStatus(
43
45
  runner: QueryRunner,
44
46
  models: ModelDescriptor[] | null,
47
+ sequences?: SequenceDescriptor[],
45
48
  ): Promise<FingerprintStatus> {
46
49
  const snapshot = await introspectSchema(runner);
47
50
  const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
48
51
  const live = fingerprintLive(snapshot, contract).hash;
49
- const declared = models ? fingerprintModels(models).hash : null;
52
+ const declared = models ? fingerprintModels(models, { sequences }).hash : null;
50
53
  const unfingerprinted = mapUnfingerprintedRows((await runner(UNFINGERPRINTED_SQL)) as any[]);
51
54
  return { live, declared, match: declared === null ? null : live === declared, unfingerprinted };
52
55
  }
@@ -71,8 +74,10 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
71
74
 
72
75
  const modelsPath = resolveModelsPath(flags.models);
73
76
  let models: ModelDescriptor[] | null = null;
77
+ let sequences: SequenceDescriptor[] | undefined;
74
78
  try {
75
79
  models = await loadModels(modelsPath);
80
+ sequences = (await loadDeclaredDerived(flags.models))?.sequences;
76
81
  } catch {
77
82
  // Live-only mode: no models barrel — still useful (what fingerprint is this DB at?).
78
83
  }
@@ -89,7 +94,7 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
89
94
  }
90
95
 
91
96
  try {
92
- const status = await computeFingerprintStatus(runner, models);
97
+ const status = await computeFingerprintStatus(runner, models, sequences);
93
98
 
94
99
  if (flags.json === 'true') {
95
100
  console.log(JSON.stringify(status, null, 2));
@@ -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,11 +26,12 @@ 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';
34
+ import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived.js';
32
35
  import { applyStateAndVerify, classifyGeneratedStatements, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
33
36
  import { resolveConfig, opsFunction } from '../config.js';
34
37
  import { invokeAction } from '../aws.js';
@@ -76,7 +79,7 @@ async function loadModules(modelsPath: string): Promise<Module[]> {
76
79
  }
77
80
  if (Array.isArray(mod.modules)) return mod.modules;
78
81
  const models = mod.models ?? mod.default;
79
- if (Array.isArray(models)) return [{ models, extensions: [], functions: [] }];
82
+ if (Array.isArray(models)) return [{ models, extensions: [], sequences: [], derived: [], functions: [] }];
80
83
  throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
81
84
  }
82
85
 
@@ -87,14 +90,14 @@ async function loadModules(modelsPath: string): Promise<Module[]> {
87
90
  * `drizzle/`. Roles are excluded by design (they are `db:provision`). Use it to (re)initialize
88
91
  * a clean migration history: clear `drizzle/`, then `db:generate --init`.
89
92
  */
90
- async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaOut: string, name: string): Promise<void> {
93
+ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaOut: string, name: string, schemaOutRecord: string): Promise<void> {
91
94
  step(`Loading modules from ${modelsPath}...`);
92
95
  const modules = await loadModules(modelsPath);
93
96
  const models = modules.flatMap((m) => m.models);
94
97
  info(`${modules.length} module(s), ${models.length} model(s).`);
95
98
 
96
99
  step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
97
- await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
100
+ await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived: modules.flatMap((m) => m.derived) }), 'utf8');
98
101
 
99
102
  const statements = compileModuleMigration(modules);
100
103
 
@@ -106,6 +109,7 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
106
109
  journal = { version: '7', dialect: 'postgresql', entries: [] };
107
110
  }
108
111
  const { filename, journal: nextJournal } = planMigrationFile(journal, name, Date.now());
112
+ nextJournal.schemaOut = schemaOutRecord; // D2 — flag-less later runs reuse it
109
113
  const filePath = path.join(migrationsDir, filename);
110
114
  await fs.mkdir(path.join(migrationsDir, 'meta'), { recursive: true });
111
115
  await fs.writeFile(filePath, formatMigrationFile(statements), 'utf8');
@@ -117,18 +121,43 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
117
121
  process.exit(0);
118
122
  }
119
123
 
124
+ /** Read the journal if present (null = no history yet). */
125
+ async function readJournal(migrationsDir: string): Promise<Journal | null> {
126
+ try {
127
+ return JSON.parse(await fs.readFile(path.join(migrationsDir, 'meta', '_journal.json'), 'utf8'));
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
120
133
  export async function dbGenerateCommand(flags: Record<string, string>): Promise<void> {
121
134
  const modelsPath = resolveModelsPath(flags.models);
122
135
  const migrationsDir = path.resolve(flags.dir || DEFAULT_MIGRATIONS);
123
- const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
124
136
  const name = flags.name || 'generated';
125
137
  const allowDrops = flags['allow-drops'] === 'true';
138
+ const dryRun = flags['dry-run'] === 'true';
139
+
140
+ // D2 — schema-out resolution: flag > journal-recorded > default. Recording the
141
+ // resolved path in the journal makes the relocated-artifact orphan unmintable:
142
+ // a later run without the flag keeps writing where the project writes.
143
+ const journal = await readJournal(migrationsDir);
144
+ const schemaOutRes = resolveSchemaOut(flags['schema-out'], journal, DEFAULT_SCHEMA_OUT);
145
+ const schemaOut = path.resolve(schemaOutRes.path);
146
+ if (schemaOutRes.source === 'recorded') {
147
+ info(`schema-out: ${schemaOutRes.path} (recorded in the journal; override with --schema-out)`);
148
+ } else if (schemaOutRes.source === 'flag' && schemaOutRes.updateRecord && journal?.schemaOut) {
149
+ warn(`schema-out moves: ${journal.schemaOut} → ${schemaOutRes.path} — the journal record updates with this run; delete the old artifact.`);
150
+ }
126
151
 
127
152
  // --init: greenfield, no live DB — the model emits the whole migration (extensions + schema +
128
153
  // authz + package functions). The brownfield path below introspects the deployed DB and diffs.
129
154
  if (flags.init === 'true') {
155
+ if (dryRun) {
156
+ fail('--dry-run previews the brownfield diff — --init is the greenfield generator (its output IS the whole migration; there is nothing cheaper to preview).');
157
+ process.exit(1);
158
+ }
130
159
  try {
131
- await dbGenerateInit(modelsPath, migrationsDir, schemaOut, flags.name || 'init');
160
+ await dbGenerateInit(modelsPath, migrationsDir, schemaOut, flags.name || 'init', schemaOutRes.path);
132
161
  } catch (err: any) {
133
162
  fail(err.message);
134
163
  process.exit(1);
@@ -137,7 +166,12 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
137
166
  }
138
167
 
139
168
  const apply = flags.apply === 'true';
169
+ if (dryRun && apply) {
170
+ fail('--dry-run and --apply are opposites — a dry run writes and executes nothing. Pick one.');
171
+ process.exit(1);
172
+ }
140
173
  let models: ModelDescriptor[];
174
+ let declaredDb: DeclaredDerived | null = null;
141
175
  let current;
142
176
  let liveAuthz;
143
177
  let runner!: QueryRunner;
@@ -145,12 +179,16 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
145
179
  try {
146
180
  step(`Loading models from ${modelsPath}...`);
147
181
  models = await loadModels(modelsPath);
182
+ declaredDb = await loadDeclaredDerived(flags.models);
148
183
  info(`${models.length} model(s).`);
149
184
  // The drizzle runtime schema is a derived artifact — refresh it from the models
150
185
  // 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');
186
+ // updates it. The drift-guard test asserts this file matches the models (and the
187
+ // typed derived relations B6a). A dry run refreshes NOTHING — not even this.
188
+ if (!dryRun) {
189
+ step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
190
+ await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived: declaredDb?.derived }), 'utf8');
191
+ }
154
192
  const dbSource: DbSource = resolveDbSource(flags);
155
193
  if (apply && dbSource.kind !== 'url') {
156
194
  fail('--apply needs a direct connection (--database-url or DATABASE_URL) — the ops Lambda query path is read-only by design.');
@@ -179,14 +217,29 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
179
217
 
180
218
  // One ordered migration carries both layers: data DDL first, then the authz reconcile
181
219
  // (RLS/policies/grants) the Models' abilities declare, diffed against the live contract.
182
- const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz });
220
+ const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences });
183
221
  const unmodeled = unmodeledTables(models, current);
184
222
  if (unmodeled.length) {
185
223
  info(`${unmodeled.length} table(s) in the database are not declared by any model — left untouched (db:generate manages only declared tables).`);
186
224
  }
187
225
  console.log('');
188
226
  if (statements.length === 0) {
189
- success('db:generate — the live database already matches the models. No migration written.');
227
+ success(`db:generate — the live database already matches the models. ${dryRun ? 'Nothing to preview.' : 'No migration written.'}`);
228
+ process.exit(0);
229
+ }
230
+
231
+ // D1 — --dry-run: the edge as SQL, nothing else. No migration file, no journal
232
+ // entry, no schema refresh — preview traffic belongs here (and db:diff computes
233
+ // the models-vs-models edge with no database at all).
234
+ if (dryRun) {
235
+ const classified = classifyGeneratedStatements(statements);
236
+ info(`dry run — ${statements.length} statement(s), nothing written:`);
237
+ console.log('');
238
+ for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
239
+ console.log('');
240
+ if (classified.heldDrops.length) warn(`${classified.heldDrops.length} destructive change(s) held back (shown as comments) — --allow-drops includes them.`);
241
+ if (classified.notices.length) info(`${classified.notices.length} notice(s) — manual follow-ups.`);
242
+ info('Nothing was written. Drop --dry-run to mint the migration, or `db:generate --apply` to run it directly on a dev database.');
190
243
  process.exit(0);
191
244
  }
192
245
 
@@ -209,7 +262,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
209
262
  let outcome: StateSyncOutcome;
210
263
  try {
211
264
  outcome = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
212
- allowDrops, actor: process.env.USER ?? null, gitRef: currentGitRef(),
265
+ allowDrops, sequences: declaredDb?.sequences, actor: process.env.USER ?? null, gitRef: currentGitRef(),
213
266
  });
214
267
  } catch (err: any) {
215
268
  fail(`Apply failed and rolled back: ${err.message}`);
@@ -228,14 +281,12 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
228
281
  }
229
282
 
230
283
  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());
284
+ const { filename, journal: nextJournal } = planMigrationFile(
285
+ journal ?? { version: '7', dialect: 'postgresql', entries: [] }, name, Date.now(),
286
+ );
287
+ // D2 — the resolved schema-out path rides the journal, so the next flag-less run
288
+ // keeps writing where this project writes.
289
+ nextJournal.schemaOut = schemaOutRes.path;
239
290
  const filePath = path.join(migrationsDir, filename);
240
291
  try {
241
292
  await fs.mkdir(path.join(migrationsDir, 'meta'), { recursive: true });