@everystack/api 0.2.10 → 0.2.12
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 +7 -7
- package/src/handler/index.ts +57 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
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>",
|
|
@@ -30,11 +30,6 @@
|
|
|
30
30
|
"default": "./src/client/index.ts"
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
|
-
"scripts": {
|
|
34
|
-
"test": "jest",
|
|
35
|
-
"build": "tsc --build",
|
|
36
|
-
"lint": "tsc --noEmit"
|
|
37
|
-
},
|
|
38
33
|
"peerDependencies": {
|
|
39
34
|
"drizzle-orm": "0.41.0"
|
|
40
35
|
},
|
|
@@ -45,5 +40,10 @@
|
|
|
45
40
|
"jest": "29.7.0",
|
|
46
41
|
"ts-jest": "29.4.9",
|
|
47
42
|
"typescript": "5.9.3"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "jest",
|
|
46
|
+
"build": "tsc --build",
|
|
47
|
+
"lint": "tsc --noEmit"
|
|
48
48
|
}
|
|
49
|
-
}
|
|
49
|
+
}
|
package/src/handler/index.ts
CHANGED
|
@@ -156,6 +156,23 @@ export interface CacheConfig {
|
|
|
156
156
|
private?: string;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/** Global post-RPC hook — fired after a SUCCESSFUL RPC dispatch, fire-and-forget.
|
|
160
|
+
* The RPC counterpart to the per-table after* hooks (afterCreate/afterUpdate/
|
|
161
|
+
* afterDelete). RPCs are function-scoped, not table-scoped, so this is global
|
|
162
|
+
* rather than keyed by table. Receives the RPC's return value (the row(s) it
|
|
163
|
+
* produced), the function name, the request body, the verified user/client, and
|
|
164
|
+
* the trace-id ctx. Errors are swallowed and never affect the response — a
|
|
165
|
+
* side-effect failure (cache purge, webhook) must not fail the write. Runs only
|
|
166
|
+
* on the success path. See docs/plans/afterrpc-cache-purge.md. */
|
|
167
|
+
export type AfterRpcHook = (
|
|
168
|
+
result: unknown,
|
|
169
|
+
fnName: string,
|
|
170
|
+
body: unknown,
|
|
171
|
+
user: Record<string, unknown> | null,
|
|
172
|
+
client: Record<string, unknown> | null,
|
|
173
|
+
ctx: RequestContext,
|
|
174
|
+
) => Promise<void>;
|
|
175
|
+
|
|
159
176
|
/** RPC handler — plain function (backward compatible).
|
|
160
177
|
* Plain handlers dispatch directly: no transaction, no pgSettings — they own
|
|
161
178
|
* their DB access through whatever they close over, same privilege model as
|
|
@@ -177,11 +194,27 @@ export interface RpcHandlerWithRole {
|
|
|
177
194
|
* connection without the settings, and on a single-connection pool
|
|
178
195
|
* (Lambda) they deadlock against the open transaction. */
|
|
179
196
|
settings?: boolean;
|
|
197
|
+
/** Route this RPC's ANONYMOUS calls to `options.readDb` (the read replica)
|
|
198
|
+
* when one is configured. Authenticated callers stay on the primary
|
|
199
|
+
* (read-your-writes). Only meaningful with `settings: true` — plain RPCs
|
|
200
|
+
* own their DB access through what they close over, so there is nothing
|
|
201
|
+
* for the handler to route (use createReadDb() in the fn instead). The
|
|
202
|
+
* fn must not write: on a real replica a write fails with "cannot
|
|
203
|
+
* execute … in a read-only transaction". */
|
|
204
|
+
readOnly?: boolean;
|
|
180
205
|
}
|
|
181
206
|
|
|
182
207
|
export interface HandlerOptions {
|
|
183
208
|
auth?: AuthConfig;
|
|
184
209
|
rpc?: Record<string, RpcHandler | RpcHandlerWithRole>;
|
|
210
|
+
/** Read-replica connection (e.g. createReadDb from @everystack/server/db).
|
|
211
|
+
* When provided, ANONYMOUS GET table reads execute against it; writes,
|
|
212
|
+
* authenticated requests, and RPCs stay on the primary `db`. RPCs opt in
|
|
213
|
+
* per-function with `readOnly: true`. Anonymous-only routing is what makes
|
|
214
|
+
* replication lag safe: authenticated users never read the replica, so
|
|
215
|
+
* read-your-writes holds trivially. Omitted → behavior identical to a
|
|
216
|
+
* single-connection handler. See docs/plans/read-replica-routing.md. */
|
|
217
|
+
readDb?: DrizzleDb;
|
|
185
218
|
/** Relation definitions for resource embedding: { [sourceTable]: { [relationName]: RelationConfig } } */
|
|
186
219
|
relations?: Record<string, Record<string, RelationConfig>>;
|
|
187
220
|
/** Base path to strip from request URL (e.g., '/api' when mounted at /api/[...path]) */
|
|
@@ -196,6 +229,10 @@ export interface HandlerOptions {
|
|
|
196
229
|
pgSettings?: (user: Record<string, unknown> | null, client?: Record<string, unknown> | null) => Record<string, string>;
|
|
197
230
|
/** Per-table lifecycle hooks for mutations. */
|
|
198
231
|
hooks?: Record<string, HookConfig>;
|
|
232
|
+
/** Global hook fired after a successful RPC. Fire-and-forget; errors swallowed.
|
|
233
|
+
* RPC writes fire no per-table hooks, so this is the only post-RPC side-effect
|
|
234
|
+
* seam (cache purge, webhooks, audit enrichment). See docs/plans/afterrpc-cache-purge.md. */
|
|
235
|
+
afterRpc?: AfterRpcHook;
|
|
199
236
|
/** API naming convention for query params, request bodies, and responses.
|
|
200
237
|
* 'snake_case': accept snake_case inbound, return snake_case outbound.
|
|
201
238
|
* Default: use Drizzle property names as-is (typically camelCase). */
|
|
@@ -605,9 +642,15 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
605
642
|
// go through a captured db that waits on the connection the
|
|
606
643
|
// transaction holds). Only `settings: true` entries get the
|
|
607
644
|
// transaction, and they receive it as the 4th argument.
|
|
645
|
+
// Read routing: a readOnly-marked settings RPC serves ANONYMOUS
|
|
646
|
+
// callers from the replica. Authenticated callers stay on the
|
|
647
|
+
// primary (read-your-writes).
|
|
648
|
+
const rpcDb = isObjectForm && entry.readOnly && !user && options?.readDb
|
|
649
|
+
? options.readDb
|
|
650
|
+
: db;
|
|
608
651
|
const result = isObjectForm && entry.settings
|
|
609
652
|
? await executeWithSettings(
|
|
610
|
-
|
|
653
|
+
rpcDb,
|
|
611
654
|
options?.pgSettings?.(user, client),
|
|
612
655
|
(tx) => fn(body, user, client, tx, ctx),
|
|
613
656
|
)
|
|
@@ -617,6 +660,12 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
617
660
|
kind: 'rpc', name: fnName, user, client,
|
|
618
661
|
result: 'allow', requestId, body: body as Record<string, unknown>,
|
|
619
662
|
});
|
|
663
|
+
// Global post-RPC hook: fire-and-forget side effects (cache purge,
|
|
664
|
+
// webhooks). Swallow errors — a side-effect failure must not fail the
|
|
665
|
+
// write, exactly like the after* REST hooks. Success path only.
|
|
666
|
+
if (options?.afterRpc) {
|
|
667
|
+
try { await options.afterRpc(result, fnName, body, user, client, ctx); } catch {}
|
|
668
|
+
}
|
|
620
669
|
// Same cache discipline as table GETs: with pgSettings configured the
|
|
621
670
|
// response can vary by identity (opportunistic auth on public RPCs),
|
|
622
671
|
// so it must forbid shared caching ITSELF — never rely on the CDN
|
|
@@ -715,8 +764,14 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
715
764
|
}
|
|
716
765
|
}
|
|
717
766
|
|
|
767
|
+
// Read routing: anonymous GETs serve from the replica when one is
|
|
768
|
+
// configured. `user` is resolved (incl. opportunistic auth) above, so an
|
|
769
|
+
// authenticated GET stays on the primary — read-your-writes holds because
|
|
770
|
+
// only anonymous traffic can ever reach the replica. Writes never route.
|
|
771
|
+
const routedDb = method === 'GET' && !user && options?.readDb ? options.readDb : db;
|
|
772
|
+
|
|
718
773
|
try {
|
|
719
|
-
const response = await executeWithSettings(
|
|
774
|
+
const response = await executeWithSettings(routedDb, settings, async (dbOrTx) => {
|
|
720
775
|
if (method === 'GET') {
|
|
721
776
|
const cacheControl = getCacheControl(options);
|
|
722
777
|
return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
|