@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 +1 -1
- package/src/handler/index.ts +105 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.
|
|
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>",
|
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
|
|
@@ -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
|
-
|
|
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
|
-
?
|
|
569
|
-
:
|
|
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
|