@everystack/cli 0.4.11 → 0.4.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -82,7 +82,7 @@
82
82
  "structured-headers": "1.0.1",
83
83
  "tsx": "4.21.0",
84
84
  "typescript": "5.9.3",
85
- "@everystack/model": "0.4.2"
85
+ "@everystack/model": "0.4.3"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "@everystack/server": ">=0.1.0",
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `everystack db:export --schema <name> --stage <n>` — the schema-scoped, fingerprint-stamped
3
+ * artifact (canonical-sync gap B). pg_dump ONE schema → the stage's S3 artifact bucket, stamped
4
+ * with the DECLARED schema fingerprint from the checkout, so db:swap can gate an incoming artifact
5
+ * against the target's declared shape (declared-vs-declared: does the artifact and the stage agree
6
+ * on the schema's shape). everystack owns the export so that fingerprint is one formula both sides.
7
+ *
8
+ * The dump runs in the ops Lambda (the DB is private); the CLI computes the fingerprint from the
9
+ * models and invokes the action. Verification of the streaming path is a deploy smoke test.
10
+ */
11
+
12
+ import type { ModelDescriptor } from '@everystack/model';
13
+ import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
14
+ import { invokeAction } from '../aws.js';
15
+ import { fingerprintModels } from '../schema-fingerprint.js';
16
+ import { resolveModelsPath } from '../models-path.js';
17
+ import { loadModels } from './db-generate.js';
18
+ import { loadDeclaredDerived } from '../declared-derived.js';
19
+ import { pgDumpPreflightError } from './db-backup.js';
20
+ import { step, success, fail, info } from '../output.js';
21
+
22
+ const fmtBytes = (b?: number): string =>
23
+ b == null ? '?' : b >= 1024 ** 3 ? `${(b / 1024 ** 3).toFixed(1)} GB` : b >= 1024 ** 2 ? `${(b / 1024 ** 2).toFixed(1)} MB` : `${(b / 1024).toFixed(0)} KB`;
24
+
25
+ export async function dbExportCommand(flags: Record<string, string>): Promise<void> {
26
+ const schema = flags.schema;
27
+ if (!schema) {
28
+ fail('db:export needs --schema <name> (the schema to export as an artifact).');
29
+ process.exit(1);
30
+ }
31
+
32
+ // The DECLARED fingerprint of just this schema — the stamp the swap gate compares against.
33
+ const modelsPath = resolveModelsPath(flags.models);
34
+ let fingerprint: string;
35
+ try {
36
+ step(`Loading models from ${modelsPath}...`);
37
+ const models: ModelDescriptor[] = await loadModels(modelsPath);
38
+ const declaredDb = await loadDeclaredDerived(flags.models);
39
+ fingerprint = fingerprintModels(models, { schemas: [schema], sequences: declaredDb?.sequences }).hash;
40
+ info(`Declared ${schema} fingerprint: ${fingerprint.slice(0, 12)}`);
41
+ } catch (err: any) {
42
+ fail(err.message);
43
+ process.exit(1);
44
+ }
45
+
46
+ step('Resolving deployed config...');
47
+ let config: CliConfig;
48
+ try {
49
+ config = await resolveConfig(flags.stage);
50
+ } catch (err: any) {
51
+ fail(err.message);
52
+ process.exit(1);
53
+ }
54
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
55
+
56
+ // The dump needs the pg_dump layer — surface a missing/incompatible layer as a one-line remedy
57
+ // here, not a mid-dump crash (same preflight db:backup runs).
58
+ step('Checking the pg_dump layer...');
59
+ try {
60
+ const probe = await invokeAction(config.region, opsFunction(config), 'db:backup:probe', {});
61
+ const preflightError = pgDumpPreflightError(probe);
62
+ if (preflightError) { fail(preflightError); process.exit(1); }
63
+ } catch (err: any) {
64
+ fail(`Export pre-flight failed: ${err.message}`);
65
+ process.exit(1);
66
+ }
67
+
68
+ step(`Running pg_dump --schema=${schema} → S3 (this may take a while for large schemas)...`);
69
+ let result: any;
70
+ try {
71
+ result = await invokeAction(config.region, opsFunction(config), 'db:export', { schema, stage: flags.stage, fingerprint });
72
+ } catch (err: any) {
73
+ fail(`Export failed: ${err.message}`);
74
+ process.exit(1);
75
+ }
76
+ if (result?.error) {
77
+ fail(`Export failed: ${result.error}`);
78
+ process.exit(1);
79
+ }
80
+ success(`Artifact ${result.id} (${fmtBytes(result.bytes)}, fingerprint ${String(result.fingerprint).slice(0, 12)}). Deploy with: everystack db:swap --schema ${schema} --stage <target>`);
81
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `everystack db:swap --schema <name> --database-url <url> --from <artifact.dump> [--confirm]` —
3
+ * the refresh-free, fingerprint-gated hot-swap (canonical-sync gap B).
4
+ *
5
+ * Lands a schema-scoped artifact onto a target and swaps it in atomically: gate on the declared
6
+ * fingerprint, snapshot, restore into `<schema>_incoming` (COPY-safe schema rewrite), the atomic
7
+ * rename + FK-recreate + authz-reapply transaction, a post-commit verify hook, then drop-retiring
8
+ * or roll back. The decision spine (swap-execute.ts) is proven on a real database; this shell wires
9
+ * the IO — the artifact restore and the connection.
10
+ *
11
+ * v1 venue: a direct `--database-url` (the operator connection). The credential-free `--stage`
12
+ * ops-Lambda / `--direct` venues ride the stage-write-lanes bricks; a multi-GB restore needs the
13
+ * unbounded clock those give, so `--stage` alone will refuse with the `--direct` advice.
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import { spawn } from 'node:child_process';
18
+ import { Transform } from 'node:stream';
19
+ import { pipeline } from 'node:stream/promises';
20
+ import type { ModelDescriptor } from '@everystack/model';
21
+ import { fingerprintModels } from '../schema-fingerprint.js';
22
+ import { resolveModelsPath } from '../models-path.js';
23
+ import { loadModels } from './db-generate.js';
24
+ import { loadDeclaredDerived } from '../declared-derived.js';
25
+ import { createUrlRunner } from '../db-source.js';
26
+ import { executeSwap, type SwapVerdict } from '../swap-execute.js';
27
+ import { rewriteStatementLine, opensCopyData, closesCopyData } from '../schema-rewrite.js';
28
+ import { step, success, fail, warn } from '../output.js';
29
+
30
+ /** A COPY-aware line transform that rewrites the schema token on statement lines only. */
31
+ function schemaRewriteStream(from: string, to: string): Transform {
32
+ let buf = '';
33
+ let inCopy = false;
34
+ const flushLine = (line: string, push: (s: string) => void) => {
35
+ if (inCopy) {
36
+ if (closesCopyData(line)) inCopy = false;
37
+ push(line + '\n');
38
+ return;
39
+ }
40
+ const rewritten = rewriteStatementLine(line, from, to);
41
+ if (opensCopyData(rewritten)) inCopy = true;
42
+ push(rewritten + '\n');
43
+ };
44
+ return new Transform({
45
+ transform(chunk, _enc, cb) {
46
+ buf += chunk.toString('utf8');
47
+ let nl: number;
48
+ const out: string[] = [];
49
+ while ((nl = buf.indexOf('\n')) !== -1) {
50
+ flushLine(buf.slice(0, nl), (s) => out.push(s));
51
+ buf = buf.slice(nl + 1);
52
+ }
53
+ cb(null, out.join(''));
54
+ },
55
+ flush(cb) {
56
+ if (buf.length) flushLine(buf, (s) => this.push(s));
57
+ cb();
58
+ },
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Restore a `-Fc` artifact into `<schema>_incoming`: pg_restore -f - (archive → SQL) → the schema
64
+ * rewrite → psql. The archive names `<schema>`; the rewrite lands it as `<incoming>`, COPY-data-safe.
65
+ */
66
+ async function restoreIntoIncoming(url: string, artifactPath: string, schema: string, incoming: string): Promise<void> {
67
+ // psql takes the connection URL directly (credential parsed by libpq, not on the process table
68
+ // beyond argv-as-URL); pg_restore -f - just reads the archive file to SQL on stdout.
69
+ const restore = spawn('pg_restore', ['-f', '-', artifactPath], { stdio: ['ignore', 'pipe', 'pipe'] });
70
+ const psql = spawn('psql', ['-v', 'ON_ERROR_STOP=1', '-d', url], { stdio: ['pipe', 'ignore', 'pipe'] });
71
+ let rErr = '', pErr = '';
72
+ restore.stderr.on('data', (d) => { rErr += d.toString(); });
73
+ psql.stderr.on('data', (d) => { pErr += d.toString(); });
74
+ const psqlExit = new Promise<void>((res, rej) => {
75
+ psql.on('error', rej);
76
+ psql.on('close', (c) => c === 0 ? res() : rej(new Error(`psql exited ${c}: ${pErr.trim()}`)));
77
+ });
78
+ const restoreExit = new Promise<void>((res, rej) => {
79
+ restore.on('error', rej);
80
+ restore.on('close', (c) => c === 0 ? res() : rej(new Error(`pg_restore exited ${c}: ${rErr.trim()}`)));
81
+ });
82
+ await Promise.all([
83
+ pipeline(restore.stdout!, schemaRewriteStream(schema, incoming), psql.stdin!),
84
+ restoreExit,
85
+ psqlExit,
86
+ ]);
87
+ }
88
+
89
+ /** Load the artifact's meta (fingerprint) from a sibling `.meta.json`, or fall back to a flag. */
90
+ function readArtifactFingerprint(artifactPath: string, flag?: string): string | null {
91
+ if (flag) return flag;
92
+ const metaPath = artifactPath.replace(/\.dump(\.gz)?$/, '.meta.json');
93
+ try {
94
+ return JSON.parse(fs.readFileSync(metaPath, 'utf8')).fingerprint ?? null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export async function dbSwapCommand(flags: Record<string, string>): Promise<void> {
101
+ const schema = flags.schema;
102
+ const url = flags['database-url'] || process.env.DATABASE_URL;
103
+ const from = flags.from;
104
+ if (!schema) { fail('db:swap needs --schema <name>.'); process.exit(1); }
105
+ if (!from) { fail('db:swap needs --from <artifact.dump> (the schema-scoped -Fc archive to land).'); process.exit(1); }
106
+ if (!url) {
107
+ fail('db:swap v1 needs a direct --database-url (the operator connection). The credential-free --stage venue rides the stage-write-lanes bricks; a large restore needs its unbounded clock.');
108
+ process.exit(1);
109
+ }
110
+
111
+ const modelsPath = resolveModelsPath(flags.models);
112
+ let models: ModelDescriptor[];
113
+ let declaredFingerprint: string;
114
+ try {
115
+ step(`Loading models from ${modelsPath}...`);
116
+ models = await loadModels(modelsPath);
117
+ const declaredDb = await loadDeclaredDerived(flags.models);
118
+ declaredFingerprint = fingerprintModels(models, { schemas: [schema], sequences: declaredDb?.sequences }).hash;
119
+ } catch (err: any) { fail(err.message); process.exit(1); }
120
+
121
+ const artifactFingerprint = readArtifactFingerprint(from, flags.fingerprint);
122
+ if (!artifactFingerprint) {
123
+ fail(`db:swap can't find the artifact fingerprint — expected a sibling .meta.json next to ${from}, or pass --fingerprint. Without it the gate can't run, and an unchecked swap is exactly what the gate prevents.`);
124
+ process.exit(1);
125
+ }
126
+
127
+ const { runner, end } = await createUrlRunner(url);
128
+ try {
129
+ step(`Swapping ${schema} — gate, land incoming, atomic swap, verify...`);
130
+ const res = await executeSwap(runner, {
131
+ models, schema,
132
+ artifactFingerprint,
133
+ declaredFingerprint,
134
+ applyIncoming: async () => { await restoreIntoIncoming(url, from, schema, `${schema}_incoming`); },
135
+ // Snapshot + verify-hook wiring land with the ops venue; a direct v1 swap warns rather than
136
+ // silently skipping the safety net.
137
+ snapshot: async () => { warn('no snapshot taken (direct v1) — take one first: everystack db:backup --database-url … before a production swap.'); },
138
+ });
139
+
140
+ if (res.status === 'swapped') {
141
+ success(`Swapped ${schema} — the artifact is live (no refresh ran).`);
142
+ for (const w of res.warnings ?? []) warn(`verify warning: ${w.name}${w.detail ? ` — ${w.detail}` : ''}`);
143
+ } else {
144
+ fail(`db:swap ${res.status}: ${res.reason}`);
145
+ process.exit(1);
146
+ }
147
+ } catch (err: any) {
148
+ fail(`Swap failed: ${err.message}`);
149
+ process.exit(1);
150
+ } finally {
151
+ await end?.();
152
+ }
153
+ }
package/src/cli/index.ts CHANGED
@@ -25,6 +25,8 @@ import { dbTemplateRefreshCommand, dbBranchCommand } from './commands/db-branch.
25
25
  import { dbForkCommand } from './commands/db-fork.js';
26
26
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
27
27
  import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
28
+ import { dbExportCommand } from './commands/db-export.js';
29
+ import { dbSwapCommand } from './commands/db-swap.js';
28
30
  import { consoleCommand } from './commands/console.js';
29
31
  import { cachePurgeCommand } from './commands/cache.js';
30
32
  import { statusCommand } from './commands/status.js';
@@ -181,6 +183,12 @@ async function main() {
181
183
  case 'db:restore':
182
184
  await dbRestoreCommand(flags);
183
185
  break;
186
+ case 'db:export':
187
+ await dbExportCommand(flags);
188
+ break;
189
+ case 'db:swap':
190
+ await dbSwapCommand(flags);
191
+ break;
184
192
  case 'db:backup:download': {
185
193
  const backupId = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
186
194
  await dbBackupDownloadCommand(flags, backupId);
@@ -353,6 +361,8 @@ Usage:
353
361
  everystack db:backups [--stage <name>] List logical backups (id, size, created)
354
362
  everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
355
363
  everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
364
+ everystack db:export --schema <name> [--stage <name>] [--models <barrel>] Schema-scoped pg_dump → S3 artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp)
365
+ everystack db:swap --schema <name> --database-url <url> --from <artifact.dump> [--fingerprint <hash>] Land a schema artifact atomically: fingerprint gate → restore into <schema>_incoming (COPY-safe rewrite) → one txn (drop+rename+recreate app→schema FKs, re-apply authz) → verify → drop retiring. Refresh-free; app.* untouched. DESTRUCTIVE (--stage/--direct ops venue rides stage-write-lanes)
356
366
  everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
357
367
  everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored); --abilities public-read stamps the common stanza (public read, admin write) uncommented — explicit generated code, never a runtime default
358
368
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
@@ -161,26 +161,54 @@ export interface CanonicalState {
161
161
  sequences?: Array<Record<string, unknown>>;
162
162
  }
163
163
 
164
+ /** The schema of a (possibly) qualified name — `stats.entities` → `stats`, a bare name → `public`. */
165
+ export function schemaOf(qualifiedName: string): string {
166
+ const dot = qualifiedName.indexOf('.');
167
+ return dot === -1 ? 'public' : qualifiedName.slice(0, dot);
168
+ }
169
+
170
+ export interface CanonicalizeOptions {
171
+ /** Restrict the state to these schemas — the content address of ONE schema (e.g. a
172
+ * schema-scoped artifact) instead of the whole database. Omitted = every schema. */
173
+ schemas?: string[];
174
+ }
175
+
164
176
  export function canonicalizeState(
165
177
  snapshot: SchemaSnapshot,
166
178
  authzTables: TableContract[],
179
+ opts: CanonicalizeOptions = {},
167
180
  ): CanonicalState {
181
+ const inScope = opts.schemas ? new Set(opts.schemas) : null;
182
+ const keepTable = (name: string) => inScope === null || inScope.has(schemaOf(name));
183
+
184
+ const tables = snapshot.tables.filter((t) => keepTable(t.table));
185
+ // Enums carry no schema in the descriptor (keyed by bare name, like the model side).
186
+ // Scope them by USE: an enum is in-scope when a kept table has a column of that type
187
+ // (the type may be qualified under canonical introspection — `stats.status` — so match
188
+ // on the bare tail). This keeps the exact set the scoped models declare.
189
+ const usedEnum = new Set<string>();
190
+ for (const t of tables) for (const c of t.columns) usedEnum.add(schemaOf(c.type) === 'public' ? c.type : c.type.slice(c.type.indexOf('.') + 1));
191
+
168
192
  const sequences = byKey(
169
- (snapshot.sequences ?? []).map((s) => ({
170
- name: s.name, as: s.as,
171
- ...(s.start != null ? { start: s.start } : {}),
172
- ...(s.increment != null ? { increment: s.increment } : {}),
173
- })),
193
+ (snapshot.sequences ?? [])
194
+ .filter((s) => inScope === null || inScope.has(schemaOf(s.name)))
195
+ .map((s) => ({
196
+ name: s.name, as: s.as,
197
+ ...(s.start != null ? { start: s.start } : {}),
198
+ ...(s.increment != null ? { increment: s.increment } : {}),
199
+ })),
174
200
  (s) => String(s.name),
175
201
  );
176
202
  return {
177
203
  v: FINGERPRINT_VERSION,
178
- tables: byKey(snapshot.tables.map(canonicalTable), (t) => String(t.table)),
204
+ tables: byKey(tables.map(canonicalTable), (t) => String(t.table)),
179
205
  enums: byKey(
180
- (snapshot.enums ?? []).map((e) => ({ name: e.name, values: e.values })),
206
+ (snapshot.enums ?? [])
207
+ .filter((e) => inScope === null || usedEnum.has(e.name))
208
+ .map((e) => ({ name: e.name, values: e.values })),
181
209
  (e) => e.name,
182
210
  ),
183
- authz: byKey(authzTables.map(canonicalAuthzTable), (t) => String(t.table)),
211
+ authz: byKey(authzTables.filter((t) => keepTable(t.table)).map(canonicalAuthzTable), (t) => String(t.table)),
184
212
  ...(sequences.length ? { sequences } : {}),
185
213
  };
186
214
  }
@@ -195,28 +223,38 @@ export interface Fingerprint {
195
223
  export function fingerprintState(
196
224
  snapshot: SchemaSnapshot,
197
225
  authzTables: TableContract[],
226
+ opts: CanonicalizeOptions = {},
198
227
  ): Fingerprint {
199
- const canonical = canonicalizeState(snapshot, authzTables);
228
+ const canonical = canonicalizeState(snapshot, authzTables, opts);
200
229
  return { hash: createHash('sha256').update(stableStringify(canonical)).digest('hex'), canonical };
201
230
  }
202
231
 
203
- /** The DECLARED state: compile the models to the same canonical shape and hash it. */
232
+ /** The DECLARED state: compile the models to the same canonical shape and hash it. When
233
+ * `schemas` is set, only models in those schemas are compiled — the content address of a
234
+ * schema slice (the export stamp and the swap gate compute this identically). */
204
235
  export function fingerprintModels(
205
236
  models: ModelDescriptor[],
206
- opts: { schema?: string; sequences?: SequenceDescriptor[] } = {},
237
+ opts: { schema?: string; sequences?: SequenceDescriptor[]; schemas?: string[] } = {},
207
238
  ): Fingerprint {
239
+ const scoped = opts.schemas ? models.filter((m) => opts.schemas!.includes(m.schema)) : models;
208
240
  const snapshot: SchemaSnapshot = {
209
- tables: models.map((m) => compileTableSchema(m, opts)),
210
- enums: compileEnums(models),
241
+ tables: scoped.map((m) => compileTableSchema(m, opts)),
242
+ enums: compileEnums(scoped),
211
243
  ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}),
212
244
  };
213
- const authzTables = models.map((m) => compileTableContract(m, opts));
214
- return fingerprintState(snapshot, authzTables);
245
+ const authzTables = scoped.map((m) => compileTableContract(m, opts));
246
+ // The models are already scoped, so no further schema filter — but standalone sequences
247
+ // are declared apart from models; scope them here too.
248
+ return fingerprintState(snapshot, authzTables, opts.schemas ? { schemas: opts.schemas } : {});
215
249
  }
216
250
 
217
251
  /** Convenience: the live side, from the two existing introspections. */
218
- export function fingerprintLive(snapshot: SchemaSnapshot, contract: AuthzContract): Fingerprint {
219
- return fingerprintState(snapshot, contract.tables);
252
+ export function fingerprintLive(
253
+ snapshot: SchemaSnapshot,
254
+ contract: AuthzContract,
255
+ opts: CanonicalizeOptions = {},
256
+ ): Fingerprint {
257
+ return fingerprintState(snapshot, contract.tables, opts);
220
258
  }
221
259
 
222
260
  // ---------------------------------------------------------------------------
@@ -0,0 +1,67 @@
1
+ /**
2
+ * schema-rewrite — land a schema dump under a DIFFERENT schema name, COPY-data-safe.
3
+ *
4
+ * A `-Fc` archive pins the schema name it was dumped from; `pg_restore` filters by schema but can't
5
+ * remap one. For a ZERO-WINDOW hot-swap the incoming data must land in `<schema>_incoming` while the
6
+ * live `<schema>` keeps serving, so we restore the archive to SQL (`pg_restore -f -`) and rewrite the
7
+ * schema token on the way to `psql`. everystack owns both the export and this rewrite, so the token
8
+ * is our own dump output — deterministic.
9
+ *
10
+ * The one hazard is a COPY data row whose VALUE contains the schema name (`see stats.entities for…`):
11
+ * it must survive untouched. So the rewrite is COPY-aware — it rewrites STATEMENT lines only and
12
+ * passes every byte between `COPY … FROM stdin;` and the terminating `\.` through verbatim.
13
+ *
14
+ * Pure and line-oriented so it composes into a stream transform (the ops/-direct restore pipes
15
+ * pg_restore stdout → this → psql stdin); a failure here is a loud restore error, never silent
16
+ * corruption. When the rewrite can't be trusted (a truly exotic dump), the swap's documented
17
+ * fallback is the rename-dance (a brief window, snapshot-backed).
18
+ */
19
+
20
+ const IDENT = '[A-Za-z_][A-Za-z0-9_$]*';
21
+
22
+ /** True once this line OPENS a COPY data block (`COPY … FROM stdin;`) — data follows until `\.`. */
23
+ export function opensCopyData(line: string): boolean {
24
+ return /^\s*COPY\s+.*\sFROM\s+stdin;\s*$/i.test(line);
25
+ }
26
+
27
+ /** True when this line CLOSES a COPY data block (a lone `\.`). */
28
+ export function closesCopyData(line: string): boolean {
29
+ return line === '\\.' || /^\\\.\s*$/.test(line);
30
+ }
31
+
32
+ /**
33
+ * Rewrite the schema token `from` → `to` on a single STATEMENT line (never call on COPY data).
34
+ * Handles the forms pg_dump emits: the qualifier (`from.x`, `"from".x`), the standalone schema
35
+ * statements (`CREATE SCHEMA from`, `ALTER SCHEMA from …`, `DROP SCHEMA … from`), and a
36
+ * `SET search_path` naming it. The schema token must be a plain identifier at both ends so a
37
+ * substring of another name (`from_archive`) is never touched.
38
+ */
39
+ export function rewriteStatementLine(line: string, from: string, to: string): string {
40
+ const f = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ // 1. Schema-qualified refs: `from.` or `"from".` → `to.` (normalize to bare; the token is safe).
42
+ let out = line.replace(new RegExp(`(^|[^A-Za-z0-9_$."])(?:${f}|"${f}")\\.`, 'g'), `$1${to}.`);
43
+ // 2. Standalone schema in CREATE/ALTER/DROP SCHEMA and search_path — the token as a whole word,
44
+ // bare or quoted, not followed by a dot (those were handled above).
45
+ out = out.replace(new RegExp(`(^|[^A-Za-z0-9_$.])(?:${f}|"${f}")(?![A-Za-z0-9_$."])`, 'g'), `$1${to}`);
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Rewrite a whole plain-SQL dump, COPY-data-safe. Splits on newlines, tracks COPY blocks, and
51
+ * rewrites only statement lines. Returns the rewritten SQL. (The streaming transform in the restore
52
+ * pipeline applies `rewriteStatementLine` per line with the same COPY tracking; this is the
53
+ * in-memory form the tests pin.)
54
+ */
55
+ export function rewriteSchemaDump(sql: string, from: string, to: string): string {
56
+ const lines = sql.split('\n');
57
+ let inCopy = false;
58
+ for (let i = 0; i < lines.length; i++) {
59
+ if (inCopy) {
60
+ if (closesCopyData(lines[i])) inCopy = false;
61
+ continue; // data (or the terminator) — never rewritten
62
+ }
63
+ lines[i] = rewriteStatementLine(lines[i], from, to);
64
+ if (opensCopyData(lines[i])) inCopy = true;
65
+ }
66
+ return lines.join('\n');
67
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * schema-swap — the refresh-free, FK-safe, atomic hot-swap of one schema (the core of
3
+ * db:swap; docs/plans/canonical-sync-artifacts.md, gap B).
4
+ *
5
+ * The incoming data has already been restored into `<schema>_incoming` while the live
6
+ * `<schema>` keeps serving. This renders the ONE transaction that swaps them in atomically:
7
+ * readers see the old schema or the new, never a partial. Pure — no database here; the
8
+ * command sends `statements` as one transaction and owns the snapshot / verify / drop-retiring
9
+ * lifecycle around it.
10
+ *
11
+ * The order is load-bearing, and every step verified on PostgreSQL 16:
12
+ * 1. DROP the app→schema foreign keys. They bind their target by OID, so the rename below
13
+ * would carry them onto the RETIRING schema (they would reference stale data, and the
14
+ * final DROP of the retiring schema would fail). Drop them first.
15
+ * 2. RENAME the live schema out (`<schema>` → `<schema>_retiring`) and the incoming in
16
+ * (`<schema>_incoming` → `<schema>`). Schema renames are transactional and atomic.
17
+ * 3. RECREATE the app→schema foreign keys against the NEW schema. This RE-VALIDATES against
18
+ * the new data: an app row referencing a key the new artifact dropped fails the
19
+ * ADD CONSTRAINT, the whole transaction rolls back, and the swap is refused — the
20
+ * cross-schema integrity gate, for free.
21
+ * 4. RE-APPLY the schema's authz (RLS, policies, grants) from the declared descriptors. The
22
+ * incoming schema was restored `--no-owner --no-privileges`, so it carries no grants;
23
+ * GRANT/POLICY are transactional, so they ride the same transaction — there is no
24
+ * committed instant where the new schema serves with absent or stale grants.
25
+ *
26
+ * No REFRESH anywhere: the artifact ships computed rows as tables (defineMaterializedTable),
27
+ * so the swap is a pointer flip, never a recompute.
28
+ */
29
+
30
+ import type { ModelDescriptor } from '@everystack/model';
31
+ import { modelForeignKeys, qualifiedTable } from './schema-compile.js';
32
+ import { compileTableContract } from './authz-compile.js';
33
+ import { emitReconcileSql } from './authz-reconcile.js';
34
+ import { schemaOf } from './schema-fingerprint.js';
35
+
36
+ const SAFE_SCHEMA = /^[a-z_][a-z0-9_$]*$/;
37
+
38
+ function assertSafeSchema(name: string): void {
39
+ if (!SAFE_SCHEMA.test(name)) {
40
+ throw new Error(`swap schema ${JSON.stringify(name)} is not a plain lowercase identifier — everystack swaps schemas it can name without quoting games.`);
41
+ }
42
+ }
43
+
44
+ /** ` ON DELETE … ON UPDATE …` for a FK, or '' when both are the PostgreSQL default. */
45
+ function refActionSql(fk: { onDelete?: string; onUpdate?: string }): string {
46
+ let s = '';
47
+ if (fk.onDelete) s += ` ON DELETE ${fk.onDelete.toUpperCase()}`;
48
+ if (fk.onUpdate) s += ` ON UPDATE ${fk.onUpdate.toUpperCase()}`;
49
+ return s;
50
+ }
51
+
52
+ /** Quote a (possibly) schema-qualified ref: `stats.entities` → `"stats"."entities"`, `uploads` → `"uploads"`. */
53
+ function quoteRef(name: string): string {
54
+ return name.split('.').map((p) => `"${p}"`).join('.');
55
+ }
56
+
57
+ export interface CrossSchemaFk {
58
+ /** The model that holds the FK (an app/public table pointing INTO the swapped schema). */
59
+ table: string;
60
+ name: string;
61
+ dropSql: string;
62
+ addSql: string;
63
+ }
64
+
65
+ /**
66
+ * The foreign keys that cross INTO `targetSchema` from a model in ANOTHER schema — the only FKs
67
+ * the swap must drop and recreate. FKs WITHIN the swapped schema ride the dump; FKs from the
68
+ * swapped schema OUT to a stable schema (e.g. stats → app) resolve by name on restore and are
69
+ * untouched. Only external → swapped edges bind the old schema by OID.
70
+ */
71
+ export function crossSchemaForeignKeys(models: ModelDescriptor[], targetSchema: string): CrossSchemaFk[] {
72
+ const out: CrossSchemaFk[] = [];
73
+ for (const model of models) {
74
+ if ((model.schema || 'public') === targetSchema) continue; // internal edge, not external
75
+ const from = qualifiedTable(model);
76
+ for (const fk of modelForeignKeys(model)) {
77
+ if (schemaOf(fk.refTable) !== targetSchema) continue; // not pointing into the swapped schema
78
+ const cols = fk.columns.map((c) => `"${c}"`).join(', ');
79
+ const refCols = fk.refColumns.map((c) => `"${c}"`).join(', ');
80
+ out.push({
81
+ table: from,
82
+ name: fk.name,
83
+ dropSql: `ALTER TABLE ${from} DROP CONSTRAINT IF EXISTS "${fk.name}";`,
84
+ addSql: `ALTER TABLE ${from} ADD CONSTRAINT "${fk.name}" FOREIGN KEY (${cols}) REFERENCES ${quoteRef(fk.refTable)}(${refCols})${refActionSql(fk)};`,
85
+ });
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ export interface SwapPlan {
92
+ /** The ordered SQL for ONE transaction (the command wraps BEGIN/COMMIT). */
93
+ statements: string[];
94
+ /** The app→schema FKs dropped and recreated (the integrity gate). */
95
+ crossSchemaFks: CrossSchemaFk[];
96
+ /** The schema the live data is renamed to before the retiring drop (outside the txn). */
97
+ retiring: string;
98
+ incoming: string;
99
+ }
100
+
101
+ export interface SwapOptions {
102
+ /** The schema being swapped (e.g. `stats`). */
103
+ schema: string;
104
+ /** Where the incoming data was restored. Default `<schema>_incoming`. */
105
+ incoming?: string;
106
+ /** Where the live schema is renamed. Default `<schema>_retiring`; pass a stamped name for uniqueness. */
107
+ retiring?: string;
108
+ }
109
+
110
+ /**
111
+ * Render the atomic swap transaction for `opts.schema`, given the full declared model set (to
112
+ * find the app→schema FKs and the schema's authz). The returned `statements` go as ONE
113
+ * transaction; `crossSchemaFks` and `retiring` let the command report and drive the
114
+ * drop-retiring / rollback lifecycle.
115
+ */
116
+ export function renderSchemaSwap(models: ModelDescriptor[], opts: SwapOptions): SwapPlan {
117
+ const { schema } = opts;
118
+ const incoming = opts.incoming ?? `${schema}_incoming`;
119
+ const retiring = opts.retiring ?? `${schema}_retiring`;
120
+ assertSafeSchema(schema);
121
+ assertSafeSchema(incoming);
122
+ assertSafeSchema(retiring);
123
+
124
+ const fks = crossSchemaForeignKeys(models, schema);
125
+ // Full authz for the swapped-in schema, from scratch (live side empty = every declared grant
126
+ // is a create) — the incoming schema was restored with privileges stripped.
127
+ const statsContracts = models
128
+ .filter((m) => (m.schema || 'public') === schema)
129
+ .map((m) => compileTableContract(m));
130
+ const authz = emitReconcileSql({ tables: statsContracts, functions: [] }, { tables: [], functions: [] });
131
+
132
+ const statements = [
133
+ ...fks.map((f) => f.dropSql),
134
+ `ALTER SCHEMA "${schema}" RENAME TO "${retiring}";`,
135
+ `ALTER SCHEMA "${incoming}" RENAME TO "${schema}";`,
136
+ ...fks.map((f) => f.addSql),
137
+ ...authz,
138
+ ];
139
+
140
+ return { statements, crossSchemaFks: fks, retiring, incoming };
141
+ }
142
+
143
+ /** The drop of the retiring schema, run AFTER the swap transaction commits and verify passes. */
144
+ export function dropRetiringSql(retiring: string): string {
145
+ assertSafeSchema(retiring);
146
+ return `DROP SCHEMA IF EXISTS "${retiring}" CASCADE;`;
147
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * swap-execute — the db:swap ceremony: gate → snapshot → land incoming → atomic swap → verify →
3
+ * drop-retiring-or-rollback, as one function over an injected runner and injected IO.
4
+ *
5
+ * The destructive moves (the S3 artifact fetch, the pg_restore into the incoming schema, the RDS
6
+ * snapshot, the consumer's verify validators) are all injected, so this core is the DECISION spine
7
+ * and is proven on a real database without S3 or a deployed stage. The command shell wires the IO.
8
+ *
9
+ * Order, none skippable (docs/plans/canonical-sync-artifacts.md):
10
+ * 1. FINGERPRINT GATE — the artifact's declared-schema fingerprint must equal the target's
11
+ * declared fingerprint, or REFUSE. Stale-shaped rows never load into a redefined schema.
12
+ * 2. SNAPSHOT — the rollback point, before anything destructive.
13
+ * 3. LAND INCOMING — restore the artifact into `<schema>_incoming` while live keeps serving.
14
+ * 4. ATOMIC SWAP — one transaction: drop app→schema FKs, rename out+in, recreate FKs (which
15
+ * RE-VALIDATE against the new data — a bad artifact rolls back here), re-apply authz. A
16
+ * failure rolls back whole; live never moved.
17
+ * 5. VERIFY — the post-commit hook runs against the swapped-in schema. A fatal verdict rolls the
18
+ * stage back to the snapshot; a warn surfaces without rolling back.
19
+ * 6. DROP RETIRING — only after verify passes.
20
+ */
21
+
22
+ import type { ModelDescriptor } from '@everystack/model';
23
+ import type { QueryRunner } from './authz-contract.js';
24
+ import { renderSchemaSwap, dropRetiringSql } from './schema-swap.js';
25
+
26
+ /** One validator's result. A `fatal` (default) failure rolls back; a `warn` failure is surfaced only. */
27
+ export interface SwapCheck {
28
+ name: string;
29
+ ok: boolean;
30
+ detail?: string;
31
+ severity?: 'fatal' | 'warn';
32
+ }
33
+
34
+ /** The post-commit verify hook's structured verdict — names WHICH validator failed, not just "failed". */
35
+ export interface SwapVerdict {
36
+ ok: boolean;
37
+ checks?: SwapCheck[];
38
+ }
39
+
40
+ export interface ExecuteSwapOptions {
41
+ models: ModelDescriptor[];
42
+ schema: string;
43
+ incoming?: string;
44
+ retiring?: string;
45
+ /** The fingerprint the artifact was stamped with (from its meta). */
46
+ artifactFingerprint: string;
47
+ /** The target's DECLARED scoped fingerprint (the caller computes it from the checkout). */
48
+ declaredFingerprint: string;
49
+ /** Restore the artifact into the incoming schema (S3 → pg_restore → schema-rewrite → psql). */
50
+ applyIncoming: (runner: QueryRunner) => Promise<void>;
51
+ /** Take the pre-swap rollback point (db:snapshot / db:backup). Optional in tests. */
52
+ snapshot?: () => Promise<void>;
53
+ /** The consumer's post-commit validators — must see committed state, so they run after COMMIT. */
54
+ verify?: () => Promise<SwapVerdict>;
55
+ /** Restore the stage to the snapshot from step 2 when verify is fatal. */
56
+ rollbackToSnapshot?: () => Promise<void>;
57
+ }
58
+
59
+ export type SwapStatus =
60
+ | 'swapped'
61
+ | 'refused-fingerprint'
62
+ | 'refused-integrity'
63
+ | 'rolled-back-verify';
64
+
65
+ export interface SwapResult {
66
+ status: SwapStatus;
67
+ reason?: string;
68
+ /** The verify verdict, when the hook ran. */
69
+ verdict?: SwapVerdict;
70
+ /** Warn-severity checks that did NOT trigger rollback (surfaced). */
71
+ warnings?: SwapCheck[];
72
+ }
73
+
74
+ /** Does the verdict force a rollback? Any failing check whose severity is fatal (the default). */
75
+ export function isFatalVerdict(verdict: SwapVerdict): boolean {
76
+ if (verdict.ok) return false;
77
+ const checks = verdict.checks ?? [];
78
+ if (checks.length === 0) return true; // ok:false with no detail = fatal
79
+ return checks.some((c) => !c.ok && (c.severity ?? 'fatal') === 'fatal');
80
+ }
81
+
82
+ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions): Promise<SwapResult> {
83
+ // 1. Fingerprint gate — declared-vs-declared: does the artifact and the target agree on the shape.
84
+ if (opts.artifactFingerprint !== opts.declaredFingerprint) {
85
+ return {
86
+ status: 'refused-fingerprint',
87
+ reason: `the artifact was built against ${opts.schema} fingerprint ${opts.artifactFingerprint.slice(0, 12)}, but the checkout declares ${opts.declaredFingerprint.slice(0, 12)} — rebase to the artifact's schema, or rebuild the artifact.`,
88
+ };
89
+ }
90
+
91
+ const plan = renderSchemaSwap(opts.models, { schema: opts.schema, incoming: opts.incoming, retiring: opts.retiring });
92
+
93
+ // 2. Snapshot (the rollback point) before anything destructive.
94
+ if (opts.snapshot) await opts.snapshot();
95
+
96
+ // 3. Land the incoming schema while live keeps serving.
97
+ await opts.applyIncoming(runner);
98
+
99
+ // 4. The atomic swap. A FK re-validation failure (a bad artifact) rolls the whole thing back.
100
+ await runner('BEGIN');
101
+ try {
102
+ await runner(plan.statements.join('\n'));
103
+ await runner('COMMIT');
104
+ } catch (err: any) {
105
+ try { await runner('ROLLBACK'); } catch { /* already aborted */ }
106
+ // Clean up the landed-but-unused incoming schema so a retry is idempotent.
107
+ try { await runner(`DROP SCHEMA IF EXISTS "${plan.incoming}" CASCADE`); } catch { /* best effort */ }
108
+ return {
109
+ status: 'refused-integrity',
110
+ reason: `the swap transaction failed — usually the cross-schema FK re-validation (an app row references a ${opts.schema} key the artifact dropped): ${String(err?.message ?? err)}`,
111
+ };
112
+ }
113
+
114
+ // 5. Verify (post-commit). Fatal → roll back to the snapshot; warn → surface only.
115
+ let verdict: SwapVerdict | undefined;
116
+ if (opts.verify) {
117
+ verdict = await opts.verify();
118
+ if (isFatalVerdict(verdict)) {
119
+ if (opts.rollbackToSnapshot) await opts.rollbackToSnapshot();
120
+ return {
121
+ status: 'rolled-back-verify',
122
+ reason: `verify failed: ${(verdict.checks ?? []).filter((c) => !c.ok).map((c) => c.name).join(', ') || 'no detail'} — rolled back to the pre-swap snapshot.`,
123
+ verdict,
124
+ };
125
+ }
126
+ }
127
+
128
+ // 6. Drop the retiring schema — the swap is committed and verified.
129
+ await runner(dropRetiringSql(plan.retiring));
130
+
131
+ const warnings = (verdict?.checks ?? []).filter((c) => !c.ok && c.severity === 'warn');
132
+ return { status: 'swapped', verdict, ...(warnings.length ? { warnings } : {}) };
133
+ }