@everystack/cli 0.3.31 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/.pdr-tmp-20277-1783706384842.ts +59 -0
  4. package/src/.roundtrip-tmp-20275-1783706385459.ts +121 -0
  5. package/src/cli/authz-compile.ts +5 -2
  6. package/src/cli/aws.ts +12 -3
  7. package/src/cli/backfill.ts +1 -1
  8. package/src/cli/commands/db-authz.ts +3 -17
  9. package/src/cli/commands/db-branch.ts +28 -4
  10. package/src/cli/commands/db-check.ts +84 -33
  11. package/src/cli/commands/db-fingerprint.ts +7 -2
  12. package/src/cli/commands/db-generate.ts +102 -34
  13. package/src/cli/commands/db-pull.ts +82 -6
  14. package/src/cli/commands/db-reconcile.ts +132 -41
  15. package/src/cli/commands/db-sync.ts +54 -19
  16. package/src/cli/commands/db.ts +7 -6
  17. package/src/cli/commands/update.ts +2 -1
  18. package/src/cli/db-build.ts +9 -3
  19. package/src/cli/db-source.ts +5 -1
  20. package/src/cli/declared-derived.ts +173 -0
  21. package/src/cli/derived-apply.ts +40 -6
  22. package/src/cli/derived-compile.ts +377 -0
  23. package/src/cli/derived-grants.ts +96 -0
  24. package/src/cli/derived-introspect.ts +258 -21
  25. package/src/cli/derived-lint.ts +261 -0
  26. package/src/cli/derived-plan.ts +176 -10
  27. package/src/cli/derived-render.ts +366 -0
  28. package/src/cli/derived-source.ts +53 -125
  29. package/src/cli/edge-plan.ts +4 -1
  30. package/src/cli/git-descent.ts +15 -2
  31. package/src/cli/index.ts +6 -6
  32. package/src/cli/migration-compile.ts +38 -7
  33. package/src/cli/migration-generate.ts +43 -4
  34. package/src/cli/model-render.ts +125 -16
  35. package/src/cli/models-path.ts +1 -1
  36. package/src/cli/ops-advice.ts +66 -0
  37. package/src/cli/pipeline-path.ts +1 -1
  38. package/src/cli/schema-compile.ts +41 -10
  39. package/src/cli/schema-diff.ts +123 -17
  40. package/src/cli/schema-fingerprint.ts +28 -18
  41. package/src/cli/schema-introspect.ts +175 -8
  42. package/src/cli/schema-source.ts +75 -6
  43. package/src/cli/state-apply.ts +52 -1
package/src/cli/index.ts CHANGED
@@ -353,21 +353,21 @@ Usage:
353
353
  everystack db:backups [--stage <name>] List logical backups (id, size, created)
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
- everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] 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)
357
- everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] 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
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>] [--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
- everystack db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews) against db/sql sources: 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.
361
- everystack db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against db/sql, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
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.
361
+ everystack db:sync [--database-url <url>] [--models <barrel>] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against the declared descriptors, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
362
362
  everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
363
363
  everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
364
364
  everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + a snapshot (automatic via db:backup with --stage, else --snapshot-ref) + the stage's approver set when declared (STS identity-verified). Every refusal is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there; direct connection required
365
- everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
365
+ everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
366
366
  everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
367
367
  everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
368
368
  everystack pipeline:run [--stage <name> | --database-url <url>] [--rebuild | --curate] [--only <substr> | --from <id> | --to <id>] [--resume [--run-id <id>]] [--dry-run] [--continue-on-error] [--json] Reproduce data from committed source: run the pipeline's stages in topological order, each idempotent and inside its own transaction, recorded in everystack.pipeline_log. Lanes: --rebuild (automated+frozen) / --curate (curated) / neither (all in order). --resume picks up the latest run, skipping applied stages. With --stage the orchestrator runs credential-free IN the ops Lambda (no URL held; heavy stages refused — run those local); --database-url is local-direct and unbounded
369
369
  everystack pipeline:list [--rebuild | --curate] [--only <substr> | --from <id> | --to <id>] Show the pipeline's stages in run order (topological) with their lane and dependency edges — no execution, no database
