@everystack/cli 0.3.31 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/.pdr-tmp-20277-1783706384842.ts +59 -0
- package/src/.roundtrip-tmp-20275-1783706385459.ts +121 -0
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/aws.ts +12 -3
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-authz.ts +3 -17
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +84 -33
- package/src/cli/commands/db-fingerprint.ts +7 -2
- package/src/cli/commands/db-generate.ts +102 -34
- package/src/cli/commands/db-pull.ts +82 -6
- package/src/cli/commands/db-reconcile.ts +132 -41
- package/src/cli/commands/db-sync.ts +54 -19
- package/src/cli/commands/db.ts +7 -6
- package/src/cli/commands/update.ts +2 -1
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +5 -1
- package/src/cli/declared-derived.ts +173 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +377 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +176 -10
- package/src/cli/derived-render.ts +366 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/edge-plan.ts +4 -1
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +6 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +125 -16
- package/src/cli/models-path.ts +1 -1
- package/src/cli/ops-advice.ts +66 -0
- package/src/cli/pipeline-path.ts +1 -1
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- package/src/cli/state-apply.ts +52 -1
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 [--
|
|
361
|
-
everystack db:sync [--database-url <url>] [--models <barrel>] [--
|
|
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>] [--
|
|
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>] [--
|
|
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({
|
|
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. */
|
package/src/cli/model-render.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/cli/models-path.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* models-path — where the Models barrel lives.
|
|
3
3
|
*
|
|
4
|
-
* One home for schema: `db/models/` (
|
|
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
|
+
}
|
package/src/cli/pipeline-path.ts
CHANGED
|
@@ -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/
|
|
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
|