@everystack/cli 0.3.31 → 0.4.0

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.
@@ -1,26 +1,32 @@
1
1
  /**
2
2
  * derived-source — the source side of the derived-object reconciler.
3
3
  *
4
- * Parses an app's ordered db/sql files into structured derived objects
5
- * (functions, views, materialized views), attaching the statements that ride
6
- * with an object (its indexes, comments, grants) and hashing each object's
7
- * normalized content. The reconciler diffs these hashes against the live
8
- * catalog's provenance to decide what to create, replace, or drop.
4
+ * The `SourceObject` shape every derived declaration compiles to (identity,
5
+ * content hash, attachments), plus the SQL infrastructure shared across the
6
+ * reconciler: the Postgres-aware lexer, statement splitting, hash
7
+ * normalization, and qualified-name parsing.
8
+ *
9
+ * History note: this file once parsed an app's raw `db/sql` files into these
10
+ * shapes. That parser is retired (B7, read-model-everywhere) — descriptors
11
+ * (defineView / defineMaterializedView / defineFunction / defineSql, compiled
12
+ * by derived-compile.ts) are the single home. The content-hash formula is
13
+ * unchanged, so provenance recorded from db/sql-era objects still matches the
14
+ * descriptor-compiled hash of the same SQL — the migration reconciles as a
15
+ * no-op, never a rebuild.
9
16
  *
10
17
  * Everything here is pure and database-free. The lexer respects Postgres
11
18
  * string syntax — single quotes with '' doubling, E-strings with backslash
12
19
  * escapes, double-quoted identifiers, tagged dollar quoting, and nested block
13
20
  * comments — because a matview body is exactly where naive splitting dies.
14
- *
15
- * Identity note: functions are identified by schema-qualified NAME, not full
16
- * signature. Overloads are rare in app SQL layers; when they appear we warn
17
- * rather than mis-model them (the honest limitation, revisit if a consumer
18
- * actually needs overloads).
19
21
  */
20
22
 
21
23
  import { createHash } from 'node:crypto';
22
24
 
23
- export type DerivedKind = 'function' | 'view' | 'materialized view';
25
+ /**
26
+ * `'trigger'` and `'sql'` carry the extra fields the reconciler edges need (a trigger's
27
+ * table for `DROP TRIGGER … ON`, a defineSql object's explicit drop).
28
+ */
29
+ export type DerivedKind = 'function' | 'view' | 'materialized view' | 'trigger' | 'sql';
24
30
 
25
31
  /** A statement that belongs to an object: its index, comment, or grant. */
