@everystack/server 0.4.2 → 0.4.4
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 +3 -3
- package/src/backup-run.ts +72 -0
- package/src/backup.ts +44 -2
- package/src/db.ts +31 -21
- package/src/plugin.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -151,9 +151,9 @@
|
|
|
151
151
|
"sst": "4.13.1",
|
|
152
152
|
"ts-jest": "29.4.9",
|
|
153
153
|
"typescript": "5.9.3",
|
|
154
|
-
"@everystack/pg-tools": "0.4.0",
|
|
155
154
|
"@everystack/auth": "0.4.0",
|
|
156
|
-
"@everystack/cli": "0.4.
|
|
155
|
+
"@everystack/cli": "0.4.18",
|
|
156
|
+
"@everystack/pg-tools": "0.4.0"
|
|
157
157
|
},
|
|
158
158
|
"scripts": {
|
|
159
159
|
"test": "jest",
|
package/src/backup-run.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
19
19
|
import type { Readable } from 'node:stream';
|
|
20
20
|
import {
|
|
21
21
|
backupPrefix, backupKey, metaKey, backupId, idForKey, keyForId, utcStamp,
|
|
22
|
+
artifactKey, artifactId, artifactPrefix, artifactIdForKey,
|
|
22
23
|
pgEnvFromUrl, dbNameFromUrl, pgDumpArgs, pgRestoreArgs,
|
|
23
24
|
} from './backup';
|
|
24
25
|
|
|
@@ -147,6 +148,77 @@ export async function runBackup(deps: BackupRunDeps, stage: string): Promise<Bac
|
|
|
147
148
|
return { id, key, bytes };
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
export interface ExportRunResult {
|
|
152
|
+
id: string;
|
|
153
|
+
key: string;
|
|
154
|
+
bytes?: number;
|
|
155
|
+
/** The schema fingerprint the artifact was built against (the swap's gate compares it). */
|
|
156
|
+
fingerprint: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* pg_dump ONE schema (`--schema=<schema>`) → gzip → S3 under the artifact prefix, with a
|
|
161
|
+
* `.meta.json` carrying the schema fingerprint — the canonical-sync export (docs/plans/
|
|
162
|
+
* canonical-sync-artifacts.md). The fingerprint is computed by the CLI from the same live schema
|
|
163
|
+
* (one formula both sides) and passed in; this stamps it into the artifact so db:swap can gate on
|
|
164
|
+
* it. Same streaming + child-exit guard as runBackup: a truncated dump never ships.
|
|
165
|
+
*/
|
|
166
|
+
export async function runExport(
|
|
167
|
+
deps: BackupRunDeps,
|
|
168
|
+
opts: { schema: string; stage: string; fingerprint: string },
|
|
169
|
+
): Promise<ExportRunResult> {
|
|
170
|
+
const { bucket, region, adminUrl } = deps;
|
|
171
|
+
const { schema, stage, fingerprint } = opts;
|
|
172
|
+
const env = { ...process.env, ...pgEnvFromUrl(adminUrl) };
|
|
173
|
+
const stamp = utcStamp(new Date());
|
|
174
|
+
const shortId = randomBytes(3).toString('hex');
|
|
175
|
+
const key = artifactKey(schema, stage, stamp, shortId);
|
|
176
|
+
const mkey = metaKey(key);
|
|
177
|
+
const id = artifactId(schema, stage, stamp, shortId);
|
|
178
|
+
|
|
179
|
+
const s3 = await getS3(region);
|
|
180
|
+
const { Upload } = await importOptionalAws(() => import('@aws-sdk/lib-storage'), '@aws-sdk/lib-storage', 'db:export');
|
|
181
|
+
const { DeleteObjectCommand, PutObjectCommand, HeadObjectCommand } = await importOptionalAws(
|
|
182
|
+
() => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'db:export',
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const child = spawn('pg_dump', pgDumpArgs({ schema }), { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
186
|
+
let stderr = '';
|
|
187
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
188
|
+
const childExit = new Promise<void>((resolve, reject) => {
|
|
189
|
+
child.on('error', reject);
|
|
190
|
+
child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`pg_dump exited ${code}: ${stderr.trim()}`)));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const gzip = createGzip();
|
|
194
|
+
const upload = new Upload({
|
|
195
|
+
client: s3,
|
|
196
|
+
params: { Bucket: bucket, Key: key, Body: gzip, ContentType: 'application/gzip', ServerSideEncryption: 'AES256' },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await Promise.all([pipeline(child.stdout!, gzip), upload.done(), childExit]);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
try { await upload.abort(); } catch { /* may already have completed */ }
|
|
203
|
+
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })).catch(() => {});
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let bytes: number | undefined;
|
|
208
|
+
try {
|
|
209
|
+
const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
210
|
+
bytes = head.ContentLength;
|
|
211
|
+
} catch { /* size is best-effort */ }
|
|
212
|
+
|
|
213
|
+
const meta = { id, schema, stage, createdAt: new Date().toISOString(), bytes, key, fingerprint };
|
|
214
|
+
await s3.send(new PutObjectCommand({
|
|
215
|
+
Bucket: bucket, Key: mkey, Body: JSON.stringify(meta),
|
|
216
|
+
ContentType: 'application/json', ServerSideEncryption: 'AES256',
|
|
217
|
+
}));
|
|
218
|
+
|
|
219
|
+
return { id, key, bytes, fingerprint };
|
|
220
|
+
}
|
|
221
|
+
|
|
150
222
|
/** List a stage's backups (newest first) from S3 object metadata — one list call, no per-item gets. */
|
|
151
223
|
export async function listBackups(deps: BackupRunDeps, stage: string): Promise<BackupSummary[]> {
|
|
152
224
|
const s3 = await getS3(deps.region);
|
package/src/backup.ts
CHANGED
|
@@ -72,6 +72,44 @@ export function idForKey(dumpKey: string): string | null {
|
|
|
72
72
|
return m ? `${m[1]}/${m[2]}` : null;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// --- Artifact key scheme (schema-scoped exports for the canonical-sync hot-swap) ----------------
|
|
76
|
+
// A backup is the whole database; an ARTIFACT is one schema, content-addressed, carrying the schema
|
|
77
|
+
// fingerprint it was built against. Separate prefix so lifecycle rules and the swap resolver never
|
|
78
|
+
// confuse the two. See docs/plans/canonical-sync-artifacts.md.
|
|
79
|
+
|
|
80
|
+
/** The S3 prefix a schema's artifacts live under (admin-only, encrypted). */
|
|
81
|
+
export function artifactPrefix(schema: string, stage: string): string {
|
|
82
|
+
return `artifacts/${schema}/${stage}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The object key for a new schema artifact. `stamp` is a UTC `YYYYMMDDTHHMMSSZ` string. */
|
|
86
|
+
export function artifactKey(schema: string, stage: string, stamp: string, shortId: string): string {
|
|
87
|
+
return `${artifactPrefix(schema, stage)}/${stamp}-${shortId}.dump.gz`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A schema-qualified artifact id — `stats/dev/20260716T120000Z-ab12cd`. */
|
|
91
|
+
export function artifactId(schema: string, stage: string, stamp: string, shortId: string): string {
|
|
92
|
+
return `${schema}/${stage}/${stamp}-${shortId}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Parse an artifact id into schema/stage/name, or null when malformed. */
|
|
96
|
+
export function parseArtifactRef(id: string): { schema: string; stage: string; name: string } | null {
|
|
97
|
+
const m = id.match(/^([A-Za-z0-9_]+)\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.:-]+)$/);
|
|
98
|
+
return m ? { schema: m[1], stage: m[2], name: m[3] } : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The S3 dump key for an artifact id (inverse of artifactId), or null when malformed. */
|
|
102
|
+
export function keyForArtifactId(id: string): string | null {
|
|
103
|
+
const ref = parseArtifactRef(id);
|
|
104
|
+
return ref ? `${artifactPrefix(ref.schema, ref.stage)}/${ref.name}.dump.gz` : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The artifact id from a dump key (inverse of artifactKey), or null when it isn't an artifact key. */
|
|
108
|
+
export function artifactIdForKey(dumpKey: string): string | null {
|
|
109
|
+
const m = dumpKey.match(/^artifacts\/([A-Za-z0-9_]+)\/([A-Za-z0-9_-]+)\/(.+)\.dump\.gz$/);
|
|
110
|
+
return m ? `${m[1]}/${m[2]}/${m[3]}` : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
75
113
|
/** Format a Date as the UTC `YYYYMMDDTHHMMSSZ` stamp (caller's clock; pure). */
|
|
76
114
|
export function utcStamp(date: Date): string {
|
|
77
115
|
const p = (n: number): string => String(n).padStart(2, '0');
|
|
@@ -115,8 +153,12 @@ export function dbNameFromUrl(url: string): string {
|
|
|
115
153
|
* connection comes from PG* env, so no host/db on argv. --no-owner/--no-privileges/--no-comments
|
|
116
154
|
* make the dump restore cleanly into a different stage (and dodge the RDS plpgsql-comment error).
|
|
117
155
|
*/
|
|
118
|
-
export function pgDumpArgs(): string[] {
|
|
119
|
-
|
|
156
|
+
export function pgDumpArgs(opts: { schema?: string } = {}): string[] {
|
|
157
|
+
const args = ['-Fc', '--no-owner', '--no-privileges', '--no-comments'];
|
|
158
|
+
// A schema-scoped artifact dumps ONE schema (the canonical-sync export). pg_dump's --schema
|
|
159
|
+
// takes a pattern; a plain identifier scopes to exactly that schema and its objects.
|
|
160
|
+
if (opts.schema) args.push(`--schema=${opts.schema}`);
|
|
161
|
+
return args;
|
|
120
162
|
}
|
|
121
163
|
|
|
122
164
|
/**
|
package/src/db.ts
CHANGED
|
@@ -96,6 +96,34 @@ export function getJwtSecret(): Uint8Array {
|
|
|
96
96
|
throw new Error('JWT_SECRET not set — link an sst.Secret or set process.env.JWT_SECRET');
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The ssl option for a connection URL: an explicit `sslmode` query param wins
|
|
101
|
+
* (return no override — postgres.js honors the URL natively), otherwise default
|
|
102
|
+
* to 'require' (RDS). This is what lets ONE handler serve both venues: local
|
|
103
|
+
* Postgres appends `?sslmode=disable` to its URL, deployed stages carry no
|
|
104
|
+
* param and keep TLS. An unparseable URL fails toward TLS and lets the connect
|
|
105
|
+
* report the real error.
|
|
106
|
+
*/
|
|
107
|
+
export function sslOptionForUrl(url: string): 'require' | undefined {
|
|
108
|
+
try {
|
|
109
|
+
return new URL(url).searchParams.has('sslmode') ? undefined : 'require';
|
|
110
|
+
} catch {
|
|
111
|
+
return 'require';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** postgres.js options shared by every connection — ssl derived from the URL. */
|
|
116
|
+
function connectionOptions(url: string, maxConnections?: number): Parameters<typeof postgres>[1] {
|
|
117
|
+
const ssl = sslOptionForUrl(url);
|
|
118
|
+
return {
|
|
119
|
+
max: maxConnections ?? 1,
|
|
120
|
+
idle_timeout: 60,
|
|
121
|
+
connect_timeout: 5,
|
|
122
|
+
max_lifetime: 60 * 5,
|
|
123
|
+
...(ssl ? { ssl } : {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
99
127
|
// Lazy singleton DB connection
|
|
100
128
|
let dbInstance: ReturnType<typeof drizzle> | null = null;
|
|
101
129
|
let sqlClient: ReturnType<typeof postgres> | null = null;
|
|
@@ -110,13 +138,7 @@ export function createDb<T extends Record<string, unknown>>(
|
|
|
110
138
|
if (dbInstance) return { db: dbInstance, schema };
|
|
111
139
|
|
|
112
140
|
const url = getDatabaseUrl();
|
|
113
|
-
sqlClient = postgres(url,
|
|
114
|
-
max: options?.maxConnections ?? 1,
|
|
115
|
-
idle_timeout: 60,
|
|
116
|
-
connect_timeout: 5,
|
|
117
|
-
max_lifetime: 60 * 5,
|
|
118
|
-
ssl: 'require',
|
|
119
|
-
});
|
|
141
|
+
sqlClient = postgres(url, connectionOptions(url, options?.maxConnections));
|
|
120
142
|
dbInstance = drizzle(sqlClient, { schema });
|
|
121
143
|
return { db: dbInstance, schema };
|
|
122
144
|
}
|
|
@@ -242,13 +264,7 @@ export function createAdminDb<T extends Record<string, unknown>>(
|
|
|
242
264
|
return createDb(schema, options);
|
|
243
265
|
}
|
|
244
266
|
|
|
245
|
-
adminSqlClient = postgres(adminUrl,
|
|
246
|
-
max: options?.maxConnections ?? 1,
|
|
247
|
-
idle_timeout: 60,
|
|
248
|
-
connect_timeout: 5,
|
|
249
|
-
max_lifetime: 60 * 5,
|
|
250
|
-
ssl: 'require',
|
|
251
|
-
});
|
|
267
|
+
adminSqlClient = postgres(adminUrl, connectionOptions(adminUrl, options?.maxConnections));
|
|
252
268
|
adminDbInstance = drizzle(adminSqlClient, { schema });
|
|
253
269
|
return { db: adminDbInstance, schema };
|
|
254
270
|
}
|
|
@@ -314,13 +330,7 @@ export function createReadDb<T extends Record<string, unknown>>(
|
|
|
314
330
|
const url = getReadDatabaseUrl();
|
|
315
331
|
if (!url) return createDb(schema, options);
|
|
316
332
|
|
|
317
|
-
readSqlClient = postgres(url,
|
|
318
|
-
max: options?.maxConnections ?? 1,
|
|
319
|
-
idle_timeout: 60,
|
|
320
|
-
connect_timeout: 5,
|
|
321
|
-
max_lifetime: 60 * 5,
|
|
322
|
-
ssl: 'require',
|
|
323
|
-
});
|
|
333
|
+
readSqlClient = postgres(url, connectionOptions(url, options?.maxConnections));
|
|
324
334
|
readDbInstance = drizzle(readSqlClient, { schema });
|
|
325
335
|
return { db: readDbInstance, schema };
|
|
326
336
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -885,6 +885,24 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
885
885
|
return { error: err?.message || String(err) };
|
|
886
886
|
}
|
|
887
887
|
};
|
|
888
|
+
|
|
889
|
+
// --- db:export — the schema-scoped, fingerprint-stamped artifact (canonical-sync gap B) ---
|
|
890
|
+
// pg_dump ONE schema → S3 artifact carrying the schema fingerprint the CLI computed from the
|
|
891
|
+
// same live schema (one formula both sides). db:swap gates on that fingerprint before it lands.
|
|
892
|
+
actions['db:export'] = async (payload, ctx) => {
|
|
893
|
+
const { schema, fingerprint } = (payload ?? {}) as { schema?: string; fingerprint?: string };
|
|
894
|
+
const stage = ((payload ?? {}) as { stage?: string }).stage ?? ctx.environment ?? 'default';
|
|
895
|
+
if (!schema) return { error: 'db:export requires { schema }' };
|
|
896
|
+
if (!fingerprint) return { error: 'db:export requires { fingerprint } — the CLI computes the scoped schema fingerprint so the artifact and the swap gate agree' };
|
|
897
|
+
const r = await resolveBackupDeps();
|
|
898
|
+
if ('error' in r) return { error: r.error };
|
|
899
|
+
const { runExport } = await import('./backup-run');
|
|
900
|
+
try {
|
|
901
|
+
return await runExport(r.deps, { schema, stage, fingerprint });
|
|
902
|
+
} catch (err: any) {
|
|
903
|
+
return { error: err?.message || String(err) };
|
|
904
|
+
}
|
|
905
|
+
};
|
|
888
906
|
}
|
|
889
907
|
|
|
890
908
|
// --- db:apply — the credential-free staged write (Phase 2) ---
|