@everystack/cli 0.3.23 → 0.3.25

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 CHANGED
@@ -223,6 +223,9 @@ everystack db:diff # the state edge between two declared st
223
223
  everystack db:plan | db:apply # mint a reviewable plan against a target, apply it fingerprint-verified at both ends + the fast-forward rule (checkout must descend from the commit declaring the target's state); destructive plans: --confirm + snapshot + approver set when declared
224
224
  everystack db:check # the CI gate per PR: merged declared state composes + artifacts match regeneration (+ ephemeral from-scratch build w/ a scratch PG)
225
225
  everystack db:approvers # declare who can DESTROY: the stage's destructive-approver set (SSM, STS-verified at apply)
226
+ everystack db:backfill # one-shot data jobs in db/backfills/*.sql — content-hash identity, own per-DB record, never a schema side effect
227
+ everystack db:template:refresh # (re)build the dev template from the declared state + your db:seed script — never a data copy
228
+ everystack db:branch # per-branch dev DB minted from the template, keyed on the git branch (--list / --prune --confirm)
226
229
 
227
230
  # Security
228
231
  everystack db:doctor # is the DB least-privilege + RLS-subject?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -0,0 +1,231 @@
1
+ /**
2
+ * backfill — one-shot data jobs in their own lane (brick 8 of the
3
+ * migration-authority design, decision 15).
4
+ *
5
+ * Schema is fingerprint-governed; DATA has no live authority to fingerprint,
6
+ * so the backfill lane is the one place a per-database ledger is correct:
7
+ * `everystack.backfill_log` records what ran here, and the runner READS it —
8
+ * unlike `schema_log`, which stays a write-only memoir. The distrust that
9
+ * killed the migration tape doesn't transfer: nothing is numbered, nothing
10
+ * is watermarked, and identity is the NORMALIZED CONTENT HASH (the same
11
+ * comment-insensitive, dollar-quote-aware lexing as db/sql) — renaming a
12
+ * file or editing a comment is a no-op, never a re-run.
13
+ *
14
+ * One-shot jobs are IMMUTABLE. A file whose name already ran on this
15
+ * database in a different form is BLOCKED, loudly, with the fix named: a
16
+ * genuinely new job gets a new file; a cosmetic-but-not-comment edit gets
17
+ * `--mark-applied`. Never silently re-run, never silently skipped. A prior
18
+ * FAILED run is a retry, not an edit — it stays pending.
19
+ *
20
+ * Backfills never run as a side effect of a schema apply (expand →
21
+ * backfill → contract is a sequence of deliberate acts); the schema verbs
22
+ * only SURFACE pending backfills as notices. Long-running smells get an
23
+ * advisory naming the jobs lane — advisory only, taste is not enforced.
24
+ */
25
+
26
+ import { createHash } from 'node:crypto';
27
+ import { lex, normalizeSql, splitSqlStatements, type SourceFile } from './derived-source.js';
28
+ import { escapeLiteral } from './derived-apply.js';
29
+ import type { QueryRunner } from './authz-contract.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Identity + advisories (pure).
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** The job's identity: sha256 over the comment-insensitive canonical form. */
36
+ export function backfillIdentity(sql: string): string {
37
+ return createHash('sha256').update(normalizeSql(sql)).digest('hex');
38
+ }
39
+
40
+ /**
41
+ * The statement with comments dropped and quoted content blanked — a shape
42
+ * regexes can interrogate without being fooled by `'WHERE it hurts'`.
43
+ */
44
+ function sqlSkeleton(sql: string): string {
45
+ let out = '';
46
+ lex(sql, (ch, state) => {
47
+ if (state === 'line-comment' || state === 'block-comment') return;
48
+ if (state === 'single' || state === 'dollar') return; // quoted content blanked
49
+ out += ch;
50
+ });
51
+ return out;
52
+ }
53
+
54
+ /** Advisory only — an unbounded data pass belongs in the jobs lane, but taste never blocks. */
55
+ export function backfillAdvisories(sql: string): string[] {
56
+ const advisories: string[] = [];
57
+ for (const statement of splitSqlStatements(sql)) {
58
+ const skeleton = sqlSkeleton(statement);
59
+ const verb = skeleton.match(/^\s*(UPDATE|DELETE)\b/i)?.[1];
60
+ if (verb && !/\bWHERE\b/i.test(skeleton)) {
61
+ advisories.push(
62
+ `${verb.toUpperCase()} without WHERE — an unbounded pass over the table. Fine for small tables; for big ones, batch it or run it as a job (@everystack/jobs).`,
63
+ );
64
+ }
65
+ }
66
+ return advisories;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Planning (pure).
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export interface BackfillRecord {
74
+ name: string;
75
+ identity: string;
76
+ outcome: string; // 'applied' | 'marked-applied' | 'failed'
77
+ }
78
+
79
+ export interface BackfillPlan {
80
+ applied: { file: string; identity: string }[];
81
+ /** To run, in file-name order. */
82
+ pending: { file: string; identity: string; sql: string; advisories: string[] }[];
83
+ /** Edited-after-run — refused with the fix named. */
84
+ blocked: { file: string; identity: string; reason: string }[];
85
+ }
86
+
87
+ const baseName = (file: string): string => file.split('/').pop() ?? file;
88
+ const counts = (outcome: string): boolean => outcome === 'applied' || outcome === 'marked-applied';
89
+
90
+ export function planBackfills(files: SourceFile[], log: BackfillRecord[]): BackfillPlan {
91
+ const appliedIdentities = new Set(log.filter((r) => counts(r.outcome)).map((r) => r.identity));
92
+ const appliedByName = new Map<string, string>(); // name → identity that ran under it
93
+ for (const r of log) {
94
+ if (counts(r.outcome)) appliedByName.set(r.name, r.identity);
95
+ }
96
+
97
+ const plan: BackfillPlan = { applied: [], pending: [], blocked: [] };
98
+ for (const file of [...files].sort((a, b) => baseName(a.file).localeCompare(baseName(b.file)))) {
99
+ const identity = backfillIdentity(file.sql);
100
+ if (appliedIdentities.has(identity)) {
101
+ plan.applied.push({ file: file.file, identity });
102
+ continue;
103
+ }
104
+ const ranAs = appliedByName.get(baseName(file.file));
105
+ if (ranAs !== undefined && ranAs !== identity) {
106
+ plan.blocked.push({
107
+ file: file.file,
108
+ identity,
109
+ reason:
110
+ `${baseName(file.file)} already ran on this database in a different form — one-shot jobs are immutable. ` +
111
+ 'A genuinely new job gets a NEW file name; if this edit changed nothing the job does, record it: --mark-applied.',
112
+ });
113
+ continue;
114
+ }
115
+ plan.pending.push({ file: file.file, identity, sql: file.sql, advisories: backfillAdvisories(file.sql) });
116
+ }
117
+ return plan;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // The record + the runner.
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export const ENSURE_BACKFILL_SQL: string[] = [
125
+ 'CREATE SCHEMA IF NOT EXISTS everystack',
126
+ `CREATE TABLE IF NOT EXISTS everystack.backfill_log (
127
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
128
+ applied_at timestamptz NOT NULL DEFAULT now(),
129
+ identity text NOT NULL,
130
+ name text NOT NULL,
131
+ actor text,
132
+ git_ref text,
133
+ sql text NOT NULL,
134
+ duration_ms integer,
135
+ outcome text NOT NULL
136
+ )`,
137
+ ];
138
+
139
+ export interface BackfillLogEntry {
140
+ identity: string;
141
+ name: string;
142
+ sql: string;
143
+ outcome: string;
144
+ actor?: string | null;
145
+ gitRef?: string | null;
146
+ durationMs?: number;
147
+ }
148
+
149
+ export function renderBackfillLogInsert(entry: BackfillLogEntry): string {
150
+ const opt = (v: string | null | undefined): string => (v == null ? 'NULL' : escapeLiteral(v));
151
+ const duration = entry.durationMs === undefined ? 'NULL' : String(Math.round(entry.durationMs));
152
+ return `INSERT INTO everystack.backfill_log (identity, name, actor, git_ref, sql, duration_ms, outcome)
153
+ VALUES (${escapeLiteral(entry.identity)}, ${escapeLiteral(entry.name)}, ${opt(entry.actor)}, ${opt(entry.gitRef)}, ${escapeLiteral(entry.sql)}, ${duration}, ${escapeLiteral(entry.outcome)})`;
154
+ }
155
+
156
+ /** The applied record for this database. Empty when the lane has never run here. */
157
+ export async function readBackfillLog(runner: QueryRunner): Promise<BackfillRecord[]> {
158
+ const rows = await runner(`
159
+ SELECT name, identity, outcome FROM everystack.backfill_log ORDER BY id
160
+ `).catch((err: any) => {
161
+ if (/relation .*backfill_log.* does not exist|schema .* does not exist/i.test(err.message ?? '')) return [];
162
+ throw err;
163
+ });
164
+ return rows as BackfillRecord[];
165
+ }
166
+
167
+ export interface BackfillRunOptions {
168
+ actor?: string | null;
169
+ gitRef?: string | null;
170
+ /** Injectable clock for tests. */
171
+ now?: () => number;
172
+ }
173
+
174
+ export interface BackfillRunResult {
175
+ ran: { file: string; identity: string; durationMs: number }[];
176
+ /** The first failure, if any — the run STOPS there (later files may depend on it). */
177
+ failed?: { file: string; identity: string; error: string };
178
+ }
179
+
180
+ /**
181
+ * Run the pending jobs in order, each as its own single batch (one implicit
182
+ * transaction per job — a failed job rolls back alone and is recorded as
183
+ * `failed`, staying pending for the retry). Blocked files never execute.
184
+ */
185
+ export async function executeBackfills(
186
+ runner: QueryRunner,
187
+ plan: BackfillPlan,
188
+ options: BackfillRunOptions = {},
189
+ ): Promise<BackfillRunResult> {
190
+ const now = options.now ?? Date.now;
191
+ const result: BackfillRunResult = { ran: [] };
192
+ if (plan.pending.length === 0) return result;
193
+
194
+ await runner(ENSURE_BACKFILL_SQL.join(';\n'));
195
+ for (const job of plan.pending) {
196
+ const name = baseName(job.file);
197
+ const started = now();
198
+ try {
199
+ await runner(job.sql);
200
+ const durationMs = now() - started;
201
+ await runner(renderBackfillLogInsert({
202
+ identity: job.identity, name, sql: job.sql, outcome: 'applied',
203
+ actor: options.actor, gitRef: options.gitRef, durationMs,
204
+ }));
205
+ result.ran.push({ file: job.file, identity: job.identity, durationMs });
206
+ } catch (err: any) {
207
+ await runner(renderBackfillLogInsert({
208
+ identity: job.identity, name, sql: `-- FAILED: ${err.message}\n${job.sql}`, outcome: 'failed',
209
+ actor: options.actor, gitRef: options.gitRef, durationMs: now() - started,
210
+ }));
211
+ result.failed = { file: job.file, identity: job.identity, error: err.message };
212
+ return result;
213
+ }
214
+ }
215
+ return result;
216
+ }
217
+
218
+ /** The adopt hatch: record a job as applied WITHOUT running it (mirrors --baseline). */
219
+ export async function markBackfillApplied(
220
+ runner: QueryRunner,
221
+ file: SourceFile,
222
+ options: BackfillRunOptions = {},
223
+ ): Promise<string> {
224
+ const identity = backfillIdentity(file.sql);
225
+ await runner(ENSURE_BACKFILL_SQL.join(';\n'));
226
+ await runner(renderBackfillLogInsert({
227
+ identity, name: baseName(file.file), sql: file.sql, outcome: 'marked-applied',
228
+ actor: options.actor, gitRef: options.gitRef,
229
+ }));
230
+ return identity;
231
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * branch-db — per-branch dev databases from a models-built template
3
+ * (brick 5-remainder of the migration-authority design, decision 14).
4
+ *
5
+ * The original wound: one shared dev database, poisoned by every branch
6
+ * switch. The cure: the dev template `<base>_tpl` is built FROM THE
7
+ * DECLARED STATE plus seed-as-code — deterministic, PII-free, refreshed by
8
+ * an explicit `db:template:refresh`, never a data copy (real data enters
9
+ * branch testing only through stage forks, brick 10) — and each branch
10
+ * gets its own database, minted `CREATE DATABASE … TEMPLATE`, keyed on the
11
+ * git branch. `db:sync` then evolves it with the checkout. Branch
12
+ * databases are disposable by construction: never long-lived, never
13
+ * poisoned.
14
+ *
15
+ * Names are deterministic lowercase identifiers; branches that clean to
16
+ * the same text (`feat/foo` vs `feat_foo`) get a disambiguating content
17
+ * hash. The raw branch name travels as a database COMMENT, so listing and
18
+ * pruning map databases back to branches without guessing — a database
19
+ * whose comment is gone is never pruned.
20
+ */
21
+
22
+ import { createHash } from 'node:crypto';
23
+ import { execSync } from 'node:child_process';
24
+ import type { ModelDescriptor } from '@everystack/model';
25
+ import type { QueryRunner } from './authz-contract.js';
26
+ import { escapeLiteral } from './derived-apply.js';
27
+ import { createUrlRunner } from './db-source.js';
28
+ import {
29
+ withDatabase,
30
+ createDatabase,
31
+ dropDatabase,
32
+ buildIntoDatabase,
33
+ type BuildOptions,
34
+ } from './db-build.js';
35
+
36
+ export { withDatabase } from './db-build.js';
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Naming (pure).
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const FAMILY_SUFFIX = /(_tpl|_br_.*|_eph_.*)$/;
43
+
44
+ /** The family's base name, from ANY member's URL (base, template, branch, ephemeral). */
45
+ export function baseNameFromUrl(url: string): string {
46
+ const m = url.match(/\/([^/?]+)(\?|$)/);
47
+ if (!m) throw new Error('the connection URL names no database — add the path segment (postgres://…/mydb)');
48
+ return m[1].replace(FAMILY_SUFFIX, '');
49
+ }
50
+
51
+ export function templateName(base: string): string {
52
+ return `${base}_tpl`;
53
+ }
54
+
55
+ /**
56
+ * The branch database's name: deterministic, collision-free, ≤63 chars.
57
+ * A branch that IS already a clean identifier passes through readably;
58
+ * anything cleaned (case, slashes, unicode) or truncated carries a 6-char
59
+ * content hash so `feat/foo` and `feat_foo` never collide.
60
+ */
61
+ export function branchDatabaseName(base: string, branch: string): string {
62
+ const prefix = `${base}_br_`;
63
+ const clean = branch.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
64
+ const budget = 63 - prefix.length;
65
+ if (clean === branch && clean.length > 0 && clean.length <= budget) return `${prefix}${clean}`;
66
+ const hash = createHash('sha256').update(branch).digest('hex').slice(0, 6);
67
+ const body = clean.slice(0, Math.max(0, budget - 7));
68
+ return `${prefix}${body ? `${body}_` : ''}${hash}`;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Comments — the branch mapping and the template provenance (pure).
73
+ // ---------------------------------------------------------------------------
74
+
75
+ const BRANCH_PREFIX = 'everystack branch: ';
76
+
77
+ export function branchComment(branch: string): string {
78
+ return `${BRANCH_PREFIX}${branch}`;
79
+ }
80
+
81
+ export function parseBranchComment(comment: string | null): string | null {
82
+ if (!comment || !comment.startsWith(BRANCH_PREFIX)) return null;
83
+ return comment.slice(BRANCH_PREFIX.length);
84
+ }
85
+
86
+ export function templateComment(fingerprint: string, gitRef: string | null, builtAt: string): string {
87
+ return `everystack template fp=${fingerprint} git=${gitRef ?? 'none'} built=${builtAt}`;
88
+ }
89
+
90
+ export function parseTemplateComment(
91
+ comment: string | null,
92
+ ): { fingerprint: string; gitRef: string | null; builtAt: string } | null {
93
+ const m = comment?.match(/^everystack template fp=(\S+) git=(\S+) built=(\S+)$/);
94
+ if (!m) return null;
95
+ return { fingerprint: m[1], gitRef: m[2] === 'none' ? null : m[2], builtAt: m[3] };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Pruning (pure).
100
+ // ---------------------------------------------------------------------------
101
+
102
+ export interface BranchDatabase {
103
+ database: string;
104
+ /** From the database comment; null when the mapping is lost — never pruned. */
105
+ branch: string | null;
106
+ }
107
+
108
+ /** Databases whose branch no longer exists — and ONLY those. Unknown = kept. */
109
+ export function prunableBranchDatabases(
110
+ databases: BranchDatabase[],
111
+ liveBranches: string[],
112
+ ): BranchDatabase[] {
113
+ const live = new Set(liveBranches);
114
+ return databases.filter((d) => d.branch !== null && !live.has(d.branch));
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Git (thin).
119
+ // ---------------------------------------------------------------------------
120
+
121
+ export function currentBranch(cwd?: string): string | null {
122
+ try {
123
+ const out = execSync('git rev-parse --abbrev-ref HEAD', {
124
+ cwd, stdio: ['ignore', 'pipe', 'ignore'],
125
+ }).toString().trim();
126
+ return out === 'HEAD' ? null : out; // detached HEAD names no branch
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ export function localBranches(cwd?: string): string[] {
133
+ try {
134
+ return execSync('git for-each-ref --format=%(refname:short) refs/heads', {
135
+ cwd, stdio: ['ignore', 'pipe', 'ignore'],
136
+ }).toString().trim().split('\n').filter(Boolean);
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Orchestration.
144
+ // ---------------------------------------------------------------------------
145
+
146
+ async function withAdmin<T>(adminUrl: string, fn: (runner: QueryRunner) => Promise<T>): Promise<T> {
147
+ const { runner, end } = await createUrlRunner(adminUrl);
148
+ try {
149
+ return await fn(runner);
150
+ } finally {
151
+ await end?.();
152
+ }
153
+ }
154
+
155
+ async function databaseExists(runner: QueryRunner, name: string): Promise<boolean> {
156
+ const rows = await runner(`SELECT 1 FROM pg_database WHERE datname = ${escapeLiteral(name)}`);
157
+ return rows.length > 0;
158
+ }
159
+
160
+ async function setComment(runner: QueryRunner, database: string, comment: string): Promise<void> {
161
+ await runner(`COMMENT ON DATABASE "${database}" IS ${escapeLiteral(comment)}`);
162
+ }
163
+
164
+ export interface TemplateRefreshOptions extends BuildOptions {
165
+ /** Runs against the freshly built template (the app's own seed, injected by the shell). */
166
+ seed?: ((templateUrl: string) => Promise<void>) | null;
167
+ /** Injectable for tests. Default: now, ISO. */
168
+ builtAt?: string;
169
+ }
170
+
171
+ export interface TemplateRefreshResult {
172
+ database: string;
173
+ url: string;
174
+ fingerprint: string;
175
+ report: string[];
176
+ }
177
+
178
+ /**
179
+ * (Re)build the dev template from the declared state + seed. All or
180
+ * nothing: a template that fails to build (or seed) is DROPPED — a broken
181
+ * template silently minting broken branch databases is the one outcome
182
+ * this must never produce.
183
+ */
184
+ export async function executeTemplateRefresh(
185
+ adminUrl: string,
186
+ models: ModelDescriptor[],
187
+ options: TemplateRefreshOptions = {},
188
+ ): Promise<TemplateRefreshResult> {
189
+ const base = baseNameFromUrl(adminUrl);
190
+ const template = templateName(base);
191
+ await dropDatabase(adminUrl, template);
192
+ await createDatabase(adminUrl, template);
193
+ const url = withDatabase(adminUrl, template);
194
+ try {
195
+ const built = await buildIntoDatabase(url, models, options);
196
+ if (!(built.converged && built.fingerprintMatch)) {
197
+ throw new Error(`the template did not land on the declared state:\n${built.report.join('\n')}`);
198
+ }
199
+ if (options.seed) await options.seed(url);
200
+ await withAdmin(adminUrl, (runner) =>
201
+ setComment(runner, template, templateComment(
202
+ built.fingerprint,
203
+ options.gitRef ?? null,
204
+ options.builtAt ?? new Date().toISOString(),
205
+ )),
206
+ );
207
+ return { database: template, url, fingerprint: built.fingerprint, report: built.report };
208
+ } catch (err) {
209
+ await dropDatabase(adminUrl, template).catch(() => {});
210
+ throw err;
211
+ }
212
+ }
213
+
214
+ export type EnsureBranchResult =
215
+ | { status: 'created' | 'exists'; database: string; url: string; template: string }
216
+ | { status: 'no-template'; database: string; url: string; template: string };
217
+
218
+ /** Mint (or find) the current branch's database from the template. */
219
+ export async function ensureBranchDatabase(adminUrl: string, branch: string): Promise<EnsureBranchResult> {
220
+ const base = baseNameFromUrl(adminUrl);
221
+ const template = templateName(base);
222
+ const database = branchDatabaseName(base, branch);
223
+ const url = withDatabase(adminUrl, database);
224
+
225
+ const exists = await withAdmin(adminUrl, (runner) => databaseExists(runner, database));
226
+ if (exists) return { status: 'exists', database, url, template };
227
+
228
+ const templateExists = await withAdmin(adminUrl, (runner) => databaseExists(runner, template));
229
+ if (!templateExists) return { status: 'no-template', database, url, template };
230
+
231
+ await createDatabase(adminUrl, database, template);
232
+ await withAdmin(adminUrl, (runner) => setComment(runner, database, branchComment(branch)));
233
+ return { status: 'created', database, url, template };
234
+ }
235
+
236
+ /** Every branch database in the family, with its branch (from the comment). */
237
+ export async function listBranchDatabases(adminUrl: string): Promise<BranchDatabase[]> {
238
+ const base = baseNameFromUrl(adminUrl);
239
+ return withAdmin(adminUrl, async (runner) => {
240
+ const rows = await runner(
241
+ `SELECT datname, shobj_description(oid, 'pg_database') AS comment
242
+ FROM pg_database WHERE datname LIKE ${escapeLiteral(`${base}\\_br\\_%`)} ORDER BY datname`,
243
+ );
244
+ return rows.map((r: any) => ({ database: r.datname, branch: parseBranchComment(r.comment) }));
245
+ });
246
+ }
247
+
248
+ /** The template's provenance, or null when no template exists. */
249
+ export async function readTemplateProvenance(
250
+ adminUrl: string,
251
+ ): Promise<{ fingerprint: string; gitRef: string | null; builtAt: string } | null> {
252
+ const base = baseNameFromUrl(adminUrl);
253
+ return withAdmin(adminUrl, async (runner) => {
254
+ const rows = await runner(
255
+ `SELECT shobj_description(oid, 'pg_database') AS comment
256
+ FROM pg_database WHERE datname = ${escapeLiteral(templateName(base))}`,
257
+ );
258
+ if (rows.length === 0) return null;
259
+ return parseTemplateComment(rows[0].comment);
260
+ });
261
+ }
262
+
263
+ /** Drop a branch database — refuses anything outside the `_br_` family. */
264
+ export async function dropBranchDatabase(adminUrl: string, database: string): Promise<void> {
265
+ if (!/_br_/.test(database)) {
266
+ throw new Error(`${database} is not a branch database — this path only drops the _br_ family.`);
267
+ }
268
+ await dropDatabase(adminUrl, database);
269
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `everystack db:backfill` — the one-shot data-job lane (brick 8).
3
+ *
4
+ * db:backfill [--database-url <url>] [--dir db/backfills] [--json]
5
+ * db:backfill --apply # run the pending jobs, in order
6
+ * db:backfill --mark-applied <file.sql> # record without running (adopt hatch)
7
+ *
8
+ * Plan first, always: applied (by content identity — renames and comment
9
+ * edits are no-ops), pending (in file-name order, with jobs-lane advisories
10
+ * for unbounded passes), and blocked (a file name that already ran here in
11
+ * a different form — one-shot jobs are immutable; the fix is named). Apply
12
+ * runs each pending job as its own transaction and records it in
13
+ * `everystack.backfill_log`; a failure rolls back alone, is recorded, and
14
+ * stops the run. Backfills NEVER run as a side effect of a schema verb —
15
+ * db:sync and db:plan only surface the pending count.
16
+ *
17
+ * Needs a direct connection (--database-url / DATABASE_URL): the lane
18
+ * reads and writes its own per-database record.
19
+ */
20
+
21
+ import type { QueryRunner } from '../authz-contract.js';
22
+ import {
23
+ planBackfills,
24
+ readBackfillLog,
25
+ executeBackfills,
26
+ markBackfillApplied,
27
+ type BackfillPlan,
28
+ } from '../backfill.js';
29
+ import { currentGitRef } from '../state-apply.js';
30
+ import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
31
+ import { readSqlDirIfPresent } from './db-sync.js';
32
+ import { step, success, fail, info, warn } from '../output.js';
33
+
34
+ const DEFAULT_DIR = 'db/backfills';
35
+
36
+ export function buildBackfillReport(plan: BackfillPlan): string[] {
37
+ const lines: string[] = [];
38
+ lines.push(`= ${plan.applied.length} applied (by content identity)`);
39
+ for (const b of plan.blocked) lines.push(`! BLOCKED ${b.file}: ${b.reason}`);
40
+ if (plan.pending.length === 0) {
41
+ lines.push('= nothing pending');
42
+ } else {
43
+ lines.push(`~ ${plan.pending.length} pending, in order:`);
44
+ for (const p of plan.pending) {
45
+ lines.push(`~ ${p.file}`);
46
+ for (const a of p.advisories) lines.push(`· advisory: ${a}`);
47
+ }
48
+ }
49
+ return lines;
50
+ }
51
+
52
+ export async function dbBackfillCommand(flags: Record<string, string>): Promise<void> {
53
+ const dir = flags.dir || DEFAULT_DIR;
54
+ const files = await readSqlDirIfPresent(dir);
55
+ if (files === null) {
56
+ info(`No ${dir}/ directory — the backfill lane is not declared. One-shot data jobs live there as *.sql files.`);
57
+ return;
58
+ }
59
+
60
+ let dbSource: DbSource;
61
+ try {
62
+ dbSource = resolveDbSource(flags);
63
+ } catch (err: any) {
64
+ fail(err.message);
65
+ process.exit(1);
66
+ }
67
+ if (dbSource.kind !== 'url') {
68
+ fail('db:backfill needs a direct connection (--database-url or DATABASE_URL) — the lane reads and writes its per-database record.');
69
+ process.exit(1);
70
+ }
71
+
72
+ step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
73
+ const { runner, end } = await createUrlRunner(dbSource.url);
74
+ try {
75
+ const opts = { actor: process.env.USER ?? null, gitRef: currentGitRef() };
76
+
77
+ if (flags['mark-applied'] !== undefined) {
78
+ const name = flags['mark-applied'];
79
+ const file = files.find((f) => f.file.endsWith(`/${name}`) || f.file === name);
80
+ if (name === 'true' || !file) {
81
+ fail(`--mark-applied needs a file from ${dir}/ (got ${name === 'true' ? 'nothing' : name}).`);
82
+ process.exit(1);
83
+ }
84
+ const identity = await markBackfillApplied(runner as QueryRunner, file, opts);
85
+ success(`Recorded ${file.file} as applied WITHOUT running it (identity ${identity.slice(0, 12)}).`);
86
+ return;
87
+ }
88
+
89
+ const plan = planBackfills(files, await readBackfillLog(runner));
90
+ for (const line of buildBackfillReport(plan)) info(line);
91
+
92
+ if (flags.json === 'true') {
93
+ console.log(JSON.stringify({
94
+ applied: plan.applied,
95
+ pending: plan.pending.map(({ sql: _sql, ...rest }) => rest),
96
+ blocked: plan.blocked,
97
+ }, null, 2));
98
+ }
99
+
100
+ if (flags.apply !== 'true') {
101
+ if (plan.pending.length > 0) info(`Run them: everystack db:backfill --apply`);
102
+ if (plan.blocked.length > 0) process.exit(1);
103
+ return;
104
+ }
105
+
106
+ if (plan.pending.length === 0) {
107
+ success('Nothing to run.');
108
+ if (plan.blocked.length > 0) process.exit(1);
109
+ return;
110
+ }
111
+
112
+ step(`Running ${plan.pending.length} backfill(s), each as its own transaction...`);
113
+ const result = await executeBackfills(runner, plan, opts);
114
+ for (const ran of result.ran) info(`~ ${ran.file} applied in ${ran.durationMs}ms`);
115
+ if (result.failed) {
116
+ fail(`${result.failed.file} FAILED and rolled back (recorded; it stays pending for the retry): ${result.failed.error}`);
117
+ process.exit(1);
118
+ }
119
+ if (plan.blocked.length > 0) {
120
+ fail('Pending jobs ran, but blocked file(s) remain above — fix them (new file name, or --mark-applied).');
121
+ process.exit(1);
122
+ }
123
+ success(`${result.ran.length} backfill(s) applied and recorded in everystack.backfill_log.`);
124
+ } finally {
125
+ await end?.();
126
+ }
127
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * `everystack db:template:refresh` + `everystack db:branch` — per-branch
3
+ * dev databases from a models-built template (brick 5-remainder,
4
+ * decision 14).
5
+ *
6
+ * db:template:refresh [--database-url <url>] [--models <barrel>]
7
+ * [--sql-dir db/sql] [--seed "<cmd>"] [--no-seed]
8
+ * db:branch # mint (or find) the current branch's DB
9
+ * db:branch --list # every branch DB, mapped to its branch
10
+ * db:branch --drop --confirm # drop the current branch's DB
11
+ * db:branch --prune --confirm # drop DBs whose git branch is gone
12
+ *
13
+ * The template is built FROM THE DECLARED STATE (both layers, fingerprint
14
+ * MATCH bar) plus seed-as-code — the app's own `db:seed` script, run as a
15
+ * subprocess with DATABASE_URL pointed at the fresh template. Never a data
16
+ * copy: deterministic, PII-free (real data enters branch testing through
17
+ * stage forks, brick 10). Branch databases mint via `CREATE DATABASE …
18
+ * TEMPLATE`, keyed on the git branch, and `db:sync` evolves them with the
19
+ * checkout — disposable by construction, never long-lived, never poisoned.
20
+ */
21
+
22
+ import fs from 'node:fs/promises';
23
+ import { spawnSync } from 'node:child_process';
24
+ import type { ModelDescriptor } from '@everystack/model';
25
+ import {
26
+ executeTemplateRefresh,
27
+ ensureBranchDatabase,
28
+ listBranchDatabases,
29
+ readTemplateProvenance,
30
+ prunableBranchDatabases,
31
+ dropBranchDatabase,
32
+ branchDatabaseName,
33
+ baseNameFromUrl,
34
+ templateName,
35
+ currentBranch,
36
+ localBranches,
37
+ } from '../branch-db.js';
38
+ import { currentGitRef } from '../state-apply.js';
39
+ import { fingerprintModels } from '../schema-fingerprint.js';
40
+ import { resolveDbSource, type DbSource } from '../db-source.js';
41
+ import { resolveModelsPath } from '../models-path.js';
42
+ import { readSqlDirIfPresent } from './db-sync.js';
43
+ import { loadModels } from './db-generate.js';
44
+ import { step, success, fail, info, warn } from '../output.js';
45
+
46
+ function requireUrl(flags: Record<string, string>, verb: string): string {
47
+ let dbSource: DbSource;
48
+ try {
49
+ dbSource = resolveDbSource(flags);
50
+ } catch (err: any) {
51
+ fail(err.message);
52
+ process.exit(1);
53
+ }
54
+ if (dbSource.kind !== 'url') {
55
+ fail(`${verb} manages local databases — it needs a direct connection (--database-url or DATABASE_URL).`);
56
+ process.exit(1);
57
+ }
58
+ return dbSource.url;
59
+ }
60
+
61
+ /** The seed convention: the app's own `db:seed` script from package.json. */
62
+ async function detectSeedCommand(): Promise<string | null> {
63
+ try {
64
+ const pkg = JSON.parse(await fs.readFile('package.json', 'utf8'));
65
+ return pkg?.scripts?.['db:seed'] ? 'npm run db:seed' : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function runSeedSubprocess(command: string, templateUrl: string): void {
72
+ const result = spawnSync(command, {
73
+ shell: true,
74
+ stdio: 'inherit',
75
+ env: { ...process.env, DATABASE_URL: templateUrl },
76
+ });
77
+ if (result.status !== 0) {
78
+ throw new Error(`seed command failed (exit ${result.status}): ${command}`);
79
+ }
80
+ }
81
+
82
+ export async function dbTemplateRefreshCommand(flags: Record<string, string>): Promise<void> {
83
+ const url = requireUrl(flags, 'db:template:refresh');
84
+
85
+ const modelsPath = resolveModelsPath(flags.models);
86
+ let models: ModelDescriptor[];
87
+ try {
88
+ step(`Loading models from ${modelsPath}...`);
89
+ models = await loadModels(modelsPath);
90
+ info(`${models.length} model(s).`);
91
+ } catch (err: any) {
92
+ fail(err.message);
93
+ process.exit(1);
94
+ }
95
+ const sources = await readSqlDirIfPresent(flags['sql-dir'] || 'db/sql');
96
+
97
+ let seedCommand: string | null = null;
98
+ if (flags['no-seed'] === 'true') {
99
+ info('Seeding skipped (--no-seed).');
100
+ } else if (flags.seed && flags.seed !== 'true') {
101
+ seedCommand = flags.seed;
102
+ } else {
103
+ seedCommand = await detectSeedCommand();
104
+ if (seedCommand) info(`Seed: package.json db:seed script (override with --seed "<cmd>" / --no-seed).`);
105
+ else warn('No db:seed script in package.json — the template builds UNSEEDED (declare one, or pass --seed "<cmd>").');
106
+ }
107
+
108
+ const template = templateName(baseNameFromUrl(url));
109
+ step(`Rebuilding ${template} from the declared state (drop → create → sync both layers → seed)...`);
110
+ try {
111
+ const result = await executeTemplateRefresh(url, models, {
112
+ sources,
113
+ actor: process.env.USER ?? null,
114
+ gitRef: currentGitRef(),
115
+ seed: seedCommand === null ? null : (templateUrl) => {
116
+ step(`Seeding: ${seedCommand} (DATABASE_URL → ${redactUrl(templateUrl)})...`);
117
+ runSeedSubprocess(seedCommand!, templateUrl);
118
+ return Promise.resolve();
119
+ },
120
+ });
121
+ for (const line of result.report) info(` ${line}`);
122
+ success(`Template ${result.database} is the declared state at ${result.fingerprint.slice(0, 12)}${seedCommand ? ', seeded' : ''}. Mint branch databases: everystack db:branch`);
123
+ } catch (err: any) {
124
+ fail(`Template refresh failed — no template was left behind (all or nothing): ${err.message}`);
125
+ process.exit(1);
126
+ }
127
+ }
128
+
129
+ /** Redact credentials when echoing a URL. */
130
+ function redactUrl(url: string): string {
131
+ return url.replace(/\/\/[^@/]+@/, '//***@');
132
+ }
133
+
134
+ export async function dbBranchCommand(flags: Record<string, string>): Promise<void> {
135
+ const url = requireUrl(flags, 'db:branch');
136
+
137
+ if (flags.list === 'true') {
138
+ const provenance = await readTemplateProvenance(url);
139
+ if (provenance) {
140
+ info(`template ${templateName(baseNameFromUrl(url))}: fingerprint ${provenance.fingerprint.slice(0, 12)}, built ${provenance.builtAt}${provenance.gitRef ? ` at ${provenance.gitRef.slice(0, 12)}` : ''}`);
141
+ } else {
142
+ warn(`no template — build it once: everystack db:template:refresh`);
143
+ }
144
+ const all = await listBranchDatabases(url);
145
+ if (all.length === 0) {
146
+ info('no branch databases.');
147
+ return;
148
+ }
149
+ const live = new Set(localBranches());
150
+ for (const d of all) {
151
+ const state = d.branch === null ? 'branch unknown (comment lost)' : live.has(d.branch) ? d.branch : `${d.branch} — branch GONE (db:branch --prune)`;
152
+ info(` ${d.database} ← ${state}`);
153
+ }
154
+ return;
155
+ }
156
+
157
+ if (flags.prune === 'true') {
158
+ const dead = prunableBranchDatabases(await listBranchDatabases(url), localBranches());
159
+ if (dead.length === 0) {
160
+ success('Nothing to prune — every branch database maps to a live branch.');
161
+ return;
162
+ }
163
+ for (const d of dead) info(` ${d.database} ← ${d.branch} (gone)`);
164
+ if (flags.confirm !== 'true') {
165
+ fail(`Would drop the ${dead.length} database(s) above — confirm with --confirm.`);
166
+ process.exit(1);
167
+ }
168
+ for (const d of dead) {
169
+ await dropBranchDatabase(url, d.database);
170
+ info(`dropped ${d.database}`);
171
+ }
172
+ success(`Pruned ${dead.length} branch database(s).`);
173
+ return;
174
+ }
175
+
176
+ const branch = currentBranch();
177
+ if (branch === null) {
178
+ fail('db:branch is keyed on the git branch — not a repository, or detached HEAD.');
179
+ process.exit(1);
180
+ }
181
+
182
+ if (flags.drop === 'true') {
183
+ const database = branchDatabaseName(baseNameFromUrl(url), branch);
184
+ if (flags.confirm !== 'true') {
185
+ fail(`Would drop ${database} (branch ${branch}) — confirm with --confirm.`);
186
+ process.exit(1);
187
+ }
188
+ await dropBranchDatabase(url, database);
189
+ success(`Dropped ${database}.`);
190
+ return;
191
+ }
192
+
193
+ const result = await ensureBranchDatabase(url, branch);
194
+ switch (result.status) {
195
+ case 'no-template':
196
+ fail(`No template ${result.template} — build it once: everystack db:template:refresh. Branch databases mint from it.`);
197
+ process.exit(1);
198
+ break;
199
+ case 'exists':
200
+ info(`${result.database} already exists for branch ${branch}.`);
201
+ break;
202
+ case 'created': {
203
+ success(`Minted ${result.database} from ${result.template} (branch ${branch}).`);
204
+ try {
205
+ const provenance = await readTemplateProvenance(url);
206
+ const declared = fingerprintModels(await loadModels(resolveModelsPath(flags.models))).hash;
207
+ if (provenance && provenance.fingerprint !== declared) {
208
+ info(`· your checkout declares ${declared.slice(0, 12)}; the template was built at ${provenance.fingerprint.slice(0, 12)} — db:sync below evolves it (refresh the template when it drifts far).`);
209
+ }
210
+ } catch {
211
+ // The staleness note is best-effort — minting already succeeded.
212
+ }
213
+ break;
214
+ }
215
+ }
216
+ console.log('');
217
+ console.log(` export DATABASE_URL="${result.url}"`);
218
+ console.log('');
219
+ info(`then: everystack db:sync # evolve it with your checkout as you edit`);
220
+ }
@@ -28,20 +28,22 @@
28
28
  */
29
29
 
30
30
  import fs from 'node:fs/promises';
31
- import postgres from 'postgres';
32
31
  import type { ModelDescriptor } from '@everystack/model';
33
- import type { TableContract } from '../authz-contract.js';
34
32
  import { compileDeclaredState } from '../declared-diff.js';
35
33
  import { compileMigration } from '../migration-compile.js';
36
34
  import { compileDrizzleSource } from '../schema-source.js';
37
35
  import { parseSqlSources, type SourceFile } from '../derived-source.js';
38
36
  import { currentGitRef } from '../state-apply.js';
39
- import { createUrlRunner } from '../db-source.js';
37
+ import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build.js';
40
38
  import { resolveModelsPath } from '../models-path.js';
41
- import { executeSync, buildSyncReport, readSqlDirIfPresent } from './db-sync.js';
39
+ import { readSqlDirIfPresent } from './db-sync.js';
42
40
  import { loadModels } from './db-generate.js';
43
41
  import { step, success, fail, info, warn } from '../output.js';
44
42
 
43
+ // The role derivation moved to the shared builder (db-build) with brick
44
+ // 5-remainder; re-exported here because db:check is where it grew up.
45
+ export { rolesFromContract } from '../db-build.js';
46
+
45
47
  const DEFAULT_SQL_DIR = 'db/sql';
46
48
  const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
47
49
 
@@ -145,19 +147,6 @@ export function checkFailed(findings: CheckFinding[]): boolean {
145
147
  // The ephemeral compose ring.
146
148
  // ---------------------------------------------------------------------------
147
149
 
148
- /** Every role the contract grants to or names in a policy — the compose DB needs them. */
149
- export function rolesFromContract(tables: TableContract[]): string[] {
150
- const roles = new Set<string>();
151
- for (const t of tables) {
152
- for (const grantee of Object.keys(t.grants)) roles.add(grantee);
153
- for (const grantee of Object.keys(t.columnGrants ?? {})) roles.add(grantee);
154
- for (const p of t.policies) for (const r of p.roles) roles.add(r);
155
- }
156
- roles.delete('PUBLIC');
157
- roles.delete('public');
158
- return [...roles].sort();
159
- }
160
-
161
150
  export interface EphemeralComposeResult {
162
151
  database: string;
163
152
  createdRoles: string[];
@@ -168,12 +157,10 @@ export interface EphemeralComposeResult {
168
157
  }
169
158
 
170
159
  /**
171
- * Build the declared state from scratch on an ephemeral database: CREATE
172
- * DATABASE, roles the contract references (NOLOGIN, cluster-level, created
173
- * only if absent), one `executeSync` for both layers, DROP DATABASE —
174
- * always, error or not. The bar is `converged && fingerprintMatch`: a
175
- * fresh database has nothing unmodeled and nothing to drop, so anything
176
- * short of MATCH is a real compose fault.
160
+ * Build the declared state from scratch on an ephemeral database (the
161
+ * shared db-build core), then DROP it always, error or not. The bar is
162
+ * `converged && fingerprintMatch`: a fresh database has nothing unmodeled
163
+ * and nothing to drop, so anything short of MATCH is a real compose fault.
177
164
  */
178
165
  export async function executeEphemeralCompose(
179
166
  adminUrl: string,
@@ -181,46 +168,17 @@ export async function executeEphemeralCompose(
181
168
  sources: SourceFile[] | null,
182
169
  opts: { actor?: string | null; gitRef?: string | null } = {},
183
170
  ): Promise<EphemeralComposeResult> {
184
- const dbName = `escheck_${process.pid}_${Date.now()}`;
185
- const admin = postgres(adminUrl, { max: 1 });
186
- try {
187
- await admin.unsafe(`CREATE DATABASE "${dbName}"`);
188
- } finally {
189
- await admin.end();
190
- }
191
-
192
- const { runner, end } = await createUrlRunner(adminUrl.replace(/\/[^/?]+(\?|$)/, `/${dbName}$1`));
171
+ const database = `escheck_${process.pid}_${Date.now()}`;
172
+ await createDatabase(adminUrl, database);
193
173
  try {
194
- const roles = rolesFromContract(compileDeclaredState(models).contract.tables);
195
- for (const role of roles) {
196
- if (!/^[a-z_][a-z0-9_$]*$/i.test(role)) {
197
- throw new Error(`contract references a role name that is not a plain identifier: ${JSON.stringify(role)}`);
198
- }
199
- await runner(
200
- `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN CREATE ROLE "${role}" NOLOGIN; END IF; END $$`,
201
- );
202
- }
203
-
204
- const run = await executeSync(runner, models, sources, {
174
+ const built = await buildIntoDatabase(withDatabase(adminUrl, database), models, {
175
+ sources,
205
176
  actor: opts.actor ?? 'db:check',
206
177
  gitRef: opts.gitRef ?? null,
207
178
  });
208
- return {
209
- database: dbName,
210
- createdRoles: roles,
211
- converged: run.converged,
212
- fingerprintMatch: run.fingerprintMatch,
213
- fingerprint: run.state.toFingerprint,
214
- report: buildSyncReport(run),
215
- };
179
+ return { database, ...built };
216
180
  } finally {
217
- await end?.();
218
- const reaper = postgres(adminUrl, { max: 1 });
219
- try {
220
- await reaper.unsafe(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
221
- } finally {
222
- await reaper.end();
223
- }
181
+ await dropDatabase(adminUrl, database);
224
182
  }
225
183
  }
226
184
 
@@ -25,6 +25,8 @@ import { introspectSchema } from '../schema-introspect.js';
25
25
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
26
26
  import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan.js';
27
27
  import { verifyDescent } from '../git-descent.js';
28
+ import { planBackfills, readBackfillLog } from '../backfill.js';
29
+ import { readSqlDirIfPresent } from './db-sync.js';
28
30
  import { currentGitRef } from '../state-apply.js';
29
31
  import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
30
32
  import { resolveModelsPath } from '../models-path.js';
@@ -113,6 +115,25 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
113
115
  warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
114
116
  }
115
117
  }
118
+
119
+ // Visibility, never automation (brick 8): the review surface names the
120
+ // target's pending backfills — expand → backfill → contract stays a
121
+ // sequence of deliberate acts, and a contraction plan minted while its
122
+ // data move is unrun should be caught by the human reading this.
123
+ const backfillFiles = await readSqlDirIfPresent('db/backfills');
124
+ if (backfillFiles !== null) {
125
+ try {
126
+ const bf = planBackfills(backfillFiles, await readBackfillLog(runner));
127
+ if (bf.blocked.length > 0) {
128
+ warn(`${bf.blocked.length} backfill(s) BLOCKED on this target (edited after running) — everystack db:backfill names the fix.`);
129
+ }
130
+ if (bf.pending.length > 0) {
131
+ warn(`${bf.pending.length} backfill(s) pending on this target: ${bf.pending.map((p) => p.file).join(', ')} — run them deliberately (db:backfill --apply) before a contraction lands.`);
132
+ }
133
+ } catch (err: any) {
134
+ warn(`backfill lane: could not read everystack.backfill_log (${err.message}) — pending-backfill visibility unavailable.`);
135
+ }
136
+ }
116
137
  if (out !== '-') {
117
138
  success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
118
139
  warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
@@ -46,6 +46,7 @@ import { fingerprintModels } from '../schema-fingerprint.js';
46
46
  import { compileDrizzleSource } from '../schema-source.js';
47
47
  import type { SourceFile } from '../derived-source.js';
48
48
  import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
49
+ import { planBackfills, readBackfillLog } from '../backfill.js';
49
50
  import { resolveModelsPath } from '../models-path.js';
50
51
  import { executeReconcile, buildReconcileReport, type ReconcileRun } from './db-reconcile.js';
51
52
  import { loadModels } from './db-generate.js';
@@ -294,8 +295,22 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
294
295
  },
295
296
  );
296
297
 
298
+ // Visibility, never automation (brick 8): pending backfills surface
299
+ // here, but a schema verb never runs a data job.
300
+ let backfills: { pending: number; blocked: number } | null = null;
301
+ const backfillFiles = await readSqlDirIfPresent('db/backfills');
302
+ if (backfillFiles !== null) {
303
+ try {
304
+ const bf = planBackfills(backfillFiles, await readBackfillLog(runner));
305
+ backfills = { pending: bf.pending.length, blocked: bf.blocked.length };
306
+ } catch (err: any) {
307
+ warn(`backfill lane: could not read everystack.backfill_log (${err.message}) — pending-backfill visibility unavailable.`);
308
+ }
309
+ }
310
+
297
311
  if (flags.json === 'true') {
298
312
  console.log(JSON.stringify({
313
+ backfills,
299
314
  state: {
300
315
  applied: run.state.applied,
301
316
  executed: run.state.classified.executable.length,
@@ -317,6 +332,12 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
317
332
  }, null, 2));
318
333
  } else {
319
334
  for (const line of buildSyncReport(run)) info(line);
335
+ if (backfills && backfills.blocked > 0) {
336
+ warn(`${backfills.blocked} backfill(s) BLOCKED (edited after running) — everystack db:backfill names the fix.`);
337
+ }
338
+ if (backfills && backfills.pending > 0) {
339
+ info(`· ${backfills.pending} backfill(s) pending — data jobs run deliberately, never as a sync side effect: everystack db:backfill --apply`);
340
+ }
320
341
  console.log('');
321
342
  if (!run.converged) {
322
343
  fail('NOT converged — fix the findings above and re-run db:sync.');
@@ -0,0 +1,161 @@
1
+ /**
2
+ * db-build — build a database FROM the declared state (brick 5-remainder's
3
+ * shared core; decision 14: models-built, never data copies).
4
+ *
5
+ * One builder, three consumers: `db:check`'s ephemeral compose ring, the
6
+ * dev template (`db:template:refresh`), and `createEphemeralDatabase` — the
7
+ * exported test-database helper (the create-sync-drop shape this repo's own
8
+ * integration harness proves on every run, packaged for consumers). The bar
9
+ * is always the brick-2 identity: a fresh database built from the declared
10
+ * state lands EXACTLY on the models' fingerprint.
11
+ */
12
+
13
+ import type { ModelDescriptor } from '@everystack/model';
14
+ import type { TableContract, QueryRunner } from './authz-contract.js';
15
+ import type { SourceFile } from './derived-source.js';
16
+ import { compileDeclaredState } from './declared-diff.js';
17
+ import { createUrlRunner } from './db-source.js';
18
+ import { executeSync, buildSyncReport } from './commands/db-sync.js';
19
+
20
+ const SAFE_NAME = /^[a-z_][a-z0-9_$]*$/;
21
+
22
+ function assertSafeName(name: string, what: string): void {
23
+ if (!SAFE_NAME.test(name)) {
24
+ throw new Error(`${what} ${JSON.stringify(name)} is not a plain lowercase identifier — everystack manages databases it can name without quoting games.`);
25
+ }
26
+ }
27
+
28
+ /** Swap the database path segment of a connection URL. */
29
+ export function withDatabase(url: string, database: string): string {
30
+ if (!/\/[^/?]+(\?|$)/.test(url)) {
31
+ throw new Error('the connection URL names no database — add the path segment (postgres://…/mydb)');
32
+ }
33
+ return url.replace(/\/[^/?]+(\?|$)/, `/${database}$1`);
34
+ }
35
+
36
+ /** Every role the contract grants to or names in a policy — a fresh database needs them. */
37
+ export function rolesFromContract(tables: TableContract[]): string[] {
38
+ const roles = new Set<string>();
39
+ for (const t of tables) {
40
+ for (const grantee of Object.keys(t.grants)) roles.add(grantee);
41
+ for (const grantee of Object.keys(t.columnGrants ?? {})) roles.add(grantee);
42
+ for (const p of t.policies) for (const r of p.roles) roles.add(r);
43
+ }
44
+ roles.delete('PUBLIC');
45
+ roles.delete('public');
46
+ return [...roles].sort();
47
+ }
48
+
49
+ /** Create the contract's roles, NOLOGIN, only if absent (roles are cluster-level). */
50
+ export async function ensureContractRoles(runner: QueryRunner, models: ModelDescriptor[]): Promise<string[]> {
51
+ const roles = rolesFromContract(compileDeclaredState(models).contract.tables);
52
+ for (const role of roles) {
53
+ assertSafeName(role, 'contract role');
54
+ await runner(
55
+ `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN CREATE ROLE "${role}" NOLOGIN; END IF; END $$`,
56
+ );
57
+ }
58
+ return roles;
59
+ }
60
+
61
+ export async function createDatabase(adminUrl: string, name: string, template?: string): Promise<void> {
62
+ assertSafeName(name, 'database name');
63
+ if (template !== undefined) assertSafeName(template, 'template name');
64
+ const { runner, end } = await createUrlRunner(adminUrl);
65
+ try {
66
+ await runner(`CREATE DATABASE "${name}"${template ? ` TEMPLATE "${template}"` : ''}`);
67
+ } finally {
68
+ await end?.();
69
+ }
70
+ }
71
+
72
+ export async function dropDatabase(adminUrl: string, name: string): Promise<void> {
73
+ assertSafeName(name, 'database name');
74
+ const { runner, end } = await createUrlRunner(adminUrl);
75
+ try {
76
+ await runner(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`);
77
+ } finally {
78
+ await end?.();
79
+ }
80
+ }
81
+
82
+ export interface BuildResult {
83
+ converged: boolean;
84
+ fingerprintMatch: boolean;
85
+ fingerprint: string;
86
+ createdRoles: string[];
87
+ report: string[];
88
+ }
89
+
90
+ export interface BuildOptions {
91
+ sources?: SourceFile[] | null;
92
+ actor?: string | null;
93
+ gitRef?: string | null;
94
+ }
95
+
96
+ /**
97
+ * Sync a (typically fresh) database to the declared state — both layers —
98
+ * and report against the MATCH bar. Opens and closes its own connection,
99
+ * so the database is immediately usable as a `TEMPLATE` source afterwards.
100
+ */
101
+ export async function buildIntoDatabase(
102
+ url: string,
103
+ models: ModelDescriptor[],
104
+ options: BuildOptions = {},
105
+ ): Promise<BuildResult> {
106
+ const { runner, end } = await createUrlRunner(url);
107
+ try {
108
+ const createdRoles = await ensureContractRoles(runner, models);
109
+ const run = await executeSync(runner, models, options.sources ?? null, {
110
+ actor: options.actor ?? 'db-build',
111
+ gitRef: options.gitRef ?? null,
112
+ });
113
+ return {
114
+ converged: run.converged,
115
+ fingerprintMatch: run.fingerprintMatch,
116
+ fingerprint: run.state.toFingerprint,
117
+ createdRoles,
118
+ report: buildSyncReport(run),
119
+ };
120
+ } finally {
121
+ await end?.();
122
+ }
123
+ }
124
+
125
+ export interface EphemeralDatabase {
126
+ database: string;
127
+ url: string;
128
+ fingerprint: string;
129
+ drop: () => Promise<void>;
130
+ }
131
+
132
+ /**
133
+ * The packaged create-sync-drop shape for consumer test suites: a fresh
134
+ * database at the declared state (state + authz + derived layer), never
135
+ * long-lived, so never poisoned. Throws (and drops) unless the build lands
136
+ * MATCH — a test database that isn't the declared state proves nothing.
137
+ */
138
+ export async function createEphemeralDatabase(
139
+ adminUrl: string,
140
+ models: ModelDescriptor[],
141
+ options: BuildOptions & { name?: string } = {},
142
+ ): Promise<EphemeralDatabase> {
143
+ const database = options.name ?? `eph_${process.pid}_${Date.now()}`;
144
+ await createDatabase(adminUrl, database);
145
+ const url = withDatabase(adminUrl, database);
146
+ try {
147
+ const built = await buildIntoDatabase(url, models, options);
148
+ if (!(built.converged && built.fingerprintMatch)) {
149
+ throw new Error(`the ephemeral database did not land on the declared state:\n${built.report.join('\n')}`);
150
+ }
151
+ return {
152
+ database,
153
+ url,
154
+ fingerprint: built.fingerprint,
155
+ drop: () => dropDatabase(adminUrl, database),
156
+ };
157
+ } catch (err) {
158
+ await dropDatabase(adminUrl, database).catch(() => {});
159
+ throw err;
160
+ }
161
+ }
@@ -62,7 +62,7 @@ export interface ParsedSources {
62
62
  // Lexer.
63
63
  // ---------------------------------------------------------------------------
64
64
 
65
- type LexState =
65
+ export type LexState =
66
66
  | { in: 'normal' }
67
67
  | { in: 'single'; escaped: boolean }
68
68
  | { in: 'double' }
@@ -82,7 +82,8 @@ function dollarTagAt(text: string, i: number): string | null {
82
82
  * (comments/strings/normal). The shared core under both the statement splitter
83
83
  * and the normalizer, so they can never disagree about where a string ends.
84
84
  */
85
- function lex(
85
+ /** The dollar-quote-aware SQL lexer — exported for siblings that need string-safe scans (the backfill lane). */
86
+ export function lex(
86
87
  text: string,
87
88
  emit: (ch: string, state: LexState['in'], index: number) => void,
88
89
  ): void {
package/src/cli/index.ts CHANGED
@@ -19,6 +19,8 @@ import { dbPlanCommand } from './commands/db-plan.js';
19
19
  import { dbApplyCommand } from './commands/db-apply.js';
20
20
  import { dbCheckCommand } from './commands/db-check.js';
21
21
  import { dbApproversCommand } from './commands/db-approvers.js';
22
+ import { dbBackfillCommand } from './commands/db-backfill.js';
23
+ import { dbTemplateRefreshCommand, dbBranchCommand } from './commands/db-branch.js';
22
24
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
23
25
  import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
24
26
  import { consoleCommand } from './commands/console.js';
@@ -204,6 +206,15 @@ async function main() {
204
206
  case 'db:approvers':
205
207
  await dbApproversCommand(flags);
206
208
  break;
209
+ case 'db:backfill':
210
+ await dbBackfillCommand(flags);
211
+ break;
212
+ case 'db:template:refresh':
213
+ await dbTemplateRefreshCommand(flags);
214
+ break;
215
+ case 'db:branch':
216
+ await dbBranchCommand(flags);
217
+ break;
207
218
  case 'db:authz:pull':
208
219
  await dbAuthzPullCommand(flags);
209
220
  break;
@@ -334,6 +345,9 @@ Usage:
334
345
  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
335
346
  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) 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
336
347
  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
348
+ 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
349
+ 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
350
+ 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)
337
351
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
338
352
  everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
339
353
  everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
@@ -34,3 +34,10 @@ export * from './authz-owner-probe.js';
34
34
 
35
35
  // --- the SECDEF function catalog the contract introspection needs -----------
36
36
  export { FUNCTIONS_SQL, catalogFunctionToDescriptor } from './security-catalog.js';
37
+
38
+ // --- databases FROM the declared state: ephemeral test DBs + the builder ----
39
+ // `createEphemeralDatabase(adminUrl, models, { sources })` is the packaged
40
+ // create-sync-drop shape for consumer test suites: a fresh database at the
41
+ // declared state (state + authz + derived), verified to fingerprint MATCH,
42
+ // dropped when you're done — never long-lived, never poisoned.
43
+ export * from './db-build.js';