@everystack/api 0.4.9 → 0.4.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/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/src/client/index.ts +29 -0
- package/src/handler/index.ts +625 -78
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ All notable changes to `@everystack/api` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.11] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- per-parent embed order, limit and where on both read paths (F10)
|
|
12
|
+
- hasOne one-row embed on both read paths (P2)
|
|
13
|
+
- createHandler refuses a handler relation drizzleRelations does not declare (P3)
|
|
14
|
+
- the handler deletes, hides and restores lifecycle rows through the database (H1, P1.4)
|
|
15
|
+
- profiles, posts, likes and follows are one lifecycle family; account delete and restore use it (P2.1, P2.2)
|
|
16
|
+
- lifecycle columns are hidden from callers that cannot manage the table
|
|
17
|
+
- beforeDestroy and afterDestroy hooks at a real removal
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- an unresolved relation no longer returns a same-named column
|
|
22
|
+
- match embedded rows on a hidden child key before stripping it (P2 review)
|
|
23
|
+
- a belongsTo embed matches on a hidden target key before stripping it
|
|
24
|
+
- a beforeDestroy HandlerError renders with its own status
|
|
25
|
+
|
|
26
|
+
## [0.4.10] - 2026-10-18
|
|
27
|
+
|
|
28
|
+
### BREAKING CHANGES
|
|
29
|
+
|
|
30
|
+
- an aggregate inside an embed (author(count())) answers 400 instead of being dropped, on every verb including writes that carry such a select
|
|
31
|
+
- a nested embed on the join path (drizzleRelations) answers 400 instead of being dropped, on every verb including writes that carry such a select; the batch path now keeps nested embeds and accepts snake_case embed column names
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- table-keyed option maps are read by own key, so a table named constructor reads no prototype value
|
|
36
|
+
|
|
7
37
|
## [0.4.9] - 2026-10-18
|
|
8
38
|
|
|
9
39
|
### BREAKING CHANGES
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.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/client/index.ts
CHANGED
|
@@ -83,6 +83,15 @@ export interface QueryBuilder {
|
|
|
83
83
|
insert(data: Record<string, unknown>): Promise<ClientResponse<unknown>>;
|
|
84
84
|
update(data: Record<string, unknown>): Promise<ClientResponse<unknown[]>>;
|
|
85
85
|
remove(): Promise<ClientResponse<unknown[]>>;
|
|
86
|
+
/** Lifecycle tables: restore a deleted row and its delete batch, on the table's delete rule.
|
|
87
|
+
* POSTs `{baseUrl}/{table}/restore` with `{ id }`; the data is `{ restored, skipped }`. */
|
|
88
|
+
restore(id: string | number): Promise<ClientResponse<RestoreResult>>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What a lifecycle restore brought back: rows restored, and batch rows left deleted. */
|
|
92
|
+
export interface RestoreResult {
|
|
93
|
+
restored: number;
|
|
94
|
+
skipped: number;
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
export interface Client {
|
|
@@ -270,6 +279,26 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
|
|
|
270
279
|
const result = await response.json();
|
|
271
280
|
return { data: result, error: null };
|
|
272
281
|
},
|
|
282
|
+
|
|
283
|
+
async restore(id: string | number): Promise<ClientResponse<RestoreResult>> {
|
|
284
|
+
const url = `${options.baseUrl}/${table}/restore`;
|
|
285
|
+
const headers = await buildHeaders(options);
|
|
286
|
+
headers['Content-Type'] = 'application/json';
|
|
287
|
+
|
|
288
|
+
const { response, retried } = await fetchWithRetry(
|
|
289
|
+
url,
|
|
290
|
+
{ method: 'POST', headers, body: JSON.stringify({ id }) },
|
|
291
|
+
options,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
if (retried) return retried as ClientResponse<RestoreResult>;
|
|
295
|
+
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
return { data: null, error: await parseError(response) };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { data: (await response.json()) as RestoreResult, error: null };
|
|
301
|
+
},
|
|
273
302
|
};
|
|
274
303
|
|
|
275
304
|
return builder;
|
package/src/handler/index.ts
CHANGED
|
@@ -216,7 +216,11 @@ export interface AuthConfig {
|
|
|
216
216
|
/** Ordered role hierarchy, lowest to highest privilege.
|
|
217
217
|
* Used by RPC role gating. Default: ['public', 'authenticated', 'admin'] */
|
|
218
218
|
roleHierarchy?: string[];
|
|
219
|
-
/** Field in the user payload containing the role string.
|
|
219
|
+
/** Field in the user payload containing the role string. Default: 'role'
|
|
220
|
+
* `roleField` must hold one string. A missing, non-string or array value is treated as
|
|
221
|
+
* no role and fails every gate; if your tokens carry several roles, collapse them to one
|
|
222
|
+
* in `auth.verifyToken` before the payload reaches the handler, so the RPC, ownership,
|
|
223
|
+
* restore (`PATCH ?deleted=`) gates and your `pgSettings` all read the same value. */
|
|
220
224
|
roleField?: string;
|
|
221
225
|
}
|
|
222
226
|
|
|
@@ -229,6 +233,20 @@ export interface RelationConfig {
|
|
|
229
233
|
to: string;
|
|
230
234
|
/** If true, this is a one-to-many relation (returns array). Default: false (many-to-one, returns object) */
|
|
231
235
|
many?: boolean;
|
|
236
|
+
/** To-one from the parent side (returns an object or null): the first target row whose `to`
|
|
237
|
+
* equals the parent's `from`, in `orderBy` order. Same column roles as `many`. Not with
|
|
238
|
+
* `many` or `limit`. The target must have a primary key. */
|
|
239
|
+
hasOne?: true;
|
|
240
|
+
/** Many or hasOne. Display order of each parent's rows, by target column. The target's primary
|
|
241
|
+
* key is appended as the tie-breaker (a hasOne with no orderBy is ordered by that key).
|
|
242
|
+
* Never read from the request. */
|
|
243
|
+
orderBy?: Array<{ column: string; order: 'asc' | 'desc' }>;
|
|
244
|
+
/** Many only. At most this many rows per parent, in `orderBy` order. Unrelated to `?limit=`. */
|
|
245
|
+
limit?: number;
|
|
246
|
+
/** Many or hasOne. Equality on target columns (`null` means IS NULL), applied before `limit`
|
|
247
|
+
* or the hasOne choice. A display filter the request cannot widen. It is not access control:
|
|
248
|
+
* use RLS for that. */
|
|
249
|
+
where?: Record<string, string | number | boolean | null>;
|
|
232
250
|
}
|
|
233
251
|
|
|
234
252
|
export interface SoftDeleteConfig {
|
|
@@ -241,10 +259,28 @@ export interface SoftDeleteConfig {
|
|
|
241
259
|
* default `role`) is in the table's list: exact string match, no hierarchy, no case folding.
|
|
242
260
|
* Anyone else gets live rows only, as if the param were absent. A restore body must be
|
|
243
261
|
* exactly `{ <column>: null }`, or 400. Omitted: no one restores through the API.
|
|
244
|
-
* This is a backstop. RLS must also keep other roles off deleted rows (docs/handler.md).
|
|
262
|
+
* This is a backstop. RLS must also keep other roles off deleted rows (docs/handler.md).
|
|
263
|
+
* A lifecycle table may not appear here: it restores through `POST {table}/restore`. */
|
|
245
264
|
restore?: Record<string, string[]>;
|
|
265
|
+
/** `lifecycle: true` tables (deriveHandlerConfig emits them), keyed like `tables`. Each is a
|
|
266
|
+
* soft-delete table whose column is always `deletedAt`, soft-deleted by the database itself:
|
|
267
|
+
* DELETE runs a real SQL DELETE (a trigger stamps the row), reads and embeds hide deleted rows
|
|
268
|
+
* for every role unless `?deleted=include|only` (honoured for any role; RLS decides what each
|
|
269
|
+
* sees), and `POST {basePath}/{table}/restore {id}` calls `<schema>.<table>_restore(id)` as the
|
|
270
|
+
* caller. PATCH with `?deleted=include|only` is refused with 400. `?deleted=only` reaches embeds
|
|
271
|
+
* too, so a live parent embedded under a deleted child comes back null. */
|
|
272
|
+
lifecycle?: string[];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Context for a real removal. External effects must be idempotent across retries. */
|
|
276
|
+
export interface DestroyContext {
|
|
277
|
+
source: 'purge' | 'api';
|
|
278
|
+
user: Record<string, unknown> | null;
|
|
279
|
+
client: Record<string, unknown> | null;
|
|
246
280
|
}
|
|
247
281
|
|
|
282
|
+
export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | Promise<void>;
|
|
283
|
+
|
|
248
284
|
export interface HookConfig {
|
|
249
285
|
/** Called before INSERT. Can modify the body (e.g., inject userId). Throw to abort. */
|
|
250
286
|
beforeCreate?: (body: Record<string, unknown>, user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<Record<string, unknown>>;
|
|
@@ -258,6 +294,14 @@ export interface HookConfig {
|
|
|
258
294
|
beforeDelete?: (user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
|
|
259
295
|
/** Called after DELETE with all deleted rows. Fire-and-forget. */
|
|
260
296
|
afterDelete?: (rows: Record<string, unknown>[], user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
|
|
297
|
+
/** After the hard DELETE statement and before commit, for each returned row. A throw
|
|
298
|
+
* rolls back the database delete only. No transaction is passed; external effects
|
|
299
|
+
* cannot be undone and must be idempotent. Lifecycle API deletes do not call this.
|
|
300
|
+
* Treat the row as read-only: it is the same object later serialized into the response. */
|
|
301
|
+
beforeDestroy?: DestroyHook;
|
|
302
|
+
/** After a real removal commits, per returned row, best-effort. Must be idempotent.
|
|
303
|
+
* Treat the row as read-only: it is the same object later serialized into the response. */
|
|
304
|
+
afterDestroy?: DestroyHook;
|
|
261
305
|
}
|
|
262
306
|
|
|
263
307
|
/** Cache-Control header configuration for different response categories. */
|
|
@@ -339,7 +383,8 @@ export interface HandlerOptions {
|
|
|
339
383
|
drizzleRelations?: Record<string, unknown>;
|
|
340
384
|
/** Compute PostgreSQL session variables from the authenticated user.
|
|
341
385
|
* When provided, queries are wrapped in a transaction with set_config() calls
|
|
342
|
-
* so RLS policies can reference the user context.
|
|
386
|
+
* so RLS policies can reference the user context.
|
|
387
|
+
* Read the same field `roleField` names. Do not derive the database role from a different claim. */
|
|
343
388
|
pgSettings?: (user: Record<string, unknown> | null, client?: Record<string, unknown> | null) => Record<string, string>;
|
|
344
389
|
/** Per-table lifecycle hooks for mutations. */
|
|
345
390
|
hooks?: Record<string, HookConfig>;
|
|
@@ -361,6 +406,12 @@ export interface HandlerOptions {
|
|
|
361
406
|
* Explicit references in query parameters are refused before database access.
|
|
362
407
|
* Does not prevent writes to these columns; use protectedFields for that. */
|
|
363
408
|
hiddenColumns?: Record<string, string[]>;
|
|
409
|
+
/** Exact roles allowed to read deletedBy, deletedBatch and deletedXid on each lifecycle
|
|
410
|
+
* table. Other hidden columns remain hidden. Uses auth.roleField (default role), with
|
|
411
|
+
* no hierarchy or case folding. Missing/non-string claims never qualify. The claim is compared
|
|
412
|
+
* to the model's PostgreSQL role name, so an app whose pgSettings maps claim to role
|
|
413
|
+
* differently sees the two diverge: keep the claim values equal to the manage role names. */
|
|
414
|
+
hiddenColumnsExceptRoles?: Record<string, string[]>;
|
|
364
415
|
/** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
|
|
365
416
|
* Prevents mass assignment / privilege escalation on sensitive columns. */
|
|
366
417
|
protectedFields?: Record<string, string[]>;
|
|
@@ -410,8 +461,16 @@ export interface HandlerOptions {
|
|
|
410
461
|
onError?: (fault: HandlerFault) => void | Promise<void>;
|
|
411
462
|
}
|
|
412
463
|
|
|
413
|
-
|
|
464
|
+
/** The lifecycle machinery's timestamp column: fixed, whatever softDelete.column says. */
|
|
465
|
+
const LIFECYCLE_DELETED_AT = 'deletedAt';
|
|
466
|
+
|
|
467
|
+
function isLifecycleTable(tableName: string, options?: HandlerOptions): boolean {
|
|
468
|
+
return options?.softDelete?.lifecycle?.includes(tableName) ?? false;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function getSoftDeleteColumn(tableName: string, table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
|
|
414
472
|
if (!options?.softDelete) return null;
|
|
473
|
+
if (isLifecycleTable(tableName, options)) return getColumn(table, LIFECYCLE_DELETED_AT);
|
|
415
474
|
const colName = options.softDelete.column || 'deletedAt';
|
|
416
475
|
return getColumn(table, colName);
|
|
417
476
|
}
|
|
@@ -429,11 +488,29 @@ function softDeleteCondition(sdCol: DrizzleColumn, params: URLSearchParams): SQL
|
|
|
429
488
|
|
|
430
489
|
function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: HandlerOptions): boolean {
|
|
431
490
|
if (!options?.softDelete) return false;
|
|
491
|
+
if (isLifecycleTable(tableName, options)) return true;
|
|
432
492
|
if (options.softDelete.tables) {
|
|
433
493
|
return options.softDelete.tables.includes(tableName);
|
|
434
494
|
}
|
|
435
495
|
// Auto-detect: table has the soft-delete column
|
|
436
|
-
return getSoftDeleteColumn(table, options) != null;
|
|
496
|
+
return getSoftDeleteColumn(tableName, table, options) != null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The soft-delete guard on an embedded table. Live rows only, except that a lifecycle table
|
|
501
|
+
* honours the request's ?deleted=include|only like its top-level read: RLS decides what each
|
|
502
|
+
* role sees. `params` is absent where no request applies (a pre-DB validation pass).
|
|
503
|
+
*/
|
|
504
|
+
function embedSoftDeleteCondition(
|
|
505
|
+
tableName: string,
|
|
506
|
+
table: DrizzleTable,
|
|
507
|
+
options: HandlerOptions | undefined,
|
|
508
|
+
params: URLSearchParams | undefined,
|
|
509
|
+
): SQL | null {
|
|
510
|
+
if (!isSoftDeleteTable(tableName, table, options)) return null;
|
|
511
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
512
|
+
if (!sdCol) return null;
|
|
513
|
+
return params && isLifecycleTable(tableName, options) ? softDeleteCondition(sdCol, params) : isNull(sdCol);
|
|
437
514
|
}
|
|
438
515
|
|
|
439
516
|
/**
|
|
@@ -452,17 +529,18 @@ async function executeWithSettings<T>(
|
|
|
452
529
|
db: DrizzleDb,
|
|
453
530
|
settings: Record<string, string> | undefined,
|
|
454
531
|
fn: (dbOrTx: DrizzleDb) => Promise<T>,
|
|
532
|
+
forceTransaction = false,
|
|
455
533
|
): Promise<T> {
|
|
456
|
-
if (settings && Object.keys(settings).length > 0) {
|
|
534
|
+
if (forceTransaction || (settings && Object.keys(settings).length > 0)) {
|
|
457
535
|
return db.transaction(async (tx: DrizzleDb) => {
|
|
458
|
-
const role = settings['role'];
|
|
536
|
+
const role = settings?.['role'];
|
|
459
537
|
if (role) {
|
|
460
538
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(role)) {
|
|
461
539
|
throw new Error(`Invalid role name: ${role}`);
|
|
462
540
|
}
|
|
463
541
|
await tx.execute(sql.raw(`SET LOCAL ROLE ${role}`));
|
|
464
542
|
}
|
|
465
|
-
for (const [key, value] of Object.entries(settings)) {
|
|
543
|
+
for (const [key, value] of Object.entries(settings ?? {})) {
|
|
466
544
|
if (key !== 'role' && value !== undefined && value !== '') {
|
|
467
545
|
await tx.execute(sql`SELECT set_config(${key}, ${value}, true)`);
|
|
468
546
|
}
|
|
@@ -587,11 +665,11 @@ async function checkRoleAuth(
|
|
|
587
665
|
|
|
588
666
|
/**
|
|
589
667
|
* Determine the Cache-Control header for a GET response.
|
|
590
|
-
* - pgSettings
|
|
591
|
-
* -
|
|
668
|
+
* - pgSettings or role-dependent column visibility configured → private
|
|
669
|
+
* - Neither configured → null (let server package set its default)
|
|
592
670
|
*/
|
|
593
671
|
function getCacheControl(options?: HandlerOptions): string | null {
|
|
594
|
-
if (options?.pgSettings) {
|
|
672
|
+
if (options?.pgSettings || Object.keys(options?.hiddenColumnsExceptRoles ?? {}).length) {
|
|
595
673
|
return options?.cache?.private ?? 'private, no-store';
|
|
596
674
|
}
|
|
597
675
|
return null;
|
|
@@ -637,7 +715,7 @@ function stripProtectedFields(
|
|
|
637
715
|
tableName: string,
|
|
638
716
|
options?: HandlerOptions,
|
|
639
717
|
): unknown {
|
|
640
|
-
const fields = options?.protectedFields
|
|
718
|
+
const fields = ownEntry(options?.protectedFields, tableName);
|
|
641
719
|
if (!fields || fields.length === 0) return body;
|
|
642
720
|
if (Array.isArray(body)) return body.map(row => stripProtectedFields(row, tableName, options));
|
|
643
721
|
if (body === null || typeof body !== 'object') {
|
|
@@ -655,7 +733,7 @@ function stripHiddenColumns(
|
|
|
655
733
|
tableName: string,
|
|
656
734
|
options?: HandlerOptions,
|
|
657
735
|
): unknown {
|
|
658
|
-
const columns = options?.hiddenColumns
|
|
736
|
+
const columns = ownEntry(options?.hiddenColumns, tableName);
|
|
659
737
|
if (!columns || columns.length === 0) return data;
|
|
660
738
|
|
|
661
739
|
const strip = (row: Record<string, unknown>): Record<string, unknown> => {
|
|
@@ -835,6 +913,7 @@ function expandTableKeyedOptions(
|
|
|
835
913
|
...options,
|
|
836
914
|
...(options.exposedTables ? { exposedTables: expandList(options.exposedTables) } : {}),
|
|
837
915
|
...(options.hiddenColumns ? { hiddenColumns: expandMap(options.hiddenColumns) } : {}),
|
|
916
|
+
...(options.hiddenColumnsExceptRoles ? { hiddenColumnsExceptRoles: expandMap(options.hiddenColumnsExceptRoles) } : {}),
|
|
838
917
|
...(options.protectedFields ? { protectedFields: expandMap(options.protectedFields) } : {}),
|
|
839
918
|
...(options.sanitizeColumns ? { sanitizeColumns: expandMap(options.sanitizeColumns) } : {}),
|
|
840
919
|
...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
|
|
@@ -846,6 +925,7 @@ function expandTableKeyedOptions(
|
|
|
846
925
|
...options.softDelete,
|
|
847
926
|
...(options.softDelete.tables ? { tables: expandList(options.softDelete.tables) } : {}),
|
|
848
927
|
...(options.softDelete.restore ? { restore: expandMap(options.softDelete.restore) } : {}),
|
|
928
|
+
...(options.softDelete.lifecycle ? { lifecycle: expandList(options.softDelete.lifecycle) } : {}),
|
|
849
929
|
},
|
|
850
930
|
}
|
|
851
931
|
: {}),
|
|
@@ -865,6 +945,7 @@ function declaredTables(options: HandlerOptions): Set<string> {
|
|
|
865
945
|
...Object.keys(options.relations ?? {}),
|
|
866
946
|
...Object.values(options.relations ?? {}).flatMap(rels => Object.values(rels).map(rel => rel.table)),
|
|
867
947
|
...(options.softDelete?.tables ?? []),
|
|
948
|
+
...(options.softDelete?.lifecycle ?? []),
|
|
868
949
|
];
|
|
869
950
|
return new Set(names.map(toSnakeCase));
|
|
870
951
|
}
|
|
@@ -917,6 +998,11 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
|
|
|
917
998
|
const colName = softDelete?.column || 'deletedAt';
|
|
918
999
|
for (const [tableName, roles] of Object.entries(softDelete?.restore ?? {})) {
|
|
919
1000
|
const table = schema[tableName];
|
|
1001
|
+
if (isLifecycleTable(tableName, options)) {
|
|
1002
|
+
throw new Error(
|
|
1003
|
+
`[everystack/api] softDelete.restore.${tableName}: table "${tableName}" is a lifecycle table. It restores through POST {basePath}/${tableName}/restore, on its delete rule; remove the entry.`,
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
920
1006
|
const isSoftDelete = table
|
|
921
1007
|
? isSoftDeleteTable(tableName, table, options)
|
|
922
1008
|
: (softDelete?.tables?.includes(tableName) ?? false);
|
|
@@ -928,7 +1014,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
|
|
|
928
1014
|
const sameColumn = (field: string): boolean => (table
|
|
929
1015
|
? getColumn(table, field) !== null && getColumn(table, field) === getColumn(table, colName)
|
|
930
1016
|
: toSnakeCase(field) === toSnakeCase(colName));
|
|
931
|
-
if (roles.length > 0 && (options?.protectedFields
|
|
1017
|
+
if (roles.length > 0 && (ownEntry(options?.protectedFields, tableName) ?? []).some(sameColumn)) {
|
|
932
1018
|
throw new Error(
|
|
933
1019
|
`[everystack/api] softDelete.restore.${tableName}: the soft-delete column "${colName}" is in protectedFields.${tableName}, so every restore body is stripped to {}. Remove it from protectedFields.${tableName} or remove the restore entry.`,
|
|
934
1020
|
);
|
|
@@ -942,7 +1028,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
|
|
|
942
1028
|
* non-string role claim is not a member.
|
|
943
1029
|
*/
|
|
944
1030
|
function mayRestore(tableName: string, options: HandlerOptions | undefined, user: Record<string, unknown> | null): boolean {
|
|
945
|
-
const roles = options?.softDelete?.restore
|
|
1031
|
+
const roles = ownEntry(options?.softDelete?.restore, tableName);
|
|
946
1032
|
if (!roles?.length || !user) return false;
|
|
947
1033
|
const role = readRoleClaim(user, options?.auth?.roleField ?? 'role');
|
|
948
1034
|
return role !== '' && roles.includes(role);
|
|
@@ -984,8 +1070,148 @@ function validateSanitizeColumns(schema: Schema, options: HandlerOptions | undef
|
|
|
984
1070
|
}
|
|
985
1071
|
}
|
|
986
1072
|
|
|
1073
|
+
/**
|
|
1074
|
+
* With `drizzleRelations`, the JOIN path hands each relation to drizzle's `with` by its handler
|
|
1075
|
+
* name, so drizzle must declare a relation of that name on that table. A mismatch used to fail
|
|
1076
|
+
* inside drizzle at request time; it is refused here instead. A hasOne never goes to `with` (it is
|
|
1077
|
+
* resolved by a windowed query), and a table drizzle does not serve relationally takes the batch
|
|
1078
|
+
* path, so neither is checked.
|
|
1079
|
+
*/
|
|
1080
|
+
function validateDrizzleRelationNames(db: DrizzleDb, options: HandlerOptions | undefined): void {
|
|
1081
|
+
if (!options?.drizzleRelations) return;
|
|
1082
|
+
const relational = (db as { _?: { schema?: Record<string, { relations?: Record<string, unknown> }> } })._?.schema;
|
|
1083
|
+
if (!relational) return;
|
|
1084
|
+
for (const [tableName, tableRelations] of Object.entries(options.relations ?? {})) {
|
|
1085
|
+
if (!(db as any).query?.[tableName]) continue;
|
|
1086
|
+
const declared = Object.keys(ownEntry(relational, tableName)?.relations ?? {});
|
|
1087
|
+
for (const [name, relation] of Object.entries(tableRelations)) {
|
|
1088
|
+
if (relation.hasOne || declared.includes(name)) continue;
|
|
1089
|
+
throw new Error(
|
|
1090
|
+
`[everystack/api] relations.${tableName}.${name}: drizzleRelations declares no relation "${name}" on "${tableName}" `
|
|
1091
|
+
+ `(it has: ${declared.length ? [...declared].sort().join(', ') : 'none'}). The JOIN path would fail at request time. `
|
|
1092
|
+
+ 'Declare it in your drizzle relations, rename the handler relation, or leave out drizzleRelations.',
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Refuse a relation orderBy, limit, where or hasOne the read paths cannot apply. Only a relation
|
|
1100
|
+
* that declares one is checked. Its target must be in the schema when its source is: derived
|
|
1101
|
+
* configs name every table while a handler serves a few, and an unserved source never embeds.
|
|
1102
|
+
*/
|
|
1103
|
+
function validateRelationDisplay(schema: Schema, options: HandlerOptions | undefined): void {
|
|
1104
|
+
for (const [tableName, tableRelations] of Object.entries(options?.relations ?? {})) {
|
|
1105
|
+
for (const [name, relation] of Object.entries(tableRelations)) {
|
|
1106
|
+
const { orderBy, limit, where, hasOne } = relation;
|
|
1107
|
+
const prefix = `[everystack/api] relations.${tableName}.${name}:`;
|
|
1108
|
+
if (hasOne !== undefined) {
|
|
1109
|
+
if (hasOne !== true) throw new Error(`${prefix} hasOne must be true`);
|
|
1110
|
+
if (relation.many) throw new Error(`${prefix} a relation is many or hasOne, not both`);
|
|
1111
|
+
if (limit !== undefined) throw new Error(`${prefix} hasOne returns one row: limit is not allowed`);
|
|
1112
|
+
} else if (orderBy === undefined && limit === undefined && where === undefined) {
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (!relation.many && !hasOne) {
|
|
1116
|
+
throw new Error(`${prefix} orderBy and where apply to a many or hasOne relation only, and limit to a many relation only`);
|
|
1117
|
+
}
|
|
1118
|
+
const target = schema[relation.table];
|
|
1119
|
+
if (!target) {
|
|
1120
|
+
if (!schema[tableName]) continue;
|
|
1121
|
+
throw new Error(`${prefix} target table "${relation.table}" is not in the schema`);
|
|
1122
|
+
}
|
|
1123
|
+
// The key is the last tie-breaker. Without one, two runs could choose different rows.
|
|
1124
|
+
if (hasOne && primaryKeyColumns(target).length === 0) {
|
|
1125
|
+
throw new Error(`${prefix} target table "${relation.table}" has no primary key to break ties`);
|
|
1126
|
+
}
|
|
1127
|
+
const resolves = (column: unknown): boolean => typeof column === 'string' && columnPropertyName(target, column) !== undefined;
|
|
1128
|
+
if (orderBy !== undefined) {
|
|
1129
|
+
if (!Array.isArray(orderBy)) throw new Error(`${prefix} orderBy must be an array of { column, order }`);
|
|
1130
|
+
for (const term of orderBy) {
|
|
1131
|
+
if (!resolves(term?.column)) throw new Error(`${prefix} orderBy column "${term?.column}" is not a column of table "${relation.table}"`);
|
|
1132
|
+
if (term.order !== 'asc' && term.order !== 'desc') throw new Error(`${prefix} orderBy order for "${term.column}" must be 'asc' or 'desc'`);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (limit !== undefined && !(typeof limit === 'number' && Number.isInteger(limit) && limit > 0)) {
|
|
1136
|
+
throw new Error(`${prefix} limit must be a positive integer`);
|
|
1137
|
+
}
|
|
1138
|
+
if (where !== undefined) {
|
|
1139
|
+
if (where === null || typeof where !== 'object' || Array.isArray(where)) throw new Error(`${prefix} where must be an object of column → value`);
|
|
1140
|
+
for (const [column, value] of Object.entries(where)) {
|
|
1141
|
+
if (!resolves(column)) throw new Error(`${prefix} where column "${column}" is not a column of table "${relation.table}"`);
|
|
1142
|
+
const valid = value === null || typeof value === 'string' || typeof value === 'boolean'
|
|
1143
|
+
|| (typeof value === 'number' && Number.isFinite(value));
|
|
1144
|
+
if (!valid) throw new Error(`${prefix} where value for "${column}" must be a string, a finite number, a boolean or null`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** The target's primary-key columns, in declaration order. Empty for a keyless or non-pg table. */
|
|
1152
|
+
function primaryKeyColumns(table: DrizzleTable): DrizzleColumn[] {
|
|
1153
|
+
if (!is(table, PgTable)) return [];
|
|
1154
|
+
const config = getTableConfig(table);
|
|
1155
|
+
const columns = Object.values(getTableColumns(table)) as DrizzleColumn[];
|
|
1156
|
+
const byName = new Map(columns.map(c => [c.name, c]));
|
|
1157
|
+
return [
|
|
1158
|
+
...columns.filter(c => c.primary),
|
|
1159
|
+
...config.primaryKeys.flatMap(key => key.columns.map(keyColumn => byName.get(keyColumn.name))),
|
|
1160
|
+
].filter((c): c is DrizzleColumn => c !== undefined);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* A relation's row filter and order, over the target table's own column objects (no config
|
|
1165
|
+
* value reaches SQL text). The filter is the soft-delete guard and the declared where. The
|
|
1166
|
+
* order is the declared orderBy then the primary key, or none when no orderBy, limit or hasOne
|
|
1167
|
+
* is declared. validateRelationDisplay has checked every name.
|
|
1168
|
+
*/
|
|
1169
|
+
function relationDisplay(
|
|
1170
|
+
relation: RelationConfig,
|
|
1171
|
+
targetTable: DrizzleTable,
|
|
1172
|
+
options?: HandlerOptions,
|
|
1173
|
+
params?: URLSearchParams,
|
|
1174
|
+
): { conditions: SQL[]; order: SQL[] } {
|
|
1175
|
+
const column = (name: string): DrizzleColumn => getTableColumns(targetTable)[columnPropertyName(targetTable, name)!] as DrizzleColumn;
|
|
1176
|
+
const conditions: SQL[] = [];
|
|
1177
|
+
const deletedGuard = embedSoftDeleteCondition(relation.table, targetTable, options, params);
|
|
1178
|
+
if (deletedGuard) conditions.push(deletedGuard);
|
|
1179
|
+
for (const [name, value] of Object.entries(relation.where ?? {})) {
|
|
1180
|
+
conditions.push(value === null ? isNull(column(name)) : eq(column(name), value));
|
|
1181
|
+
}
|
|
1182
|
+
const order: SQL[] = [];
|
|
1183
|
+
if (relation.orderBy !== undefined || relation.limit !== undefined || relation.hasOne) {
|
|
1184
|
+
const named = new Set<DrizzleColumn>();
|
|
1185
|
+
for (const term of relation.orderBy ?? []) {
|
|
1186
|
+
named.add(column(term.column));
|
|
1187
|
+
order.push(term.order === 'desc' ? desc(column(term.column)) : asc(column(term.column)));
|
|
1188
|
+
}
|
|
1189
|
+
for (const c of primaryKeyColumns(targetTable)) {
|
|
1190
|
+
if (!named.has(c)) { named.add(c); order.push(asc(c)); }
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return { conditions, order };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
987
1196
|
/** Resolve hidden columns once so every read uses the same column spellings. */
|
|
988
1197
|
function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): HandlerOptions | undefined {
|
|
1198
|
+
const rolesByTable = new Map<DrizzleTable, string[]>();
|
|
1199
|
+
const configuredHidden = { ...options?.hiddenColumns };
|
|
1200
|
+
for (const [tableName, roles] of Object.entries(options?.hiddenColumnsExceptRoles ?? {})) {
|
|
1201
|
+
const table = schema[tableName];
|
|
1202
|
+
if (!table || !Array.isArray(roles) || roles.some(role => typeof role !== 'string' || role === '')) {
|
|
1203
|
+
throw new Error(`[everystack/api] hiddenColumnsExceptRoles.${tableName}: expected a known table and an array of role names`);
|
|
1204
|
+
}
|
|
1205
|
+
const previous = rolesByTable.get(table);
|
|
1206
|
+
if (previous && (previous.length !== roles.length || previous.some(role => !roles.includes(role)))) {
|
|
1207
|
+
throw new Error(`[everystack/api] hiddenColumnsExceptRoles.${tableName}: conflicting role exceptions for one table`);
|
|
1208
|
+
}
|
|
1209
|
+
rolesByTable.set(table, roles);
|
|
1210
|
+
if (isLifecycleTable(tableName, options)) {
|
|
1211
|
+
configuredHidden[tableName] = [...new Set([...(configuredHidden[tableName] ?? []), 'deletedBy', 'deletedBatch', 'deletedXid'])];
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (options?.hiddenColumnsExceptRoles) options = { ...options, hiddenColumns: configuredHidden };
|
|
989
1215
|
if (!options?.hiddenColumns) return options;
|
|
990
1216
|
const byTable = new Map<DrizzleTable, Set<string>>();
|
|
991
1217
|
for (const [tableName, names] of Object.entries(options.hiddenColumns)) {
|
|
@@ -1017,6 +1243,27 @@ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): Handler
|
|
|
1017
1243
|
return { ...options, hiddenColumns };
|
|
1018
1244
|
}
|
|
1019
1245
|
|
|
1246
|
+
/** Resolve one request's visibility before query validation and every response path. */
|
|
1247
|
+
function hiddenColumnsForUser(schema: Schema, options: HandlerOptions | undefined, user: Record<string, unknown> | null): HandlerOptions | undefined {
|
|
1248
|
+
if (!options?.hiddenColumnsExceptRoles || !user) return options;
|
|
1249
|
+
const role = readRoleClaim(user, options.auth?.roleField ?? 'role');
|
|
1250
|
+
if (!role) return options;
|
|
1251
|
+
const hiddenColumns = Object.fromEntries(Object.entries(options.hiddenColumns ?? {}).map(([tableName, columns]) => {
|
|
1252
|
+
const except = ownEntry(options.hiddenColumnsExceptRoles, tableName);
|
|
1253
|
+
if (!isLifecycleTable(tableName, options) || !except?.includes(role)) return [tableName, columns];
|
|
1254
|
+
const table = schema[tableName];
|
|
1255
|
+
const metadata = new Set<string>();
|
|
1256
|
+
for (const name of ['deleted_by', 'deleted_batch', 'deleted_xid']) {
|
|
1257
|
+
const property = columnPropertyName(table, name);
|
|
1258
|
+
if (!property) continue;
|
|
1259
|
+
const column = getTableColumns(table)[property] as DrizzleColumn;
|
|
1260
|
+
for (const spelling of [property, column.name, toSnakeCase(property), toCamelCase(column.name)]) metadata.add(spelling);
|
|
1261
|
+
}
|
|
1262
|
+
return [tableName, columns.filter(column => !metadata.has(column))];
|
|
1263
|
+
}));
|
|
1264
|
+
return { ...options, hiddenColumns };
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1020
1267
|
/** Safe, fixed client messages. Never derive these from PostgreSQL text or detail. */
|
|
1021
1268
|
const INTEGRITY_ERRORS: Record<string, { status: number; message: string }> = {
|
|
1022
1269
|
'23514': { status: 422, message: 'Value does not meet a constraint' },
|
|
@@ -1128,15 +1375,18 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1128
1375
|
// apply. The root listing below stays one entry per table (the SQL name).
|
|
1129
1376
|
const namePairs = tableNamePairs(rawSchema);
|
|
1130
1377
|
const schema = expandSchema(rawSchema, namePairs);
|
|
1131
|
-
const
|
|
1132
|
-
validateConstraintMessages(
|
|
1378
|
+
const configuredOptions = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
|
|
1379
|
+
validateConstraintMessages(configuredOptions);
|
|
1133
1380
|
validateRowOwnership(schema, rawOptions);
|
|
1134
|
-
validateSoftDeleteRestore(schema,
|
|
1135
|
-
validateSanitizeColumns(schema,
|
|
1381
|
+
validateSoftDeleteRestore(schema, configuredOptions);
|
|
1382
|
+
validateSanitizeColumns(schema, configuredOptions);
|
|
1383
|
+
validateRelationDisplay(schema, configuredOptions);
|
|
1384
|
+
validateDrizzleRelationNames(db, configuredOptions);
|
|
1136
1385
|
const tableNames = new Set(Object.keys(schema));
|
|
1137
1386
|
const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
|
|
1138
1387
|
|
|
1139
1388
|
return async (request: Request): Promise<Response> => {
|
|
1389
|
+
let options = configuredOptions;
|
|
1140
1390
|
const url = new URL(request.url);
|
|
1141
1391
|
let pathname = url.pathname;
|
|
1142
1392
|
if (options?.basePath) {
|
|
@@ -1207,8 +1457,9 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1207
1457
|
// Root path — table listing, always public. One entry per table (SQL name),
|
|
1208
1458
|
// regardless of how many spellings route to it.
|
|
1209
1459
|
if (pathSegments.length === 0) {
|
|
1210
|
-
const
|
|
1211
|
-
|
|
1460
|
+
const exposedTables = options?.exposedTables;
|
|
1461
|
+
const tables = exposedTables
|
|
1462
|
+
? canonicalTables.filter(t => exposedTables.includes(t))
|
|
1212
1463
|
: canonicalTables;
|
|
1213
1464
|
return json({ tables }, 200);
|
|
1214
1465
|
}
|
|
@@ -1263,6 +1514,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1263
1514
|
return json({ message: 'Authentication required: no auth token provided' }, 401);
|
|
1264
1515
|
}
|
|
1265
1516
|
|
|
1517
|
+
options = hiddenColumnsForUser(schema, options, user);
|
|
1266
1518
|
const fn = isObjectForm ? entry.fn : entry;
|
|
1267
1519
|
const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
|
|
1268
1520
|
// Request-scoped context for the trace-id spine — RPC handlers receive it
|
|
@@ -1411,8 +1663,24 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1411
1663
|
}
|
|
1412
1664
|
}
|
|
1413
1665
|
|
|
1666
|
+
options = hiddenColumnsForUser(schema, options, user);
|
|
1414
1667
|
const table = schema[tableName];
|
|
1415
1668
|
|
|
1669
|
+
// POST {table}/restore: a lifecycle table's restore. Before the generic routing, which would
|
|
1670
|
+
// read it as POST {table} (an insert). Any other table has no restore endpoint.
|
|
1671
|
+
if (method === 'POST' && pathSegments.length === 2 && pathSegments[1] === 'restore') {
|
|
1672
|
+
if (!isLifecycleTable(tableName, options)) {
|
|
1673
|
+
return sanitizeError(`Table '${tableName}' has no restore endpoint`, 404, options);
|
|
1674
|
+
}
|
|
1675
|
+
const response = await handleRestore(db, table, tableName, request, options, user, client, requestId);
|
|
1676
|
+
await emitAudit(options?.audit, {
|
|
1677
|
+
kind: 'rest', name: tableName, user, client,
|
|
1678
|
+
result: response.status < 400 ? 'allow' : response.status < 500 ? 'deny' : 'error',
|
|
1679
|
+
requestId, operation: 'update', columns: ['id'],
|
|
1680
|
+
});
|
|
1681
|
+
return response;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1416
1684
|
// Forensic audit of the non-GET table surface (writes). Capture the query, not
|
|
1417
1685
|
// the response: the operation, the WHERE predicate, and the body's column NAMES
|
|
1418
1686
|
// (never values — values can be PII). Unresolved embed policies also record
|
|
@@ -1453,15 +1721,22 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1453
1721
|
|
|
1454
1722
|
// A part with parentheses is an aggregate or a declared embed. Anything
|
|
1455
1723
|
// else cannot be honored, so refuse it rather than drop it from the rows.
|
|
1724
|
+
// An aggregate inside an embed cannot be honored either, nor can a nested
|
|
1725
|
+
// embed on the JOIN path: its `with` clause is one level deep.
|
|
1456
1726
|
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1457
1727
|
if (!queryError) {
|
|
1728
|
+
const joinPath = relationalQueryAvailable(routedDb, tableName, options);
|
|
1458
1729
|
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1459
|
-
const { undeclared } = parseSelectParam(selectParam, tableRelations, options?.relations);
|
|
1730
|
+
const { undeclared, embedAggregate, nestedEmbed } = parseSelectParam(selectParam, tableRelations, options?.relations);
|
|
1460
1731
|
if (undeclared !== undefined) {
|
|
1461
1732
|
queryError = undeclaredSelectPartMessage(undeclared);
|
|
1462
1733
|
queryErrorEchoesInput = true;
|
|
1463
1734
|
break;
|
|
1464
1735
|
}
|
|
1736
|
+
if (embedAggregate || (nestedEmbed && joinPath)) {
|
|
1737
|
+
queryError = 'Invalid query';
|
|
1738
|
+
break;
|
|
1739
|
+
}
|
|
1465
1740
|
}
|
|
1466
1741
|
}
|
|
1467
1742
|
|
|
@@ -1498,6 +1773,11 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1498
1773
|
// 2xx; a throw (a failed commit) skips it, since the write did not happen.
|
|
1499
1774
|
// A retry wrapper around db.transaction must reset `deferred` per attempt,
|
|
1500
1775
|
// or the hooks of an aborted attempt would run too.
|
|
1776
|
+
const destroyHook = ownEntry(options?.hooks, tableName);
|
|
1777
|
+
const destroyTransaction = method === 'DELETE'
|
|
1778
|
+
&& !isLifecycleTable(tableName, options)
|
|
1779
|
+
&& !(isSoftDeleteTable(tableName, table, options) && getSoftDeleteColumn(tableName, table, options))
|
|
1780
|
+
&& Boolean(destroyHook?.beforeDestroy || destroyHook?.afterDestroy);
|
|
1501
1781
|
const deferred: Deferred[] = [];
|
|
1502
1782
|
let response: Response;
|
|
1503
1783
|
try {
|
|
@@ -1521,7 +1801,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1521
1801
|
}
|
|
1522
1802
|
|
|
1523
1803
|
return json({ message: `Method ${method} not allowed` }, 405);
|
|
1524
|
-
});
|
|
1804
|
+
}, destroyTransaction);
|
|
1525
1805
|
} finally {
|
|
1526
1806
|
// emitFault never throws, so this cannot replace the error in flight.
|
|
1527
1807
|
for (const item of deferred) if (item.always) await item.run();
|
|
@@ -1551,7 +1831,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1551
1831
|
if (isWrite) {
|
|
1552
1832
|
await emitAudit(options?.audit, {
|
|
1553
1833
|
kind: 'rest', name: tableName, user, client,
|
|
1554
|
-
result: mapped || permissionDenied ? 'deny' : 'error',
|
|
1834
|
+
result: mapped || permissionDenied || (error instanceof HandlerError && error.status < 500) ? 'deny' : 'error',
|
|
1555
1835
|
requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
|
|
1556
1836
|
});
|
|
1557
1837
|
}
|
|
@@ -1568,6 +1848,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1568
1848
|
...describeError(error),
|
|
1569
1849
|
});
|
|
1570
1850
|
}
|
|
1851
|
+
if (error instanceof HandlerError && error.status < 500) return json({ message: error.message }, error.status);
|
|
1571
1852
|
if (unavailable) return serviceUnavailableResponse();
|
|
1572
1853
|
throw error;
|
|
1573
1854
|
}
|
|
@@ -1583,12 +1864,21 @@ function columnPropertyName(table: DrizzleTable | undefined, name: string): stri
|
|
|
1583
1864
|
?? columns.find(([, value]) => (value as DrizzleColumn).name === name)?.[0];
|
|
1584
1865
|
}
|
|
1585
1866
|
|
|
1867
|
+
/**
|
|
1868
|
+
* A read with embeds takes the JOIN path when this holds. The pre-DB refusal of
|
|
1869
|
+
* a nested embed and handleGet both ask it, so the two cannot disagree.
|
|
1870
|
+
*/
|
|
1871
|
+
function relationalQueryAvailable(db: DrizzleDb, tableName: string, options?: HandlerOptions): boolean {
|
|
1872
|
+
return Boolean(ownEntry(options?.relations, tableName) && options?.drizzleRelations && (db as any).query?.[tableName]);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1586
1875
|
/** Build each relation's projection before Drizzle reads its values. */
|
|
1587
1876
|
function buildWithClause(
|
|
1588
1877
|
embeds: EmbedRequest[],
|
|
1589
1878
|
tableRelations: Record<string, RelationConfig>,
|
|
1590
1879
|
schema: Schema,
|
|
1591
1880
|
options?: HandlerOptions,
|
|
1881
|
+
params?: URLSearchParams,
|
|
1592
1882
|
): Record<string, any> | undefined | null | 'unresolvedHiddenColumns' {
|
|
1593
1883
|
if (embeds.length === 0) return undefined;
|
|
1594
1884
|
|
|
@@ -1600,7 +1890,7 @@ function buildWithClause(
|
|
|
1600
1890
|
if (!relation) continue;
|
|
1601
1891
|
hasEmbedded = true;
|
|
1602
1892
|
const table = schema[relation.table];
|
|
1603
|
-
const names = new Set(options?.hiddenColumns
|
|
1893
|
+
const names = new Set(ownEntry(options?.hiddenColumns, relation.table) ?? []);
|
|
1604
1894
|
const hidden = new Set(table ? Object.keys(getTableColumns(table)).filter(name => names.has(name)) : []);
|
|
1605
1895
|
const config: Record<string, any> = {};
|
|
1606
1896
|
if (embed.columns !== null) {
|
|
@@ -1614,6 +1904,13 @@ function buildWithClause(
|
|
|
1614
1904
|
} else if (hidden.size > 0) {
|
|
1615
1905
|
config.columns = Object.fromEntries([...hidden].map(name => [name, false]));
|
|
1616
1906
|
}
|
|
1907
|
+
// Drizzle maps the target's columns to the join alias. The filter runs before the limit.
|
|
1908
|
+
if (table) {
|
|
1909
|
+
const { conditions, order } = relationDisplay(relation, table, options, params);
|
|
1910
|
+
if (conditions.length > 0) config.where = and(...conditions);
|
|
1911
|
+
if (order.length > 0) config.orderBy = order;
|
|
1912
|
+
if (relation.limit !== undefined) config.limit = relation.limit;
|
|
1913
|
+
}
|
|
1617
1914
|
withClause[embed.name] = Object.keys(config).length > 0 ? config : true;
|
|
1618
1915
|
}
|
|
1619
1916
|
|
|
@@ -1766,7 +2063,7 @@ async function handleGet(
|
|
|
1766
2063
|
|
|
1767
2064
|
// Soft-delete: filter deleted records unless ?deleted=include|only
|
|
1768
2065
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
1769
|
-
const sdCol = getSoftDeleteColumn(table, options);
|
|
2066
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
1770
2067
|
if (sdCol) {
|
|
1771
2068
|
const condition = softDeleteCondition(sdCol, params);
|
|
1772
2069
|
if (condition) conditions.push(condition);
|
|
@@ -1829,10 +2126,7 @@ async function handleGet(
|
|
|
1829
2126
|
|
|
1830
2127
|
// Detect if Drizzle relational query (JOINs) should be used
|
|
1831
2128
|
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1832
|
-
const useRelationalQuery = embeds.length > 0
|
|
1833
|
-
&& tableRelations
|
|
1834
|
-
&& options?.drizzleRelations
|
|
1835
|
-
&& (db as any).query?.[tableName];
|
|
2129
|
+
const useRelationalQuery = embeds.length > 0 && relationalQueryAvailable(db, tableName, options);
|
|
1836
2130
|
|
|
1837
2131
|
let rows: unknown[];
|
|
1838
2132
|
const isHidden = (column: string): boolean => isHiddenColumn(schema, tableName, column, options);
|
|
@@ -1847,10 +2141,20 @@ async function handleGet(
|
|
|
1847
2141
|
offset: offsetVal,
|
|
1848
2142
|
};
|
|
1849
2143
|
|
|
2144
|
+
// A hasOne never enters `with`: Drizzle's one() cannot order, so it cannot choose the row.
|
|
2145
|
+
// It is attached below from one windowed query, keyed on the parent's `from` column.
|
|
2146
|
+
const oneEmbeds = embeds.filter(embed => ownEntry(tableRelations, embed.name)?.hasOne);
|
|
1850
2147
|
const colSelection = buildColumnsSelection(columns);
|
|
1851
|
-
|
|
2148
|
+
const addedKeys: string[] = [];
|
|
2149
|
+
if (colSelection) {
|
|
2150
|
+
for (const embed of oneEmbeds) {
|
|
2151
|
+
const from = tableRelations![embed.name].from;
|
|
2152
|
+
if (!colSelection[from]) { colSelection[from] = true; addedKeys.push(from); }
|
|
2153
|
+
}
|
|
2154
|
+
queryOptions.columns = colSelection;
|
|
2155
|
+
}
|
|
1852
2156
|
|
|
1853
|
-
const withClause = buildWithClause(embeds, tableRelations!, schema, options);
|
|
2157
|
+
const withClause = buildWithClause(embeds.filter(embed => !oneEmbeds.includes(embed)), tableRelations!, schema, options, params);
|
|
1854
2158
|
if (withClause === null || withClause === 'unresolvedHiddenColumns') {
|
|
1855
2159
|
return json({ message: 'Invalid query' }, 400);
|
|
1856
2160
|
}
|
|
@@ -1865,6 +2169,10 @@ async function handleGet(
|
|
|
1865
2169
|
if (order.expressions.length > 0) queryOptions.orderBy = order.expressions;
|
|
1866
2170
|
|
|
1867
2171
|
rows = await (db as any).query[tableName].findMany(queryOptions);
|
|
2172
|
+
if (oneEmbeds.length > 0 && rows.length > 0) {
|
|
2173
|
+
rows = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], oneEmbeds, options!, params);
|
|
2174
|
+
for (const row of rows as Record<string, unknown>[]) for (const key of addedKeys) delete row[key];
|
|
2175
|
+
}
|
|
1868
2176
|
} else {
|
|
1869
2177
|
// Batch path — standard select + separate relation queries
|
|
1870
2178
|
const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, true, undefined, isHidden);
|
|
@@ -1910,11 +2218,13 @@ async function handleGet(
|
|
|
1910
2218
|
// Resolve relation embeds (batch path only — JOIN path already has nested data)
|
|
1911
2219
|
let responseData: unknown[] = rows as unknown[];
|
|
1912
2220
|
if (!useRelationalQuery && embeds.length > 0 && rowCount > 0) {
|
|
1913
|
-
responseData = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], embeds, options
|
|
2221
|
+
responseData = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], embeds, options!, params);
|
|
1914
2222
|
}
|
|
1915
2223
|
|
|
1916
2224
|
// Apply column selection (batch path only — JOIN path handles via queryOptions.columns)
|
|
1917
2225
|
if (!useRelationalQuery && columns) {
|
|
2226
|
+
// An embed that did not resolve was skipped above, and its name may be a real column.
|
|
2227
|
+
const resolvedEmbeds = embeds.filter(embed => resolveEmbedTarget(schema, tableRelations, embed.name));
|
|
1918
2228
|
responseData = (responseData as Record<string, unknown>[]).map(row => {
|
|
1919
2229
|
const filtered: Record<string, unknown> = {};
|
|
1920
2230
|
for (const col of columns) {
|
|
@@ -1922,7 +2232,7 @@ async function handleGet(
|
|
|
1922
2232
|
filtered[col] = row[col];
|
|
1923
2233
|
}
|
|
1924
2234
|
}
|
|
1925
|
-
for (const embed of
|
|
2235
|
+
for (const embed of resolvedEmbeds) {
|
|
1926
2236
|
if (embed.name in row) {
|
|
1927
2237
|
filtered[embed.name] = row[embed.name];
|
|
1928
2238
|
}
|
|
@@ -1983,7 +2293,7 @@ function parseSelectParam(
|
|
|
1983
2293
|
selectParam: string | null,
|
|
1984
2294
|
tableRelations?: Record<string, RelationConfig>,
|
|
1985
2295
|
allRelations?: Record<string, Record<string, RelationConfig>>,
|
|
1986
|
-
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string } {
|
|
2296
|
+
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string; embedAggregate?: true; nestedEmbed?: true } {
|
|
1987
2297
|
if (!selectParam) {
|
|
1988
2298
|
return { columns: null, embeds: [], aggregates: [] };
|
|
1989
2299
|
}
|
|
@@ -1995,6 +2305,11 @@ function parseSelectParam(
|
|
|
1995
2305
|
// The first part with parentheses that is neither an aggregate nor a declared
|
|
1996
2306
|
// embed, at any depth. The caller refuses the select; it is never a column.
|
|
1997
2307
|
let undeclared: string | undefined;
|
|
2308
|
+
// Aggregates exist at the top level only. One inside an embed, at any depth,
|
|
2309
|
+
// cannot be honored, so the caller refuses the select.
|
|
2310
|
+
let embedAggregate = false;
|
|
2311
|
+
// An embed inside an embed, at any depth. The JOIN path cannot honour one yet.
|
|
2312
|
+
let nestedEmbed = false;
|
|
1998
2313
|
|
|
1999
2314
|
// Parse: split on commas, but respect parentheses
|
|
2000
2315
|
const parts = splitSelectParam(selectParam);
|
|
@@ -2019,6 +2334,8 @@ function parseSelectParam(
|
|
|
2019
2334
|
const targetRelations = ownEntry(allRelations, targetTableName);
|
|
2020
2335
|
const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
|
|
2021
2336
|
undeclared ??= inner.undeclared;
|
|
2337
|
+
if (inner.aggregates.length > 0 || inner.embedAggregate) embedAggregate = true;
|
|
2338
|
+
if (inner.embeds.length > 0) nestedEmbed = true;
|
|
2022
2339
|
embeds.push({
|
|
2023
2340
|
name,
|
|
2024
2341
|
columns: inner.columns,
|
|
@@ -2038,6 +2355,8 @@ function parseSelectParam(
|
|
|
2038
2355
|
embeds,
|
|
2039
2356
|
aggregates,
|
|
2040
2357
|
...(undeclared === undefined ? {} : { undeclared }),
|
|
2358
|
+
...(embedAggregate ? { embedAggregate: true as const } : {}),
|
|
2359
|
+
...(nestedEmbed ? { nestedEmbed: true as const } : {}),
|
|
2041
2360
|
};
|
|
2042
2361
|
}
|
|
2043
2362
|
|
|
@@ -2172,6 +2491,47 @@ function requestUsesHiddenColumn(
|
|
|
2172
2491
|
return false;
|
|
2173
2492
|
}
|
|
2174
2493
|
|
|
2494
|
+
/** The relation an embed names, when its target table and key exist. resolveEmbeds skips any other. */
|
|
2495
|
+
function resolveEmbedTarget(
|
|
2496
|
+
schema: Schema,
|
|
2497
|
+
tableRelations: Record<string, RelationConfig> | undefined,
|
|
2498
|
+
name: string,
|
|
2499
|
+
): { relationConfig: RelationConfig; targetTable: DrizzleTable; targetColumn: DrizzleColumn } | null {
|
|
2500
|
+
const relationConfig = ownEntry(tableRelations, name);
|
|
2501
|
+
if (!relationConfig) return null;
|
|
2502
|
+
const targetTable = schema[relationConfig.table];
|
|
2503
|
+
if (!targetTable) return null;
|
|
2504
|
+
const targetColumn = getColumn(targetTable, relationConfig.to);
|
|
2505
|
+
return targetColumn ? { relationConfig, targetTable, targetColumn } : null;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
/** The per-parent rank a limited embed is cut on. Dropped from every row. */
|
|
2509
|
+
const EMBED_RANK = '__everystack_embed_rank';
|
|
2510
|
+
|
|
2511
|
+
/**
|
|
2512
|
+
* The first `cap` target rows per parent key, ranked after the filter in `order`. One windowed
|
|
2513
|
+
* query on the request's own connection. Ordered by rank, so each parent's rows stay in order
|
|
2514
|
+
* through the grouping that follows.
|
|
2515
|
+
*/
|
|
2516
|
+
async function rankedEmbedRows(
|
|
2517
|
+
db: DrizzleDb,
|
|
2518
|
+
targetTable: DrizzleTable,
|
|
2519
|
+
targetColumn: DrizzleColumn,
|
|
2520
|
+
where: SQL | undefined,
|
|
2521
|
+
order: SQL[],
|
|
2522
|
+
cap: number,
|
|
2523
|
+
): Promise<Record<string, unknown>[]> {
|
|
2524
|
+
const orderClause = order.length > 0 ? sql` order by ${sql.join(order, sql`, `)}` : sql``;
|
|
2525
|
+
const ranked = db.$with('embed_ranked').as(db.select({
|
|
2526
|
+
...getTableColumns(targetTable),
|
|
2527
|
+
[EMBED_RANK]: sql<number>`row_number() over (partition by ${targetColumn}${orderClause})`.as(EMBED_RANK),
|
|
2528
|
+
}).from(targetTable).where(where));
|
|
2529
|
+
return (await db.with(ranked).select().from(ranked)
|
|
2530
|
+
.where(sql`${ranked[EMBED_RANK]} <= ${cap}`)
|
|
2531
|
+
.orderBy(ranked[EMBED_RANK]))
|
|
2532
|
+
.map(({ [EMBED_RANK]: _rank, ...row }: Record<string, unknown>) => row);
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2175
2535
|
/**
|
|
2176
2536
|
* Resolve relation embeds by fetching related rows and nesting them.
|
|
2177
2537
|
*/
|
|
@@ -2182,6 +2542,7 @@ async function resolveEmbeds(
|
|
|
2182
2542
|
rows: Record<string, unknown>[],
|
|
2183
2543
|
embeds: EmbedRequest[],
|
|
2184
2544
|
options: HandlerOptions,
|
|
2545
|
+
params?: URLSearchParams,
|
|
2185
2546
|
): Promise<Record<string, unknown>[]> {
|
|
2186
2547
|
const tableRelations = ownEntry(options.relations, tableName);
|
|
2187
2548
|
if (!tableRelations) return rows;
|
|
@@ -2190,21 +2551,50 @@ async function resolveEmbeds(
|
|
|
2190
2551
|
const result = rows.map(r => ({ ...r }));
|
|
2191
2552
|
|
|
2192
2553
|
for (const embed of embeds) {
|
|
2193
|
-
const
|
|
2194
|
-
if (!
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2554
|
+
const target = resolveEmbedTarget(schema, tableRelations, embed.name);
|
|
2555
|
+
if (!target) continue;
|
|
2556
|
+
const { relationConfig, targetTable, targetColumn } = target;
|
|
2557
|
+
|
|
2558
|
+
// Rows are keyed by schema property, so pick by property name, as
|
|
2559
|
+
// buildWithClause does. A nested embed is kept only when it resolves: an
|
|
2560
|
+
// unresolved one is skipped below, and its name may be a real column.
|
|
2561
|
+
const nestedRelations = ownEntry(options.relations, relationConfig.table);
|
|
2562
|
+
const picked = embed.columns && [
|
|
2563
|
+
...embed.columns.map(name => columnPropertyName(targetTable, name)).filter((name): name is string => name !== undefined),
|
|
2564
|
+
...embed.embeds.filter(nested => resolveEmbedTarget(schema, nestedRelations, nested.name)).map(nested => nested.name),
|
|
2565
|
+
];
|
|
2566
|
+
|
|
2567
|
+
// The soft-delete guard for the target table
|
|
2568
|
+
const deletedGuard = embedSoftDeleteCondition(relationConfig.table, targetTable, options, params);
|
|
2569
|
+
|
|
2570
|
+
if (relationConfig.hasOne) {
|
|
2571
|
+
// One row per parent: source.from is the parent key, target.to the child FK, as for many.
|
|
2572
|
+
const sourceValues = result.map(r => r[relationConfig.from]).filter(v => v != null);
|
|
2573
|
+
if (sourceValues.length === 0) {
|
|
2574
|
+
for (const row of result) row[embed.name] = null;
|
|
2575
|
+
continue;
|
|
2576
|
+
}
|
|
2198
2577
|
|
|
2199
|
-
|
|
2200
|
-
|
|
2578
|
+
// The database chooses: the declared where and the soft-delete guard first, then the
|
|
2579
|
+
// first row by the declared order with the key last. Never a fetch of every child.
|
|
2580
|
+
const { conditions, order } = relationDisplay(relationConfig, targetTable, options, params);
|
|
2581
|
+
let relatedRows = await rankedEmbedRows(
|
|
2582
|
+
db, targetTable, targetColumn, and(inArray(targetColumn, sourceValues as string[]), ...conditions), order, 1,
|
|
2583
|
+
);
|
|
2201
2584
|
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
: null;
|
|
2585
|
+
if (embed.embeds.length > 0) {
|
|
2586
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
|
|
2587
|
+
}
|
|
2206
2588
|
|
|
2207
|
-
|
|
2589
|
+
// Match on the key, then strip: the key may itself be a hidden column.
|
|
2590
|
+
const index = new Map<unknown, Record<string, unknown>>();
|
|
2591
|
+
for (const r of relatedRows) index.set(r[relationConfig.to], visibleRow(r, relationConfig.table, options));
|
|
2592
|
+
for (const row of result) {
|
|
2593
|
+
const sourceVal = row[relationConfig.from];
|
|
2594
|
+
const related = sourceVal == null ? null : index.get(sourceVal) ?? null;
|
|
2595
|
+
row[embed.name] = related && picked ? pickColumns(related, picked) : related;
|
|
2596
|
+
}
|
|
2597
|
+
} else if (relationConfig.many) {
|
|
2208
2598
|
// One-to-many: source.from is the PK, target.to is the FK
|
|
2209
2599
|
// Collect all source PKs
|
|
2210
2600
|
const sourceValues = result
|
|
@@ -2220,27 +2610,32 @@ async function resolveEmbeds(
|
|
|
2220
2610
|
}
|
|
2221
2611
|
|
|
2222
2612
|
// Fetch all related rows in one query
|
|
2223
|
-
const
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2613
|
+
const { conditions, order } = relationDisplay(relationConfig, targetTable, options, params);
|
|
2614
|
+
const embedConditions: any[] = [inArray(targetColumn, sourceValues as string[]), ...conditions];
|
|
2615
|
+
const embedWhere = embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions);
|
|
2616
|
+
|
|
2617
|
+
let relatedRows: Record<string, unknown>[];
|
|
2618
|
+
if (relationConfig.limit !== undefined) {
|
|
2619
|
+
// Rank each parent's rows after the filter, then keep the first `limit` per parent.
|
|
2620
|
+
relatedRows = await rankedEmbedRows(db, targetTable, targetColumn, embedWhere, order, relationConfig.limit);
|
|
2621
|
+
} else {
|
|
2622
|
+
const query = db.select().from(targetTable).where(embedWhere);
|
|
2623
|
+
relatedRows = await (order.length > 0 ? query.orderBy(...order) : query);
|
|
2624
|
+
}
|
|
2231
2625
|
|
|
2232
2626
|
// Recursively resolve nested embeds
|
|
2233
2627
|
if (embed.embeds.length > 0) {
|
|
2234
|
-
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
|
|
2628
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
|
|
2235
2629
|
}
|
|
2236
2630
|
|
|
2237
|
-
// Group by FK value and assign to each source row
|
|
2631
|
+
// Group by FK value and assign to each source row. Match on the key, then strip: the key
|
|
2632
|
+
// may itself be a hidden column.
|
|
2238
2633
|
for (const row of result) {
|
|
2239
2634
|
const sourceVal = row[relationConfig.from];
|
|
2240
|
-
const matched = relatedRows
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2635
|
+
const matched = relatedRows
|
|
2636
|
+
.filter(r => r[relationConfig.to] === sourceVal)
|
|
2637
|
+
.map(r => visibleRow(r, relationConfig.table, options));
|
|
2638
|
+
row[embed.name] = picked ? matched.map(r => pickColumns(r, picked)) : matched;
|
|
2244
2639
|
}
|
|
2245
2640
|
} else {
|
|
2246
2641
|
// Many-to-one: source.from is the FK, target.to is the PK
|
|
@@ -2259,23 +2654,23 @@ async function resolveEmbeds(
|
|
|
2259
2654
|
|
|
2260
2655
|
// Fetch all referenced rows in one query (batch, no N+1)
|
|
2261
2656
|
const embedConditions: any[] = [inArray(targetColumn, fkValues as string[])];
|
|
2262
|
-
if (
|
|
2657
|
+
if (deletedGuard) embedConditions.push(deletedGuard);
|
|
2263
2658
|
|
|
2264
2659
|
let relatedRows: Record<string, unknown>[] = await db
|
|
2265
2660
|
.select()
|
|
2266
2661
|
.from(targetTable)
|
|
2267
2662
|
.where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
|
|
2268
|
-
relatedRows = stripHiddenColumns(relatedRows, relationConfig.table, options) as Record<string, unknown>[];
|
|
2269
2663
|
|
|
2270
2664
|
// Recursively resolve nested embeds
|
|
2271
2665
|
if (embed.embeds.length > 0) {
|
|
2272
|
-
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
|
|
2666
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
|
|
2273
2667
|
}
|
|
2274
2668
|
|
|
2275
|
-
// Index by target key for O(1) lookup
|
|
2669
|
+
// Index by target key for O(1) lookup. Match on the key, then strip: the key may itself
|
|
2670
|
+
// be a hidden column.
|
|
2276
2671
|
const index = new Map<unknown, Record<string, unknown>>();
|
|
2277
2672
|
for (const r of relatedRows) {
|
|
2278
|
-
index.set(r[relationConfig.to], r);
|
|
2673
|
+
index.set(r[relationConfig.to], visibleRow(r, relationConfig.table, options));
|
|
2279
2674
|
}
|
|
2280
2675
|
|
|
2281
2676
|
// Assign to each source row
|
|
@@ -2285,7 +2680,7 @@ async function resolveEmbeds(
|
|
|
2285
2680
|
row[embed.name] = null;
|
|
2286
2681
|
} else {
|
|
2287
2682
|
const related = index.get(fkVal) || null;
|
|
2288
|
-
row[embed.name] = related &&
|
|
2683
|
+
row[embed.name] = related && picked ? pickColumns(related, picked) : related;
|
|
2289
2684
|
}
|
|
2290
2685
|
}
|
|
2291
2686
|
}
|
|
@@ -2294,6 +2689,11 @@ async function resolveEmbeds(
|
|
|
2294
2689
|
return result;
|
|
2295
2690
|
}
|
|
2296
2691
|
|
|
2692
|
+
/** One embedded row without its table's hidden columns. */
|
|
2693
|
+
function visibleRow(row: Record<string, unknown>, tableName: string, options: HandlerOptions): Record<string, unknown> {
|
|
2694
|
+
return stripHiddenColumns(row, tableName, options) as Record<string, unknown>;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2297
2697
|
function pickColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
|
2298
2698
|
const result: Record<string, unknown> = {};
|
|
2299
2699
|
for (const col of columns) {
|
|
@@ -2371,10 +2771,10 @@ function sanitizeWriteBody(
|
|
|
2371
2771
|
at: { requestId?: string; name: string; method: string },
|
|
2372
2772
|
deferred: Deferred[],
|
|
2373
2773
|
): { body: unknown; changed: string[] } | { refusal: Response } {
|
|
2374
|
-
const columns = options?.sanitizeColumns
|
|
2774
|
+
const columns = ownEntry(options?.sanitizeColumns, tableName);
|
|
2375
2775
|
if (!columns?.length) return { body, changed: [] };
|
|
2376
2776
|
const keys = [...new Set(columns.map(name => columnPropertyName(table, name)!))];
|
|
2377
|
-
const hidden = new Set((options?.hiddenColumns
|
|
2777
|
+
const hidden = new Set((ownEntry(options?.hiddenColumns, tableName) ?? []).map(name => columnPropertyName(table, name)));
|
|
2378
2778
|
const refuse = (message: string, column: string): { refusal: Response } => {
|
|
2379
2779
|
const fault: HandlerFault = {
|
|
2380
2780
|
...at, kind: 'rest', status: 500, table: sqlNameOf(table)!, column, message,
|
|
@@ -2456,7 +2856,7 @@ async function handlePost(
|
|
|
2456
2856
|
throw err;
|
|
2457
2857
|
}
|
|
2458
2858
|
|
|
2459
|
-
const hook = options?.hooks
|
|
2859
|
+
const hook = ownEntry(options?.hooks, tableName);
|
|
2460
2860
|
|
|
2461
2861
|
if (hook?.beforeCreate) {
|
|
2462
2862
|
try {
|
|
@@ -2509,6 +2909,12 @@ async function handlePatch(
|
|
|
2509
2909
|
const scope = ownershipScope(table, tableName, options, user);
|
|
2510
2910
|
if ('refusal' in scope) return scope.refusal;
|
|
2511
2911
|
|
|
2912
|
+
// A lifecycle row is restored by its own function, on its delete rule, never by a PATCH.
|
|
2913
|
+
const requestedDeleted = params.get('deleted');
|
|
2914
|
+
if (isLifecycleTable(tableName, options) && (requestedDeleted === 'include' || requestedDeleted === 'only')) {
|
|
2915
|
+
return json({ message: `PATCH does not accept ?deleted= on ${tableName}: restore a deleted row with POST ${restorePath(tableName, options)} { "id": <id> }` }, 400);
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2512
2918
|
let body: Record<string, unknown>;
|
|
2513
2919
|
try {
|
|
2514
2920
|
body = await request.json();
|
|
@@ -2533,7 +2939,7 @@ async function handlePatch(
|
|
|
2533
2939
|
|
|
2534
2940
|
// Restore: ?deleted=include|only is honoured only for a role in softDelete.restore for
|
|
2535
2941
|
// this table. Anyone else is matched to live rows, as if the param were absent.
|
|
2536
|
-
const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(table, options) : null;
|
|
2942
|
+
const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(tableName, table, options) : null;
|
|
2537
2943
|
const restoring = sdCol !== null && mayRestore(tableName, options, user);
|
|
2538
2944
|
const deletedMode = params.get('deleted');
|
|
2539
2945
|
if (restoring && (deletedMode === 'include' || deletedMode === 'only')) {
|
|
@@ -2550,7 +2956,7 @@ async function handlePatch(
|
|
|
2550
2956
|
|
|
2551
2957
|
// With the ownership backstop lifted, an unfiltered PATCH would write every row the
|
|
2552
2958
|
// role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
|
|
2553
|
-
const ownership = options?.rowOwnership
|
|
2959
|
+
const ownership = ownEntry(options?.rowOwnership, tableName);
|
|
2554
2960
|
if (user && options && ownership && isOwnershipExempt(ownership, user, options)
|
|
2555
2961
|
&& buildsNoFilter(table, params)) {
|
|
2556
2962
|
return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
|
|
@@ -2574,7 +2980,7 @@ async function handlePatch(
|
|
|
2574
2980
|
return sanitizeError(filterResult.error, 400, options);
|
|
2575
2981
|
}
|
|
2576
2982
|
|
|
2577
|
-
const hook = options?.hooks
|
|
2983
|
+
const hook = ownEntry(options?.hooks, tableName);
|
|
2578
2984
|
|
|
2579
2985
|
if (hook?.beforeUpdate) {
|
|
2580
2986
|
try {
|
|
@@ -2666,7 +3072,7 @@ function unbackedExemptionRefusal(
|
|
|
2666
3072
|
settings: Record<string, string> | undefined,
|
|
2667
3073
|
): string | null {
|
|
2668
3074
|
if (method !== 'PATCH' && method !== 'DELETE') return null;
|
|
2669
|
-
const ownership = options?.rowOwnership
|
|
3075
|
+
const ownership = ownEntry(options?.rowOwnership, tableName);
|
|
2670
3076
|
if (!options || !ownership || !user || !isOwnershipExempt(ownership, user, options)) return null;
|
|
2671
3077
|
if (settings?.['role']) return null;
|
|
2672
3078
|
const message = `rowOwnership.${tableName}.exceptRoles: pgSettings returned no role for this request, so SET LOCAL ROLE did not run and no RLS policy backs the exemption. Write refused.`;
|
|
@@ -2714,7 +3120,7 @@ function ownershipScope(
|
|
|
2714
3120
|
options: HandlerOptions | undefined,
|
|
2715
3121
|
user: Record<string, unknown> | null,
|
|
2716
3122
|
): OwnershipScope {
|
|
2717
|
-
const ownership = options?.rowOwnership
|
|
3123
|
+
const ownership = ownEntry(options?.rowOwnership, tableName);
|
|
2718
3124
|
if (!options || !ownership) return { condition: null };
|
|
2719
3125
|
if (!user) {
|
|
2720
3126
|
return { refusal: json({ message: 'Unauthorized: this table requires an authenticated owner' }, 401) };
|
|
@@ -2763,7 +3169,7 @@ async function handleDelete(
|
|
|
2763
3169
|
return sanitizeError(filterResult.error, 400, options);
|
|
2764
3170
|
}
|
|
2765
3171
|
|
|
2766
|
-
const hook = options?.hooks
|
|
3172
|
+
const hook = ownEntry(options?.hooks, tableName);
|
|
2767
3173
|
|
|
2768
3174
|
if (hook?.beforeDelete) {
|
|
2769
3175
|
try {
|
|
@@ -2778,9 +3184,20 @@ async function handleDelete(
|
|
|
2778
3184
|
// Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
|
|
2779
3185
|
if (scope.condition) conditions.push(scope.condition);
|
|
2780
3186
|
|
|
3187
|
+
// Lifecycle: a real DELETE, which the table's trigger turns into a stamp (it reports 0 rows).
|
|
3188
|
+
if (isLifecycleTable(tableName, options)) {
|
|
3189
|
+
const rows = await lifecycleDelete(db, table, tableName, conditions, options);
|
|
3190
|
+
if (hook?.afterDelete && rows.length > 0) {
|
|
3191
|
+
const { afterDelete } = hook;
|
|
3192
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
|
|
3193
|
+
() => afterDelete.call(hook, rows, user, client));
|
|
3194
|
+
}
|
|
3195
|
+
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
|
|
3196
|
+
}
|
|
3197
|
+
|
|
2781
3198
|
// Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
|
|
2782
3199
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
2783
|
-
const sdCol = getSoftDeleteColumn(table, options);
|
|
3200
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
2784
3201
|
if (sdCol) {
|
|
2785
3202
|
// Only soft-delete records that aren't already deleted
|
|
2786
3203
|
conditions.push(isNull(sdCol));
|
|
@@ -2814,7 +3231,21 @@ async function handleDelete(
|
|
|
2814
3231
|
query = query.where(and(...conditions));
|
|
2815
3232
|
}
|
|
2816
3233
|
|
|
2817
|
-
const rows = await query.returning();
|
|
3234
|
+
const rows: Record<string, unknown>[] = await query.returning();
|
|
3235
|
+
|
|
3236
|
+
// RETURNING proves DELETE authority, including policies that differ from SELECT.
|
|
3237
|
+
// The dispatcher keeps this statement and beforeDestroy in one caller transaction.
|
|
3238
|
+
const ctx: DestroyContext = { source: 'api', user, client };
|
|
3239
|
+
for (const row of rows) {
|
|
3240
|
+
if (hook?.beforeDestroy) await hook.beforeDestroy(row, ctx);
|
|
3241
|
+
}
|
|
3242
|
+
if (hook?.afterDestroy) {
|
|
3243
|
+
const { afterDestroy } = hook;
|
|
3244
|
+
for (const row of rows) {
|
|
3245
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDestroy`, method: 'DELETE' },
|
|
3246
|
+
() => Promise.resolve(afterDestroy.call(hook, row, ctx)));
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
2818
3249
|
|
|
2819
3250
|
if (hook?.afterDelete && rows.length > 0) {
|
|
2820
3251
|
const { afterDelete } = hook;
|
|
@@ -2825,6 +3256,122 @@ async function handleDelete(
|
|
|
2825
3256
|
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
|
|
2826
3257
|
}
|
|
2827
3258
|
|
|
3259
|
+
/**
|
|
3260
|
+
* DELETE on a lifecycle table. The trigger stamps each row and cancels its delete, so DELETE
|
|
3261
|
+
* reports 0 rows and RETURNING has nothing. In one transaction: read the live rows the request
|
|
3262
|
+
* matches (the pre-image), DELETE with the same conditions, then drop every row still live: RLS
|
|
3263
|
+
* refused its delete. What is left is the pre-image of the rows this request soft-deleted.
|
|
3264
|
+
* Under READ COMMITTED a row another request stamps between the read and the DELETE is still
|
|
3265
|
+
* listed (and reaches afterDelete): a false attribution, never a leak, since the caller could read it.
|
|
3266
|
+
*/
|
|
3267
|
+
async function lifecycleDelete(
|
|
3268
|
+
db: DrizzleDb,
|
|
3269
|
+
table: DrizzleTable,
|
|
3270
|
+
tableName: string,
|
|
3271
|
+
conditions: SQL[],
|
|
3272
|
+
options?: HandlerOptions,
|
|
3273
|
+
): Promise<Record<string, unknown>[]> {
|
|
3274
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
3275
|
+
const keys = primaryKeyColumns(table)
|
|
3276
|
+
.map(column => columnPropertyName(table, column.name))
|
|
3277
|
+
.filter((key): key is string => key !== undefined);
|
|
3278
|
+
if (!sdCol || keys.length === 0) {
|
|
3279
|
+
throw new Error(`[everystack/api] softDelete.lifecycle.${tableName}: the table needs a ${LIFECYCLE_DELETED_AT} column and a primary key`);
|
|
3280
|
+
}
|
|
3281
|
+
const where = and(...conditions, isNull(sdCol))!;
|
|
3282
|
+
const keyOf = (row: Record<string, unknown>): string => JSON.stringify(keys.map(key => row[key]));
|
|
3283
|
+
return db.transaction(async (tx: DrizzleDb) => {
|
|
3284
|
+
const matched: Record<string, unknown>[] = await tx.select().from(table).where(where);
|
|
3285
|
+
if (matched.length === 0) return [];
|
|
3286
|
+
await tx.delete(table).where(where);
|
|
3287
|
+
const stillLive: Record<string, unknown>[] = await tx
|
|
3288
|
+
.select(Object.fromEntries(keys.map(key => [key, getTableColumns(table)[key]])))
|
|
3289
|
+
.from(table)
|
|
3290
|
+
.where(where);
|
|
3291
|
+
const live = new Set(stillLive.map(keyOf));
|
|
3292
|
+
return matched.filter(row => !live.has(keyOf(row)));
|
|
3293
|
+
});
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
/**
|
|
3297
|
+
* POST {table}/restore { id } on a lifecycle table: `<schema>.<table>_restore(id)` inside the
|
|
3298
|
+
* request's pgSettings transaction, so the function sees the caller's role and claims and decides
|
|
3299
|
+
* on the table's delete rule. The id is bound and cast to the key's type, so an author's own
|
|
3300
|
+
* overload of the name is never chosen. 42501 (the function's one refusal) is 403 with its
|
|
3301
|
+
* message; 55000 (not READ COMMITTED) is 409; 22P02 (an id the key type cannot read) is 400.
|
|
3302
|
+
*/
|
|
3303
|
+
async function handleRestore(
|
|
3304
|
+
db: DrizzleDb,
|
|
3305
|
+
table: DrizzleTable,
|
|
3306
|
+
tableName: string,
|
|
3307
|
+
request: Request,
|
|
3308
|
+
options: HandlerOptions | undefined,
|
|
3309
|
+
user: Record<string, unknown> | null,
|
|
3310
|
+
client: Record<string, unknown> | null,
|
|
3311
|
+
requestId: string | undefined,
|
|
3312
|
+
): Promise<Response> {
|
|
3313
|
+
const body: unknown = await request.json().catch(() => undefined);
|
|
3314
|
+
const id = body !== null && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>).id : undefined;
|
|
3315
|
+
if (!(typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id)))) {
|
|
3316
|
+
return json({ message: 'restore requires a JSON body { "id": <id> }' }, 400);
|
|
3317
|
+
}
|
|
3318
|
+
const [key] = primaryKeyColumns(table);
|
|
3319
|
+
const keyType = key ? restoreArgType(key) : null;
|
|
3320
|
+
const name = sqlNameOf(table);
|
|
3321
|
+
if (!keyType || name === null || primaryKeyColumns(table).length !== 1) {
|
|
3322
|
+
const message = `[everystack/api] softDelete.lifecycle.${tableName}: restore needs a table with a single-column primary key of a plain type`;
|
|
3323
|
+
await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method: 'POST', status: 500, message });
|
|
3324
|
+
return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : message }, 500);
|
|
3325
|
+
}
|
|
3326
|
+
const fn = sql`${sql.identifier(getTableConfig(table).schema ?? 'public')}.${sql.identifier(`${name}_restore`)}`;
|
|
3327
|
+
try {
|
|
3328
|
+
const result = await executeWithSettings(db, options?.pgSettings?.(user, client), async (tx) => {
|
|
3329
|
+
const rows = await tx.execute(sql`SELECT ${fn}(CAST(${id} AS ${sql.raw(keyType)})) AS result`);
|
|
3330
|
+
return (rows as unknown as Array<{ result: unknown }>)[0]?.result;
|
|
3331
|
+
});
|
|
3332
|
+
return json(result, 200);
|
|
3333
|
+
} catch (error) {
|
|
3334
|
+
// Code and message from the one object that carries the code: a wrapper's message can quote the query.
|
|
3335
|
+
const carrier = pgErrorCarrier(error);
|
|
3336
|
+
const code = carrier && safeRead(carrier, 'code') as string;
|
|
3337
|
+
const text = carrier && safeRead(carrier, 'message');
|
|
3338
|
+
const message = typeof text === 'string' ? text : '';
|
|
3339
|
+
if (code === '42501') return sanitizeError(message || 'Permission denied', 403, options);
|
|
3340
|
+
if (code === '55000') return json({ message, code }, 409);
|
|
3341
|
+
if (code === '22P02') return json({ message: 'Invalid id', code }, 400);
|
|
3342
|
+
if (isServiceUnavailable(error)) return serviceUnavailableResponse();
|
|
3343
|
+
await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method: 'POST', status: 500, ...describeError(error) });
|
|
3344
|
+
return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : 'Restore failed' }, 500);
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
/** The first object on the error's cause chain with a string `code` (drivers and wrappers nest it). */
|
|
3349
|
+
function pgErrorCarrier(error: unknown): object | undefined {
|
|
3350
|
+
let current: unknown = error;
|
|
3351
|
+
for (let hop = 0; hop <= MAX_CAUSE_HOPS && current !== null && typeof current === 'object'; hop++) {
|
|
3352
|
+
if (typeof safeRead(current, 'code') === 'string') return current;
|
|
3353
|
+
const cause = safeRead(current, 'cause');
|
|
3354
|
+
if (cause === UNREADABLE || cause === current) return undefined;
|
|
3355
|
+
current = cause;
|
|
3356
|
+
}
|
|
3357
|
+
return undefined;
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
/**
|
|
3361
|
+
* The SQL type the restore id is cast to: the key column's declared type, a serial read as its
|
|
3362
|
+
* integer type. Null for a type text this function will not put in SQL.
|
|
3363
|
+
*/
|
|
3364
|
+
function restoreArgType(column: DrizzleColumn): string | null {
|
|
3365
|
+
const declared = String((column as { getSQLType?: () => string }).getSQLType?.() ?? '').trim().toLowerCase();
|
|
3366
|
+
const type = ({ serial: 'integer', smallserial: 'smallint', bigserial: 'bigint' } as Record<string, string>)[declared] ?? declared;
|
|
3367
|
+
return /^[a-z][a-z0-9_ ]*(\(\d+(, ?\d+)?\))?$/.test(type) ? type : null;
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
/** The restore endpoint's path, as a client calls it. */
|
|
3371
|
+
function restorePath(tableName: string, options?: HandlerOptions): string {
|
|
3372
|
+
return `${(options?.basePath ?? '').replace(/\/+$/, '')}/${tableName}/restore`;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
2828
3375
|
interface JsonPath {
|
|
2829
3376
|
baseColumn: string;
|
|
2830
3377
|
steps: Array<{ key: string; asText: boolean }>;
|