@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.
@@ -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,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)
@@ -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
- '- **Add a table or field:** edit the Model in `models/`, run `everystack db:generate`,',
154
- ' review the emitted migration, then `everystack db:migrate`. Never hand-write a SQL',
155
- ' migration, never edit `db/schema.ts`after any change `db:generate` must be a clean no-op.',
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 flow is: edit Model → `everystack db:generate` → `db:migrate`.',
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
- ? ['- `everystack db:generate` — compile Models → next migration (schema + RLS + grants)']
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:generate`; it must produce a clean, empty data diff before the',
201
- ' generated flow takes over. Until then, review hand-written SQL like the liability it is.',
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; from there `everystack db:generate` compiles',
206
- ' migrations (schema + RLS + grants) and hand-written SQL retires.',
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',