26
32
  export interface Attachment {
@@ -33,7 +39,8 @@ export interface SourceObject {
33
39
  /** Schema name, defaulted to `public` when unqualified. */
34
40
  schema: string;
35
41
  name: string;
36
- /** `schema.name` — the reconciler's join key against the live catalog. */
42
+ /** `schema.name` — the reconciler's join key against the live catalog.
43
+ * (Triggers: `schema.table.name` — compound identity, joined against pg_trigger.) */
37
44
  identity: string;
38
45
  /** The CREATE statement as authored (no trailing semicolon). */
39
46
  sql: string;
@@ -41,17 +48,46 @@ export interface SourceObject {
41
48
  attachments: Attachment[];
42
49
  /** sha256 of the normalized CREATE + attachments — comment/whitespace edits do not change it. */
43
50
  hash: string;
44
- /** Source file this object came from. */
51
+ /** Source file this object came from (descriptors: the declared-models marker). */
45
52
  file: string;
46
53
  /** Position in the concatenated source — a valid dependency order by convention. */
47
54
  seq: number;
55
+ /** kind 'sql' only: the declared PostgreSQL object kind (`'aggregate'`, …). */
56
+ objectKind?: string;
57
+ /** kind 'sql' only: the explicit DROP for kinds whose drop isn't derivable from the name. */
58
+ drop?: string;
59
+ /** kind 'trigger' only: the (possibly schema-qualified) table/view the trigger rides. */
60
+ table?: string;
61
+ /**
62
+ * Views/matviews: every declared `dependsOn` ref as an identity (models AND derived),
63
+ * sorted. B4's dependency drift verifies it against the live catalog's actual edges —
64
+ * "declared, then verified". Absent on kinds whose live edges pg_depend can't see.
65
+ */
66
+ declaredDeps?: string[];
67
+ }
68
+
69
+ /** The provenance marker descriptor-compiled objects carry in `file` — the compiler
70
+ * ALWAYS declares authz (a private relation is declared-empty, not undeclared), so
71
+ * B4's grant drift checks every object. */
72
+ export const DECLARED_SOURCE_FILE = 'db/models (declared)';
73
+
74
+ /** The one content-hash formula. Unchanged since the db/sql era on purpose: provenance
75
+ * recorded from parser-era objects still matches the descriptor-compiled hash of the
76
+ * same SQL, so the db/sql → descriptor migration reconciles as a no-op. */
77
+ export function hashSourceContent(createSql: string, attachments: Attachment[]): string {
78
+ const content = [normalizeSql(createSql), ...attachments.map((a) => normalizeSql(a.sql))].join('\n');
79
+ return createHash('sha256').update(content).digest('hex');
48
80
  }
49
81
 
82
+ /** A raw file the generic dir readers return (db/backfills, seed lanes). */
50
83
  export interface SourceFile {
51
84
  file: string;
52
85
  sql: string;
53
86
  }
54
87
 
88
+ /** The reconciler's source-stream input: the declared objects plus any compile-time
89
+ * warnings to surface with the plan. (Named for its parser-era producer; the only
90
+ * producer now is the descriptor compiler.) */
55
91
  export interface ParsedSources {
56
92
  objects: SourceObject[];
57
93
  /** Statements the reconciler does not manage — surfaced, never silently dropped. */
@@ -242,7 +278,7 @@ export function normalizeSql(sql: string): string {
242
278
  // ---------------------------------------------------------------------------
243
279
 
244
280
  /** Strip quotes from an identifier and split an optionally schema-qualified name. */
245
- function parseQualified(raw: string): { schema: string; name: string } {
281
+ export function parseQualified(raw: string): { schema: string; name: string } {
246
282
  const parts: string[] = [];
247
283
  let current = '';
248
284
  let quoted = false;
@@ -261,114 +297,6 @@ function parseQualified(raw: string): { schema: string; name: string } {
261
297
  return { schema: 'public', name: parts[0] };
262
298
  }
263
299
 
264
- const FUNCTION_RE = /^CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+((?:"[^"]*"|[^\s(])+)\s*\(/i;
265
- const VIEW_RE = /^CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+((?:"[^"]*"|[^\s(])+)/i;
266
- const MATVIEW_RE = /^CREATE\s+MATERIALIZED\s+VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?((?:"[^"]*"|[^\s(])+)/i;
267
- const INDEX_RE = /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:(?:"[^"]*"|[^\s(])+\s+)?ON\s+(?:ONLY\s+)?((?:"[^"]*"|[^\s(])+)/i;
268
- const COMMENT_RE = /^COMMENT\s+ON\s+(MATERIALIZED\s+VIEW|VIEW|FUNCTION|COLUMN)\s+((?:"[^"]*"|[^\s(,])+)/i;
269
- const GRANT_RE = /^GRANT\s+[\s\S]*?\bON\s+(?:TABLE\s+)?((?:"[^"]*"|[^\s(,])+)/i;
270
-
271
- /**
272
- * The first non-comment text of a statement, whitespace collapsed but token
273
- * boundaries preserved (unlike the aggressive hash form — `VIEW "MyView"` must
274
- * keep its space for the classification regexes).
275
- */
276
- function statementHead(statement: string): string {
277
- let out = '';
278
- lex(statement, (ch, state) => {
279
- if (state === 'line-comment' || state === 'block-comment') return;
280
- if (state === 'normal' && /\s/.test(ch)) {
281
- if (out.length > 0 && !out.endsWith(' ')) out += ' ';
282
- return;
283
- }
284
- out += ch;
285
- });
286
- return out.trim().slice(0, 400);
287
- }
288
-
289
- /**
290
- * Parse ordered source files into derived objects. Attachments (indexes,
291
- * comments, grants) join the object they target; statements that are neither a
292
- * derived object nor an attachment become warnings — the reconciler manages
293
- * the derived layer only, and it says so out loud.
294
- */
295
- export function parseSqlSources(files: SourceFile[]): ParsedSources {
296
- const objects: SourceObject[] = [];
297
- const warnings: string[] = [];
298
- const byIdentity = new Map<string, SourceObject>();
299
- let seq = 0;
300
-
301
- const identityOf = (raw: string): { schema: string; name: string; identity: string } => {
302
- const { schema, name } = parseQualified(raw);
303
- return { schema, name, identity: `${schema}.${name}` };
304
- };
305
-
306
- for (const { file, sql } of files) {
307
- for (const statement of splitSqlStatements(sql)) {
308
- const head = statementHead(statement);
309
-
310
- const asObject = (kind: DerivedKind, rawName: string): void => {
311
- const { schema, name, identity } = identityOf(rawName);
312
- if (byIdentity.has(identity)) {
313
- warnings.push(
314
- `${file}: duplicate definition for ${identity} — function overloads are not modeled; ` +
315
- `the reconciler tracks one definition per name (last one wins).`,
316
- );
317
- }
318
- const obj: SourceObject = {
319
- kind, schema, name, identity,
320
- sql: statement, attachments: [], hash: '', file, seq: seq++,
321
- };
322
- byIdentity.set(identity, obj);
323
- objects.push(obj);
324
- };
325
-
326
- const attach = (kind: Attachment['kind'], rawTarget: string, stripColumn = false): boolean => {
327
- let { identity } = identityOf(rawTarget);
328
- if (stripColumn) {
329
- // COMMENT ON COLUMN schema.obj.col — the owning object is one segment up.
330
- const parts = identity.split('.');
331
- identity = parts.length > 2 ? parts.slice(0, -1).join('.') : `public.${parts[0]}`;
332
- }
333
- const target = byIdentity.get(identity);
334
- if (!target) return false;
335
- target.attachments.push({ kind, sql: statement });
336
- return true;
337
- };
338
-
339
- let m: RegExpExecArray | null;
340
- if ((m = FUNCTION_RE.exec(head))) { asObject('function', m[1]); continue; }
341
- if ((m = MATVIEW_RE.exec(head))) { asObject('materialized view', m[1]); continue; }
342
- if ((m = VIEW_RE.exec(head))) { asObject('view', m[1]); continue; }
343
-
344
- if ((m = INDEX_RE.exec(head))) {
345
- if (!attach('index', m[1])) {
346
- warnings.push(`${file}: CREATE INDEX targets ${m[1]}, which is not a derived object in these sources — not managed here (base-table indexes belong to the model layer).`);
347
- }
348
- continue;
349
- }
350
- if ((m = COMMENT_RE.exec(head))) {
351
- const isColumn = /^COLUMN$/i.test(m[1].replace(/\s+/g, ' ').split(' ')[0]) || /^COLUMN/i.test(m[1]);
352
- if (!attach('comment', m[2], isColumn)) {
353
- warnings.push(`${file}: COMMENT ON targets ${m[2]}, which is not a derived object in these sources.`);
354
- }
355
- continue;
356
- }
357
- if ((m = GRANT_RE.exec(head))) {
358
- if (!attach('grant', m[1])) {
359
- warnings.push(`${file}: GRANT targets ${m[1]}, which is not a derived object in these sources.`);
360
- }
361
- continue;
362
- }
363
-
364
- warnings.push(`${file}: unmanaged statement (${head.slice(0, 60)}…) — the reconciler manages functions, views, and materialized views; move this to the appropriate layer.`);
365
- }
366
- }
367
-
368
- for (const obj of objects) {
369
- const content = [normalizeSql(obj.sql), ...obj.attachments.map((a) => normalizeSql(a.sql))].join('\n');
370
- obj.hash = createHash('sha256').update(content).digest('hex');
371
- }
372
-
373
- return { objects, warnings };
374
- }
300
+ // (The db/sql statement classifier and parseSqlSources lived here until B7 —
301
+ // descriptors are the single home; declared-derived.ts's retiredSqlDir() is the
302
+ // loud tombstone for checkouts still carrying the directory.)
@@ -188,10 +188,23 @@ export async function verifyDescent(
188
188
  for (const candidate of candidates) {
189
189
  const dest = path.join(materializeRoot, candidate.tree);
190
190
  let predicted: string;
191
- try {
191
+ // A swallowed error here skips the candidate — and if it was the DECLARING tree, the
192
+ // verdict silently falls through to 'drift'. Real compile failures are deterministic;
193
+ // a transient materialize/import failure (fd pressure, module-loader contention under
194
+ // a parallel test run or a busy CI box) is not — so a failed candidate gets exactly
195
+ // one retry, from a clean materialization, before it is recorded as a compile failure.
196
+ const attempt = async (): Promise<string> => {
192
197
  if (!fs.existsSync(dest)) materializeTree(candidate.tree, dest, repoRoot);
193
198
  const models = await loader(path.join(dest, barrel));
194
- predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema });
199
+ return predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema });
200
+ };
201
+ try {
202
+ try {
203
+ predicted = await attempt();
204
+ } catch {
205
+ fs.rmSync(dest, { recursive: true, force: true });
206
+ predicted = await attempt();
207
+ }
195
208
  } catch (err: any) {
196
209
  compileFailures.push(`${short(candidate.tree)} (at ${short(candidate.commits[0])}): ${err.message}`);
197
210
  continue;
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>] [--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
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. */