@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.2",
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.8"
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
- return ['-Fc', '--no-owner', '--no-privileges', '--no-comments'];
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) ---