@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 +3 -0
- package/package.json +1 -1
- package/src/cli/backfill.ts +231 -0
- package/src/cli/branch-db.ts +269 -0
- package/src/cli/commands/db-backfill.ts +127 -0
- package/src/cli/commands/db-branch.ts +220 -0
- package/src/cli/commands/db-check.ts +16 -58
- package/src/cli/commands/db-plan.ts +21 -0
- package/src/cli/commands/db-sync.ts +21 -0
- package/src/cli/db-build.ts +161 -0
- package/src/cli/derived-source.ts +3 -2
- package/src/cli/index.ts +14 -0
- package/src/cli/model-api.ts +7 -0
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
|
@@ -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 {
|
|
37
|
+
import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build.js';
|
|
40
38
|
import { resolveModelsPath } from '../models-path.js';
|
|
41
|
-
import {
|
|
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
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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
|
|
185
|
-
|
|
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
|
|
195
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
package/src/cli/model-api.ts
CHANGED
|
@@ -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';
|