@everystack/cli 0.4.12 → 0.4.13
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 +1 -1
- package/src/cli/commands/db-export.ts +147 -9
- package/src/cli/commands/db-swap.ts +5 -2
- package/src/cli/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `everystack db:export --schema <name> --stage <n
|
|
3
|
-
* artifact (canonical-sync gap B). pg_dump ONE schema
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* `everystack db:export --schema <name> [--stage <n> | --database-url <url>]` — the schema-scoped,
|
|
3
|
+
* fingerprint-stamped artifact (canonical-sync gap B). pg_dump ONE schema, stamped with the
|
|
4
|
+
* DECLARED schema fingerprint from the checkout, so db:swap can gate an incoming artifact against
|
|
5
|
+
* the target's declared shape (declared-vs-declared: does the artifact and the stage agree on the
|
|
6
|
+
* schema's shape). everystack owns the export so that fingerprint is one formula both sides.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Two venues, mirroring db:swap:
|
|
9
|
+
* - `--stage` (default): the dump runs in the ops Lambda (the DB is private) → the stage's S3
|
|
10
|
+
* artifact bucket, gzipped. Verification of the streaming path is a deploy smoke test.
|
|
11
|
+
* - `--database-url` (explicit flag only, never the env — the venue choice must be deliberate):
|
|
12
|
+
* the CLI runs pg_dump itself against a reachable database and writes a plain `-Fc` `.dump`
|
|
13
|
+
* plus the sibling `.meta.json` db:swap reads. This is the build-locally → publish → swap
|
|
14
|
+
* on-ramp: a consumer pipeline shells this instead of hand-rolling pg_dump and duplicating
|
|
15
|
+
* the fingerprint formula.
|
|
10
16
|
*/
|
|
11
17
|
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { randomBytes } from 'node:crypto';
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
12
22
|
import type { ModelDescriptor } from '@everystack/model';
|
|
13
23
|
import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
|
|
14
24
|
import { invokeAction } from '../aws.js';
|
|
@@ -17,11 +27,119 @@ import { resolveModelsPath } from '../models-path.js';
|
|
|
17
27
|
import { loadModels } from './db-generate.js';
|
|
18
28
|
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
19
29
|
import { pgDumpPreflightError } from './db-backup.js';
|
|
30
|
+
import { pgEnvFromUrl } from './db.js';
|
|
31
|
+
import { utcStamp } from '../backup.js';
|
|
20
32
|
import { step, success, fail, info } from '../output.js';
|
|
21
33
|
|
|
22
34
|
const fmtBytes = (b?: number): string =>
|
|
23
35
|
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
36
|
|
|
37
|
+
export type ExportVenue = { kind: 'local'; url: string } | { kind: 'stage'; stage: string | undefined };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which venue runs the dump. Local ONLY on an explicit `--database-url` — never the env, so a
|
|
41
|
+
* DATABASE_URL in the shell can't silently flip a stage export into a local one. Both flags at
|
|
42
|
+
* once is ambiguous and refused.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveExportVenue(flags: Record<string, string>): ExportVenue {
|
|
45
|
+
const url = flags['database-url'];
|
|
46
|
+
if (url && flags.stage) {
|
|
47
|
+
throw new Error('db:export takes one venue: --database-url (dump a reachable database locally) OR --stage (dump the stage\'s private database in the ops Lambda) — not both.');
|
|
48
|
+
}
|
|
49
|
+
return url ? { kind: 'local', url } : { kind: 'stage', stage: flags.stage };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Where a local artifact lands. Mirrors the S3 scheme (`<stamp>-<shortId>`, meta as a sibling)
|
|
54
|
+
* with the schema in the file name since there is no bucket prefix; the id says `local` where
|
|
55
|
+
* the S3 id says the stage. Plain `.dump` (no gzip): db:swap's pg_restore reads the file as-is.
|
|
56
|
+
*/
|
|
57
|
+
export function localArtifactPaths(
|
|
58
|
+
schema: string,
|
|
59
|
+
now: Date,
|
|
60
|
+
shortId: string,
|
|
61
|
+
opts: { out?: string; outDir?: string } = {},
|
|
62
|
+
): { dumpPath: string; metaPath: string; id: string } {
|
|
63
|
+
const stamp = utcStamp(now);
|
|
64
|
+
let dumpPath: string;
|
|
65
|
+
if (opts.out) {
|
|
66
|
+
if (!opts.out.endsWith('.dump')) {
|
|
67
|
+
throw new Error(`--out must end in .dump (got ${opts.out}) — db:swap resolves the sibling .meta.json from that suffix.`);
|
|
68
|
+
}
|
|
69
|
+
dumpPath = opts.out;
|
|
70
|
+
} else {
|
|
71
|
+
dumpPath = path.join(opts.outDir ?? '.', `${schema}-${stamp}-${shortId}.dump`);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
dumpPath,
|
|
75
|
+
metaPath: dumpPath.replace(/\.dump$/, '.meta.json'),
|
|
76
|
+
id: `${schema}/local/${stamp}-${shortId}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The meta record the swap gate reads — same shape as the S3 sidecar, `path` instead of `key`. */
|
|
81
|
+
export function localArtifactMeta(opts: {
|
|
82
|
+
id: string;
|
|
83
|
+
schema: string;
|
|
84
|
+
createdAt: Date;
|
|
85
|
+
bytes: number;
|
|
86
|
+
path: string;
|
|
87
|
+
fingerprint: string;
|
|
88
|
+
}): Record<string, unknown> {
|
|
89
|
+
return {
|
|
90
|
+
id: opts.id,
|
|
91
|
+
schema: opts.schema,
|
|
92
|
+
stage: 'local',
|
|
93
|
+
createdAt: opts.createdAt.toISOString(),
|
|
94
|
+
bytes: opts.bytes,
|
|
95
|
+
path: opts.path,
|
|
96
|
+
fingerprint: opts.fingerprint,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** pg_dump args for the local venue — same dump shape as the ops venue, written straight to a file. */
|
|
101
|
+
export function pgDumpLocalArgs(schema: string, dumpPath: string): string[] {
|
|
102
|
+
return ['-Fc', '--no-owner', '--no-privileges', '--no-comments', `--schema=${schema}`, '-f', dumpPath];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Run the local export: pg_dump one schema to a `-Fc` file, then write the meta sidecar. The
|
|
107
|
+
* connection rides PG* env (never argv). A failed dump removes the partial file — a truncated
|
|
108
|
+
* artifact never ships. Throws on failure; the command wraps it.
|
|
109
|
+
*/
|
|
110
|
+
export async function runLocalExport(opts: {
|
|
111
|
+
url: string;
|
|
112
|
+
schema: string;
|
|
113
|
+
fingerprint: string;
|
|
114
|
+
out?: string;
|
|
115
|
+
outDir?: string;
|
|
116
|
+
}): Promise<{ id: string; dumpPath: string; metaPath: string; bytes: number }> {
|
|
117
|
+
const now = new Date();
|
|
118
|
+
const shortId = randomBytes(3).toString('hex');
|
|
119
|
+
const { dumpPath, metaPath, id } = localArtifactPaths(opts.schema, now, shortId, opts);
|
|
120
|
+
|
|
121
|
+
const env = { ...process.env, ...pgEnvFromUrl(opts.url) };
|
|
122
|
+
const child = spawn('pg_dump', pgDumpLocalArgs(opts.schema, dumpPath), { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
123
|
+
let stderr = '';
|
|
124
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
125
|
+
try {
|
|
126
|
+
await new Promise<void>((resolve, reject) => {
|
|
127
|
+
child.on('error', reject);
|
|
128
|
+
child.on('close', (code) =>
|
|
129
|
+
code === 0 ? resolve() : reject(new Error(`pg_dump exited ${code}: ${stderr.trim()}`)));
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
fs.rmSync(dumpPath, { force: true });
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const bytes = fs.statSync(dumpPath).size;
|
|
137
|
+
fs.writeFileSync(metaPath, JSON.stringify(localArtifactMeta({
|
|
138
|
+
id, schema: opts.schema, createdAt: now, bytes, path: dumpPath, fingerprint: opts.fingerprint,
|
|
139
|
+
}), null, 2));
|
|
140
|
+
return { id, dumpPath, metaPath, bytes };
|
|
141
|
+
}
|
|
142
|
+
|
|
25
143
|
export async function dbExportCommand(flags: Record<string, string>): Promise<void> {
|
|
26
144
|
const schema = flags.schema;
|
|
27
145
|
if (!schema) {
|
|
@@ -29,6 +147,14 @@ export async function dbExportCommand(flags: Record<string, string>): Promise<vo
|
|
|
29
147
|
process.exit(1);
|
|
30
148
|
}
|
|
31
149
|
|
|
150
|
+
let venue: ExportVenue;
|
|
151
|
+
try {
|
|
152
|
+
venue = resolveExportVenue(flags);
|
|
153
|
+
} catch (err: any) {
|
|
154
|
+
fail(err.message);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
32
158
|
// The DECLARED fingerprint of just this schema — the stamp the swap gate compares against.
|
|
33
159
|
const modelsPath = resolveModelsPath(flags.models);
|
|
34
160
|
let fingerprint: string;
|
|
@@ -43,10 +169,22 @@ export async function dbExportCommand(flags: Record<string, string>): Promise<vo
|
|
|
43
169
|
process.exit(1);
|
|
44
170
|
}
|
|
45
171
|
|
|
172
|
+
if (venue.kind === 'local') {
|
|
173
|
+
step(`Running pg_dump --schema=${schema} against --database-url...`);
|
|
174
|
+
try {
|
|
175
|
+
const res = await runLocalExport({ url: venue.url, schema, fingerprint, out: flags.out });
|
|
176
|
+
success(`Artifact ${res.dumpPath} (${fmtBytes(res.bytes)}, fingerprint ${fingerprint.slice(0, 12)}, meta ${res.metaPath}). Deploy with: everystack db:swap --schema ${schema} --from ${res.dumpPath} --database-url <target>`);
|
|
177
|
+
} catch (err: any) {
|
|
178
|
+
fail(`Export failed: ${err.message}`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
46
184
|
step('Resolving deployed config...');
|
|
47
185
|
let config: CliConfig;
|
|
48
186
|
try {
|
|
49
|
-
config = await resolveConfig(
|
|
187
|
+
config = await resolveConfig(venue.stage);
|
|
50
188
|
} catch (err: any) {
|
|
51
189
|
fail(err.message);
|
|
52
190
|
process.exit(1);
|
|
@@ -68,7 +206,7 @@ export async function dbExportCommand(flags: Record<string, string>): Promise<vo
|
|
|
68
206
|
step(`Running pg_dump --schema=${schema} → S3 (this may take a while for large schemas)...`);
|
|
69
207
|
let result: any;
|
|
70
208
|
try {
|
|
71
|
-
result = await invokeAction(config.region, opsFunction(config), 'db:export', { schema, stage:
|
|
209
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:export', { schema, stage: venue.stage, fingerprint });
|
|
72
210
|
} catch (err: any) {
|
|
73
211
|
fail(`Export failed: ${err.message}`);
|
|
74
212
|
process.exit(1);
|
|
@@ -86,8 +86,11 @@ async function restoreIntoIncoming(url: string, artifactPath: string, schema: st
|
|
|
86
86
|
]);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Load the artifact's meta (fingerprint) from a sibling `.meta.json`, or fall back to a flag.
|
|
91
|
+
* Exported so the db:export local venue can prove its meta round-trips into this gate.
|
|
92
|
+
*/
|
|
93
|
+
export function readArtifactFingerprint(artifactPath: string, flag?: string): string | null {
|
|
91
94
|
if (flag) return flag;
|
|
92
95
|
const metaPath = artifactPath.replace(/\.dump(\.gz)?$/, '.meta.json');
|
|
93
96
|
try {
|
package/src/cli/index.ts
CHANGED
|
@@ -361,7 +361,7 @@ Usage:
|
|
|
361
361
|
everystack db:backups [--stage <name>] List logical backups (id, size, created)
|
|
362
362
|
everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
|
|
363
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
|
|
364
|
+
everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
|
|
365
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)
|
|
366
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
|
|
367
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
|