@everystack/cli 0.3.23 → 0.3.27
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 +4 -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-fork.ts +139 -0
- package/src/cli/commands/db-plan.ts +21 -0
- package/src/cli/commands/db-sync.ts +21 -0
- package/src/cli/commands/runbook.ts +5 -4
- package/src/cli/db-build.ts +161 -0
- package/src/cli/derived-source.ts +3 -2
- package/src/cli/index.ts +19 -0
- package/src/cli/model-api.ts +7 -0
- package/src/cli/runbook/sections.ts +23 -10
|
@@ -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,9 @@ 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';
|
|
24
|
+
import { dbForkCommand } from './commands/db-fork.js';
|
|
22
25
|
import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
|
|
23
26
|
import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
|
|
24
27
|
import { consoleCommand } from './commands/console.js';
|
|
@@ -204,6 +207,18 @@ async function main() {
|
|
|
204
207
|
case 'db:approvers':
|
|
205
208
|
await dbApproversCommand(flags);
|
|
206
209
|
break;
|
|
210
|
+
case 'db:backfill':
|
|
211
|
+
await dbBackfillCommand(flags);
|
|
212
|
+
break;
|
|
213
|
+
case 'db:template:refresh':
|
|
214
|
+
await dbTemplateRefreshCommand(flags);
|
|
215
|
+
break;
|
|
216
|
+
case 'db:branch':
|
|
217
|
+
await dbBranchCommand(flags);
|
|
218
|
+
break;
|
|
219
|
+
case 'db:fork':
|
|
220
|
+
await dbForkCommand(flags);
|
|
221
|
+
break;
|
|
207
222
|
case 'db:authz:pull':
|
|
208
223
|
await dbAuthzPullCommand(flags);
|
|
209
224
|
break;
|
|
@@ -334,6 +349,10 @@ Usage:
|
|
|
334
349
|
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
350
|
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
351
|
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
|
|
352
|
+
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
|
|
353
|
+
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
|
|
354
|
+
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)
|
|
355
|
+
everystack db:fork --from-stage <src> --stage <target> --confirm [--backup <id>] Fork one DEPLOYED stage's database into another: back up the source (or reuse --backup <id>), presign the dump (the operator's credentials ARE the cross-stage authorization; expires in 1h), restore into the target via its ops Lambda. Production is never a target (that's db:restore); forking FROM production warns about PII; the branch's schema edge then lands via db:plan → db:apply (descent composes). Teardown: sst remove --stage <target>
|
|
337
356
|
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
338
357
|
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
339
358
|
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';
|
|
@@ -150,9 +150,14 @@ function extend(r: ProjectReality): Block {
|
|
|
150
150
|
];
|
|
151
151
|
if (r.models.dir) {
|
|
152
152
|
lines.push(
|
|
153
|
-
|
|
154
|
-
'
|
|
155
|
-
' migration, never edit
|
|
153
|
+
`- **Add a table or field:** edit the Model in \`${r.models.dir}/\`, run \`everystack db:sync\` —`,
|
|
154
|
+
' the dev database follows your checkout (state + authz + derived, verified by fingerprint).',
|
|
155
|
+
' Never hand-write a SQL migration, never edit generated schema — CI runs `everystack db:check`',
|
|
156
|
+
' and refuses artifacts that do not match regeneration.',
|
|
157
|
+
'- **Ship a schema change:** protected stages take plans, not syncs: `everystack db:plan`',
|
|
158
|
+
' (a reviewable edge, fingerprint-pinned at both ends) → review → `everystack db:apply`.',
|
|
159
|
+
'- **Move data:** one-shot jobs in `db/backfills/*.sql`, run deliberately via',
|
|
160
|
+
' `everystack db:backfill --apply` — never as a schema side effect.',
|
|
156
161
|
);
|
|
157
162
|
if (r.migrationMode === 'transitional') {
|
|
158
163
|
lines.push(
|
|
@@ -165,8 +170,9 @@ function extend(r: ProjectReality): Block {
|
|
|
165
170
|
);
|
|
166
171
|
} else if (r.hasDatabase) {
|
|
167
172
|
lines.push(
|
|
168
|
-
'- **Schema changes:** migrate to Models (`everystack db:pull` writes `models/` from the',
|
|
169
|
-
' live database), then the
|
|
173
|
+
'- **Schema changes:** migrate to Models (`everystack db:pull` writes `db/models/` from the',
|
|
174
|
+
' live database — COMMIT that state before evolving it), then the loop is: edit Model →',
|
|
175
|
+
' `everystack db:sync` (dev) / `db:plan` → `db:apply` (protected stages).',
|
|
170
176
|
);
|
|
171
177
|
}
|
|
172
178
|
if (r.handlers.api) {
|
|
@@ -193,17 +199,24 @@ function extend(r: ProjectReality): Block {
|
|
|
193
199
|
function database(r: ProjectReality): Block {
|
|
194
200
|
const flow =
|
|
195
201
|
r.migrationMode === 'everystack'
|
|
196
|
-
? [
|
|
202
|
+
? [
|
|
203
|
+
'- `everystack db:sync` — dev edit loop: the database follows your checkout (state + authz + derived)',
|
|
204
|
+
'- `everystack db:check` — the CI gate: merged Models compose, generated artifacts match regeneration',
|
|
205
|
+
'- `everystack db:plan` → `everystack db:apply` — protected stages: reviewable, fingerprint-verified edges',
|
|
206
|
+
'- `everystack db:branch` / `db:template:refresh` — per-git-branch dev databases from a seeded template',
|
|
207
|
+
'- `everystack db:backfill` — one-shot data jobs (`db/backfills/*.sql`), own record, run deliberately',
|
|
208
|
+
]
|
|
197
209
|
: r.migrationMode === 'transitional'
|
|
198
210
|
? [
|
|
199
211
|
'- **Mid-migration to Models:** the journal is still hand-authored. Target flow is',
|
|
200
|
-
' `everystack db:
|
|
201
|
-
'
|
|
212
|
+
' `everystack db:sync` (dev) / `db:plan` → `db:apply` (protected); `everystack db:generate`',
|
|
213
|
+
' must produce a clean, empty data diff before the generated flow takes over. Until then,',
|
|
214
|
+
' review hand-written SQL like the liability it is.',
|
|
202
215
|
]
|
|
203
216
|
: [
|
|
204
217
|
'- Migrations are hand-authored (drizzle-kit). The Models on-ramp: `everystack db:pull`',
|
|
205
|
-
' writes `models/` from the live database
|
|
206
|
-
'
|
|
218
|
+
' writes `db/models/` from the live database (COMMIT that state before evolving it); from',
|
|
219
|
+
' there `everystack db:sync` / `db:plan` → `db:apply` take over and hand-written SQL retires.',
|
|
207
220
|
];
|
|
208
221
|
return gen('database', [
|
|
209
222
|
'## Database operations',
|