@everystack/api 0.2.9 → 0.2.11

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/api",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -177,11 +177,27 @@ export interface RpcHandlerWithRole {
177
177
  * connection without the settings, and on a single-connection pool
178
178
  * (Lambda) they deadlock against the open transaction. */
179
179
  settings?: boolean;
180
+ /** Route this RPC's ANONYMOUS calls to `options.readDb` (the read replica)
181
+ * when one is configured. Authenticated callers stay on the primary
182
+ * (read-your-writes). Only meaningful with `settings: true` — plain RPCs
183
+ * own their DB access through what they close over, so there is nothing
184
+ * for the handler to route (use createReadDb() in the fn instead). The
185
+ * fn must not write: on a real replica a write fails with "cannot
186
+ * execute … in a read-only transaction". */
187
+ readOnly?: boolean;
180
188
  }
181
189
 
182
190
  export interface HandlerOptions {
183
191
  auth?: AuthConfig;
184
192
  rpc?: Record<string, RpcHandler | RpcHandlerWithRole>;
193
+ /** Read-replica connection (e.g. createReadDb from @everystack/server/db).
194
+ * When provided, ANONYMOUS GET table reads execute against it; writes,
195
+ * authenticated requests, and RPCs stay on the primary `db`. RPCs opt in
196
+ * per-function with `readOnly: true`. Anonymous-only routing is what makes
197
+ * replication lag safe: authenticated users never read the replica, so
198
+ * read-your-writes holds trivially. Omitted → behavior identical to a
199
+ * single-connection handler. See docs/plans/read-replica-routing.md. */
200
+ readDb?: DrizzleDb;
185
201
  /** Relation definitions for resource embedding: { [sourceTable]: { [relationName]: RelationConfig } } */
186
202
  relations?: Record<string, Record<string, RelationConfig>>;
187
203
  /** Base path to strip from request URL (e.g., '/api' when mounted at /api/[...path]) */
@@ -564,9 +580,16 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
564
580
  const result = await verifyUserToken(request, options.auth);
565
581
  if (result.error) return result.error;
566
582
  user = result.user;
567
- if (user && options.auth.onAuthenticated) {
568
- options.auth.onAuthenticated(user);
569
- }
583
+ } else if (request.headers.get('authorization')) {
584
+ // Public RPC with token present — verify opportunistically, same
585
+ // policy as public table routes below. If valid, the request runs
586
+ // as authenticated (RLS self-visibility, PII self-tiers). If
587
+ // invalid, ignore and fall through as anonymous.
588
+ const result = await verifyUserToken(request, options.auth);
589
+ if (!result.error) user = result.user;
590
+ }
591
+ if (user && options.auth.onAuthenticated) {
592
+ options.auth.onAuthenticated(user);
570
593
  }
571
594
  }
572
595
 
@@ -598,9 +621,15 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
598
621
  // go through a captured db that waits on the connection the
599
622
  // transaction holds). Only `settings: true` entries get the
600
623
  // transaction, and they receive it as the 4th argument.
624
+ // Read routing: a readOnly-marked settings RPC serves ANONYMOUS
625
+ // callers from the replica. Authenticated callers stay on the
626
+ // primary (read-your-writes).
627
+ const rpcDb = isObjectForm && entry.readOnly && !user && options?.readDb
628
+ ? options.readDb
629
+ : db;
601
630
  const result = isObjectForm && entry.settings
602
631
  ? await executeWithSettings(
603
- db,
632
+ rpcDb,
604
633
  options?.pgSettings?.(user, client),
605
634
  (tx) => fn(body, user, client, tx, ctx),
606
635
  )
@@ -610,7 +639,14 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
610
639
  kind: 'rpc', name: fnName, user, client,
611
640
  result: 'allow', requestId, body: body as Record<string, unknown>,
612
641
  });
613
- return json(result, 200);
642
+ // Same cache discipline as table GETs: with pgSettings configured the
643
+ // response can vary by identity (opportunistic auth on public RPCs),
644
+ // so it must forbid shared caching ITSELF — never rely on the CDN
645
+ // config (the cache key carries no identity; a consumer overriding
646
+ // the stock defaultTtl:0 policy would otherwise cache one user's
647
+ // personalized response and serve it to everyone).
648
+ const rpcCacheControl = getCacheControl(options);
649
+ return json(result, 200, rpcCacheControl ? { 'Cache-Control': rpcCacheControl } : undefined);
614
650
  } catch (error) {
615
651
  const pgCode = (error as any)?.code;
616
652
  let status = 500;
@@ -701,8 +737,14 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
701
737
  }
702
738
  }
703
739
 
740
+ // Read routing: anonymous GETs serve from the replica when one is
741
+ // configured. `user` is resolved (incl. opportunistic auth) above, so an
742
+ // authenticated GET stays on the primary — read-your-writes holds because
743
+ // only anonymous traffic can ever reach the replica. Writes never route.
744
+ const routedDb = method === 'GET' && !user && options?.readDb ? options.readDb : db;
745
+
704
746
  try {
705
- const response = await executeWithSettings(db, settings, async (dbOrTx) => {
747
+ const response = await executeWithSettings(routedDb, settings, async (dbOrTx) => {
706
748
  if (method === 'GET') {
707
749
  const cacheControl = getCacheControl(options);
708
750
  return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
@@ -1933,9 +1975,9 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { con
1933
1975
  return { conditions };
1934
1976
  }
1935
1977
 
1936
- function json(data: unknown, status: number): Response {
1978
+ function json(data: unknown, status: number, headers?: Record<string, string>): Response {
1937
1979
  return new Response(JSON.stringify(data), {
1938
1980
  status,
1939
- headers: { 'Content-Type': 'application/json' },
1981
+ headers: { 'Content-Type': 'application/json', ...headers },
1940
1982
  });
1941
1983
  }