@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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/.pdr-tmp-20277-1783706384842.ts +59 -0
- package/src/.roundtrip-tmp-20275-1783706385459.ts +121 -0
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/aws.ts +12 -3
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-authz.ts +3 -17
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +84 -33
- package/src/cli/commands/db-fingerprint.ts +7 -2
- package/src/cli/commands/db-generate.ts +102 -34
- package/src/cli/commands/db-pull.ts +82 -6
- package/src/cli/commands/db-reconcile.ts +132 -41
- package/src/cli/commands/db-sync.ts +54 -19
- package/src/cli/commands/db.ts +7 -6
- package/src/cli/commands/update.ts +2 -1
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +5 -1
- package/src/cli/declared-derived.ts +173 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +377 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +176 -10
- package/src/cli/derived-render.ts +366 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/edge-plan.ts +4 -1
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +6 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +125 -16
- package/src/cli/models-path.ts +1 -1
- package/src/cli/ops-advice.ts +66 -0
- package/src/cli/pipeline-path.ts +1 -1
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- 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 {
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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(
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 {
|
|
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
|
-
|
|
82
|
-
|
|
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
|
}
|