@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 +1 -1
- package/src/handler/index.ts +50 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.2.
|
|
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>",
|
package/src/handler/index.ts
CHANGED
|
@@ -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
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|