@everystack/cli 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cli/aws.ts +12 -3
- package/src/cli/commands/db-authz.ts +3 -17
- package/src/cli/commands/db-generate.ts +34 -17
- package/src/cli/commands/db-pull.ts +51 -7
- package/src/cli/commands/db.ts +7 -6
- package/src/cli/commands/update.ts +2 -1
- package/src/cli/declared-derived.ts +55 -18
- package/src/cli/derived-compile.ts +52 -2
- package/src/cli/derived-render.ts +48 -2
- package/src/cli/edge-plan.ts +4 -1
- package/src/cli/index.ts +1 -1
- package/src/cli/model-render.ts +20 -4
- package/src/cli/ops-advice.ts +66 -0
- package/src/cli/state-apply.ts +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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.4.
|
|
85
|
+
"@everystack/model": "0.4.1"
|
|
86
86
|
},
|
|
87
87
|
"peerDependencies": {
|
|
88
88
|
"@everystack/server": ">=0.1.0",
|
package/src/cli/aws.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { isOpsHandlerCrash, extractCrashDiagnosis, formatOpsCrashReport } from './ops-diagnostics.js';
|
|
9
|
+
import { tagInvokeTransport } from './ops-advice.js';
|
|
9
10
|
|
|
10
11
|
let s3Client: InstanceType<typeof import('@aws-sdk/client-s3').S3Client> | null = null;
|
|
11
12
|
let lambdaClient: InstanceType<typeof import('@aws-sdk/client-lambda').LambdaClient> | null = null;
|
|
@@ -262,6 +263,10 @@ export async function invokeAction(
|
|
|
262
263
|
const invoke = (fnName: string) =>
|
|
263
264
|
client.send(new InvokeCommand({ FunctionName: fnName, Payload: encoded }));
|
|
264
265
|
|
|
266
|
+
// A throw from the SDK means the Lambda NEVER RAN (credentials, permissions,
|
|
267
|
+
// network, missing function) — tagged as transport so the shells' IAM advice
|
|
268
|
+
// prints for THIS class only. A FunctionError below is the opposite: the
|
|
269
|
+
// handler ran and threw, and IAM advice would be a lie.
|
|
265
270
|
let response;
|
|
266
271
|
let invokedName = functionName;
|
|
267
272
|
try {
|
|
@@ -272,7 +277,7 @@ export async function invokeAction(
|
|
|
272
277
|
// then bust the stale cache so the next command discovers fresh.
|
|
273
278
|
if (err && typeof err === 'object' && (err as { name?: string }).name === 'ResourceNotFoundException') {
|
|
274
279
|
const live = await resolveFunctionName(region, functionName);
|
|
275
|
-
if (live === functionName) throw err;
|
|
280
|
+
if (live === functionName) throw tagInvokeTransport(err);
|
|
276
281
|
try {
|
|
277
282
|
const { invalidateCacheByFunctionName } = await import('./discover.js');
|
|
278
283
|
await invalidateCacheByFunctionName(functionName);
|
|
@@ -280,9 +285,13 @@ export async function invokeAction(
|
|
|
280
285
|
// Cache invalidation is best-effort
|
|
281
286
|
}
|
|
282
287
|
invokedName = live;
|
|
283
|
-
|
|
288
|
+
try {
|
|
289
|
+
response = await invoke(live);
|
|
290
|
+
} catch (err2: unknown) {
|
|
291
|
+
throw tagInvokeTransport(err2);
|
|
292
|
+
}
|
|
284
293
|
} else {
|
|
285
|
-
throw err;
|
|
294
|
+
throw tagInvokeTransport(err);
|
|
286
295
|
}
|
|
287
296
|
}
|
|
288
297
|
|
|
@@ -15,8 +15,6 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import path from 'node:path';
|
|
18
|
-
import { pathToFileURL } from 'node:url';
|
|
19
|
-
import type { ModelDescriptor } from '@everystack/model';
|
|
20
18
|
import {
|
|
21
19
|
introspectContract,
|
|
22
20
|
diffContracts,
|
|
@@ -47,6 +45,8 @@ import { resolveConfig, opsFunction } from '../config.js';
|
|
|
47
45
|
import { resolveModelsPath } from '../models-path.js';
|
|
48
46
|
import { invokeAction } from '../aws.js';
|
|
49
47
|
import { step, success, fail, info, warn } from '../output.js';
|
|
48
|
+
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
|
|
49
|
+
import { loadModels } from './db-generate.js';
|
|
50
50
|
|
|
51
51
|
const DEFAULT_DIR = 'authz';
|
|
52
52
|
|
|
@@ -75,7 +75,7 @@ export async function dbAuthzPullCommand(flags: Record<string, string>): Promise
|
|
|
75
75
|
contract = await introspectStage(flags);
|
|
76
76
|
} catch (err: any) {
|
|
77
77
|
fail(err.message);
|
|
78
|
-
|
|
78
|
+
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
79
79
|
process.exit(1);
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -207,20 +207,6 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
|
|
|
207
207
|
process.exit(1);
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
/** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work). */
|
|
211
|
-
async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
212
|
-
const abs = path.resolve(modelsPath);
|
|
213
|
-
let mod: any;
|
|
214
|
-
try {
|
|
215
|
-
mod = await import(pathToFileURL(abs).href);
|
|
216
|
-
} catch (err: any) {
|
|
217
|
-
throw new Error(`Could not load models from ${modelsPath}: ${err.message}`);
|
|
218
|
-
}
|
|
219
|
-
const models = mod.models ?? mod.default;
|
|
220
|
-
if (!Array.isArray(models)) throw new Error(`${modelsPath} must export a \`models\` array (got ${typeof models}).`);
|
|
221
|
-
return models;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
210
|
/**
|
|
225
211
|
* `db:authz:owner` — the owner-aware red-team. Where `db:authz:test` proves grants (can a ROLE
|
|
226
212
|
* do a command), this proves ISOLATION: can one user read or mutate another user's row? It is
|
|
@@ -31,8 +31,9 @@ import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
|
31
31
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
32
32
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
33
33
|
import { resolveModelsPath } from '../models-path.js';
|
|
34
|
-
import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived.js';
|
|
35
|
-
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';
|
|
36
37
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
37
38
|
import { invokeAction } from '../aws.js';
|
|
38
39
|
import { step, success, fail, info, warn } from '../output.js';
|
|
@@ -49,18 +50,24 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/** 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. */
|
|
53
55
|
export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
54
|
-
const abs = path.resolve(modelsPath);
|
|
55
|
-
let mod: any;
|
|
56
56
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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);
|
|
60
70
|
}
|
|
61
|
-
const models = mod.models ?? mod.default;
|
|
62
|
-
if (!Array.isArray(models)) throw new Error(`${modelsPath} must export a \`models\` array (got ${typeof models}).`);
|
|
63
|
-
return models;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
/**
|
|
@@ -210,8 +217,10 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
210
217
|
if (!apply) await end?.();
|
|
211
218
|
} catch (err: any) {
|
|
212
219
|
fail(err.message);
|
|
213
|
-
|
|
214
|
-
|
|
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);
|
|
215
224
|
process.exit(1);
|
|
216
225
|
}
|
|
217
226
|
|
|
@@ -233,12 +242,15 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
233
242
|
// the models-vs-models edge with no database at all).
|
|
234
243
|
if (dryRun) {
|
|
235
244
|
const classified = classifyGeneratedStatements(statements);
|
|
245
|
+
// Summary FIRST — the shape of the edge is the review surface; the SQL wall follows.
|
|
236
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.`);
|
|
237
251
|
console.log('');
|
|
238
252
|
for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
|
|
239
253
|
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
254
|
info('Nothing was written. Drop --dry-run to mint the migration, or `db:generate --apply` to run it directly on a dev database.');
|
|
243
255
|
process.exit(0);
|
|
244
256
|
}
|
|
@@ -248,12 +260,15 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
248
260
|
// means db:generate is a no-op again, not "a file was written".
|
|
249
261
|
if (apply) {
|
|
250
262
|
const classified = classifyGeneratedStatements(statements);
|
|
263
|
+
// Summary FIRST — the shape of the edge is the review surface; the SQL wall follows.
|
|
251
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.`);
|
|
252
269
|
console.log('');
|
|
253
270
|
for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
|
|
254
271
|
console.log('');
|
|
255
|
-
if (classified.heldDrops.length) warn(`${classified.heldDrops.length} destructive change(s) held back — NOT executed (re-run with --allow-drops to include them).`);
|
|
256
|
-
if (classified.notices.length) info(`${classified.notices.length} notice(s) — manual follow-ups, nothing executed for them.`);
|
|
257
272
|
if (classified.executable.length === 0) {
|
|
258
273
|
success('Nothing executable — only held/notice items differ.');
|
|
259
274
|
process.exit(0);
|
|
@@ -298,6 +313,8 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
298
313
|
}
|
|
299
314
|
|
|
300
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}`);
|
|
301
318
|
const warnings = statements.filter((s) => s.includes('-- WARNING')).length;
|
|
302
319
|
const notices = statements.filter((s) => s.startsWith('-- NOTICE')).length;
|
|
303
320
|
const held = statements.filter((s) => s.startsWith(HELD_DROP_PREFIX)).length;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
|
|
3
3
|
*
|
|
4
|
-
* db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>]
|
|
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,19 +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
33
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
|
|
27
|
-
import { renderDerivedSource, type DerivedRenderResult } from '../derived-render.js';
|
|
34
|
+
import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
|
|
28
35
|
import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
|
|
29
36
|
import type { QueryRunner } from '../authz-contract.js';
|
|
30
37
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
31
38
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
32
39
|
import { invokeAction } from '../aws.js';
|
|
33
40
|
import { fail } from '../output.js';
|
|
41
|
+
import { opsAdviceLines } from '../ops-advice.js';
|
|
34
42
|
|
|
35
43
|
// Progress → stderr, so stdout carries only the rendered source (this command is codegen,
|
|
36
44
|
// not a report — the shared output helpers write to stdout, which would corrupt a redirect).
|
|
@@ -51,6 +59,15 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
|
51
59
|
export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
|
|
52
60
|
const schema = flags.schema || 'public';
|
|
53
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
|
+
|
|
54
71
|
// The read-model scaffold: default 'commented' surfaces the authz decision in every
|
|
55
72
|
// model; a preset stamps it. Validated up front so a typo fails before introspection.
|
|
56
73
|
const abilities = flags.abilities || 'commented';
|
|
@@ -92,8 +109,12 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
92
109
|
if (dbSource.kind === 'url') {
|
|
93
110
|
detail('Check the connection string and that the database is accepting connections.');
|
|
94
111
|
} else {
|
|
95
|
-
|
|
96
|
-
|
|
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);
|
|
97
118
|
}
|
|
98
119
|
process.exit(1);
|
|
99
120
|
}
|
|
@@ -117,11 +138,34 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
117
138
|
detail(`Derived layer: ${derived.names.length} descriptor(s) + ${derived.sequenceNames.length} sequence(s) rendered (adopt with db:reconcile --baseline after committing).`);
|
|
118
139
|
}
|
|
119
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
|
+
|
|
120
164
|
let source: string;
|
|
121
165
|
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
122
166
|
// A directory: one file per model + index.ts — the default shape for a real app.
|
|
123
167
|
const dir = path.resolve(flags.out);
|
|
124
|
-
const files = renderModelFiles(current, { schema, abilities, derived });
|
|
168
|
+
const files = renderModelFiles(current, { schema, abilities, derived: embeddedDerived });
|
|
125
169
|
try {
|
|
126
170
|
await fs.mkdir(dir, { recursive: true });
|
|
127
171
|
const written = new Set(files.map((f) => f.file));
|
|
@@ -138,7 +182,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
138
182
|
source = files.map((f) => f.source).join('\n');
|
|
139
183
|
} else if (flags.out) {
|
|
140
184
|
const outPath = path.resolve(flags.out);
|
|
141
|
-
source = renderModelSource(current, { schema, abilities, derived });
|
|
185
|
+
source = renderModelSource(current, { schema, abilities, derived: embeddedDerived });
|
|
142
186
|
try {
|
|
143
187
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
144
188
|
await fs.writeFile(outPath, source, 'utf8');
|
|
@@ -148,7 +192,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
148
192
|
}
|
|
149
193
|
ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
|
|
150
194
|
} else {
|
|
151
|
-
source = renderModelSource(current, { schema, abilities, derived });
|
|
195
|
+
source = renderModelSource(current, { schema, abilities, derived: embeddedDerived });
|
|
152
196
|
process.stdout.write(source);
|
|
153
197
|
ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
|
|
154
198
|
}
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
9
9
|
import { invokeAction } from '../aws.js';
|
|
10
10
|
import { step, success, fail, info, warn } from '../output.js';
|
|
11
|
+
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
|
|
11
12
|
|
|
12
13
|
/** Secret-store keys that may hold the privileged (operator) connection URL,
|
|
13
14
|
* in precedence order. Names vary by deployment; --secret-name overrides. */
|
|
@@ -69,7 +70,7 @@ export async function dbMigrateCommand(flags: Record<string, string>): Promise<v
|
|
|
69
70
|
}
|
|
70
71
|
} catch (err: any) {
|
|
71
72
|
fail(`Migration failed: ${err.message}`);
|
|
72
|
-
|
|
73
|
+
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
73
74
|
process.exit(1);
|
|
74
75
|
}
|
|
75
76
|
}
|
|
@@ -105,7 +106,7 @@ export async function dbSeedCommand(flags: Record<string, string>): Promise<void
|
|
|
105
106
|
}
|
|
106
107
|
} catch (err: any) {
|
|
107
108
|
fail(`Seed failed: ${err.message}`);
|
|
108
|
-
|
|
109
|
+
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
109
110
|
process.exit(1);
|
|
110
111
|
}
|
|
111
112
|
}
|
|
@@ -138,7 +139,7 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
|
|
|
138
139
|
success(result?.message || 'Database reset and migrations applied');
|
|
139
140
|
} catch (err: any) {
|
|
140
141
|
fail(`Reset failed: ${err.message}`);
|
|
141
|
-
|
|
142
|
+
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
142
143
|
process.exit(1);
|
|
143
144
|
}
|
|
144
145
|
}
|
|
@@ -172,7 +173,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
172
173
|
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword });
|
|
173
174
|
} catch (err: any) {
|
|
174
175
|
fail(`db:provision failed: ${err.message}`);
|
|
175
|
-
|
|
176
|
+
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
176
177
|
process.exit(1);
|
|
177
178
|
}
|
|
178
179
|
if (result?.error) {
|
|
@@ -236,7 +237,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
236
237
|
report = await invokeAction(config.region, opsFunction(config), 'db:doctor', {});
|
|
237
238
|
} catch (err: any) {
|
|
238
239
|
fail(`db:doctor failed: ${err.message}`);
|
|
239
|
-
|
|
240
|
+
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
240
241
|
process.exit(1);
|
|
241
242
|
}
|
|
242
243
|
|
|
@@ -327,7 +328,7 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
|
|
|
327
328
|
process.exit(0);
|
|
328
329
|
} catch (err: any) {
|
|
329
330
|
fail(`Query failed: ${err.message}`);
|
|
330
|
-
|
|
331
|
+
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
331
332
|
process.exit(1);
|
|
332
333
|
}
|
|
333
334
|
}
|
|
@@ -8,6 +8,7 @@ import { spawn } from 'node:child_process';
|
|
|
8
8
|
import { resolveConfig, type CliConfig } from '../config.js';
|
|
9
9
|
import { uploadToS3, headS3, invokeAction } from '../aws.js';
|
|
10
10
|
import { step, success, warn, fail, info } from '../output.js';
|
|
11
|
+
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
|
|
11
12
|
import { exportApp } from '../utils/export.js';
|
|
12
13
|
import { walkDirectory } from '../utils/walk.js';
|
|
13
14
|
import { loadSstSecrets } from '../utils/secrets.js';
|
|
@@ -301,7 +302,7 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
301
302
|
}
|
|
302
303
|
} catch (err: any) {
|
|
303
304
|
fail(`Registration failed for ${platform}: ${err.message}`);
|
|
304
|
-
|
|
305
|
+
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
305
306
|
process.exit(1);
|
|
306
307
|
}
|
|
307
308
|
}
|
|
@@ -16,6 +16,7 @@ import { compileDerived } from './derived-compile.js';
|
|
|
16
16
|
import { compileTableRenames } from './schema-compile.js';
|
|
17
17
|
import type { SourceObject } from './derived-source.js';
|
|
18
18
|
import { resolveModelsPath } from './models-path.js';
|
|
19
|
+
import { asModelComposeError } from './ops-advice.js';
|
|
19
20
|
|
|
20
21
|
export interface DeclaredDerived {
|
|
21
22
|
/** Descriptor-compiled derived objects, in dependency order. */
|
|
@@ -28,19 +29,44 @@ export interface DeclaredDerived {
|
|
|
28
29
|
renamedTables: Record<string, string>;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* The loud undefined gate at the barrel boundary: a bare `export const models = [...]`
|
|
34
|
+
* never passes through defineModule, so a missing named import (undefined under CJS
|
|
35
|
+
* interop) survives to the loader. Name the file, the list, and the index here.
|
|
36
|
+
*/
|
|
37
|
+
export function assertNoHoles(source: string, listName: string, list: readonly unknown[]): void {
|
|
38
|
+
list.forEach((entry, i) => {
|
|
39
|
+
if (entry == null) {
|
|
40
|
+
throw new Error(`${source}: ${listName}[${i}] is ${entry === undefined ? 'undefined' : 'null'} — renamed or missing import?`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Barrel → normalized Module list (a bare `models` export wraps into one module).
|
|
46
|
+
* Every failure here is the operator's own barrel — thrown as ModelComposeError so
|
|
47
|
+
* the command shells never dress it as an IAM/Lambda problem. */
|
|
32
48
|
export async function loadModulesFrom(modelsPath: string): Promise<Module[]> {
|
|
33
|
-
const abs = path.resolve(modelsPath);
|
|
34
|
-
let mod: any;
|
|
35
49
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
const abs = path.resolve(modelsPath);
|
|
51
|
+
let mod: any;
|
|
52
|
+
try {
|
|
53
|
+
mod = await import(pathToFileURL(abs).href);
|
|
54
|
+
} catch (err: any) {
|
|
55
|
+
throw new Error(`Could not load modules from ${modelsPath}: ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(mod.modules)) {
|
|
58
|
+
assertNoHoles(modelsPath, 'modules', mod.modules);
|
|
59
|
+
return mod.modules;
|
|
60
|
+
}
|
|
61
|
+
const models = mod.models ?? mod.default;
|
|
62
|
+
if (Array.isArray(models)) {
|
|
63
|
+
assertNoHoles(modelsPath, 'models', models);
|
|
64
|
+
return [{ models, extensions: [], sequences: [], derived: [], functions: [] }];
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw asModelComposeError(modelsPath, err);
|
|
39
69
|
}
|
|
40
|
-
if (Array.isArray(mod.modules)) return mod.modules;
|
|
41
|
-
const models = mod.models ?? mod.default;
|
|
42
|
-
if (Array.isArray(models)) return [{ models, extensions: [], sequences: [], derived: [], functions: [] }];
|
|
43
|
-
throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
/**
|
|
@@ -125,12 +151,23 @@ export async function loadDeclaredDerived(modelsFlag?: string): Promise<Declared
|
|
|
125
151
|
return null;
|
|
126
152
|
}
|
|
127
153
|
const modules = await loadModulesFrom(modelsPath);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
154
|
+
return composeDeclaredDerived(modules, modelsPath);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The pure compose half of {@link loadDeclaredDerived} — every gate failure in the
|
|
158
|
+
* compile (holes, reachability, cycles, undeclared trigger functions) is the
|
|
159
|
+
* operator's own declaration, wrapped as ModelComposeError naming the barrel. */
|
|
160
|
+
export function composeDeclaredDerived(modules: Module[], modelsPath: string): DeclaredDerived {
|
|
161
|
+
try {
|
|
162
|
+
const models = modules.flatMap((m) => m.models);
|
|
163
|
+
const derived = modules.flatMap((m) => m.derived);
|
|
164
|
+
return {
|
|
165
|
+
objects: compileDerived(models, derived),
|
|
166
|
+
derived,
|
|
167
|
+
sequences: modules.flatMap((m) => m.sequences),
|
|
168
|
+
renamedTables: compileTableRenames(models, {}),
|
|
169
|
+
};
|
|
170
|
+
} catch (err) {
|
|
171
|
+
throw asModelComposeError(modelsPath, err);
|
|
172
|
+
}
|
|
136
173
|
}
|
|
@@ -192,6 +192,50 @@ function derivedIdentity(d: DerivedDescriptor): string {
|
|
|
192
192
|
return `${schema}.${name}`;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/** What a bad ref IS, for the gate's message — null if the value is a usable object. */
|
|
196
|
+
function badRef(r: unknown): string | null {
|
|
197
|
+
if (r === undefined) return 'undefined';
|
|
198
|
+
if (r === null) return 'null';
|
|
199
|
+
if (typeof r !== 'object') return `not a descriptor (${typeof r})`;
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The loud undefined gate — every list compileDerived walks, checked up front. The
|
|
205
|
+
* message shape mirrors the define-time gate in @everystack/model: owner, list,
|
|
206
|
+
* index, and the likely cause (a missing named import evaluates to undefined).
|
|
207
|
+
*/
|
|
208
|
+
function rejectBadRefs(models: readonly ModelDescriptor[], derived: readonly DerivedDescriptor[]): void {
|
|
209
|
+
const hole = (context: string, listName: string, list: readonly unknown[], indexed = true): void => {
|
|
210
|
+
list.forEach((entry, i) => {
|
|
211
|
+
const bad = badRef(entry);
|
|
212
|
+
if (bad) throw new Error(`${context}: ${listName}${indexed ? `[${i}]` : ''} is ${bad} — renamed or missing import?`);
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
hole('compileDerived', 'models', models);
|
|
216
|
+
hole('compileDerived', 'derived', derived);
|
|
217
|
+
for (const d of derived) {
|
|
218
|
+
hole(`${d.kind} '${d.name}'`, 'dependsOn', d.dependsOn ?? []);
|
|
219
|
+
if (d.kind === 'function' && typeof d.returns !== 'string') {
|
|
220
|
+
hole(`function '${d.name}'`, 'returns.setof', [d.returns.setof], false);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const triggerOwners = [
|
|
224
|
+
...models.map((m) => ({ owner: `${m.schema}.${m.table}`, triggers: m.triggers ?? [] })),
|
|
225
|
+
...derived.filter((d): d is ViewDescriptor => d.kind === 'view').map((v) => {
|
|
226
|
+
const q = parseQualified(v.name);
|
|
227
|
+
return { owner: `${q.schema}.${q.name}`, triggers: v.triggers ?? [] };
|
|
228
|
+
}),
|
|
229
|
+
];
|
|
230
|
+
for (const { owner, triggers } of triggerOwners) {
|
|
231
|
+
hole(owner, 'triggers', triggers);
|
|
232
|
+
for (const t of triggers) {
|
|
233
|
+
const bad = badRef(t.execute);
|
|
234
|
+
if (bad) throw new Error(`trigger '${t.name}' on ${owner}: execute is ${bad} — renamed or missing import?`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
195
239
|
function make(kind: SourceObject['kind'], rawName: string, sql: string, attachments: Attachment[], extra: Partial<SourceObject> = {}): (seq: number) => SourceObject {
|
|
196
240
|
const { schema, name } = parseQualified(rawName);
|
|
197
241
|
return (seq) => ({
|
|
@@ -210,6 +254,12 @@ function make(kind: SourceObject['kind'], rawName: string, sql: string, attachme
|
|
|
210
254
|
* the derived set — a trigger cannot draw on a function nothing declares.
|
|
211
255
|
*/
|
|
212
256
|
export function compileDerived(models: readonly ModelDescriptor[], derived: readonly DerivedDescriptor[]): SourceObject[] {
|
|
257
|
+
// The loud undefined gate, compile-side. Define-time already rejects holes, but
|
|
258
|
+
// hand-rolled descriptors and a stale @everystack/model can carry them here — so a
|
|
259
|
+
// bad ref is a HARD error naming the descriptor and index, never a silent drop
|
|
260
|
+
// (the old non-object filter dropped the edge and let the lint crash on it later).
|
|
261
|
+
rejectBadRefs(models, derived);
|
|
262
|
+
|
|
213
263
|
// Invoker-view reachability is a COMPILE fail (B2): a granted-but-unreadable view is
|
|
214
264
|
// structurally broken — every verb that consumes descriptors refuses, not just db:check.
|
|
215
265
|
const unreachable = findInvokerReachabilityGaps(derived);
|
|
@@ -220,9 +270,9 @@ export function compileDerived(models: readonly ModelDescriptor[], derived: read
|
|
|
220
270
|
const identities = new Set(derived.map(derivedIdentity));
|
|
221
271
|
const nodes: Node[] = [];
|
|
222
272
|
|
|
223
|
-
const depIdentities = (refs: readonly
|
|
273
|
+
const depIdentities = (refs: readonly DependsOnRef[]): string[] =>
|
|
224
274
|
refs
|
|
225
|
-
.filter((r): r is DerivedDescriptor =>
|
|
275
|
+
.filter((r): r is DerivedDescriptor => !('table' in r))
|
|
226
276
|
.map((r) => derivedIdentity(r))
|
|
227
277
|
.filter((id) => identities.has(id));
|
|
228
278
|
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
|
|
17
17
|
import type { SequenceSchema } from './schema-introspect.js';
|
|
18
18
|
import { parseIndexDefinition } from './schema-introspect.js';
|
|
19
|
+
import { modelFileName } from './model-render.js';
|
|
19
20
|
|
|
20
21
|
export interface DerivedRenderResult {
|
|
21
22
|
/** The source block: `export const … = defineView(…)` etc., dependency-ordered. */
|
|
@@ -26,6 +27,9 @@ export interface DerivedRenderResult {
|
|
|
26
27
|
sequenceNames: string[];
|
|
27
28
|
/** Model-package symbols the block uses (defineView, can, sql, arg, index, …). */
|
|
28
29
|
imports: string[];
|
|
30
|
+
/** Qualified tables the block references through knownTables — what a standalone
|
|
31
|
+
* derived file must import (sorted). */
|
|
32
|
+
modelRefs: string[];
|
|
29
33
|
/** Everything skipped or flagged — mirrored as FIXME comments in the block. */
|
|
30
34
|
warnings: string[];
|
|
31
35
|
}
|
|
@@ -39,6 +43,12 @@ export interface DerivedRenderOptions {
|
|
|
39
43
|
|
|
40
44
|
const PLAIN_IDENT = /^[a-z_][a-z0-9_$]*$/;
|
|
41
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Derived symbols are camelCase VERBATIM — never singularized (deliberately distinct
|
|
48
|
+
* from modelVarName's rule; a matview named `user_sessions` is `userSessions`).
|
|
49
|
+
* NAMING CONTRACT — a rename here breaks consumer splices; pinned by
|
|
50
|
+
* naming-contract.test.ts.
|
|
51
|
+
*/
|
|
42
52
|
function toCamelCase(name: string): string {
|
|
43
53
|
return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
44
54
|
}
|
|
@@ -186,6 +196,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
186
196
|
|
|
187
197
|
// -- per-object rendering ----------------------------------------------------
|
|
188
198
|
|
|
199
|
+
const modelRefs = new Set<string>();
|
|
189
200
|
const depsFor = (identity: string): { refs: string[]; fixmes: string[] } => {
|
|
190
201
|
const refs: string[] = [];
|
|
191
202
|
const fixmes: string[] = [];
|
|
@@ -195,8 +206,10 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
195
206
|
seen.add(e.referenced);
|
|
196
207
|
const model = opts.knownTables.get(e.referenced);
|
|
197
208
|
const derived = varOf.get(e.referenced);
|
|
198
|
-
if (model)
|
|
199
|
-
|
|
209
|
+
if (model) {
|
|
210
|
+
refs.push(model);
|
|
211
|
+
modelRefs.add(e.referenced);
|
|
212
|
+
} else if (derived) refs.push(derived);
|
|
200
213
|
else fixmes.push(`// FIXME: dependsOn ${e.referenced} — not in the pulled set; declare it by hand.`);
|
|
201
214
|
}
|
|
202
215
|
return { refs: refs.sort(), fixmes };
|
|
@@ -315,6 +328,39 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
315
328
|
names,
|
|
316
329
|
sequenceNames,
|
|
317
330
|
imports: [...used].sort(),
|
|
331
|
+
modelRefs: [...modelRefs].sort(),
|
|
318
332
|
warnings,
|
|
319
333
|
};
|
|
320
334
|
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The standalone derived module — `db:pull --derived-out <file>` (the brownfield
|
|
338
|
+
* splice, first-class). A consumer with an existing hand-maintained barrel wants the
|
|
339
|
+
* derived layer as its OWN file, not spliced out of a rendered monolith by hand: this
|
|
340
|
+
* renders exactly the shape their module wires — model-package imports, model-var
|
|
341
|
+
* imports from the contract kebab files (naming-contract.test.ts), the descriptors,
|
|
342
|
+
* and the `sequences`/`derived` arrays for `defineModule({ models, sequences, derived })`.
|
|
343
|
+
*/
|
|
344
|
+
export function renderDerivedFile(result: DerivedRenderResult, knownTables: Map<string, string>): string {
|
|
345
|
+
const lines: string[] = [
|
|
346
|
+
'// Derived layer (views, materialized views, functions, sequences) — rendered by `everystack db:pull`.',
|
|
347
|
+
'// Bodies are the catalog\'s canonical deparse (they never match an authored spelling byte-for-byte);',
|
|
348
|
+
'// commit, wire it on your module — defineModule({ models, sequences, derived }) — then adopt with',
|
|
349
|
+
'// `db:reconcile --apply --baseline` (or --rebaseline over db/sql-era provenance). docs/derived-objects.md.',
|
|
350
|
+
];
|
|
351
|
+
if (result.imports.length) {
|
|
352
|
+
lines.push(`import { ${result.imports.join(', ')} } from '@everystack/model';`);
|
|
353
|
+
}
|
|
354
|
+
const refs = result.modelRefs
|
|
355
|
+
.map((table) => ({ varName: knownTables.get(table)!, path: `./${modelFileName(table).replace(/\.ts$/, '')}` }))
|
|
356
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
357
|
+
for (const ref of refs) lines.push(`import { ${ref.varName} } from '${ref.path}';`);
|
|
358
|
+
lines.push('', result.block, '');
|
|
359
|
+
if (result.sequenceNames.length) {
|
|
360
|
+
lines.push(`export const sequences = [\n${result.sequenceNames.map((n) => ` ${n},`).join('\n')}\n];`);
|
|
361
|
+
}
|
|
362
|
+
if (result.names.length) {
|
|
363
|
+
lines.push(`export const derived = [\n${result.names.map((n) => ` ${n},`).join('\n')}\n];`);
|
|
364
|
+
}
|
|
365
|
+
return lines.join('\n') + '\n';
|
|
366
|
+
}
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -29,7 +29,7 @@ import type { ModelDescriptor } from '@everystack/model';
|
|
|
29
29
|
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
30
30
|
import type { AuthzContract } from './authz-contract.js';
|
|
31
31
|
import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
|
|
32
|
-
import { classifyGeneratedStatements, classifyDestructive } from './state-apply.js';
|
|
32
|
+
import { classifyGeneratedStatements, classifyDestructive, renderStatementHistogram } from './state-apply.js';
|
|
33
33
|
import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
|
|
34
34
|
import { compileDeclaredState } from './declared-diff.js';
|
|
35
35
|
import { compileTableRenames } from './schema-compile.js';
|
|
@@ -202,6 +202,9 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
|
|
|
202
202
|
return lines;
|
|
203
203
|
}
|
|
204
204
|
lines.push(`${plan.executable} executable statement(s), ${plan.notices} notice(s)`);
|
|
205
|
+
// The shape first — a four-digit edge is reviewable as a histogram, not a wall.
|
|
206
|
+
const histogram = renderStatementHistogram(classifyGeneratedStatements(plan.statements).executable);
|
|
207
|
+
if (histogram) lines.push(`shape: ${histogram}`);
|
|
205
208
|
if (plan.destructive > 0) {
|
|
206
209
|
const { drops, narrowings } = classifyDestructive(classifyGeneratedStatements(plan.statements).executable);
|
|
207
210
|
lines.push(
|
package/src/cli/index.ts
CHANGED
|
@@ -354,7 +354,7 @@ Usage:
|
|
|
354
354
|
everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
|
|
355
355
|
everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
|
|
356
356
|
everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
|
|
357
|
-
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. Every model scaffolds its authz decision as comments (db:check fails until authored); --abilities public-read stamps the common stanza (public read, admin write) uncommented — explicit generated code, never a runtime default
|
|
357
|
+
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored); --abilities public-read stamps the common stanza (public read, admin write) uncommented — explicit generated code, never a runtime default
|
|
358
358
|
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
359
359
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
360
360
|
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (direct connection only, atomic — DDL + provenance in one transaction) and records provenance + schema_log. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive.
|
package/src/cli/model-render.ts
CHANGED
|
@@ -166,7 +166,16 @@ export function pullableTables(snapshot: SchemaSnapshot, schema: string): TableS
|
|
|
166
166
|
return snapshot.tables.filter((t) => t.table.startsWith(`${schema}.`) && !INFRASTRUCTURE_TABLES.has(bareName(t.table)));
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* A table name → its model variable: PascalCase, naively singularized (one trailing
|
|
171
|
+
* `s` strips, whatever the word). `image_variants` → `ImageVariant`.
|
|
172
|
+
*
|
|
173
|
+
* NAMING CONTRACT — changing this rule is a BREAKING CHANGE. Consumer barrels build
|
|
174
|
+
* on the rendered symbols (derived dependsOn refs, cross-file FK imports, index
|
|
175
|
+
* re-exports); a rename here breaks every brownfield splice on the next pull. The
|
|
176
|
+
* exact vocabulary — including the naive `status` → `Statu` and the singular/plural
|
|
177
|
+
* collision — is pinned by naming-contract.test.ts.
|
|
178
|
+
*/
|
|
170
179
|
export function modelVarName(table: string): string {
|
|
171
180
|
const bare = bareName(table);
|
|
172
181
|
const singular = bare.endsWith('s') ? bare.slice(0, -1) : bare;
|
|
@@ -177,7 +186,7 @@ export function modelVarName(table: string): string {
|
|
|
177
186
|
* `author_id` → `authorId` — a SQL column to its field key, LOSSLESSLY: the compiler's
|
|
178
187
|
* snake_case must reconstruct the exact column (`snake(key) === column`, always). Only a
|
|
179
188
|
* letter after `_` camelizes, because only a capital letter survives the trip back — an
|
|
180
|
-
* underscore before a digit is preserved (`
|
|
189
|
+
* underscore before a digit is preserved (`percentile_100` stays `percentile_100`,
|
|
181
190
|
* `top_3_best` → `top_3Best`). Swallowing it (the old `_([a-z0-9])` rule) generated a
|
|
182
191
|
* phantom rename for every digit-boundary column and COLLIDED distinct columns
|
|
183
192
|
* (`a_1` and `a1`) onto one key.
|
|
@@ -397,8 +406,15 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
|
|
|
397
406
|
return [header, ...blocks, footer].join('\n\n') + '\n';
|
|
398
407
|
}
|
|
399
408
|
|
|
400
|
-
/**
|
|
401
|
-
|
|
409
|
+
/**
|
|
410
|
+
* `image_variants` → `image-variants.ts` — kebab of the bare table, NEVER singularized
|
|
411
|
+
* (the file carries the table name, greppable against the database; the var carries
|
|
412
|
+
* the singularized model name — the two disagree on number, on purpose).
|
|
413
|
+
*
|
|
414
|
+
* NAMING CONTRACT — changing this rule is a BREAKING CHANGE: index.ts and cross-file
|
|
415
|
+
* FK imports reference these paths. Pinned by naming-contract.test.ts.
|
|
416
|
+
*/
|
|
417
|
+
export function modelFileName(table: string): string {
|
|
402
418
|
return `${bareName(table).replace(/_/g, '-')}.ts`;
|
|
403
419
|
}
|
|
404
420
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bimodal failure advice — which advice a command shell may print after `fail()`.
|
|
3
|
+
*
|
|
4
|
+
* The failure taxonomy behind every deployed-stage verb has exactly three classes,
|
|
5
|
+
* and the advice must match the class (a model-compose error dressed as an IAM
|
|
6
|
+
* problem sends the operator to AWS for a typo in their own models file):
|
|
7
|
+
*
|
|
8
|
+
* - MODEL COMPOSE — the barrel failed to load, or the descriptors failed a
|
|
9
|
+
* define/compile gate. Thrown as {@link ModelComposeError} by the loaders.
|
|
10
|
+
* Advice: fix the models file. Never mention IAM.
|
|
11
|
+
* - INVOKE TRANSPORT — the AWS SDK threw before the Lambda ran (credentials,
|
|
12
|
+
* permissions, network, missing function). Tagged by `invokeAction` via
|
|
13
|
+
* {@link tagInvokeTransport}. Advice: the classic IAM line.
|
|
14
|
+
* - EVERYTHING ELSE — the Lambda RAN and returned an error, a SQL statement
|
|
15
|
+
* failed, a local connection refused. No advice; the message is the truth.
|
|
16
|
+
*
|
|
17
|
+
* Pure (printer-agnostic): shells print the returned lines with their own channel —
|
|
18
|
+
* `info` for report commands, stderr `detail` for codegen commands like db:pull.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The classic transport advice line — callers compose extras around it. */
|
|
22
|
+
export const IAM_ADVICE = 'Ensure your IAM user/role has lambda:InvokeFunction permission.';
|
|
23
|
+
|
|
24
|
+
/** A failure in the operator's OWN declared models — load or compose. */
|
|
25
|
+
export class ModelComposeError extends Error {
|
|
26
|
+
readonly modelsPath: string;
|
|
27
|
+
constructor(modelsPath: string, cause: unknown) {
|
|
28
|
+
super(cause instanceof Error ? cause.message : String(cause));
|
|
29
|
+
this.name = 'ModelComposeError';
|
|
30
|
+
this.modelsPath = modelsPath;
|
|
31
|
+
if (cause instanceof Error && cause.stack) this.stack = cause.stack;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Wrap as compose unless already wrapped (nesting would bury the models path). */
|
|
36
|
+
export function asModelComposeError(modelsPath: string, err: unknown): ModelComposeError {
|
|
37
|
+
return err instanceof ModelComposeError ? err : new ModelComposeError(modelsPath, err);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const INVOKE_TRANSPORT = Symbol.for('everystack.invokeTransport');
|
|
41
|
+
|
|
42
|
+
/** Mark an error as thrown by the AWS transport — the Lambda never ran. */
|
|
43
|
+
export function tagInvokeTransport<T>(err: T): T {
|
|
44
|
+
if (typeof err === 'object' && err !== null) {
|
|
45
|
+
(err as Record<symbol, unknown>)[INVOKE_TRANSPORT] = true;
|
|
46
|
+
}
|
|
47
|
+
return err;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isInvokeTransport(err: unknown): boolean {
|
|
51
|
+
return typeof err === 'object' && err !== null && (err as Record<symbol, unknown>)[INVOKE_TRANSPORT] === true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The advice for a failure, by class: compose → the models file; transport → the
|
|
56
|
+
* caller's IAM lines; anything else → nothing.
|
|
57
|
+
*/
|
|
58
|
+
export function opsAdviceLines(err: unknown, iamLines: readonly string[]): string[] {
|
|
59
|
+
if (err instanceof ModelComposeError) {
|
|
60
|
+
return [
|
|
61
|
+
`The failure is in your declared models — fix ${err.modelsPath}. Nothing was invoked in AWS.`,
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
if (isInvokeTransport(err)) return [...iamLines];
|
|
65
|
+
return [];
|
|
66
|
+
}
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -87,6 +87,54 @@ export function classifyDestructive(executable: string[]): DestructiveBreakdown
|
|
|
87
87
|
return { drops, narrowings };
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The kind histogram — what a plan IS, before anyone reads its SQL. A real edge can
|
|
92
|
+
* run to four digits of statements (a consumer's adoption edge was 1,671), and the
|
|
93
|
+
* reviewable fact is the shape: how many policies, grants, RLS toggles, tables. The
|
|
94
|
+
* kind is decided by the first non-comment line, so WARNING prologues don't hide it.
|
|
95
|
+
*/
|
|
96
|
+
const KIND_MATCHERS: Array<[RegExp, string]> = [
|
|
97
|
+
[/ROW LEVEL SECURITY/, 'rls'],
|
|
98
|
+
[/^(CREATE|DROP|ALTER)\s+POLICY\b/, 'policies'],
|
|
99
|
+
[/^(GRANT|REVOKE)\b/, 'grants'],
|
|
100
|
+
[/^CREATE\s+TABLE\b/, 'create table'],
|
|
101
|
+
[/^DROP\s+TABLE\b/, 'drop table'],
|
|
102
|
+
[/^ALTER\s+TABLE\b/, 'alter table'],
|
|
103
|
+
[/^(CREATE\s+(UNIQUE\s+)?INDEX|DROP\s+INDEX|ALTER\s+INDEX)\b/, 'indexes'],
|
|
104
|
+
[/^(CREATE|ALTER|DROP)\s+SEQUENCE\b/, 'sequences'],
|
|
105
|
+
[/^CREATE\s+EXTENSION\b/, 'extensions'],
|
|
106
|
+
[/^(CREATE|ALTER|DROP)\s+TYPE\b/, 'types'],
|
|
107
|
+
[/^COMMENT\s+ON\b/, 'comments'],
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
function statementKind(statement: string): string {
|
|
111
|
+
const first = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '';
|
|
112
|
+
const s = first.trim().toUpperCase();
|
|
113
|
+
for (const [re, kind] of KIND_MATCHERS) {
|
|
114
|
+
if (re.test(s)) return kind;
|
|
115
|
+
}
|
|
116
|
+
return 'other';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Bucket executable statements by kind — largest bucket first, ties by name. */
|
|
120
|
+
export function histogramStatements(executable: string[]): Array<{ kind: string; count: number }> {
|
|
121
|
+
const counts = new Map<string, number>();
|
|
122
|
+
for (const statement of executable) {
|
|
123
|
+
const kind = statementKind(statement);
|
|
124
|
+
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
|
125
|
+
}
|
|
126
|
+
return [...counts.entries()]
|
|
127
|
+
.map(([kind, count]) => ({ kind, count }))
|
|
128
|
+
.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The one-line rendering (`402 policies, 398 grants, 201 rls, …`); null when empty. */
|
|
132
|
+
export function renderStatementHistogram(executable: string[]): string | null {
|
|
133
|
+
const kinds = histogramStatements(executable);
|
|
134
|
+
if (kinds.length === 0) return null;
|
|
135
|
+
return kinds.map(({ kind, count }) => `${count} ${kind}`).join(', ');
|
|
136
|
+
}
|
|
137
|
+
|
|
90
138
|
/** The current git HEAD, for schema_log's intent pointer. Null outside a repo. */
|
|
91
139
|
export function currentGitRef(): string | null {
|
|
92
140
|
try {
|