370
- everystack db:template:refresh [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--seed "<cmd>"] [--no-seed] (Re)build the dev template <base>_tpl FROM THE DECLARED STATE (both layers, fingerprint MATCH bar) + seed-as-code (the app's db:seed script, run with DATABASE_URL pointed at the template; --seed overrides). All or nothing: a failed build/seed leaves NO template. Never a data copy
370
+ everystack db:template:refresh [--database-url <url>] [--models <barrel>] [--seed "<cmd>"] [--no-seed] (Re)build the dev template <base>_tpl FROM THE DECLARED STATE (both layers, fingerprint MATCH bar) + seed-as-code (the app's db:seed script, run with DATABASE_URL pointed at the template; --seed overrides). All or nothing: a failed build/seed leaves NO template. Never a data copy
371
371
  everystack db:branch [--list | --drop --confirm | --prune --confirm] [--database-url <url>] Mint (or find) the current git branch's database from the template (CREATE DATABASE … TEMPLATE — schema, authz, derived layer, and seed rows inherited), print its DATABASE_URL, then db:sync evolves it with the checkout. --list maps every branch DB to its branch; --prune drops the ones whose branch is gone (never guesses: unknown mappings are kept)
372
372
  everystack db:fork --from-stage <src> --stage <target> --confirm [--backup <id>] Fork one DEPLOYED stage's database into another: back up the source (or reuse --backup <id>), presign the dump (the operator's credentials ARE the cross-stage authorization; expires in 1h), restore into the target via its ops Lambda. Production is never a target (that's db:restore); forking FROM production warns about PII; the branch's schema edge then lands via db:plan → db:apply (descent composes). Teardown: sst remove --stage <target>
373
373
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
@@ -19,12 +19,13 @@
19
19
  * lands with whole-DB introspection. The emission is identical either way.
20
20
  */
21
21
 
22
- import type { ModelDescriptor, Module } from '@everystack/model';
22
+ import type { ModelDescriptor, Module, SequenceDescriptor } from '@everystack/model';
23
23
  import type { AuthzContract, TableContract } from './authz-contract.js';
24
- import { compileCreateTable, compileEnums, foreignKeySql, modelConstraintSpecs, qualifiedTable, type CompileTableOptions } from './schema-compile.js';
24
+ import { compileCreateTable, compileEnums, compileSequences, sequenceCreateSql, foreignKeySql, modelConstraintSpecs, qualifiedTable, toSnakeCase, type CompileTableOptions } from './schema-compile.js';
25
25
  import { compileTableContract } from './authz-compile.js';
26
26
  import { emitReconcileSql } from './authz-reconcile.js';
27
- import { emitSchemaSql, type SchemaChange } from './schema-diff.js';
27
+ import { emitSchemaSql, nextvalSequence, type SchemaChange } from './schema-diff.js';
28
+ import { compileDerived } from './derived-compile.js';
28
29
 
29
30
  /** The empty (not-yet-created) authz state for a table — the greenfield baseline. */
30
31
  function emptyTable(table: string): TableContract {
@@ -36,7 +37,7 @@ function emptyTable(table: string): TableContract {
36
37
  * schema). Tables, then foreign keys, then authz — the order a fresh database must
37
38
  * apply them in.
38
39
  */
39
- export function compileMigration(models: ModelDescriptor[], opts: CompileTableOptions = {}): string[] {
40
+ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOptions & { sequences?: SequenceDescriptor[] } = {}): string[] {
40
41
  const sql: string[] = [];
41
42
  const schemaOf = (m: ModelDescriptor): string => m.schema ?? 'public';
42
43
 
@@ -71,9 +72,24 @@ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOp
71
72
  const enumCreates: SchemaChange[] = compileEnums(models).map((e) => ({ kind: 'createType', name: e.name, values: e.values }));
72
73
  sql.push(...emitSchemaSql(enumCreates));
73
74
 
75
+ // 0c. Standalone sequences — before any table, so a column's defaultSql(nextval(…))
76
+ // can draw on one from the first CREATE. State layer: these migrate, never reconcile.
77
+ sql.push(...compileSequences(opts.sequences ?? []).map(sequenceCreateSql));
78
+
74
79
  // 1. Tables (with columns, CHECKs, single/composite PK, uniques).
75
80
  for (const model of models) sql.push(compileCreateTable(model, opts));
76
81
 
82
+ // 1b. Foreign-sequence defaults — a `defaultSql` nextval draws from a sequence that
83
+ // may be owned by a table created later in phase 1, so fieldColumnSql held it out
84
+ // of the column line; every table (and its serial sequence) exists now.
85
+ for (const model of models) {
86
+ for (const [name, f] of Object.entries(model.fields)) {
87
+ const spec = f.spec;
88
+ if (spec.defaultKind !== 'sql' || nextvalSequence(String(spec.default)) == null) continue;
89
+ sql.push(`ALTER TABLE ${qualifiedTable(model)} ALTER COLUMN "${toSnakeCase(name)}" SET DEFAULT ${String(spec.default)};`);
90
+ }
91
+ }
92
+
77
93
  // 2. Foreign keys — after every table exists.
78
94
  for (const model of models) sql.push(...foreignKeySql(model));
79
95
 
@@ -82,7 +98,12 @@ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOp
82
98
  const indexCreates: SchemaChange[] = [];
83
99
  for (const model of models) {
84
100
  for (const ix of modelConstraintSpecs(model).indexes) {
85
- indexCreates.push({ kind: 'createIndex', table: qualifiedTable(model), name: ix.name, columns: ix.columns, unique: ix.unique, ...(ix.where ? { where: ix.where } : {}) });
101
+ indexCreates.push({
102
+ kind: 'createIndex', table: qualifiedTable(model), name: ix.name, columns: ix.columns, unique: ix.unique,
103
+ ...(ix.where ? { where: ix.where } : {}),
104
+ ...(ix.using ? { using: ix.using } : {}),
105
+ ...(ix.include?.length ? { include: ix.include } : {}),
106
+ });
86
107
  }
87
108
  }
88
109
  sql.push(...emitSchemaSql(indexCreates));
@@ -119,15 +140,25 @@ export function compileModuleMigration(modules: Module[], opts: CompileTableOpti
119
140
  const extensions = [...new Set(modules.flatMap((m) => m.extensions))].sort();
120
141
  sql.push(...extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}";`));
121
142
 
122
- // 2. Schema + authz for every modeled table.
123
- sql.push(...compileMigration(modules.flatMap((m) => m.models), opts));
143
+ // 2. Schema + authz for every modeled table (plus the modules' standalone sequences).
144
+ sql.push(...compileMigration(modules.flatMap((m) => m.models), { ...opts, sequences: modules.flatMap((m) => m.sequences) }));
124
145
 
125
146
  // 3. Package SQL — functions + triggers, after the tables they reference. Each thunk's
126
147
  // output is a self-contained multi-statement block applied as one unit.
148
+ // (@deprecated — the declared derived layer below supersedes it.)
127
149
  for (const fn of modules.flatMap((m) => m.functions)) {
128
150
  const text = fn().trim();
129
151
  if (text) sql.push(text);
130
152
  }
131
153
 
154
+ // 4. The DECLARED derived layer — views, matviews, functions, triggers, escape-hatch
155
+ // objects — compiled in topological order, after every table. Greenfield = one
156
+ // complete script: state + compute; from then on the layer deploys via db:reconcile.
157
+ const models = modules.flatMap((m) => m.models);
158
+ for (const obj of compileDerived(models, modules.flatMap((m) => m.derived))) {
159
+ sql.push(`${obj.sql};`);
160
+ for (const a of obj.attachments) sql.push(`${a.sql};`);
161
+ }
162
+
132
163
  return sql;
133
164
  }
@@ -15,10 +15,10 @@
15
15
  * so the command shell (commands/db-generate.ts) is a thin layer of IO over it.
16
16
  */
17
17
 
18
- import type { ModelDescriptor } from '@everystack/model';
18
+ import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
19
19
  import type { SchemaSnapshot } from './schema-introspect.js';
20
20
  import type { AuthzContract } from './authz-contract.js';
21
- import { compileTableSchema, compileRenames, compileTableRenames, compileCreateTable, compileEnums } from './schema-compile.js';
21
+ import { compileTableSchema, compileRenames, compileTableRenames, compileCreateTable, compileEnums, compileSequences } from './schema-compile.js';
22
22
  import { compileTableContract } from './authz-compile.js';
23
23
  import { emitReconcileSql } from './authz-reconcile.js';
24
24
  import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
@@ -26,6 +26,8 @@ import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
26
26
  export interface GenerateOptions {
27
27
  /** The Postgres schema the Models live in. Default: `public`. */
28
28
  schema?: string;
29
+ /** Standalone sequences the modules declare (state layer — created before tables). */
30
+ sequences?: SequenceDescriptor[];
29
31
  /**
30
32
  * Emit real `DROP COLUMN`/`DROP CONSTRAINT`/`DROP TABLE` for things present in the
31
33
  * database but absent from the Models. Default `false` — those are held back as
@@ -104,7 +106,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
104
106
  // then the call default — so one run spans schemas.
105
107
  const schemaOf = (m: ModelDescriptor): string => m.schema ?? schema;
106
108
  const desiredEnums = compileEnums(models);
107
- const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema })), enums: desiredEnums };
109
+ const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema })), enums: desiredEnums, ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}) };
108
110
  const declaredTables = new Set(desired.tables.map((t) => t.table));
109
111
  const declaredEnums = new Set(desiredEnums.map((e) => e.name));
110
112
  // Scope both tables and enums to what the Models declare — an undeclared table OR enum
@@ -116,6 +118,18 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
116
118
  // its authz contract must be read under the NEW name (the policies/grants
117
119
  // ride the rename in Postgres; without the rewrite they would re-emit and
118
120
  // collide).
121
+ // 0. Non-public schemas the models declare but the live side lacks — created FIRST, or
122
+ // a fresh-database build (db:check's ephemeral compose, db:branch templates) fails on
123
+ // its own first CREATE TABLE. The diff analog of compileMigration's phase 0a; live
124
+ // databases already have their schemas (their own migrations made them), so this is
125
+ // invisible in the brownfield loop. IF NOT EXISTS keeps an empty-but-existing schema
126
+ // (no tables for the live side to reveal it by) from failing the create.
127
+ const liveSchemas = new Set(current.tables.map((t) => t.table.split('.')[0]));
128
+ const schemaPhase = [...new Set(desired.tables.map((t) => t.table.split('.')[0]))]
129
+ .filter((s) => s !== 'public' && !liveSchemas.has(s))
130
+ .sort()
131
+ .map((s) => `CREATE SCHEMA IF NOT EXISTS "${s}"`);
132
+
119
133
  const tableRenames = compileTableRenames(models, { schema });
120
134
  const currentNames = new Set(current.tables.map((t) => t.table));
121
135
  const pendingRenameSources = new Set(
@@ -177,7 +191,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
177
191
  ? emitReconcileSql({ tables: models.map((m) => compileTableContract(m, { schema })), functions: [] }, liveAuthzRenamed)
178
192
  : [];
179
193
 
180
- return [...dataPhase, ...authzPhase];
194
+ return [...schemaPhase, ...dataPhase, ...authzPhase];
181
195
  }
182
196
 
183
197
  /** The marker drizzle migration files put between statements. */
@@ -200,6 +214,31 @@ export interface Journal {
200
214
  version: string;
201
215
  dialect: string;
202
216
  entries: JournalEntry[];
217
+ /**
218
+ * D2 — the resolved `--schema-out` path, recorded at generate time. Later runs
219
+ * without the flag reuse it (flag > recorded > default), so a relocated artifact
220
+ * never leaves an orphan at the default path. Drizzle's migrator ignores the key.
221
+ */
222
+ schemaOut?: string;
223
+ }
224
+
225
+ /** How a schema-out path was chosen, and whether the journal record needs (re)writing. */
226
+ export interface SchemaOutResolution {
227
+ path: string;
228
+ source: 'flag' | 'recorded' | 'default';
229
+ updateRecord: boolean;
230
+ }
231
+
232
+ /**
233
+ * Resolve where the generated drizzle schema lives: an explicit flag wins, then the
234
+ * journal's recorded path, then the default. The record updates whenever the resolved
235
+ * path is not already recorded — a differing flag overwrites it (the caller says so).
236
+ */
237
+ export function resolveSchemaOut(flag: string | undefined, journal: Journal | null, fallback: string): SchemaOutResolution {
238
+ const recorded = journal?.schemaOut;
239
+ if (flag) return { path: flag, source: 'flag', updateRecord: flag !== recorded };
240
+ if (recorded) return { path: recorded, source: 'recorded', updateRecord: false };
241
+ return { path: fallback, source: 'default', updateRecord: recorded !== fallback };
203
242
  }
204
243
 
205
244
  /** Sanitize a migration name into the snake_case the `NNNN_<tag>.sql` convention uses. */
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { SchemaSnapshot, TableSchema, ColumnSchema, CheckConstraint } from './schema-introspect.js';
19
+ import type { DerivedRenderResult } from './derived-render.js';
19
20
  import { normalizeDefault, normalizeCheck } from './schema-diff.js';
20
21
 
21
22
  /**
@@ -59,6 +60,47 @@ export function checkToValidate(expr: string): { column: string; zod: string } |
59
60
  export interface RenderOptions {
60
61
  /** Only pull tables in this Postgres schema (others are framework-managed). Default: `public`. */
61
62
  schema?: string;
63
+ /**
64
+ * The read-model scaffold. Default `'commented'`: every model carries the authz decision
65
+ * as a commented stanza (same guidance as the db:check gate — the model fails the gate
66
+ * until it is authored). A preset name (`'public-read'`) stamps that stanza uncommented
67
+ * into every rendered model — an explicit, one-shot, operator-invoked choice that lands
68
+ * as reviewable code. Grants are authored, never inherited; there is no runtime default.
69
+ */
70
+ abilities?: string;
71
+ /** The rendered derived layer (B5) — rides the barrel: block after the models,
72
+ * sequences/derived arrays on the module wrapper, symbols on the import header. */
73
+ derived?: DerivedRenderResult;
74
+ }
75
+
76
+ /**
77
+ * The `--abilities` presets db:pull can stamp. Deliberately few: `public-read` is the
78
+ * dominant brownfield shape (public reference data, admin-managed). Anything else is a
79
+ * per-model edit — the decision belongs in the file, not in a flag grammar.
80
+ */
81
+ export const ABILITY_PRESETS: Record<string, string> = {
82
+ 'public-read': `abilities: [can('read'), can('manage', { role: 'admin' })],`,
83
+ };
84
+
85
+ /**
86
+ * The scaffold stanza for one model: the commented decision surface (guide text says the
87
+ * same thing the db:check gate failure says — one voice, two doorways), or a preset
88
+ * stamped uncommented. An unknown preset throws — grants are authored, never guessed.
89
+ */
90
+ function abilitiesStanza(mode: string): string {
91
+ if (mode === 'commented') {
92
+ return [
93
+ ' // Declare the read model — db:check fails this model until its authz is authored:',
94
+ ` // abilities: [can('read')], // public data`,
95
+ ` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
96
+ ' // private: true, // not part of the data API',
97
+ ].join('\n');
98
+ }
99
+ const preset = ABILITY_PRESETS[mode];
100
+ if (!preset) {
101
+ throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
102
+ }
103
+ return ` ${preset}`;
62
104
  }
63
105
 
64
106
  /** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
@@ -124,7 +166,16 @@ export function pullableTables(snapshot: SchemaSnapshot, schema: string): TableS
124
166
  return snapshot.tables.filter((t) => t.table.startsWith(`${schema}.`) && !INFRASTRUCTURE_TABLES.has(bareName(t.table)));
125
167
  }
126
168
 
127
- /** 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
+ */
128
179
  export function modelVarName(table: string): string {
129
180
  const bare = bareName(table);
130
181
  const singular = bare.endsWith('s') ? bare.slice(0, -1) : bare;
@@ -210,8 +261,11 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
210
261
 
211
262
  if (col.default != null && !serial) {
212
263
  const d = renderDefault(col.default);
213
- if (d) expr += d;
214
- else comment += ` // TODO: unmapped default ${col.default}`;
264
+ // No builder maps it → declare it verbatim with the escape hatch. RAW, not the
265
+ // normalized form stripping a trailing cast here could change semantics
266
+ // (`'7 days'::interval`); the diff normalizes both sides for comparison instead.
267
+ // Drift closed by construction: pull output converges instead of shedding a TODO.
268
+ expr += d ?? `.defaultSql(sql\`${col.default}\`)`;
215
269
  }
216
270
 
217
271
  const validate = validates.get(col.name);
@@ -236,8 +290,14 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
236
290
  items.push(`check(sql\`${normalizeCheck(ck.expr)}\`)`);
237
291
  }
238
292
  for (const ix of table.indexes) {
239
- let s = `index(${ix.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
293
+ // Plain entries render as field keys; canonical-text entries (expressions, DESC,
294
+ // opclass) render as raw sql`` args — the same form the builder declares (Brick E).
295
+ const entry = (c: string): string =>
296
+ /^[a-z_][a-z0-9_$]*$/i.test(c) ? tsLiteral(toCamelCase(c)) : `sql\`${c}\``;
297
+ let s = `index(${ix.columns.map(entry).join(', ')})`;
298
+ if (ix.using) s += `.using(${tsLiteral(ix.using)})`;
240
299
  if (ix.unique) s += '.unique()';
300
+ if (ix.include?.length) s += `.include(${ix.include.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
241
301
  if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
242
302
  items.push(s);
243
303
  }
@@ -261,7 +321,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
261
321
  }
262
322
 
263
323
  /** One `export const X = defineModel(...)` block for a table. */
264
- export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map()): string {
324
+ export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented'): string {
265
325
  // A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
266
326
  // the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
267
327
  const validates = new Map<string, string>();
@@ -275,23 +335,52 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
275
335
 
276
336
  const fields = table.columns.map((c) => renderField(table, c, known, enums, validates)).join('\n');
277
337
  const constraints = renderConstraintsBlock(table, tableChecks, known);
338
+ // The authz decision renders FIRST — before fields — because it is the first thing a
339
+ // reviewer must resolve about a model (and where the field-report consumer's codemod
340
+ // put it, proving the position is mechanical-edit-friendly).
341
+ const stanza = abilitiesStanza(abilities);
278
342
 
279
- return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n fields: {\n${fields}\n },${constraints}\n});`;
343
+ return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza}\n fields: {\n${fields}\n },${constraints}\n});`;
280
344
  }
281
345
 
282
346
  /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
283
- function importHeader(body: string): string {
347
+ function importHeader(body: string, extra: string[] = []): string {
284
348
  const symbols = ['defineModel', 'field'];
349
+ // `can` only when a stanza is stamped UNCOMMENTED (line starts with spaces, not `//`) —
350
+ // the commented scaffold must not leave an unused import behind.
351
+ if (/^\s*abilities: \[can\(/m.test(body)) symbols.push('can');
285
352
  if (/\bunique\(/.test(body)) symbols.push('unique');
286
353
  if (/\bcheck\(/.test(body)) symbols.push('check');
287
354
  if (/\bindex\(/.test(body)) symbols.push('index');
288
355
  if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
356
+ if (/\bdefineModule\(/.test(body)) symbols.push('defineModule');
289
357
  if (/\bsql`/.test(body)) symbols.push('sql');
358
+ for (const s of extra) if (!symbols.includes(s)) symbols.push(s);
290
359
  const imports = [`import { ${symbols.join(', ')} } from '@everystack/model';`];
291
360
  if (/\.validate\(/.test(body)) imports.push(`import { z } from 'zod';`);
292
361
  return imports.join('\n');
293
362
  }
294
363
 
364
+ /** The barrel's module wrapper: models always; sequences/derived when the pull found any (B5). */
365
+ function moduleFooter(modelNames: string[], derived?: DerivedRenderResult, multiline = false): string {
366
+ const models = multiline
367
+ ? `export const models = [\n${modelNames.map((n) => ` ${n},`).join('\n')}\n];`
368
+ : `export const models = [${modelNames.join(', ')}];`;
369
+ const parts = [models];
370
+ const keys = ['models'];
371
+ if (derived?.sequenceNames.length) {
372
+ parts.push(`export const sequences = [${derived.sequenceNames.join(', ')}];`);
373
+ keys.push('sequences');
374
+ }
375
+ if (derived?.names.length) {
376
+ parts.push(`export const derived = [${derived.names.join(', ')}];`);
377
+ keys.push('derived');
378
+ }
379
+ parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
380
+ parts.push(`export const modules = [appModule];`);
381
+ return parts.join('\n\n');
382
+ }
383
+
295
384
  /**
296
385
  * Render a whole snapshot as a single Models module: the import, one block per table (scoped
297
386
  * to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
@@ -303,17 +392,29 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
303
392
  const known = new Set(tables.map((t) => bareName(t.table)));
304
393
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
305
394
 
306
- const blocks = tables.map((t) => renderModelBlock(t, known, enums));
307
- const body = blocks.join('\n\n');
395
+ const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented'));
396
+ // The derived layer (B5): sequences + views/matviews/functions, after the models they
397
+ // reference, before the module wrapper that composes all three.
398
+ if (opts.derived?.block) blocks.push(opts.derived.block);
399
+
400
+ // Module composition is the norm: the pulled barrel exports `modules` alongside `models`,
401
+ // so a fresh brownfield project lands on the same shape the framework composes.
402
+ const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived);
308
403
 
309
- const header = importHeader(body);
310
- const footer = `export const models = [${tables.map((t) => modelVarName(t.table)).join(', ')}];`;
404
+ const header = importHeader([...blocks, footer].join('\n\n'), opts.derived?.imports ?? []);
311
405
 
312
406
  return [header, ...blocks, footer].join('\n\n') + '\n';
313
407
  }
314
408
 
315
- /** `image_variants` → `image-variants.ts` — the repo's kebab-case file convention. */
316
- 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 {
317
418
  return `${bareName(table).replace(/_/g, '-')}.ts`;
318
419
  }
319
420
 
@@ -349,7 +450,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
349
450
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
350
451
 
351
452
  const files: RenderedModelFile[] = tables.map((t) => {
352
- const block = renderModelBlock(t, known, enums);
453
+ const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented');
353
454
  const crossImports = referencedTables(t, known).map(
354
455
  (target) => `import { ${modelVarName(target)} } from './${bareName(target).replace(/_/g, '-')}';`,
355
456
  );
@@ -357,11 +458,19 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
357
458
  return { file: modelFileName(t.table), source: `${header}\n\n${block}\n` };
358
459
  });
359
460
 
461
+ // The index carries the module wrapper (composition is the norm) and the derived layer
462
+ // (B5 — it references the model variables the index imports); model files carry only
463
+ // their own model.
360
464
  const names = tables.map((t) => modelVarName(t.table));
465
+ const modelSymbols = ['defineModule', ...(opts.derived?.imports ?? [])];
361
466
  const index = [
362
- names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`).join('\n'),
467
+ [
468
+ `import { ${modelSymbols.join(', ')} } from '@everystack/model';`,
469
+ ...names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`),
470
+ ].join('\n'),
363
471
  `export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
364
- `export const models = [\n${names.map((n) => ` ${n},`).join('\n')}\n];`,
472
+ ...(opts.derived?.block ? [opts.derived.block] : []),
473
+ moduleFooter(names, opts.derived, true),
365
474
  ].join('\n\n');
366
475
  files.push({ file: 'index.ts', source: index + '\n' });
367
476
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * models-path — where the Models barrel lives.
3
3
  *
4
- * One home for schema: `db/models/` (state) beside `db/sql/` (compute). The
4
+ * One home for schema: `db/models/` — state (tables) and compute (descriptors). The
5
5
  * CLI looks there first, falling back to the legacy top-level `models/` so
6
6
  * existing consumers keep working without a flag. An explicit `--models`
7
7
  * always wins. When neither home exists, the NEW home is returned so error
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pipeline-path — where the consumer's pipeline definition lives.
3
3
  *
4
- * Beside the schema homes (`db/models/`, `db/sql/`, `db/backfills/`), the
4
+ * Beside the schema homes (`db/models/`, `db/backfills/`), the
5
5
  * pipeline is authored at `db/pipeline.ts` (or `db/pipeline/index.ts`),
6
6
  * exporting a `pipeline` built with definePipeline. An explicit `--pipeline`
7
7
  * always wins; when neither home exists the primary is returned so error