@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.2.10",
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
+ }
@@ -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
- db,
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(db, settings, async (dbOrTx) => {
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);