@everystack/cli 0.4.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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.0"
85
+ "@everystack/model": "0.4.1"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "@everystack/server": ">=0.1.0",
@@ -0,0 +1,59 @@
1
+ import { defineModel, field, can, index, defineModule, sql, arg, defineFunction, defineMaterializedView, defineSequence, defineView } from '@everystack/model';
2
+
3
+ export const PdrPost = defineModel('pdr_posts', {
4
+ abilities: [can('read'), can('manage', { role: 'admin' })],
5
+ fields: {
6
+ id: field.integer().primaryKey(),
7
+ title: field.text().notNull(),
8
+ tags: field.text().array().notNull().default("{}"),
9
+ },
10
+ });
11
+
12
+ export const pdrTicketSeq = defineSequence('pdr_ticket_seq', { start: 500 });
13
+
14
+ export const pdrsPdrArea = defineFunction('pdrs.pdr_area', {
15
+ args: [arg('w', 'integer'), arg('h', 'integer')],
16
+ returns: 'integer',
17
+ language: 'sql',
18
+ volatility: 'immutable',
19
+ abilities: [can('execute', { role: 'authenticated' })],
20
+ body: sql`SELECT w * h`,
21
+ });
22
+
23
+ export const pdrBoard = defineMaterializedView('pdr_board', {
24
+ abilities: [can('read')],
25
+ dependsOn: [PdrPost],
26
+ indexes: [index('tags').using('gin')],
27
+ as: sql`SELECT tags,
28
+ count(*) AS n
29
+ FROM pdr_posts
30
+ GROUP BY tags`,
31
+ });
32
+
33
+ export const pdrScore = defineFunction('pdr_score', {
34
+ args: [arg('points', 'integer', { default: sql`1` })],
35
+ returns: 'integer',
36
+ language: 'sql',
37
+ volatility: 'stable',
38
+ abilities: [can('execute', { role: 'authenticated' })],
39
+ body: sql`SELECT points * 2`,
40
+ });
41
+
42
+ export const pdrTitles = defineView('pdr_titles', {
43
+ securityInvoker: false,
44
+ abilities: [can('read')],
45
+ dependsOn: [PdrPost],
46
+ as: sql`SELECT id,
47
+ title
48
+ FROM pdr_posts`,
49
+ });
50
+
51
+ export const models = [PdrPost];
52
+
53
+ export const sequences = [pdrTicketSeq];
54
+
55
+ export const derived = [pdrsPdrArea, pdrBoard, pdrScore, pdrTitles];
56
+
57
+ export const appModule = defineModule({ models, sequences, derived });
58
+
59
+ export const modules = [appModule];
@@ -0,0 +1,121 @@
1
+ import { defineModel, field, unique, check, index, foreignKey, defineModule, sql } from '@everystack/model';
2
+ import { z } from 'zod';
3
+
4
+ export const RtAccount = defineModel('rt_accounts', {
5
+ // Declare the read model — db:check fails this model until its authz is authored:
6
+ // abilities: [can('read')], // public data
7
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
8
+ // private: true, // not part of the data API
9
+ fields: {
10
+ id: field.uuid().primaryKey().defaultRandom(),
11
+ email: field.text().notNull().unique(),
12
+ displayName: field.text().notNull(),
13
+ age: field.integer(),
14
+ balance: field.bigint().notNull().default(0),
15
+ creditLimit: field.numeric(12, 2),
16
+ score: field.numeric(8),
17
+ slug: field.varchar(255).notNull(),
18
+ region: field.varchar(),
19
+ bio: field.text().validate(z.string().max(160)),
20
+ active: field.boolean().notNull().default(true),
21
+ role: field.text().notNull().default("member"),
22
+ status: field.enum('account_status', ['active', 'suspended', 'closed']).notNull().default("active"),
23
+ metadata: field.jsonb(),
24
+ bornOn: field.date(),
25
+ createdAt: field.timestamptz().notNull().defaultNow(),
26
+ updatedAt: field.timestamp(),
27
+ },
28
+ });
29
+
30
+ export const RtMetricArchive = defineModel('rt_metric_archives', {
31
+ // Declare the read model — db:check fails this model until its authz is authored:
32
+ // abilities: [can('read')], // public data
33
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
34
+ // private: true, // not part of the data API
35
+ fields: {
36
+ id: field.integer().primaryKey().defaultSql(sql`nextval('rt_metrics_id_seq'::regclass)`),
37
+ note: field.text(),
38
+ },
39
+ });
40
+
41
+ export const RtMetric = defineModel('rt_metrics', {
42
+ // Declare the read model — db:check fails this model until its authz is authored:
43
+ // abilities: [can('read')], // public data
44
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
45
+ // private: true, // not part of the data API
46
+ fields: {
47
+ id: field.serial().primaryKey(),
48
+ noteId: field.uuid().notNull().references(() => RtNote),
49
+ coverRate: field.real(),
50
+ meanMiss: field.doublePrecision().notNull(),
51
+ counter: field.bigserial(),
52
+ yardline_100: field.integer(),
53
+ top_3Best: field.integer(),
54
+ tags: field.text().array(),
55
+ },
56
+ });
57
+
58
+ export const RtNoteTag = defineModel('rt_note_tags', {
59
+ // Declare the read model — db:check fails this model until its authz is authored:
60
+ // abilities: [can('read')], // public data
61
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
62
+ // private: true, // not part of the data API
63
+ fields: {
64
+ noteId: field.uuid().primaryKey().references(() => RtNote),
65
+ label: field.text().primaryKey(),
66
+ },
67
+ });
68
+
69
+ export const RtNote = defineModel('rt_notes', {
70
+ // Declare the read model — db:check fails this model until its authz is authored:
71
+ // abilities: [can('read')], // public data
72
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
73
+ // private: true, // not part of the data API
74
+ fields: {
75
+ id: field.uuid().primaryKey().defaultRandom(),
76
+ accountId: field.uuid().notNull().references(() => RtAccount, { onDelete: 'cascade' }),
77
+ parentId: field.uuid().references(() => RtNote, { onDelete: 'set null', onUpdate: 'cascade' }),
78
+ title: field.text().notNull(),
79
+ body: field.text(),
80
+ pinned: field.boolean().notNull().default(false),
81
+ createdAt: field.timestamptz().notNull().defaultNow(),
82
+ },
83
+ constraints: [
84
+ unique('accountId', 'title'),
85
+ check(sql`char_length(title) > 0`),
86
+ index('createdAt').where(sql`pinned = true`),
87
+ index('parentId'),
88
+ ],
89
+ });
90
+
91
+ export const RtTagLink = defineModel('rt_tag_links', {
92
+ // Declare the read model — db:check fails this model until its authz is authored:
93
+ // abilities: [can('read')], // public data
94
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
95
+ // private: true, // not part of the data API
96
+ fields: {
97
+ id: field.uuid().primaryKey().defaultRandom(),
98
+ noteId: field.uuid().notNull(),
99
+ label: field.text().notNull(),
100
+ },
101
+ constraints: [
102
+ foreignKey(['noteId', 'label'], () => RtNoteTag, ['noteId', 'label'], { onDelete: 'cascade' }),
103
+ ],
104
+ });
105
+
106
+ export const RtTicket = defineModel('rt_tickets', {
107
+ // Declare the read model — db:check fails this model until its authz is authored:
108
+ // abilities: [can('read')], // public data
109
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
110
+ // private: true, // not part of the data API
111
+ fields: {
112
+ id: field.uuid().primaryKey().defaultRandom(),
113
+ ticketNumber: field.bigint().notNull().defaultSql(sql`nextval('rt_ticket_counter'::regclass)`),
114
+ },
115
+ });
116
+
117
+ export const models = [RtAccount, RtMetricArchive, RtMetric, RtNoteTag, RtNote, RtTagLink, RtTicket];
118
+
119
+ export const appModule = defineModule({ models });
120
+
121
+ export const modules = [appModule];
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
- response = await invoke(live);
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
- info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
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 { applyStateAndVerify, classifyGeneratedStatements, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
34
+ import { loadDeclaredDerived, assertNoHoles, type DeclaredDerived } from '../declared-derived.js';
35
+ import { asModelComposeError, opsAdviceLines } from '../ops-advice.js';
36
+ import { applyStateAndVerify, classifyGeneratedStatements, renderStatementHistogram, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
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
- mod = await import(pathToFileURL(abs).href);
58
- } catch (err: any) {
59
- throw new Error(`Could not load models from ${modelsPath}: ${err.message}`);
57
+ const abs = path.resolve(modelsPath);
58
+ let mod: any;
59
+ try {
60
+ mod = await import(pathToFileURL(abs).href);
61
+ } catch (err: any) {
62
+ throw new Error(`Could not load models from ${modelsPath}: ${err.message}`);
63
+ }
64
+ const models = mod.models ?? mod.default;
65
+ if (!Array.isArray(models)) throw new Error(`${modelsPath} must export a \`models\` array (got ${typeof models}).`);
66
+ assertNoHoles(modelsPath, 'models', models);
67
+ return models;
68
+ } catch (err) {
69
+ throw asModelComposeError(modelsPath, err);
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
- info('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
214
- info('Diffing against a local database instead? Pass --database-url or set DATABASE_URL.');
220
+ for (const line of opsAdviceLines(err, [
221
+ 'Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.',
222
+ 'Diffing against a local database instead? Pass --database-url or set DATABASE_URL.',
223
+ ])) info(line);
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>] [--abilities public-read]
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
- detail('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
96
- detail('Introspecting a local database instead? Pass --database-url or set DATABASE_URL.');
112
+ // IAM advice only when the AWS transport actually threw — an introspection
113
+ // failure inside a Lambda that RAN is not a permissions problem.
114
+ for (const line of opsAdviceLines(err, [
115
+ 'Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.',
116
+ 'Introspecting a local database instead? Pass --database-url or set DATABASE_URL.',
117
+ ])) detail(line);
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
  }
@@ -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
- info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
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
- info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
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
- info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
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
- info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
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
- info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
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
- info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
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
- info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
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
- /** Barrel → normalized Module list (a bare `models` export wraps into one module). */
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
- mod = await import(pathToFileURL(abs).href);
37
- } catch (err: any) {
38
- throw new Error(`Could not load modules from ${modelsPath}: ${err.message}`);
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
- const models = modules.flatMap((m) => m.models);
129
- const derived = modules.flatMap((m) => m.derived);
130
- return {
131
- objects: compileDerived(models, derived),
132
- derived,
133
- sequences: modules.flatMap((m) => m.sequences),
134
- renamedTables: compileTableRenames(models, {}),
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 unknown[]): string[] =>
273
+ const depIdentities = (refs: readonly DependsOnRef[]): string[] =>
224
274
  refs
225
- .filter((r): r is DerivedDescriptor => typeof r === 'object' && r != null && !('table' in (r as object)))
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 `nfl_player_honors` is `nflPlayerHonors`).
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) refs.push(model);
199
- else if (derived) refs.push(derived);
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
+ }
@@ -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.
@@ -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
- /** A table name → its model variable: PascalCase, naively singularized. `image_variants` → `ImageVariant`. */
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;
@@ -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
- /** `image_variants` → `image-variants.ts` — the repo's kebab-case file convention. */
401
- function modelFileName(table: string): string {
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
+ }
@@ -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 {