@everystack/server 0.3.7 → 0.4.0

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/db.ts +72 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
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>",
package/src/db.ts CHANGED
@@ -262,3 +262,75 @@ export function getAdminSql(): ReturnType<typeof postgres> {
262
262
  if (adminSqlClient) return adminSqlClient;
263
263
  return getSql();
264
264
  }
265
+
266
+ /**
267
+ * Resolve the read-replica database URL, if configured.
268
+ *
269
+ * The third credential slot: DATABASE_URL (api), ADMIN_DATABASE_URL (operator),
270
+ * READ_DATABASE_URL (replica). Point it at a physical read replica's endpoint
271
+ * with the SAME least-privilege role as DATABASE_URL — same credential,
272
+ * different host. Returns null when no replica is configured.
273
+ */
274
+ export function getReadDatabaseUrl(): string | null {
275
+ // SST Secret — PascalCase (Resource.ReadDatabaseUrl.value)
276
+ const pascal = tryResource(() => (Resource as any).ReadDatabaseUrl?.value as string);
277
+ if (pascal) return pascal;
278
+ // SST Secret — raw env name (Resource.READ_DATABASE_URL.value)
279
+ const raw = tryResource(() => (Resource as any).READ_DATABASE_URL?.value as string);
280
+ if (raw) return raw;
281
+ // process.env fallback
282
+ if (process.env.READ_DATABASE_URL) return process.env.READ_DATABASE_URL;
283
+ return null;
284
+ }
285
+
286
+ // Lazy singleton read-replica connection (separate from the API connection)
287
+ let readDbInstance: ReturnType<typeof drizzle> | null = null;
288
+ let readSqlClient: ReturnType<typeof postgres> | null = null;
289
+
290
+ /**
291
+ * Read-replica database connection for anonymous read traffic.
292
+ *
293
+ * Connects with READ_DATABASE_URL when configured. When it isn't, returns the
294
+ * primary connection (createDb) with NO warning — unlike the admin split this
295
+ * is a scaling knob, not a security boundary: absent is normal, not a
296
+ * misconfiguration. Apps without a replica keep working with zero config.
297
+ *
298
+ * `withRole` works unchanged against a hot standby: SET LOCAL ROLE and
299
+ * set_config(…, true) are permitted in read-only transactions, and physical
300
+ * replication carries roles, GRANTs, and RLS policies with the catalog. A
301
+ * write through this connection fails loudly on a real replica ("cannot
302
+ * execute … in a read-only transaction") — a routing bug can never silently
303
+ * diverge. See docs/plans/read-replica-routing.md.
304
+ */
305
+ export function createReadDb<T extends Record<string, unknown>>(
306
+ schema: T,
307
+ options?: { maxConnections?: number }
308
+ ): {
309
+ db: ReturnType<typeof drizzle>;
310
+ schema: T;
311
+ } {
312
+ if (readDbInstance) return { db: readDbInstance, schema };
313
+
314
+ const url = getReadDatabaseUrl();
315
+ if (!url) return createDb(schema, options);
316
+
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
+ });
324
+ readDbInstance = drizzle(readSqlClient, { schema });
325
+ return { db: readDbInstance, schema };
326
+ }
327
+
328
+ /**
329
+ * Raw postgres.js client for the read-replica connection.
330
+ * Must be called after createReadDb(). Falls back to the API client when no
331
+ * replica is configured (same fallback as createReadDb).
332
+ */
333
+ export function getReadSql(): ReturnType<typeof postgres> {
334
+ if (readSqlClient) return readSqlClient;
335
+ return getSql();
336
+ }