@everystack/api 0.4.10 → 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 +19 -0
- package/package.json +1 -1
- package/src/client/index.ts +29 -0
- package/src/handler/index.ts +551 -46
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ 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
|
+
|
|
7
26
|
## [0.4.10] - 2026-10-18
|
|
8
27
|
|
|
9
28
|
### 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
|
@@ -233,6 +233,20 @@ export interface RelationConfig {
|
|
|
233
233
|
to: string;
|
|
234
234
|
/** If true, this is a one-to-many relation (returns array). Default: false (many-to-one, returns object) */
|
|
235
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>;
|
|
236
250
|
}
|
|
237
251
|
|
|
238
252
|
export interface SoftDeleteConfig {
|
|
@@ -245,10 +259,28 @@ export interface SoftDeleteConfig {
|
|
|
245
259
|
* default `role`) is in the table's list: exact string match, no hierarchy, no case folding.
|
|
246
260
|
* Anyone else gets live rows only, as if the param were absent. A restore body must be
|
|
247
261
|
* exactly `{ <column>: null }`, or 400. Omitted: no one restores through the API.
|
|
248
|
-
* 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`. */
|
|
249
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;
|
|
250
280
|
}
|
|
251
281
|
|
|
282
|
+
export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | Promise<void>;
|
|
283
|
+
|
|
252
284
|
export interface HookConfig {
|
|
253
285
|
/** Called before INSERT. Can modify the body (e.g., inject userId). Throw to abort. */
|
|
254
286
|
beforeCreate?: (body: Record<string, unknown>, user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<Record<string, unknown>>;
|
|
@@ -262,6 +294,14 @@ export interface HookConfig {
|
|
|
262
294
|
beforeDelete?: (user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
|
|
263
295
|
/** Called after DELETE with all deleted rows. Fire-and-forget. */
|
|
264
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;
|
|
265
305
|
}
|
|
266
306
|
|
|
267
307
|
/** Cache-Control header configuration for different response categories. */
|
|
@@ -366,6 +406,12 @@ export interface HandlerOptions {
|
|
|
366
406
|
* Explicit references in query parameters are refused before database access.
|
|
367
407
|
* Does not prevent writes to these columns; use protectedFields for that. */
|
|
368
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[]>;
|
|
369
415
|
/** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
|
|
370
416
|
* Prevents mass assignment / privilege escalation on sensitive columns. */
|
|
371
417
|
protectedFields?: Record<string, string[]>;
|
|
@@ -415,8 +461,16 @@ export interface HandlerOptions {
|
|
|
415
461
|
onError?: (fault: HandlerFault) => void | Promise<void>;
|
|
416
462
|
}
|
|
417
463
|
|
|
418
|
-
|
|
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 {
|
|
419
472
|
if (!options?.softDelete) return null;
|
|
473
|
+
if (isLifecycleTable(tableName, options)) return getColumn(table, LIFECYCLE_DELETED_AT);
|
|
420
474
|
const colName = options.softDelete.column || 'deletedAt';
|
|
421
475
|
return getColumn(table, colName);
|
|
422
476
|
}
|
|
@@ -434,11 +488,29 @@ function softDeleteCondition(sdCol: DrizzleColumn, params: URLSearchParams): SQL
|
|
|
434
488
|
|
|
435
489
|
function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: HandlerOptions): boolean {
|
|
436
490
|
if (!options?.softDelete) return false;
|
|
491
|
+
if (isLifecycleTable(tableName, options)) return true;
|
|
437
492
|
if (options.softDelete.tables) {
|
|
438
493
|
return options.softDelete.tables.includes(tableName);
|
|
439
494
|
}
|
|
440
495
|
// Auto-detect: table has the soft-delete column
|
|
441
|
-
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);
|
|
442
514
|
}
|
|
443
515
|
|
|
444
516
|
/**
|
|
@@ -457,17 +529,18 @@ async function executeWithSettings<T>(
|
|
|
457
529
|
db: DrizzleDb,
|
|
458
530
|
settings: Record<string, string> | undefined,
|
|
459
531
|
fn: (dbOrTx: DrizzleDb) => Promise<T>,
|
|
532
|
+
forceTransaction = false,
|
|
460
533
|
): Promise<T> {
|
|
461
|
-
if (settings && Object.keys(settings).length > 0) {
|
|
534
|
+
if (forceTransaction || (settings && Object.keys(settings).length > 0)) {
|
|
462
535
|
return db.transaction(async (tx: DrizzleDb) => {
|
|
463
|
-
const role = settings['role'];
|
|
536
|
+
const role = settings?.['role'];
|
|
464
537
|
if (role) {
|
|
465
538
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(role)) {
|
|
466
539
|
throw new Error(`Invalid role name: ${role}`);
|
|
467
540
|
}
|
|
468
541
|
await tx.execute(sql.raw(`SET LOCAL ROLE ${role}`));
|
|
469
542
|
}
|
|
470
|
-
for (const [key, value] of Object.entries(settings)) {
|
|
543
|
+
for (const [key, value] of Object.entries(settings ?? {})) {
|
|
471
544
|
if (key !== 'role' && value !== undefined && value !== '') {
|
|
472
545
|
await tx.execute(sql`SELECT set_config(${key}, ${value}, true)`);
|
|
473
546
|
}
|
|
@@ -592,11 +665,11 @@ async function checkRoleAuth(
|
|
|
592
665
|
|
|
593
666
|
/**
|
|
594
667
|
* Determine the Cache-Control header for a GET response.
|
|
595
|
-
* - pgSettings
|
|
596
|
-
* -
|
|
668
|
+
* - pgSettings or role-dependent column visibility configured → private
|
|
669
|
+
* - Neither configured → null (let server package set its default)
|
|
597
670
|
*/
|
|
598
671
|
function getCacheControl(options?: HandlerOptions): string | null {
|
|
599
|
-
if (options?.pgSettings) {
|
|
672
|
+
if (options?.pgSettings || Object.keys(options?.hiddenColumnsExceptRoles ?? {}).length) {
|
|
600
673
|
return options?.cache?.private ?? 'private, no-store';
|
|
601
674
|
}
|
|
602
675
|
return null;
|
|
@@ -840,6 +913,7 @@ function expandTableKeyedOptions(
|
|
|
840
913
|
...options,
|
|
841
914
|
...(options.exposedTables ? { exposedTables: expandList(options.exposedTables) } : {}),
|
|
842
915
|
...(options.hiddenColumns ? { hiddenColumns: expandMap(options.hiddenColumns) } : {}),
|
|
916
|
+
...(options.hiddenColumnsExceptRoles ? { hiddenColumnsExceptRoles: expandMap(options.hiddenColumnsExceptRoles) } : {}),
|
|
843
917
|
...(options.protectedFields ? { protectedFields: expandMap(options.protectedFields) } : {}),
|
|
844
918
|
...(options.sanitizeColumns ? { sanitizeColumns: expandMap(options.sanitizeColumns) } : {}),
|
|
845
919
|
...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
|
|
@@ -851,6 +925,7 @@ function expandTableKeyedOptions(
|
|
|
851
925
|
...options.softDelete,
|
|
852
926
|
...(options.softDelete.tables ? { tables: expandList(options.softDelete.tables) } : {}),
|
|
853
927
|
...(options.softDelete.restore ? { restore: expandMap(options.softDelete.restore) } : {}),
|
|
928
|
+
...(options.softDelete.lifecycle ? { lifecycle: expandList(options.softDelete.lifecycle) } : {}),
|
|
854
929
|
},
|
|
855
930
|
}
|
|
856
931
|
: {}),
|
|
@@ -870,6 +945,7 @@ function declaredTables(options: HandlerOptions): Set<string> {
|
|
|
870
945
|
...Object.keys(options.relations ?? {}),
|
|
871
946
|
...Object.values(options.relations ?? {}).flatMap(rels => Object.values(rels).map(rel => rel.table)),
|
|
872
947
|
...(options.softDelete?.tables ?? []),
|
|
948
|
+
...(options.softDelete?.lifecycle ?? []),
|
|
873
949
|
];
|
|
874
950
|
return new Set(names.map(toSnakeCase));
|
|
875
951
|
}
|
|
@@ -922,6 +998,11 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
|
|
|
922
998
|
const colName = softDelete?.column || 'deletedAt';
|
|
923
999
|
for (const [tableName, roles] of Object.entries(softDelete?.restore ?? {})) {
|
|
924
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
|
+
}
|
|
925
1006
|
const isSoftDelete = table
|
|
926
1007
|
? isSoftDeleteTable(tableName, table, options)
|
|
927
1008
|
: (softDelete?.tables?.includes(tableName) ?? false);
|
|
@@ -989,8 +1070,148 @@ function validateSanitizeColumns(schema: Schema, options: HandlerOptions | undef
|
|
|
989
1070
|
}
|
|
990
1071
|
}
|
|
991
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
|
+
|
|
992
1196
|
/** Resolve hidden columns once so every read uses the same column spellings. */
|
|
993
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 };
|
|
994
1215
|
if (!options?.hiddenColumns) return options;
|
|
995
1216
|
const byTable = new Map<DrizzleTable, Set<string>>();
|
|
996
1217
|
for (const [tableName, names] of Object.entries(options.hiddenColumns)) {
|
|
@@ -1022,6 +1243,27 @@ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): Handler
|
|
|
1022
1243
|
return { ...options, hiddenColumns };
|
|
1023
1244
|
}
|
|
1024
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
|
+
|
|
1025
1267
|
/** Safe, fixed client messages. Never derive these from PostgreSQL text or detail. */
|
|
1026
1268
|
const INTEGRITY_ERRORS: Record<string, { status: number; message: string }> = {
|
|
1027
1269
|
'23514': { status: 422, message: 'Value does not meet a constraint' },
|
|
@@ -1133,15 +1375,18 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1133
1375
|
// apply. The root listing below stays one entry per table (the SQL name).
|
|
1134
1376
|
const namePairs = tableNamePairs(rawSchema);
|
|
1135
1377
|
const schema = expandSchema(rawSchema, namePairs);
|
|
1136
|
-
const
|
|
1137
|
-
validateConstraintMessages(
|
|
1378
|
+
const configuredOptions = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
|
|
1379
|
+
validateConstraintMessages(configuredOptions);
|
|
1138
1380
|
validateRowOwnership(schema, rawOptions);
|
|
1139
|
-
validateSoftDeleteRestore(schema,
|
|
1140
|
-
validateSanitizeColumns(schema,
|
|
1381
|
+
validateSoftDeleteRestore(schema, configuredOptions);
|
|
1382
|
+
validateSanitizeColumns(schema, configuredOptions);
|
|
1383
|
+
validateRelationDisplay(schema, configuredOptions);
|
|
1384
|
+
validateDrizzleRelationNames(db, configuredOptions);
|
|
1141
1385
|
const tableNames = new Set(Object.keys(schema));
|
|
1142
1386
|
const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
|
|
1143
1387
|
|
|
1144
1388
|
return async (request: Request): Promise<Response> => {
|
|
1389
|
+
let options = configuredOptions;
|
|
1145
1390
|
const url = new URL(request.url);
|
|
1146
1391
|
let pathname = url.pathname;
|
|
1147
1392
|
if (options?.basePath) {
|
|
@@ -1212,8 +1457,9 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1212
1457
|
// Root path — table listing, always public. One entry per table (SQL name),
|
|
1213
1458
|
// regardless of how many spellings route to it.
|
|
1214
1459
|
if (pathSegments.length === 0) {
|
|
1215
|
-
const
|
|
1216
|
-
|
|
1460
|
+
const exposedTables = options?.exposedTables;
|
|
1461
|
+
const tables = exposedTables
|
|
1462
|
+
? canonicalTables.filter(t => exposedTables.includes(t))
|
|
1217
1463
|
: canonicalTables;
|
|
1218
1464
|
return json({ tables }, 200);
|
|
1219
1465
|
}
|
|
@@ -1268,6 +1514,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1268
1514
|
return json({ message: 'Authentication required: no auth token provided' }, 401);
|
|
1269
1515
|
}
|
|
1270
1516
|
|
|
1517
|
+
options = hiddenColumnsForUser(schema, options, user);
|
|
1271
1518
|
const fn = isObjectForm ? entry.fn : entry;
|
|
1272
1519
|
const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
|
|
1273
1520
|
// Request-scoped context for the trace-id spine — RPC handlers receive it
|
|
@@ -1416,8 +1663,24 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1416
1663
|
}
|
|
1417
1664
|
}
|
|
1418
1665
|
|
|
1666
|
+
options = hiddenColumnsForUser(schema, options, user);
|
|
1419
1667
|
const table = schema[tableName];
|
|
1420
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
|
+
|
|
1421
1684
|
// Forensic audit of the non-GET table surface (writes). Capture the query, not
|
|
1422
1685
|
// the response: the operation, the WHERE predicate, and the body's column NAMES
|
|
1423
1686
|
// (never values — values can be PII). Unresolved embed policies also record
|
|
@@ -1510,6 +1773,11 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1510
1773
|
// 2xx; a throw (a failed commit) skips it, since the write did not happen.
|
|
1511
1774
|
// A retry wrapper around db.transaction must reset `deferred` per attempt,
|
|
1512
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);
|
|
1513
1781
|
const deferred: Deferred[] = [];
|
|
1514
1782
|
let response: Response;
|
|
1515
1783
|
try {
|
|
@@ -1533,7 +1801,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1533
1801
|
}
|
|
1534
1802
|
|
|
1535
1803
|
return json({ message: `Method ${method} not allowed` }, 405);
|
|
1536
|
-
});
|
|
1804
|
+
}, destroyTransaction);
|
|
1537
1805
|
} finally {
|
|
1538
1806
|
// emitFault never throws, so this cannot replace the error in flight.
|
|
1539
1807
|
for (const item of deferred) if (item.always) await item.run();
|
|
@@ -1563,7 +1831,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1563
1831
|
if (isWrite) {
|
|
1564
1832
|
await emitAudit(options?.audit, {
|
|
1565
1833
|
kind: 'rest', name: tableName, user, client,
|
|
1566
|
-
result: mapped || permissionDenied ? 'deny' : 'error',
|
|
1834
|
+
result: mapped || permissionDenied || (error instanceof HandlerError && error.status < 500) ? 'deny' : 'error',
|
|
1567
1835
|
requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
|
|
1568
1836
|
});
|
|
1569
1837
|
}
|
|
@@ -1580,6 +1848,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1580
1848
|
...describeError(error),
|
|
1581
1849
|
});
|
|
1582
1850
|
}
|
|
1851
|
+
if (error instanceof HandlerError && error.status < 500) return json({ message: error.message }, error.status);
|
|
1583
1852
|
if (unavailable) return serviceUnavailableResponse();
|
|
1584
1853
|
throw error;
|
|
1585
1854
|
}
|
|
@@ -1609,6 +1878,7 @@ function buildWithClause(
|
|
|
1609
1878
|
tableRelations: Record<string, RelationConfig>,
|
|
1610
1879
|
schema: Schema,
|
|
1611
1880
|
options?: HandlerOptions,
|
|
1881
|
+
params?: URLSearchParams,
|
|
1612
1882
|
): Record<string, any> | undefined | null | 'unresolvedHiddenColumns' {
|
|
1613
1883
|
if (embeds.length === 0) return undefined;
|
|
1614
1884
|
|
|
@@ -1634,6 +1904,13 @@ function buildWithClause(
|
|
|
1634
1904
|
} else if (hidden.size > 0) {
|
|
1635
1905
|
config.columns = Object.fromEntries([...hidden].map(name => [name, false]));
|
|
1636
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
|
+
}
|
|
1637
1914
|
withClause[embed.name] = Object.keys(config).length > 0 ? config : true;
|
|
1638
1915
|
}
|
|
1639
1916
|
|
|
@@ -1786,7 +2063,7 @@ async function handleGet(
|
|
|
1786
2063
|
|
|
1787
2064
|
// Soft-delete: filter deleted records unless ?deleted=include|only
|
|
1788
2065
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
1789
|
-
const sdCol = getSoftDeleteColumn(table, options);
|
|
2066
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
1790
2067
|
if (sdCol) {
|
|
1791
2068
|
const condition = softDeleteCondition(sdCol, params);
|
|
1792
2069
|
if (condition) conditions.push(condition);
|
|
@@ -1864,10 +2141,20 @@ async function handleGet(
|
|
|
1864
2141
|
offset: offsetVal,
|
|
1865
2142
|
};
|
|
1866
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);
|
|
1867
2147
|
const colSelection = buildColumnsSelection(columns);
|
|
1868
|
-
|
|
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
|
+
}
|
|
1869
2156
|
|
|
1870
|
-
const withClause = buildWithClause(embeds, tableRelations!, schema, options);
|
|
2157
|
+
const withClause = buildWithClause(embeds.filter(embed => !oneEmbeds.includes(embed)), tableRelations!, schema, options, params);
|
|
1871
2158
|
if (withClause === null || withClause === 'unresolvedHiddenColumns') {
|
|
1872
2159
|
return json({ message: 'Invalid query' }, 400);
|
|
1873
2160
|
}
|
|
@@ -1882,6 +2169,10 @@ async function handleGet(
|
|
|
1882
2169
|
if (order.expressions.length > 0) queryOptions.orderBy = order.expressions;
|
|
1883
2170
|
|
|
1884
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
|
+
}
|
|
1885
2176
|
} else {
|
|
1886
2177
|
// Batch path — standard select + separate relation queries
|
|
1887
2178
|
const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, true, undefined, isHidden);
|
|
@@ -1927,11 +2218,13 @@ async function handleGet(
|
|
|
1927
2218
|
// Resolve relation embeds (batch path only — JOIN path already has nested data)
|
|
1928
2219
|
let responseData: unknown[] = rows as unknown[];
|
|
1929
2220
|
if (!useRelationalQuery && embeds.length > 0 && rowCount > 0) {
|
|
1930
|
-
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);
|
|
1931
2222
|
}
|
|
1932
2223
|
|
|
1933
2224
|
// Apply column selection (batch path only — JOIN path handles via queryOptions.columns)
|
|
1934
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));
|
|
1935
2228
|
responseData = (responseData as Record<string, unknown>[]).map(row => {
|
|
1936
2229
|
const filtered: Record<string, unknown> = {};
|
|
1937
2230
|
for (const col of columns) {
|
|
@@ -1939,7 +2232,7 @@ async function handleGet(
|
|
|
1939
2232
|
filtered[col] = row[col];
|
|
1940
2233
|
}
|
|
1941
2234
|
}
|
|
1942
|
-
for (const embed of
|
|
2235
|
+
for (const embed of resolvedEmbeds) {
|
|
1943
2236
|
if (embed.name in row) {
|
|
1944
2237
|
filtered[embed.name] = row[embed.name];
|
|
1945
2238
|
}
|
|
@@ -2212,6 +2505,33 @@ function resolveEmbedTarget(
|
|
|
2212
2505
|
return targetColumn ? { relationConfig, targetTable, targetColumn } : null;
|
|
2213
2506
|
}
|
|
2214
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
|
+
|
|
2215
2535
|
/**
|
|
2216
2536
|
* Resolve relation embeds by fetching related rows and nesting them.
|
|
2217
2537
|
*/
|
|
@@ -2222,6 +2542,7 @@ async function resolveEmbeds(
|
|
|
2222
2542
|
rows: Record<string, unknown>[],
|
|
2223
2543
|
embeds: EmbedRequest[],
|
|
2224
2544
|
options: HandlerOptions,
|
|
2545
|
+
params?: URLSearchParams,
|
|
2225
2546
|
): Promise<Record<string, unknown>[]> {
|
|
2226
2547
|
const tableRelations = ownEntry(options.relations, tableName);
|
|
2227
2548
|
if (!tableRelations) return rows;
|
|
@@ -2243,12 +2564,37 @@ async function resolveEmbeds(
|
|
|
2243
2564
|
...embed.embeds.filter(nested => resolveEmbedTarget(schema, nestedRelations, nested.name)).map(nested => nested.name),
|
|
2244
2565
|
];
|
|
2245
2566
|
|
|
2246
|
-
//
|
|
2247
|
-
const
|
|
2248
|
-
|
|
2249
|
-
|
|
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
|
+
}
|
|
2577
|
+
|
|
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
|
+
);
|
|
2250
2584
|
|
|
2251
|
-
|
|
2585
|
+
if (embed.embeds.length > 0) {
|
|
2586
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
|
|
2587
|
+
}
|
|
2588
|
+
|
|
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) {
|
|
2252
2598
|
// One-to-many: source.from is the PK, target.to is the FK
|
|
2253
2599
|
// Collect all source PKs
|
|
2254
2600
|
const sourceValues = result
|
|
@@ -2264,24 +2610,31 @@ async function resolveEmbeds(
|
|
|
2264
2610
|
}
|
|
2265
2611
|
|
|
2266
2612
|
// Fetch all related rows in one query
|
|
2267
|
-
const
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
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
|
+
}
|
|
2275
2625
|
|
|
2276
2626
|
// Recursively resolve nested embeds
|
|
2277
2627
|
if (embed.embeds.length > 0) {
|
|
2278
|
-
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
|
|
2628
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
|
|
2279
2629
|
}
|
|
2280
2630
|
|
|
2281
|
-
// 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.
|
|
2282
2633
|
for (const row of result) {
|
|
2283
2634
|
const sourceVal = row[relationConfig.from];
|
|
2284
|
-
const matched = relatedRows
|
|
2635
|
+
const matched = relatedRows
|
|
2636
|
+
.filter(r => r[relationConfig.to] === sourceVal)
|
|
2637
|
+
.map(r => visibleRow(r, relationConfig.table, options));
|
|
2285
2638
|
row[embed.name] = picked ? matched.map(r => pickColumns(r, picked)) : matched;
|
|
2286
2639
|
}
|
|
2287
2640
|
} else {
|
|
@@ -2301,23 +2654,23 @@ async function resolveEmbeds(
|
|
|
2301
2654
|
|
|
2302
2655
|
// Fetch all referenced rows in one query (batch, no N+1)
|
|
2303
2656
|
const embedConditions: any[] = [inArray(targetColumn, fkValues as string[])];
|
|
2304
|
-
if (
|
|
2657
|
+
if (deletedGuard) embedConditions.push(deletedGuard);
|
|
2305
2658
|
|
|
2306
2659
|
let relatedRows: Record<string, unknown>[] = await db
|
|
2307
2660
|
.select()
|
|
2308
2661
|
.from(targetTable)
|
|
2309
2662
|
.where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
|
|
2310
|
-
relatedRows = stripHiddenColumns(relatedRows, relationConfig.table, options) as Record<string, unknown>[];
|
|
2311
2663
|
|
|
2312
2664
|
// Recursively resolve nested embeds
|
|
2313
2665
|
if (embed.embeds.length > 0) {
|
|
2314
|
-
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
|
|
2666
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
|
|
2315
2667
|
}
|
|
2316
2668
|
|
|
2317
|
-
// 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.
|
|
2318
2671
|
const index = new Map<unknown, Record<string, unknown>>();
|
|
2319
2672
|
for (const r of relatedRows) {
|
|
2320
|
-
index.set(r[relationConfig.to], r);
|
|
2673
|
+
index.set(r[relationConfig.to], visibleRow(r, relationConfig.table, options));
|
|
2321
2674
|
}
|
|
2322
2675
|
|
|
2323
2676
|
// Assign to each source row
|
|
@@ -2336,6 +2689,11 @@ async function resolveEmbeds(
|
|
|
2336
2689
|
return result;
|
|
2337
2690
|
}
|
|
2338
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
|
+
|
|
2339
2697
|
function pickColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
|
2340
2698
|
const result: Record<string, unknown> = {};
|
|
2341
2699
|
for (const col of columns) {
|
|
@@ -2551,6 +2909,12 @@ async function handlePatch(
|
|
|
2551
2909
|
const scope = ownershipScope(table, tableName, options, user);
|
|
2552
2910
|
if ('refusal' in scope) return scope.refusal;
|
|
2553
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
|
+
|
|
2554
2918
|
let body: Record<string, unknown>;
|
|
2555
2919
|
try {
|
|
2556
2920
|
body = await request.json();
|
|
@@ -2575,7 +2939,7 @@ async function handlePatch(
|
|
|
2575
2939
|
|
|
2576
2940
|
// Restore: ?deleted=include|only is honoured only for a role in softDelete.restore for
|
|
2577
2941
|
// this table. Anyone else is matched to live rows, as if the param were absent.
|
|
2578
|
-
const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(table, options) : null;
|
|
2942
|
+
const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(tableName, table, options) : null;
|
|
2579
2943
|
const restoring = sdCol !== null && mayRestore(tableName, options, user);
|
|
2580
2944
|
const deletedMode = params.get('deleted');
|
|
2581
2945
|
if (restoring && (deletedMode === 'include' || deletedMode === 'only')) {
|
|
@@ -2820,9 +3184,20 @@ async function handleDelete(
|
|
|
2820
3184
|
// Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
|
|
2821
3185
|
if (scope.condition) conditions.push(scope.condition);
|
|
2822
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
|
+
|
|
2823
3198
|
// Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
|
|
2824
3199
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
2825
|
-
const sdCol = getSoftDeleteColumn(table, options);
|
|
3200
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
2826
3201
|
if (sdCol) {
|
|
2827
3202
|
// Only soft-delete records that aren't already deleted
|
|
2828
3203
|
conditions.push(isNull(sdCol));
|
|
@@ -2856,7 +3231,21 @@ async function handleDelete(
|
|
|
2856
3231
|
query = query.where(and(...conditions));
|
|
2857
3232
|
}
|
|
2858
3233
|
|
|
2859
|
-
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
|
+
}
|
|
2860
3249
|
|
|
2861
3250
|
if (hook?.afterDelete && rows.length > 0) {
|
|
2862
3251
|
const { afterDelete } = hook;
|
|
@@ -2867,6 +3256,122 @@ async function handleDelete(
|
|
|
2867
3256
|
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
|
|
2868
3257
|
}
|
|
2869
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
|
+
|
|
2870
3375
|
interface JsonPath {
|
|
2871
3376
|
baseColumn: string;
|
|
2872
3377
|
steps: Array<{ key: string; asText: boolean }>;
|