@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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/backfill.ts +1 -1
- 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 +71 -20
- package/src/cli/commands/db-pull.ts +37 -5
- package/src/cli/commands/db-reconcile.ts +132 -41
- package/src/cli/commands/db-sync.ts +54 -19
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +5 -1
- package/src/cli/declared-derived.ts +136 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +327 -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 +320 -0
- package/src/cli/derived-source.ts +53 -125
- 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 +106 -13
- package/src/cli/models-path.ts +1 -1
- 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 +4 -1
|
@@ -1,26 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* derived-source — the source side of the derived-object reconciler.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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.)
|
package/src/cli/git-descent.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 [--
|
|
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. */
|