@everystack/api 0.2.11 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.2.11",
3
+ "version": "0.4.0",
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>",
@@ -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
@@ -212,6 +229,10 @@ export interface HandlerOptions {
212
229
  pgSettings?: (user: Record<string, unknown> | null, client?: Record<string, unknown> | null) => Record<string, string>;
213
230
  /** Per-table lifecycle hooks for mutations. */
214
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;
215
236
  /** API naming convention for query params, request bodies, and responses.
216
237
  * 'snake_case': accept snake_case inbound, return snake_case outbound.
217
238
  * Default: use Drizzle property names as-is (typically camelCase). */
@@ -501,8 +522,81 @@ function transformBodyKeys(body: Record<string, unknown>, table: DrizzleTable):
501
522
  return result;
502
523
  }
503
524
 
504
- export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOptions): Handler {
525
+ /** The name a drizzle pgTable knows itself by in SQL, or null for a non-table export. */
526
+ function sqlNameOf(value: unknown): string | null {
527
+ if (!value || typeof value !== 'object') return null;
528
+ const name = (value as Record<symbol, unknown>)[Symbol.for('drizzle:Name')];
529
+ return typeof name === 'string' ? name : null;
530
+ }
531
+
532
+ /** Export-key ↔ SQL-name pairs for every table whose two spellings differ. */
533
+ function tableNamePairs(schema: Schema): Array<{ exportKey: string; sqlName: string }> {
534
+ const pairs: Array<{ exportKey: string; sqlName: string }> = [];
535
+ for (const [exportKey, value] of Object.entries(schema)) {
536
+ const sqlName = sqlNameOf(value);
537
+ if (sqlName && sqlName !== exportKey) pairs.push({ exportKey, sqlName });
538
+ }
539
+ return pairs;
540
+ }
541
+
542
+ /** The schema with each table reachable under BOTH spellings (existing keys win). */
543
+ function expandSchema(schema: Schema, pairs: Array<{ exportKey: string; sqlName: string }>): Schema {
544
+ if (pairs.length === 0) return schema;
545
+ const expanded: Schema = { ...schema };
546
+ for (const { exportKey, sqlName } of pairs) {
547
+ if (!(sqlName in expanded)) expanded[sqlName] = schema[exportKey];
548
+ }
549
+ return expanded;
550
+ }
551
+
552
+ /**
553
+ * Table-keyed options written in EITHER naming domain apply to both spellings.
554
+ * `deriveHandlerConfig` emits SQL names; hand-written configs use export keys —
555
+ * each key gains its twin (same value reference), so neither authoring style
556
+ * can strand a table behind the other domain's gate.
557
+ */
558
+ function expandTableKeyedOptions(
559
+ options: HandlerOptions | undefined,
560
+ pairs: Array<{ exportKey: string; sqlName: string }>,
561
+ ): HandlerOptions | undefined {
562
+ if (!options || pairs.length === 0) return options;
563
+ const twin = (name: string): string | undefined =>
564
+ pairs.find((p) => p.exportKey === name)?.sqlName ?? pairs.find((p) => p.sqlName === name)?.exportKey;
565
+ const expandList = (list: string[]): string[] =>
566
+ [...new Set(list.flatMap((name) => { const t = twin(name); return t ? [name, t] : [name]; }))];
567
+ const expandMap = <T,>(map: Record<string, T>): Record<string, T> => {
568
+ const out: Record<string, T> = { ...map };
569
+ for (const key of Object.keys(map)) {
570
+ const t = twin(key);
571
+ if (t && !(t in out)) out[t] = map[key];
572
+ }
573
+ return out;
574
+ };
575
+ return {
576
+ ...options,
577
+ ...(options.exposedTables ? { exposedTables: expandList(options.exposedTables) } : {}),
578
+ ...(options.hiddenColumns ? { hiddenColumns: expandMap(options.hiddenColumns) } : {}),
579
+ ...(options.protectedFields ? { protectedFields: expandMap(options.protectedFields) } : {}),
580
+ ...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
581
+ ...(options.relations ? { relations: expandMap(options.relations) } : {}),
582
+ ...(options.hooks ? { hooks: expandMap(options.hooks) } : {}),
583
+ ...(options.softDelete?.tables
584
+ ? { softDelete: { ...options.softDelete, tables: expandList(options.softDelete.tables) } }
585
+ : {}),
586
+ };
587
+ }
588
+
589
+ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
590
+ // The naming seam: URLs, exposedTables, and relation targets historically mixed
591
+ // drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
592
+ // multi-word table failed one gate or the other — both spellings 404'd. Collapse
593
+ // the two domains here, once: both spellings route, and options in either domain
594
+ // apply. The root listing below stays one entry per table (the SQL name).
595
+ const namePairs = tableNamePairs(rawSchema);
596
+ const schema = expandSchema(rawSchema, namePairs);
597
+ const options = expandTableKeyedOptions(rawOptions, namePairs);
505
598
  const tableNames = new Set(Object.keys(schema));
599
+ const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
506
600
 
507
601
  return async (request: Request): Promise<Response> => {
508
602
  const url = new URL(request.url);
@@ -562,11 +656,12 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
562
656
  }
563
657
  }
564
658
 
565
- // Root path — table listing, always public
659
+ // Root path — table listing, always public. One entry per table (SQL name),
660
+ // regardless of how many spellings route to it.
566
661
  if (pathSegments.length === 0) {
567
662
  const tables = options?.exposedTables
568
- ? Array.from(tableNames).filter(t => options.exposedTables!.includes(t))
569
- : Array.from(tableNames);
663
+ ? canonicalTables.filter(t => options.exposedTables!.includes(t))
664
+ : canonicalTables;
570
665
  return json({ tables }, 200);
571
666
  }
572
667
 
@@ -639,6 +734,12 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
639
734
  kind: 'rpc', name: fnName, user, client,
640
735
  result: 'allow', requestId, body: body as Record<string, unknown>,
641
736
  });
737
+ // Global post-RPC hook: fire-and-forget side effects (cache purge,
738
+ // webhooks). Swallow errors — a side-effect failure must not fail the
739
+ // write, exactly like the after* REST hooks. Success path only.
740
+ if (options?.afterRpc) {
741
+ try { await options.afterRpc(result, fnName, body, user, client, ctx); } catch {}
742
+ }
642
743
  // Same cache discipline as table GETs: with pgSettings configured the
643
744
  // response can vary by identity (opportunistic auth on public RPCs),
644
745
  // so it must forbid shared caching ITSELF — never rely on the CDN