@everystack/api 0.4.10 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/client/index.ts +32 -0
- package/src/handler/index.ts +674 -46
- package/src/handler/veto.ts +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,46 @@ 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.12] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- lifecycle delete and restore hand the batch id to the caller
|
|
12
|
+
- lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
|
|
13
|
+
- one lifecycle hook API: (rows, ctx), veto(reason), beforeStamp
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- afterStamp and afterRestore hooks hand a lifecycle batch to app code
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- a lifecycle DELETE stamps only the rows beforeStamp saw
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
- afterStamp, afterRestore and the batch in the restore response
|
|
26
|
+
- Extending lifecycle, the phase table, the veto and failure rules
|
|
27
|
+
|
|
28
|
+
## [0.4.11] - 2026-10-23
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- per-parent embed order, limit and where on both read paths (F10)
|
|
33
|
+
- hasOne one-row embed on both read paths (P2)
|
|
34
|
+
- createHandler refuses a handler relation drizzleRelations does not declare (P3)
|
|
35
|
+
- the handler deletes, hides and restores lifecycle rows through the database (H1, P1.4)
|
|
36
|
+
- profiles, posts, likes and follows are one lifecycle family; account delete and restore use it (P2.1, P2.2)
|
|
37
|
+
- lifecycle columns are hidden from callers that cannot manage the table
|
|
38
|
+
- beforeDestroy and afterDestroy hooks at a real removal
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- an unresolved relation no longer returns a same-named column
|
|
43
|
+
- match embedded rows on a hidden child key before stripping it (P2 review)
|
|
44
|
+
- a belongsTo embed matches on a hidden target key before stripping it
|
|
45
|
+
- a beforeDestroy HandlerError renders with its own status
|
|
46
|
+
|
|
7
47
|
## [0.4.10] - 2026-10-18
|
|
8
48
|
|
|
9
49
|
### BREAKING CHANGES
|
package/README.md
CHANGED
|
@@ -101,6 +101,10 @@ createHandler(db, schema, {
|
|
|
101
101
|
afterUpdate: async (rows, user, client) => {},
|
|
102
102
|
beforeDelete: async (user, client) => {},
|
|
103
103
|
afterDelete: async (rows, user, client) => {},
|
|
104
|
+
// Lifecycle tables only (see "Extending lifecycle" in docs/handler.md); veto from '@everystack/api/handler':
|
|
105
|
+
beforeStamp: (row, ctx) => (ctx.user ? undefined : veto('sign in to delete')), // in the transaction; veto -> 409
|
|
106
|
+
afterStamp: async (rows, ctx) => {}, // after commit, once per batch (ctx.batch)
|
|
107
|
+
afterRestore: async (rows, ctx) => {}, // after commit, ctx.batch and ctx.counts
|
|
104
108
|
},
|
|
105
109
|
},
|
|
106
110
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.12",
|
|
4
4
|
"description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
package/src/client/index.ts
CHANGED
|
@@ -83,6 +83,18 @@ 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, batch }`. */
|
|
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;
|
|
95
|
+
/** The batch restored; null for a row deleted before batches existed. Absent from a server
|
|
96
|
+
* whose restore functions predate it (its next db:apply adds it). */
|
|
97
|
+
batch?: string | null;
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
export interface Client {
|
|
@@ -270,6 +282,26 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
|
|
|
270
282
|
const result = await response.json();
|
|
271
283
|
return { data: result, error: null };
|
|
272
284
|
},
|
|
285
|
+
|
|
286
|
+
async restore(id: string | number): Promise<ClientResponse<RestoreResult>> {
|
|
287
|
+
const url = `${options.baseUrl}/${table}/restore`;
|
|
288
|
+
const headers = await buildHeaders(options);
|
|
289
|
+
headers['Content-Type'] = 'application/json';
|
|
290
|
+
|
|
291
|
+
const { response, retried } = await fetchWithRetry(
|
|
292
|
+
url,
|
|
293
|
+
{ method: 'POST', headers, body: JSON.stringify({ id }) },
|
|
294
|
+
options,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
if (retried) return retried as ClientResponse<RestoreResult>;
|
|
298
|
+
|
|
299
|
+
if (!response.ok) {
|
|
300
|
+
return { data: null, error: await parseError(response) };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { data: (await response.json()) as RestoreResult, error: null };
|
|
304
|
+
},
|
|
273
305
|
};
|
|
274
306
|
|
|
275
307
|
return builder;
|
package/src/handler/index.ts
CHANGED
|
@@ -28,6 +28,19 @@ type Handler = (request: Request) => Promise<Response>;
|
|
|
28
28
|
|
|
29
29
|
export { HandlerError } from './errors';
|
|
30
30
|
import { HandlerError } from './errors';
|
|
31
|
+
export { veto, isVeto } from './veto';
|
|
32
|
+
export type { HookVeto, LifecycleContext, DestroyContext, DestroyHook, BeforeLifecycleHook, AfterLifecycleHook } from './veto';
|
|
33
|
+
import { isVeto, type AfterLifecycleHook, type BeforeLifecycleHook, type DestroyContext, type DestroyHook, type LifecycleContext } from './veto';
|
|
34
|
+
|
|
35
|
+
/** A before hook returned veto(reason): the request is refused with 409 and its transaction rolls back. */
|
|
36
|
+
class VetoRefusal extends Error {
|
|
37
|
+
constructor(readonly reason: string) { super(reason); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A lifecycle before hook threw inside its transaction; carried out so the handler can answer as for any before hook. */
|
|
41
|
+
class BeforeHookThrew extends Error {
|
|
42
|
+
constructor(readonly thrown: unknown) { super('before hook threw'); }
|
|
43
|
+
}
|
|
31
44
|
|
|
32
45
|
/**
|
|
33
46
|
* A 5xx system fault surfaced to the observability sink. Distinct from a 4xx
|
|
@@ -233,6 +246,20 @@ export interface RelationConfig {
|
|
|
233
246
|
to: string;
|
|
234
247
|
/** If true, this is a one-to-many relation (returns array). Default: false (many-to-one, returns object) */
|
|
235
248
|
many?: boolean;
|
|
249
|
+
/** To-one from the parent side (returns an object or null): the first target row whose `to`
|
|
250
|
+
* equals the parent's `from`, in `orderBy` order. Same column roles as `many`. Not with
|
|
251
|
+
* `many` or `limit`. The target must have a primary key. */
|
|
252
|
+
hasOne?: true;
|
|
253
|
+
/** Many or hasOne. Display order of each parent's rows, by target column. The target's primary
|
|
254
|
+
* key is appended as the tie-breaker (a hasOne with no orderBy is ordered by that key).
|
|
255
|
+
* Never read from the request. */
|
|
256
|
+
orderBy?: Array<{ column: string; order: 'asc' | 'desc' }>;
|
|
257
|
+
/** Many only. At most this many rows per parent, in `orderBy` order. Unrelated to `?limit=`. */
|
|
258
|
+
limit?: number;
|
|
259
|
+
/** Many or hasOne. Equality on target columns (`null` means IS NULL), applied before `limit`
|
|
260
|
+
* or the hasOne choice. A display filter the request cannot widen. It is not access control:
|
|
261
|
+
* use RLS for that. */
|
|
262
|
+
where?: Record<string, string | number | boolean | null>;
|
|
236
263
|
}
|
|
237
264
|
|
|
238
265
|
export interface SoftDeleteConfig {
|
|
@@ -245,8 +272,17 @@ export interface SoftDeleteConfig {
|
|
|
245
272
|
* default `role`) is in the table's list: exact string match, no hierarchy, no case folding.
|
|
246
273
|
* Anyone else gets live rows only, as if the param were absent. A restore body must be
|
|
247
274
|
* 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).
|
|
275
|
+
* This is a backstop. RLS must also keep other roles off deleted rows (docs/handler.md).
|
|
276
|
+
* A lifecycle table may not appear here: it restores through `POST {table}/restore`. */
|
|
249
277
|
restore?: Record<string, string[]>;
|
|
278
|
+
/** `lifecycle: true` tables (deriveHandlerConfig emits them), keyed like `tables`. Each is a
|
|
279
|
+
* soft-delete table whose column is always `deletedAt`, soft-deleted by the database itself:
|
|
280
|
+
* DELETE runs a real SQL DELETE (a trigger stamps the row), reads and embeds hide deleted rows
|
|
281
|
+
* for every role unless `?deleted=include|only` (honoured for any role; RLS decides what each
|
|
282
|
+
* sees), and `POST {basePath}/{table}/restore {id}` calls `<schema>.<table>_restore(id)` as the
|
|
283
|
+
* caller. PATCH with `?deleted=include|only` is refused with 400. `?deleted=only` reaches embeds
|
|
284
|
+
* too, so a live parent embedded under a deleted child comes back null. */
|
|
285
|
+
lifecycle?: string[];
|
|
250
286
|
}
|
|
251
287
|
|
|
252
288
|
export interface HookConfig {
|
|
@@ -262,6 +298,33 @@ export interface HookConfig {
|
|
|
262
298
|
beforeDelete?: (user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
|
|
263
299
|
/** Called after DELETE with all deleted rows. Fire-and-forget. */
|
|
264
300
|
afterDelete?: (rows: Record<string, unknown>[], user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
|
|
301
|
+
/** After the hard DELETE statement and before commit, for each returned row. Return
|
|
302
|
+
* `veto(reason)` to refuse the request (409 `{ message, code: 'vetoed' }`, nothing deleted). A
|
|
303
|
+
* throw rolls back the database delete only. No transaction is passed; external effects
|
|
304
|
+
* cannot be undone and must be idempotent. Lifecycle API deletes do not call this.
|
|
305
|
+
* Treat the row as read-only: it is the same object later serialized into the response. */
|
|
306
|
+
beforeDestroy?: DestroyHook;
|
|
307
|
+
/** After a real removal commits, per returned row, best-effort. Must be idempotent.
|
|
308
|
+
* Treat the row as read-only: it is the same object later serialized into the response. */
|
|
309
|
+
afterDestroy?: DestroyHook;
|
|
310
|
+
/** Lifecycle tables only: inside the DELETE's transaction, before the DELETE statement, once per
|
|
311
|
+
* matched ROOT row (the pre-image), with `{ phase: 'stamp', batch: null }`. Return `veto(reason)` to
|
|
312
|
+
* refuse the whole request: 409 `{ message: reason, code: 'vetoed' }`, nothing stamped, no after
|
|
313
|
+
* hooks. A throw is a failure (a HandlerError keeps its status, anything else is 500). Cascaded
|
|
314
|
+
* children run no hook: guard them with the model's `deleteWhen`. */
|
|
315
|
+
beforeStamp?: BeforeLifecycleHook;
|
|
316
|
+
/** Lifecycle tables only: after a DELETE that stamped rows COMMITS, once per batch, with the
|
|
317
|
+
* stamped root rows of that batch (the pre-image, as the response lists them) and `ctx.batch`. A
|
|
318
|
+
* DELETE matching several rows stamps one batch per row, so this runs once per row then.
|
|
319
|
+
* Best-effort like afterDelete: a throw goes to onError and never changes the response. Not
|
|
320
|
+
* called when nothing was stamped, nor on a table that is not lifecycle. */
|
|
321
|
+
afterStamp?: AfterLifecycleHook;
|
|
322
|
+
/** Lifecycle tables only: after `POST {table}/restore` COMMITS, with `[{ <key>: id }]` (the id as
|
|
323
|
+
* the request sent it), `ctx.batch` and `ctx.counts`. Not called for a row deleted before batches
|
|
324
|
+
* existed (batch null). Best-effort: a throw goes to onError and never changes the response. */
|
|
325
|
+
afterRestore?: AfterLifecycleHook;
|
|
326
|
+
/** Reserved for a future release: createHandler refuses it, so it is never silently ignored. */
|
|
327
|
+
beforeRestore?: never;
|
|
265
328
|
}
|
|
266
329
|
|
|
267
330
|
/** Cache-Control header configuration for different response categories. */
|
|
@@ -366,6 +429,12 @@ export interface HandlerOptions {
|
|
|
366
429
|
* Explicit references in query parameters are refused before database access.
|
|
367
430
|
* Does not prevent writes to these columns; use protectedFields for that. */
|
|
368
431
|
hiddenColumns?: Record<string, string[]>;
|
|
432
|
+
/** Exact roles allowed to read deletedBy, deletedBatch and deletedXid on each lifecycle
|
|
433
|
+
* table. Other hidden columns remain hidden. Uses auth.roleField (default role), with
|
|
434
|
+
* no hierarchy or case folding. Missing/non-string claims never qualify. The claim is compared
|
|
435
|
+
* to the model's PostgreSQL role name, so an app whose pgSettings maps claim to role
|
|
436
|
+
* differently sees the two diverge: keep the claim values equal to the manage role names. */
|
|
437
|
+
hiddenColumnsExceptRoles?: Record<string, string[]>;
|
|
369
438
|
/** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
|
|
370
439
|
* Prevents mass assignment / privilege escalation on sensitive columns. */
|
|
371
440
|
protectedFields?: Record<string, string[]>;
|
|
@@ -415,8 +484,18 @@ export interface HandlerOptions {
|
|
|
415
484
|
onError?: (fault: HandlerFault) => void | Promise<void>;
|
|
416
485
|
}
|
|
417
486
|
|
|
418
|
-
|
|
487
|
+
/** The lifecycle machinery's timestamp column: fixed, whatever softDelete.column says. */
|
|
488
|
+
const LIFECYCLE_DELETED_AT = 'deletedAt';
|
|
489
|
+
/** The SQL name of the lifecycle batch column (columnPropertyName maps it to the table's key). */
|
|
490
|
+
const LIFECYCLE_DELETED_BATCH = 'deleted_batch';
|
|
491
|
+
|
|
492
|
+
function isLifecycleTable(tableName: string, options?: HandlerOptions): boolean {
|
|
493
|
+
return options?.softDelete?.lifecycle?.includes(tableName) ?? false;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function getSoftDeleteColumn(tableName: string, table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
|
|
419
497
|
if (!options?.softDelete) return null;
|
|
498
|
+
if (isLifecycleTable(tableName, options)) return getColumn(table, LIFECYCLE_DELETED_AT);
|
|
420
499
|
const colName = options.softDelete.column || 'deletedAt';
|
|
421
500
|
return getColumn(table, colName);
|
|
422
501
|
}
|
|
@@ -434,11 +513,29 @@ function softDeleteCondition(sdCol: DrizzleColumn, params: URLSearchParams): SQL
|
|
|
434
513
|
|
|
435
514
|
function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: HandlerOptions): boolean {
|
|
436
515
|
if (!options?.softDelete) return false;
|
|
516
|
+
if (isLifecycleTable(tableName, options)) return true;
|
|
437
517
|
if (options.softDelete.tables) {
|
|
438
518
|
return options.softDelete.tables.includes(tableName);
|
|
439
519
|
}
|
|
440
520
|
// Auto-detect: table has the soft-delete column
|
|
441
|
-
return getSoftDeleteColumn(table, options) != null;
|
|
521
|
+
return getSoftDeleteColumn(tableName, table, options) != null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The soft-delete guard on an embedded table. Live rows only, except that a lifecycle table
|
|
526
|
+
* honours the request's ?deleted=include|only like its top-level read: RLS decides what each
|
|
527
|
+
* role sees. `params` is absent where no request applies (a pre-DB validation pass).
|
|
528
|
+
*/
|
|
529
|
+
function embedSoftDeleteCondition(
|
|
530
|
+
tableName: string,
|
|
531
|
+
table: DrizzleTable,
|
|
532
|
+
options: HandlerOptions | undefined,
|
|
533
|
+
params: URLSearchParams | undefined,
|
|
534
|
+
): SQL | null {
|
|
535
|
+
if (!isSoftDeleteTable(tableName, table, options)) return null;
|
|
536
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
537
|
+
if (!sdCol) return null;
|
|
538
|
+
return params && isLifecycleTable(tableName, options) ? softDeleteCondition(sdCol, params) : isNull(sdCol);
|
|
442
539
|
}
|
|
443
540
|
|
|
444
541
|
/**
|
|
@@ -457,17 +554,18 @@ async function executeWithSettings<T>(
|
|
|
457
554
|
db: DrizzleDb,
|
|
458
555
|
settings: Record<string, string> | undefined,
|
|
459
556
|
fn: (dbOrTx: DrizzleDb) => Promise<T>,
|
|
557
|
+
forceTransaction = false,
|
|
460
558
|
): Promise<T> {
|
|
461
|
-
if (settings && Object.keys(settings).length > 0) {
|
|
559
|
+
if (forceTransaction || (settings && Object.keys(settings).length > 0)) {
|
|
462
560
|
return db.transaction(async (tx: DrizzleDb) => {
|
|
463
|
-
const role = settings['role'];
|
|
561
|
+
const role = settings?.['role'];
|
|
464
562
|
if (role) {
|
|
465
563
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(role)) {
|
|
466
564
|
throw new Error(`Invalid role name: ${role}`);
|
|
467
565
|
}
|
|
468
566
|
await tx.execute(sql.raw(`SET LOCAL ROLE ${role}`));
|
|
469
567
|
}
|
|
470
|
-
for (const [key, value] of Object.entries(settings)) {
|
|
568
|
+
for (const [key, value] of Object.entries(settings ?? {})) {
|
|
471
569
|
if (key !== 'role' && value !== undefined && value !== '') {
|
|
472
570
|
await tx.execute(sql`SELECT set_config(${key}, ${value}, true)`);
|
|
473
571
|
}
|
|
@@ -592,11 +690,11 @@ async function checkRoleAuth(
|
|
|
592
690
|
|
|
593
691
|
/**
|
|
594
692
|
* Determine the Cache-Control header for a GET response.
|
|
595
|
-
* - pgSettings
|
|
596
|
-
* -
|
|
693
|
+
* - pgSettings or role-dependent column visibility configured → private
|
|
694
|
+
* - Neither configured → null (let server package set its default)
|
|
597
695
|
*/
|
|
598
696
|
function getCacheControl(options?: HandlerOptions): string | null {
|
|
599
|
-
if (options?.pgSettings) {
|
|
697
|
+
if (options?.pgSettings || Object.keys(options?.hiddenColumnsExceptRoles ?? {}).length) {
|
|
600
698
|
return options?.cache?.private ?? 'private, no-store';
|
|
601
699
|
}
|
|
602
700
|
return null;
|
|
@@ -840,6 +938,7 @@ function expandTableKeyedOptions(
|
|
|
840
938
|
...options,
|
|
841
939
|
...(options.exposedTables ? { exposedTables: expandList(options.exposedTables) } : {}),
|
|
842
940
|
...(options.hiddenColumns ? { hiddenColumns: expandMap(options.hiddenColumns) } : {}),
|
|
941
|
+
...(options.hiddenColumnsExceptRoles ? { hiddenColumnsExceptRoles: expandMap(options.hiddenColumnsExceptRoles) } : {}),
|
|
843
942
|
...(options.protectedFields ? { protectedFields: expandMap(options.protectedFields) } : {}),
|
|
844
943
|
...(options.sanitizeColumns ? { sanitizeColumns: expandMap(options.sanitizeColumns) } : {}),
|
|
845
944
|
...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
|
|
@@ -851,6 +950,7 @@ function expandTableKeyedOptions(
|
|
|
851
950
|
...options.softDelete,
|
|
852
951
|
...(options.softDelete.tables ? { tables: expandList(options.softDelete.tables) } : {}),
|
|
853
952
|
...(options.softDelete.restore ? { restore: expandMap(options.softDelete.restore) } : {}),
|
|
953
|
+
...(options.softDelete.lifecycle ? { lifecycle: expandList(options.softDelete.lifecycle) } : {}),
|
|
854
954
|
},
|
|
855
955
|
}
|
|
856
956
|
: {}),
|
|
@@ -870,10 +970,23 @@ function declaredTables(options: HandlerOptions): Set<string> {
|
|
|
870
970
|
...Object.keys(options.relations ?? {}),
|
|
871
971
|
...Object.values(options.relations ?? {}).flatMap(rels => Object.values(rels).map(rel => rel.table)),
|
|
872
972
|
...(options.softDelete?.tables ?? []),
|
|
973
|
+
...(options.softDelete?.lifecycle ?? []),
|
|
873
974
|
];
|
|
874
975
|
return new Set(names.map(toSnakeCase));
|
|
875
976
|
}
|
|
876
977
|
|
|
978
|
+
/**
|
|
979
|
+
* Refuse a hook name reserved for a future release, so adding it later is additive and no one
|
|
980
|
+
* ships a hook that is silently ignored. Every other unknown hook key is ignored, as before.
|
|
981
|
+
*/
|
|
982
|
+
function refuseReservedHooks(options: HandlerOptions | undefined): void {
|
|
983
|
+
for (const [tableName, hook] of Object.entries(options?.hooks ?? {})) {
|
|
984
|
+
if (hook && (hook as { beforeRestore?: unknown }).beforeRestore !== undefined) {
|
|
985
|
+
throw new Error(`[everystack/api] hooks.${tableName}.beforeRestore is reserved for a future release; remove it`);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
877
990
|
/**
|
|
878
991
|
* Refuse to start on a rowOwnership config the handler cannot enforce. The owner clause
|
|
879
992
|
* needs a user, so rowOwnership without auth is an error. An entry whose column does not
|
|
@@ -922,6 +1035,11 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
|
|
|
922
1035
|
const colName = softDelete?.column || 'deletedAt';
|
|
923
1036
|
for (const [tableName, roles] of Object.entries(softDelete?.restore ?? {})) {
|
|
924
1037
|
const table = schema[tableName];
|
|
1038
|
+
if (isLifecycleTable(tableName, options)) {
|
|
1039
|
+
throw new Error(
|
|
1040
|
+
`[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.`,
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
925
1043
|
const isSoftDelete = table
|
|
926
1044
|
? isSoftDeleteTable(tableName, table, options)
|
|
927
1045
|
: (softDelete?.tables?.includes(tableName) ?? false);
|
|
@@ -989,8 +1107,148 @@ function validateSanitizeColumns(schema: Schema, options: HandlerOptions | undef
|
|
|
989
1107
|
}
|
|
990
1108
|
}
|
|
991
1109
|
|
|
1110
|
+
/**
|
|
1111
|
+
* With `drizzleRelations`, the JOIN path hands each relation to drizzle's `with` by its handler
|
|
1112
|
+
* name, so drizzle must declare a relation of that name on that table. A mismatch used to fail
|
|
1113
|
+
* inside drizzle at request time; it is refused here instead. A hasOne never goes to `with` (it is
|
|
1114
|
+
* resolved by a windowed query), and a table drizzle does not serve relationally takes the batch
|
|
1115
|
+
* path, so neither is checked.
|
|
1116
|
+
*/
|
|
1117
|
+
function validateDrizzleRelationNames(db: DrizzleDb, options: HandlerOptions | undefined): void {
|
|
1118
|
+
if (!options?.drizzleRelations) return;
|
|
1119
|
+
const relational = (db as { _?: { schema?: Record<string, { relations?: Record<string, unknown> }> } })._?.schema;
|
|
1120
|
+
if (!relational) return;
|
|
1121
|
+
for (const [tableName, tableRelations] of Object.entries(options.relations ?? {})) {
|
|
1122
|
+
if (!(db as any).query?.[tableName]) continue;
|
|
1123
|
+
const declared = Object.keys(ownEntry(relational, tableName)?.relations ?? {});
|
|
1124
|
+
for (const [name, relation] of Object.entries(tableRelations)) {
|
|
1125
|
+
if (relation.hasOne || declared.includes(name)) continue;
|
|
1126
|
+
throw new Error(
|
|
1127
|
+
`[everystack/api] relations.${tableName}.${name}: drizzleRelations declares no relation "${name}" on "${tableName}" `
|
|
1128
|
+
+ `(it has: ${declared.length ? [...declared].sort().join(', ') : 'none'}). The JOIN path would fail at request time. `
|
|
1129
|
+
+ 'Declare it in your drizzle relations, rename the handler relation, or leave out drizzleRelations.',
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Refuse a relation orderBy, limit, where or hasOne the read paths cannot apply. Only a relation
|
|
1137
|
+
* that declares one is checked. Its target must be in the schema when its source is: derived
|
|
1138
|
+
* configs name every table while a handler serves a few, and an unserved source never embeds.
|
|
1139
|
+
*/
|
|
1140
|
+
function validateRelationDisplay(schema: Schema, options: HandlerOptions | undefined): void {
|
|
1141
|
+
for (const [tableName, tableRelations] of Object.entries(options?.relations ?? {})) {
|
|
1142
|
+
for (const [name, relation] of Object.entries(tableRelations)) {
|
|
1143
|
+
const { orderBy, limit, where, hasOne } = relation;
|
|
1144
|
+
const prefix = `[everystack/api] relations.${tableName}.${name}:`;
|
|
1145
|
+
if (hasOne !== undefined) {
|
|
1146
|
+
if (hasOne !== true) throw new Error(`${prefix} hasOne must be true`);
|
|
1147
|
+
if (relation.many) throw new Error(`${prefix} a relation is many or hasOne, not both`);
|
|
1148
|
+
if (limit !== undefined) throw new Error(`${prefix} hasOne returns one row: limit is not allowed`);
|
|
1149
|
+
} else if (orderBy === undefined && limit === undefined && where === undefined) {
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if (!relation.many && !hasOne) {
|
|
1153
|
+
throw new Error(`${prefix} orderBy and where apply to a many or hasOne relation only, and limit to a many relation only`);
|
|
1154
|
+
}
|
|
1155
|
+
const target = schema[relation.table];
|
|
1156
|
+
if (!target) {
|
|
1157
|
+
if (!schema[tableName]) continue;
|
|
1158
|
+
throw new Error(`${prefix} target table "${relation.table}" is not in the schema`);
|
|
1159
|
+
}
|
|
1160
|
+
// The key is the last tie-breaker. Without one, two runs could choose different rows.
|
|
1161
|
+
if (hasOne && primaryKeyColumns(target).length === 0) {
|
|
1162
|
+
throw new Error(`${prefix} target table "${relation.table}" has no primary key to break ties`);
|
|
1163
|
+
}
|
|
1164
|
+
const resolves = (column: unknown): boolean => typeof column === 'string' && columnPropertyName(target, column) !== undefined;
|
|
1165
|
+
if (orderBy !== undefined) {
|
|
1166
|
+
if (!Array.isArray(orderBy)) throw new Error(`${prefix} orderBy must be an array of { column, order }`);
|
|
1167
|
+
for (const term of orderBy) {
|
|
1168
|
+
if (!resolves(term?.column)) throw new Error(`${prefix} orderBy column "${term?.column}" is not a column of table "${relation.table}"`);
|
|
1169
|
+
if (term.order !== 'asc' && term.order !== 'desc') throw new Error(`${prefix} orderBy order for "${term.column}" must be 'asc' or 'desc'`);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (limit !== undefined && !(typeof limit === 'number' && Number.isInteger(limit) && limit > 0)) {
|
|
1173
|
+
throw new Error(`${prefix} limit must be a positive integer`);
|
|
1174
|
+
}
|
|
1175
|
+
if (where !== undefined) {
|
|
1176
|
+
if (where === null || typeof where !== 'object' || Array.isArray(where)) throw new Error(`${prefix} where must be an object of column → value`);
|
|
1177
|
+
for (const [column, value] of Object.entries(where)) {
|
|
1178
|
+
if (!resolves(column)) throw new Error(`${prefix} where column "${column}" is not a column of table "${relation.table}"`);
|
|
1179
|
+
const valid = value === null || typeof value === 'string' || typeof value === 'boolean'
|
|
1180
|
+
|| (typeof value === 'number' && Number.isFinite(value));
|
|
1181
|
+
if (!valid) throw new Error(`${prefix} where value for "${column}" must be a string, a finite number, a boolean or null`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** The target's primary-key columns, in declaration order. Empty for a keyless or non-pg table. */
|
|
1189
|
+
function primaryKeyColumns(table: DrizzleTable): DrizzleColumn[] {
|
|
1190
|
+
if (!is(table, PgTable)) return [];
|
|
1191
|
+
const config = getTableConfig(table);
|
|
1192
|
+
const columns = Object.values(getTableColumns(table)) as DrizzleColumn[];
|
|
1193
|
+
const byName = new Map(columns.map(c => [c.name, c]));
|
|
1194
|
+
return [
|
|
1195
|
+
...columns.filter(c => c.primary),
|
|
1196
|
+
...config.primaryKeys.flatMap(key => key.columns.map(keyColumn => byName.get(keyColumn.name))),
|
|
1197
|
+
].filter((c): c is DrizzleColumn => c !== undefined);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* A relation's row filter and order, over the target table's own column objects (no config
|
|
1202
|
+
* value reaches SQL text). The filter is the soft-delete guard and the declared where. The
|
|
1203
|
+
* order is the declared orderBy then the primary key, or none when no orderBy, limit or hasOne
|
|
1204
|
+
* is declared. validateRelationDisplay has checked every name.
|
|
1205
|
+
*/
|
|
1206
|
+
function relationDisplay(
|
|
1207
|
+
relation: RelationConfig,
|
|
1208
|
+
targetTable: DrizzleTable,
|
|
1209
|
+
options?: HandlerOptions,
|
|
1210
|
+
params?: URLSearchParams,
|
|
1211
|
+
): { conditions: SQL[]; order: SQL[] } {
|
|
1212
|
+
const column = (name: string): DrizzleColumn => getTableColumns(targetTable)[columnPropertyName(targetTable, name)!] as DrizzleColumn;
|
|
1213
|
+
const conditions: SQL[] = [];
|
|
1214
|
+
const deletedGuard = embedSoftDeleteCondition(relation.table, targetTable, options, params);
|
|
1215
|
+
if (deletedGuard) conditions.push(deletedGuard);
|
|
1216
|
+
for (const [name, value] of Object.entries(relation.where ?? {})) {
|
|
1217
|
+
conditions.push(value === null ? isNull(column(name)) : eq(column(name), value));
|
|
1218
|
+
}
|
|
1219
|
+
const order: SQL[] = [];
|
|
1220
|
+
if (relation.orderBy !== undefined || relation.limit !== undefined || relation.hasOne) {
|
|
1221
|
+
const named = new Set<DrizzleColumn>();
|
|
1222
|
+
for (const term of relation.orderBy ?? []) {
|
|
1223
|
+
named.add(column(term.column));
|
|
1224
|
+
order.push(term.order === 'desc' ? desc(column(term.column)) : asc(column(term.column)));
|
|
1225
|
+
}
|
|
1226
|
+
for (const c of primaryKeyColumns(targetTable)) {
|
|
1227
|
+
if (!named.has(c)) { named.add(c); order.push(asc(c)); }
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return { conditions, order };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
992
1233
|
/** Resolve hidden columns once so every read uses the same column spellings. */
|
|
993
1234
|
function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): HandlerOptions | undefined {
|
|
1235
|
+
const rolesByTable = new Map<DrizzleTable, string[]>();
|
|
1236
|
+
const configuredHidden = { ...options?.hiddenColumns };
|
|
1237
|
+
for (const [tableName, roles] of Object.entries(options?.hiddenColumnsExceptRoles ?? {})) {
|
|
1238
|
+
const table = schema[tableName];
|
|
1239
|
+
if (!table || !Array.isArray(roles) || roles.some(role => typeof role !== 'string' || role === '')) {
|
|
1240
|
+
throw new Error(`[everystack/api] hiddenColumnsExceptRoles.${tableName}: expected a known table and an array of role names`);
|
|
1241
|
+
}
|
|
1242
|
+
const previous = rolesByTable.get(table);
|
|
1243
|
+
if (previous && (previous.length !== roles.length || previous.some(role => !roles.includes(role)))) {
|
|
1244
|
+
throw new Error(`[everystack/api] hiddenColumnsExceptRoles.${tableName}: conflicting role exceptions for one table`);
|
|
1245
|
+
}
|
|
1246
|
+
rolesByTable.set(table, roles);
|
|
1247
|
+
if (isLifecycleTable(tableName, options)) {
|
|
1248
|
+
configuredHidden[tableName] = [...new Set([...(configuredHidden[tableName] ?? []), 'deletedBy', 'deletedBatch', 'deletedXid'])];
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (options?.hiddenColumnsExceptRoles) options = { ...options, hiddenColumns: configuredHidden };
|
|
994
1252
|
if (!options?.hiddenColumns) return options;
|
|
995
1253
|
const byTable = new Map<DrizzleTable, Set<string>>();
|
|
996
1254
|
for (const [tableName, names] of Object.entries(options.hiddenColumns)) {
|
|
@@ -1022,6 +1280,27 @@ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): Handler
|
|
|
1022
1280
|
return { ...options, hiddenColumns };
|
|
1023
1281
|
}
|
|
1024
1282
|
|
|
1283
|
+
/** Resolve one request's visibility before query validation and every response path. */
|
|
1284
|
+
function hiddenColumnsForUser(schema: Schema, options: HandlerOptions | undefined, user: Record<string, unknown> | null): HandlerOptions | undefined {
|
|
1285
|
+
if (!options?.hiddenColumnsExceptRoles || !user) return options;
|
|
1286
|
+
const role = readRoleClaim(user, options.auth?.roleField ?? 'role');
|
|
1287
|
+
if (!role) return options;
|
|
1288
|
+
const hiddenColumns = Object.fromEntries(Object.entries(options.hiddenColumns ?? {}).map(([tableName, columns]) => {
|
|
1289
|
+
const except = ownEntry(options.hiddenColumnsExceptRoles, tableName);
|
|
1290
|
+
if (!isLifecycleTable(tableName, options) || !except?.includes(role)) return [tableName, columns];
|
|
1291
|
+
const table = schema[tableName];
|
|
1292
|
+
const metadata = new Set<string>();
|
|
1293
|
+
for (const name of ['deleted_by', 'deleted_batch', 'deleted_xid']) {
|
|
1294
|
+
const property = columnPropertyName(table, name);
|
|
1295
|
+
if (!property) continue;
|
|
1296
|
+
const column = getTableColumns(table)[property] as DrizzleColumn;
|
|
1297
|
+
for (const spelling of [property, column.name, toSnakeCase(property), toCamelCase(column.name)]) metadata.add(spelling);
|
|
1298
|
+
}
|
|
1299
|
+
return [tableName, columns.filter(column => !metadata.has(column))];
|
|
1300
|
+
}));
|
|
1301
|
+
return { ...options, hiddenColumns };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1025
1304
|
/** Safe, fixed client messages. Never derive these from PostgreSQL text or detail. */
|
|
1026
1305
|
const INTEGRITY_ERRORS: Record<string, { status: number; message: string }> = {
|
|
1027
1306
|
'23514': { status: 422, message: 'Value does not meet a constraint' },
|
|
@@ -1094,6 +1373,11 @@ function mapIntegrityError(
|
|
|
1094
1373
|
if (constraint !== undefined) body.constraint = constraint;
|
|
1095
1374
|
if (code === '23502' && typeof fields.column === 'string') body.column = fields.column;
|
|
1096
1375
|
}
|
|
1376
|
+
// lifecycle: { deleteWhen }'s guard (`<table>_lifecycle_delete_when`): a stable message, never a
|
|
1377
|
+
// row value. An author's constraintMessages entry for the name still wins.
|
|
1378
|
+
if (declared === undefined && code === '23514' && constraint?.endsWith('_lifecycle_delete_when')) {
|
|
1379
|
+
body.message = "Deleting this row is not allowed by its model's deleteWhen.";
|
|
1380
|
+
}
|
|
1097
1381
|
// A failing trigger or FK action can name a different table than the URL.
|
|
1098
1382
|
// Missing driver tables (and unknown RPC policies) use every configured list.
|
|
1099
1383
|
const driverTable = typeof fields.table === 'string' ? fields.table : undefined;
|
|
@@ -1126,6 +1410,7 @@ function validateConstraintMessages(options?: HandlerOptions): void {
|
|
|
1126
1410
|
}
|
|
1127
1411
|
|
|
1128
1412
|
export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
|
|
1413
|
+
refuseReservedHooks(rawOptions);
|
|
1129
1414
|
// The naming seam: URLs, exposedTables, and relation targets historically mixed
|
|
1130
1415
|
// drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
|
|
1131
1416
|
// multi-word table failed one gate or the other — both spellings 404'd. Collapse
|
|
@@ -1133,15 +1418,18 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1133
1418
|
// apply. The root listing below stays one entry per table (the SQL name).
|
|
1134
1419
|
const namePairs = tableNamePairs(rawSchema);
|
|
1135
1420
|
const schema = expandSchema(rawSchema, namePairs);
|
|
1136
|
-
const
|
|
1137
|
-
validateConstraintMessages(
|
|
1421
|
+
const configuredOptions = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
|
|
1422
|
+
validateConstraintMessages(configuredOptions);
|
|
1138
1423
|
validateRowOwnership(schema, rawOptions);
|
|
1139
|
-
validateSoftDeleteRestore(schema,
|
|
1140
|
-
validateSanitizeColumns(schema,
|
|
1424
|
+
validateSoftDeleteRestore(schema, configuredOptions);
|
|
1425
|
+
validateSanitizeColumns(schema, configuredOptions);
|
|
1426
|
+
validateRelationDisplay(schema, configuredOptions);
|
|
1427
|
+
validateDrizzleRelationNames(db, configuredOptions);
|
|
1141
1428
|
const tableNames = new Set(Object.keys(schema));
|
|
1142
1429
|
const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
|
|
1143
1430
|
|
|
1144
1431
|
return async (request: Request): Promise<Response> => {
|
|
1432
|
+
let options = configuredOptions;
|
|
1145
1433
|
const url = new URL(request.url);
|
|
1146
1434
|
let pathname = url.pathname;
|
|
1147
1435
|
if (options?.basePath) {
|
|
@@ -1212,8 +1500,9 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1212
1500
|
// Root path — table listing, always public. One entry per table (SQL name),
|
|
1213
1501
|
// regardless of how many spellings route to it.
|
|
1214
1502
|
if (pathSegments.length === 0) {
|
|
1215
|
-
const
|
|
1216
|
-
|
|
1503
|
+
const exposedTables = options?.exposedTables;
|
|
1504
|
+
const tables = exposedTables
|
|
1505
|
+
? canonicalTables.filter(t => exposedTables.includes(t))
|
|
1217
1506
|
: canonicalTables;
|
|
1218
1507
|
return json({ tables }, 200);
|
|
1219
1508
|
}
|
|
@@ -1268,6 +1557,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1268
1557
|
return json({ message: 'Authentication required: no auth token provided' }, 401);
|
|
1269
1558
|
}
|
|
1270
1559
|
|
|
1560
|
+
options = hiddenColumnsForUser(schema, options, user);
|
|
1271
1561
|
const fn = isObjectForm ? entry.fn : entry;
|
|
1272
1562
|
const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
|
|
1273
1563
|
// Request-scoped context for the trace-id spine — RPC handlers receive it
|
|
@@ -1416,8 +1706,24 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1416
1706
|
}
|
|
1417
1707
|
}
|
|
1418
1708
|
|
|
1709
|
+
options = hiddenColumnsForUser(schema, options, user);
|
|
1419
1710
|
const table = schema[tableName];
|
|
1420
1711
|
|
|
1712
|
+
// POST {table}/restore: a lifecycle table's restore. Before the generic routing, which would
|
|
1713
|
+
// read it as POST {table} (an insert). Any other table has no restore endpoint.
|
|
1714
|
+
if (method === 'POST' && pathSegments.length === 2 && pathSegments[1] === 'restore') {
|
|
1715
|
+
if (!isLifecycleTable(tableName, options)) {
|
|
1716
|
+
return sanitizeError(`Table '${tableName}' has no restore endpoint`, 404, options);
|
|
1717
|
+
}
|
|
1718
|
+
const response = await handleRestore(db, table, tableName, request, options, user, client, requestId);
|
|
1719
|
+
await emitAudit(options?.audit, {
|
|
1720
|
+
kind: 'rest', name: tableName, user, client,
|
|
1721
|
+
result: response.status < 400 ? 'allow' : response.status < 500 ? 'deny' : 'error',
|
|
1722
|
+
requestId, operation: 'update', columns: ['id'],
|
|
1723
|
+
});
|
|
1724
|
+
return response;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1421
1727
|
// Forensic audit of the non-GET table surface (writes). Capture the query, not
|
|
1422
1728
|
// the response: the operation, the WHERE predicate, and the body's column NAMES
|
|
1423
1729
|
// (never values — values can be PII). Unresolved embed policies also record
|
|
@@ -1510,6 +1816,11 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1510
1816
|
// 2xx; a throw (a failed commit) skips it, since the write did not happen.
|
|
1511
1817
|
// A retry wrapper around db.transaction must reset `deferred` per attempt,
|
|
1512
1818
|
// or the hooks of an aborted attempt would run too.
|
|
1819
|
+
const destroyHook = ownEntry(options?.hooks, tableName);
|
|
1820
|
+
const destroyTransaction = method === 'DELETE'
|
|
1821
|
+
&& !isLifecycleTable(tableName, options)
|
|
1822
|
+
&& !(isSoftDeleteTable(tableName, table, options) && getSoftDeleteColumn(tableName, table, options))
|
|
1823
|
+
&& Boolean(destroyHook?.beforeDestroy || destroyHook?.afterDestroy);
|
|
1513
1824
|
const deferred: Deferred[] = [];
|
|
1514
1825
|
let response: Response;
|
|
1515
1826
|
try {
|
|
@@ -1533,7 +1844,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1533
1844
|
}
|
|
1534
1845
|
|
|
1535
1846
|
return json({ message: `Method ${method} not allowed` }, 405);
|
|
1536
|
-
});
|
|
1847
|
+
}, destroyTransaction);
|
|
1537
1848
|
} finally {
|
|
1538
1849
|
// emitFault never throws, so this cannot replace the error in flight.
|
|
1539
1850
|
for (const item of deferred) if (item.always) await item.run();
|
|
@@ -1554,6 +1865,16 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1554
1865
|
}
|
|
1555
1866
|
return response;
|
|
1556
1867
|
} catch (error) {
|
|
1868
|
+
// A before hook's veto: a decision, not a fault. Its transaction has rolled back.
|
|
1869
|
+
if (error instanceof VetoRefusal) {
|
|
1870
|
+
if (isWrite) {
|
|
1871
|
+
await emitAudit(options?.audit, {
|
|
1872
|
+
kind: 'rest', name: tableName, user, client, result: 'deny',
|
|
1873
|
+
requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
return json({ message: error.reason, code: 'vetoed' }, 409);
|
|
1877
|
+
}
|
|
1557
1878
|
const appError = error instanceof HandlerError;
|
|
1558
1879
|
const unavailable = !appError && isServiceUnavailable(error);
|
|
1559
1880
|
const integrity = appError || unavailable ? undefined : mapIntegrityError(error, tableName, options);
|
|
@@ -1563,7 +1884,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1563
1884
|
if (isWrite) {
|
|
1564
1885
|
await emitAudit(options?.audit, {
|
|
1565
1886
|
kind: 'rest', name: tableName, user, client,
|
|
1566
|
-
result: mapped || permissionDenied ? 'deny' : 'error',
|
|
1887
|
+
result: mapped || permissionDenied || (error instanceof HandlerError && error.status < 500) ? 'deny' : 'error',
|
|
1567
1888
|
requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
|
|
1568
1889
|
});
|
|
1569
1890
|
}
|
|
@@ -1580,6 +1901,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1580
1901
|
...describeError(error),
|
|
1581
1902
|
});
|
|
1582
1903
|
}
|
|
1904
|
+
if (error instanceof HandlerError && error.status < 500) return json({ message: error.message }, error.status);
|
|
1583
1905
|
if (unavailable) return serviceUnavailableResponse();
|
|
1584
1906
|
throw error;
|
|
1585
1907
|
}
|
|
@@ -1609,6 +1931,7 @@ function buildWithClause(
|
|
|
1609
1931
|
tableRelations: Record<string, RelationConfig>,
|
|
1610
1932
|
schema: Schema,
|
|
1611
1933
|
options?: HandlerOptions,
|
|
1934
|
+
params?: URLSearchParams,
|
|
1612
1935
|
): Record<string, any> | undefined | null | 'unresolvedHiddenColumns' {
|
|
1613
1936
|
if (embeds.length === 0) return undefined;
|
|
1614
1937
|
|
|
@@ -1634,6 +1957,13 @@ function buildWithClause(
|
|
|
1634
1957
|
} else if (hidden.size > 0) {
|
|
1635
1958
|
config.columns = Object.fromEntries([...hidden].map(name => [name, false]));
|
|
1636
1959
|
}
|
|
1960
|
+
// Drizzle maps the target's columns to the join alias. The filter runs before the limit.
|
|
1961
|
+
if (table) {
|
|
1962
|
+
const { conditions, order } = relationDisplay(relation, table, options, params);
|
|
1963
|
+
if (conditions.length > 0) config.where = and(...conditions);
|
|
1964
|
+
if (order.length > 0) config.orderBy = order;
|
|
1965
|
+
if (relation.limit !== undefined) config.limit = relation.limit;
|
|
1966
|
+
}
|
|
1637
1967
|
withClause[embed.name] = Object.keys(config).length > 0 ? config : true;
|
|
1638
1968
|
}
|
|
1639
1969
|
|
|
@@ -1786,7 +2116,7 @@ async function handleGet(
|
|
|
1786
2116
|
|
|
1787
2117
|
// Soft-delete: filter deleted records unless ?deleted=include|only
|
|
1788
2118
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
1789
|
-
const sdCol = getSoftDeleteColumn(table, options);
|
|
2119
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
1790
2120
|
if (sdCol) {
|
|
1791
2121
|
const condition = softDeleteCondition(sdCol, params);
|
|
1792
2122
|
if (condition) conditions.push(condition);
|
|
@@ -1864,10 +2194,20 @@ async function handleGet(
|
|
|
1864
2194
|
offset: offsetVal,
|
|
1865
2195
|
};
|
|
1866
2196
|
|
|
2197
|
+
// A hasOne never enters `with`: Drizzle's one() cannot order, so it cannot choose the row.
|
|
2198
|
+
// It is attached below from one windowed query, keyed on the parent's `from` column.
|
|
2199
|
+
const oneEmbeds = embeds.filter(embed => ownEntry(tableRelations, embed.name)?.hasOne);
|
|
1867
2200
|
const colSelection = buildColumnsSelection(columns);
|
|
1868
|
-
|
|
2201
|
+
const addedKeys: string[] = [];
|
|
2202
|
+
if (colSelection) {
|
|
2203
|
+
for (const embed of oneEmbeds) {
|
|
2204
|
+
const from = tableRelations![embed.name].from;
|
|
2205
|
+
if (!colSelection[from]) { colSelection[from] = true; addedKeys.push(from); }
|
|
2206
|
+
}
|
|
2207
|
+
queryOptions.columns = colSelection;
|
|
2208
|
+
}
|
|
1869
2209
|
|
|
1870
|
-
const withClause = buildWithClause(embeds, tableRelations!, schema, options);
|
|
2210
|
+
const withClause = buildWithClause(embeds.filter(embed => !oneEmbeds.includes(embed)), tableRelations!, schema, options, params);
|
|
1871
2211
|
if (withClause === null || withClause === 'unresolvedHiddenColumns') {
|
|
1872
2212
|
return json({ message: 'Invalid query' }, 400);
|
|
1873
2213
|
}
|
|
@@ -1882,6 +2222,10 @@ async function handleGet(
|
|
|
1882
2222
|
if (order.expressions.length > 0) queryOptions.orderBy = order.expressions;
|
|
1883
2223
|
|
|
1884
2224
|
rows = await (db as any).query[tableName].findMany(queryOptions);
|
|
2225
|
+
if (oneEmbeds.length > 0 && rows.length > 0) {
|
|
2226
|
+
rows = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], oneEmbeds, options!, params);
|
|
2227
|
+
for (const row of rows as Record<string, unknown>[]) for (const key of addedKeys) delete row[key];
|
|
2228
|
+
}
|
|
1885
2229
|
} else {
|
|
1886
2230
|
// Batch path — standard select + separate relation queries
|
|
1887
2231
|
const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, true, undefined, isHidden);
|
|
@@ -1927,11 +2271,13 @@ async function handleGet(
|
|
|
1927
2271
|
// Resolve relation embeds (batch path only — JOIN path already has nested data)
|
|
1928
2272
|
let responseData: unknown[] = rows as unknown[];
|
|
1929
2273
|
if (!useRelationalQuery && embeds.length > 0 && rowCount > 0) {
|
|
1930
|
-
responseData = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], embeds, options
|
|
2274
|
+
responseData = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], embeds, options!, params);
|
|
1931
2275
|
}
|
|
1932
2276
|
|
|
1933
2277
|
// Apply column selection (batch path only — JOIN path handles via queryOptions.columns)
|
|
1934
2278
|
if (!useRelationalQuery && columns) {
|
|
2279
|
+
// An embed that did not resolve was skipped above, and its name may be a real column.
|
|
2280
|
+
const resolvedEmbeds = embeds.filter(embed => resolveEmbedTarget(schema, tableRelations, embed.name));
|
|
1935
2281
|
responseData = (responseData as Record<string, unknown>[]).map(row => {
|
|
1936
2282
|
const filtered: Record<string, unknown> = {};
|
|
1937
2283
|
for (const col of columns) {
|
|
@@ -1939,7 +2285,7 @@ async function handleGet(
|
|
|
1939
2285
|
filtered[col] = row[col];
|
|
1940
2286
|
}
|
|
1941
2287
|
}
|
|
1942
|
-
for (const embed of
|
|
2288
|
+
for (const embed of resolvedEmbeds) {
|
|
1943
2289
|
if (embed.name in row) {
|
|
1944
2290
|
filtered[embed.name] = row[embed.name];
|
|
1945
2291
|
}
|
|
@@ -2212,6 +2558,33 @@ function resolveEmbedTarget(
|
|
|
2212
2558
|
return targetColumn ? { relationConfig, targetTable, targetColumn } : null;
|
|
2213
2559
|
}
|
|
2214
2560
|
|
|
2561
|
+
/** The per-parent rank a limited embed is cut on. Dropped from every row. */
|
|
2562
|
+
const EMBED_RANK = '__everystack_embed_rank';
|
|
2563
|
+
|
|
2564
|
+
/**
|
|
2565
|
+
* The first `cap` target rows per parent key, ranked after the filter in `order`. One windowed
|
|
2566
|
+
* query on the request's own connection. Ordered by rank, so each parent's rows stay in order
|
|
2567
|
+
* through the grouping that follows.
|
|
2568
|
+
*/
|
|
2569
|
+
async function rankedEmbedRows(
|
|
2570
|
+
db: DrizzleDb,
|
|
2571
|
+
targetTable: DrizzleTable,
|
|
2572
|
+
targetColumn: DrizzleColumn,
|
|
2573
|
+
where: SQL | undefined,
|
|
2574
|
+
order: SQL[],
|
|
2575
|
+
cap: number,
|
|
2576
|
+
): Promise<Record<string, unknown>[]> {
|
|
2577
|
+
const orderClause = order.length > 0 ? sql` order by ${sql.join(order, sql`, `)}` : sql``;
|
|
2578
|
+
const ranked = db.$with('embed_ranked').as(db.select({
|
|
2579
|
+
...getTableColumns(targetTable),
|
|
2580
|
+
[EMBED_RANK]: sql<number>`row_number() over (partition by ${targetColumn}${orderClause})`.as(EMBED_RANK),
|
|
2581
|
+
}).from(targetTable).where(where));
|
|
2582
|
+
return (await db.with(ranked).select().from(ranked)
|
|
2583
|
+
.where(sql`${ranked[EMBED_RANK]} <= ${cap}`)
|
|
2584
|
+
.orderBy(ranked[EMBED_RANK]))
|
|
2585
|
+
.map(({ [EMBED_RANK]: _rank, ...row }: Record<string, unknown>) => row);
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2215
2588
|
/**
|
|
2216
2589
|
* Resolve relation embeds by fetching related rows and nesting them.
|
|
2217
2590
|
*/
|
|
@@ -2222,6 +2595,7 @@ async function resolveEmbeds(
|
|
|
2222
2595
|
rows: Record<string, unknown>[],
|
|
2223
2596
|
embeds: EmbedRequest[],
|
|
2224
2597
|
options: HandlerOptions,
|
|
2598
|
+
params?: URLSearchParams,
|
|
2225
2599
|
): Promise<Record<string, unknown>[]> {
|
|
2226
2600
|
const tableRelations = ownEntry(options.relations, tableName);
|
|
2227
2601
|
if (!tableRelations) return rows;
|
|
@@ -2243,12 +2617,37 @@ async function resolveEmbeds(
|
|
|
2243
2617
|
...embed.embeds.filter(nested => resolveEmbedTarget(schema, nestedRelations, nested.name)).map(nested => nested.name),
|
|
2244
2618
|
];
|
|
2245
2619
|
|
|
2246
|
-
//
|
|
2247
|
-
const
|
|
2248
|
-
? getSoftDeleteColumn(targetTable, options)
|
|
2249
|
-
: null;
|
|
2620
|
+
// The soft-delete guard for the target table
|
|
2621
|
+
const deletedGuard = embedSoftDeleteCondition(relationConfig.table, targetTable, options, params);
|
|
2250
2622
|
|
|
2251
|
-
if (relationConfig.
|
|
2623
|
+
if (relationConfig.hasOne) {
|
|
2624
|
+
// One row per parent: source.from is the parent key, target.to the child FK, as for many.
|
|
2625
|
+
const sourceValues = result.map(r => r[relationConfig.from]).filter(v => v != null);
|
|
2626
|
+
if (sourceValues.length === 0) {
|
|
2627
|
+
for (const row of result) row[embed.name] = null;
|
|
2628
|
+
continue;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// The database chooses: the declared where and the soft-delete guard first, then the
|
|
2632
|
+
// first row by the declared order with the key last. Never a fetch of every child.
|
|
2633
|
+
const { conditions, order } = relationDisplay(relationConfig, targetTable, options, params);
|
|
2634
|
+
let relatedRows = await rankedEmbedRows(
|
|
2635
|
+
db, targetTable, targetColumn, and(inArray(targetColumn, sourceValues as string[]), ...conditions), order, 1,
|
|
2636
|
+
);
|
|
2637
|
+
|
|
2638
|
+
if (embed.embeds.length > 0) {
|
|
2639
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
// Match on the key, then strip: the key may itself be a hidden column.
|
|
2643
|
+
const index = new Map<unknown, Record<string, unknown>>();
|
|
2644
|
+
for (const r of relatedRows) index.set(r[relationConfig.to], visibleRow(r, relationConfig.table, options));
|
|
2645
|
+
for (const row of result) {
|
|
2646
|
+
const sourceVal = row[relationConfig.from];
|
|
2647
|
+
const related = sourceVal == null ? null : index.get(sourceVal) ?? null;
|
|
2648
|
+
row[embed.name] = related && picked ? pickColumns(related, picked) : related;
|
|
2649
|
+
}
|
|
2650
|
+
} else if (relationConfig.many) {
|
|
2252
2651
|
// One-to-many: source.from is the PK, target.to is the FK
|
|
2253
2652
|
// Collect all source PKs
|
|
2254
2653
|
const sourceValues = result
|
|
@@ -2264,24 +2663,31 @@ async function resolveEmbeds(
|
|
|
2264
2663
|
}
|
|
2265
2664
|
|
|
2266
2665
|
// Fetch all related rows in one query
|
|
2267
|
-
const
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2666
|
+
const { conditions, order } = relationDisplay(relationConfig, targetTable, options, params);
|
|
2667
|
+
const embedConditions: any[] = [inArray(targetColumn, sourceValues as string[]), ...conditions];
|
|
2668
|
+
const embedWhere = embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions);
|
|
2669
|
+
|
|
2670
|
+
let relatedRows: Record<string, unknown>[];
|
|
2671
|
+
if (relationConfig.limit !== undefined) {
|
|
2672
|
+
// Rank each parent's rows after the filter, then keep the first `limit` per parent.
|
|
2673
|
+
relatedRows = await rankedEmbedRows(db, targetTable, targetColumn, embedWhere, order, relationConfig.limit);
|
|
2674
|
+
} else {
|
|
2675
|
+
const query = db.select().from(targetTable).where(embedWhere);
|
|
2676
|
+
relatedRows = await (order.length > 0 ? query.orderBy(...order) : query);
|
|
2677
|
+
}
|
|
2275
2678
|
|
|
2276
2679
|
// Recursively resolve nested embeds
|
|
2277
2680
|
if (embed.embeds.length > 0) {
|
|
2278
|
-
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
|
|
2681
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
|
|
2279
2682
|
}
|
|
2280
2683
|
|
|
2281
|
-
// Group by FK value and assign to each source row
|
|
2684
|
+
// Group by FK value and assign to each source row. Match on the key, then strip: the key
|
|
2685
|
+
// may itself be a hidden column.
|
|
2282
2686
|
for (const row of result) {
|
|
2283
2687
|
const sourceVal = row[relationConfig.from];
|
|
2284
|
-
const matched = relatedRows
|
|
2688
|
+
const matched = relatedRows
|
|
2689
|
+
.filter(r => r[relationConfig.to] === sourceVal)
|
|
2690
|
+
.map(r => visibleRow(r, relationConfig.table, options));
|
|
2285
2691
|
row[embed.name] = picked ? matched.map(r => pickColumns(r, picked)) : matched;
|
|
2286
2692
|
}
|
|
2287
2693
|
} else {
|
|
@@ -2301,23 +2707,23 @@ async function resolveEmbeds(
|
|
|
2301
2707
|
|
|
2302
2708
|
// Fetch all referenced rows in one query (batch, no N+1)
|
|
2303
2709
|
const embedConditions: any[] = [inArray(targetColumn, fkValues as string[])];
|
|
2304
|
-
if (
|
|
2710
|
+
if (deletedGuard) embedConditions.push(deletedGuard);
|
|
2305
2711
|
|
|
2306
2712
|
let relatedRows: Record<string, unknown>[] = await db
|
|
2307
2713
|
.select()
|
|
2308
2714
|
.from(targetTable)
|
|
2309
2715
|
.where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
|
|
2310
|
-
relatedRows = stripHiddenColumns(relatedRows, relationConfig.table, options) as Record<string, unknown>[];
|
|
2311
2716
|
|
|
2312
2717
|
// Recursively resolve nested embeds
|
|
2313
2718
|
if (embed.embeds.length > 0) {
|
|
2314
|
-
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
|
|
2719
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
|
|
2315
2720
|
}
|
|
2316
2721
|
|
|
2317
|
-
// Index by target key for O(1) lookup
|
|
2722
|
+
// Index by target key for O(1) lookup. Match on the key, then strip: the key may itself
|
|
2723
|
+
// be a hidden column.
|
|
2318
2724
|
const index = new Map<unknown, Record<string, unknown>>();
|
|
2319
2725
|
for (const r of relatedRows) {
|
|
2320
|
-
index.set(r[relationConfig.to], r);
|
|
2726
|
+
index.set(r[relationConfig.to], visibleRow(r, relationConfig.table, options));
|
|
2321
2727
|
}
|
|
2322
2728
|
|
|
2323
2729
|
// Assign to each source row
|
|
@@ -2336,6 +2742,11 @@ async function resolveEmbeds(
|
|
|
2336
2742
|
return result;
|
|
2337
2743
|
}
|
|
2338
2744
|
|
|
2745
|
+
/** One embedded row without its table's hidden columns. */
|
|
2746
|
+
function visibleRow(row: Record<string, unknown>, tableName: string, options: HandlerOptions): Record<string, unknown> {
|
|
2747
|
+
return stripHiddenColumns(row, tableName, options) as Record<string, unknown>;
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2339
2750
|
function pickColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
|
2340
2751
|
const result: Record<string, unknown> = {};
|
|
2341
2752
|
for (const col of columns) {
|
|
@@ -2551,6 +2962,12 @@ async function handlePatch(
|
|
|
2551
2962
|
const scope = ownershipScope(table, tableName, options, user);
|
|
2552
2963
|
if ('refusal' in scope) return scope.refusal;
|
|
2553
2964
|
|
|
2965
|
+
// A lifecycle row is restored by its own function, on its delete rule, never by a PATCH.
|
|
2966
|
+
const requestedDeleted = params.get('deleted');
|
|
2967
|
+
if (isLifecycleTable(tableName, options) && (requestedDeleted === 'include' || requestedDeleted === 'only')) {
|
|
2968
|
+
return json({ message: `PATCH does not accept ?deleted= on ${tableName}: restore a deleted row with POST ${restorePath(tableName, options)} { "id": <id> }` }, 400);
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2554
2971
|
let body: Record<string, unknown>;
|
|
2555
2972
|
try {
|
|
2556
2973
|
body = await request.json();
|
|
@@ -2575,7 +2992,7 @@ async function handlePatch(
|
|
|
2575
2992
|
|
|
2576
2993
|
// Restore: ?deleted=include|only is honoured only for a role in softDelete.restore for
|
|
2577
2994
|
// 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;
|
|
2995
|
+
const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(tableName, table, options) : null;
|
|
2579
2996
|
const restoring = sdCol !== null && mayRestore(tableName, options, user);
|
|
2580
2997
|
const deletedMode = params.get('deleted');
|
|
2581
2998
|
if (restoring && (deletedMode === 'include' || deletedMode === 'only')) {
|
|
@@ -2820,9 +3237,43 @@ async function handleDelete(
|
|
|
2820
3237
|
// Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
|
|
2821
3238
|
if (scope.condition) conditions.push(scope.condition);
|
|
2822
3239
|
|
|
3240
|
+
// Lifecycle: a real DELETE, which the table's trigger turns into a stamp (it reports 0 rows).
|
|
3241
|
+
if (isLifecycleTable(tableName, options)) {
|
|
3242
|
+
let stamped: Awaited<ReturnType<typeof lifecycleDelete>>;
|
|
3243
|
+
try {
|
|
3244
|
+
stamped = await lifecycleDelete(db, table, tableName, conditions, options, hook?.beforeStamp
|
|
3245
|
+
? { run: hook.beforeStamp.bind(hook), ctx: { phase: 'stamp', source: 'api', batch: null, user, client } } : undefined);
|
|
3246
|
+
} catch (err) {
|
|
3247
|
+
// Its transaction has rolled back: nothing was stamped.
|
|
3248
|
+
if (err instanceof BeforeHookThrew) return beforeHookFailure(err.thrown, options, { requestId, name: `${tableName}.beforeStamp`, method: 'DELETE' }, deferred);
|
|
3249
|
+
throw err;
|
|
3250
|
+
}
|
|
3251
|
+
const { rows, batchOf } = stamped;
|
|
3252
|
+
// The pre-image carries deletedBatch NULL; give each root its batch. stripHiddenColumns keeps
|
|
3253
|
+
// it for a plain manage role only.
|
|
3254
|
+
const batchKey = columnPropertyName(table, LIFECYCLE_DELETED_BATCH);
|
|
3255
|
+
if (batchKey) for (const [row, batch] of batchOf) row[batchKey] = batch;
|
|
3256
|
+
if (hook?.afterDelete && rows.length > 0) {
|
|
3257
|
+
const { afterDelete } = hook;
|
|
3258
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
|
|
3259
|
+
() => afterDelete.call(hook, rows, user, client));
|
|
3260
|
+
}
|
|
3261
|
+
if (hook?.afterStamp && batchOf.size > 0) {
|
|
3262
|
+
const { afterStamp } = hook;
|
|
3263
|
+
const roots = new Map<string, Record<string, unknown>[]>();
|
|
3264
|
+
for (const [row, batch] of batchOf) roots.set(batch, [...(roots.get(batch) ?? []), row]);
|
|
3265
|
+
for (const [batch, batchRoots] of roots) {
|
|
3266
|
+
const ctx: LifecycleContext = { phase: 'stamp', source: 'api', batch, user, client };
|
|
3267
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterStamp`, method: 'DELETE' },
|
|
3268
|
+
async () => { await afterStamp.call(hook, batchRoots, ctx); });
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
|
|
3272
|
+
}
|
|
3273
|
+
|
|
2823
3274
|
// Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
|
|
2824
3275
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
2825
|
-
const sdCol = getSoftDeleteColumn(table, options);
|
|
3276
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
2826
3277
|
if (sdCol) {
|
|
2827
3278
|
// Only soft-delete records that aren't already deleted
|
|
2828
3279
|
conditions.push(isNull(sdCol));
|
|
@@ -2856,7 +3307,24 @@ async function handleDelete(
|
|
|
2856
3307
|
query = query.where(and(...conditions));
|
|
2857
3308
|
}
|
|
2858
3309
|
|
|
2859
|
-
const rows = await query.returning();
|
|
3310
|
+
const rows: Record<string, unknown>[] = await query.returning();
|
|
3311
|
+
|
|
3312
|
+
// RETURNING proves DELETE authority, including policies that differ from SELECT.
|
|
3313
|
+
// The dispatcher keeps this statement and beforeDestroy in one caller transaction.
|
|
3314
|
+
// A veto throws out of the caller transaction, so the DELETE rolls back; the dispatcher answers 409.
|
|
3315
|
+
const ctx: DestroyContext = { phase: 'destroy', source: 'api', batch: null, user, client };
|
|
3316
|
+
for (const row of rows) {
|
|
3317
|
+
if (!hook?.beforeDestroy) break;
|
|
3318
|
+
const verdict = await hook.beforeDestroy(row, ctx);
|
|
3319
|
+
if (isVeto(verdict)) throw new VetoRefusal(verdict.reason);
|
|
3320
|
+
}
|
|
3321
|
+
if (hook?.afterDestroy) {
|
|
3322
|
+
const { afterDestroy } = hook;
|
|
3323
|
+
for (const row of rows) {
|
|
3324
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDestroy`, method: 'DELETE' },
|
|
3325
|
+
async () => { await afterDestroy.call(hook, row, ctx); });
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
2860
3328
|
|
|
2861
3329
|
if (hook?.afterDelete && rows.length > 0) {
|
|
2862
3330
|
const { afterDelete } = hook;
|
|
@@ -2867,6 +3335,166 @@ async function handleDelete(
|
|
|
2867
3335
|
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
|
|
2868
3336
|
}
|
|
2869
3337
|
|
|
3338
|
+
/**
|
|
3339
|
+
* DELETE on a lifecycle table. The trigger stamps each row and cancels its delete, so DELETE
|
|
3340
|
+
* reports 0 rows and RETURNING has nothing. In one transaction: read the live rows the request
|
|
3341
|
+
* matches (the pre-image), run beforeStamp on each, DELETE those keys only (with the same
|
|
3342
|
+
* conditions), then drop every row still live: RLS or the filter refused its delete. What is left is
|
|
3343
|
+
* the pre-image of the rows this request soft-deleted. The read takes no lock, so a hook sees the
|
|
3344
|
+
* row as read; a rule that must hold on the stamped values belongs in deleteWhen, which the trigger
|
|
3345
|
+
* checks on the current row. Under READ COMMITTED a row another request stamps between the read
|
|
3346
|
+
* and the DELETE is still listed (and reaches afterDelete): a false attribution, never a leak.
|
|
3347
|
+
*/
|
|
3348
|
+
async function lifecycleDelete(
|
|
3349
|
+
db: DrizzleDb,
|
|
3350
|
+
table: DrizzleTable,
|
|
3351
|
+
tableName: string,
|
|
3352
|
+
conditions: SQL[],
|
|
3353
|
+
options?: HandlerOptions,
|
|
3354
|
+
beforeStamp?: { run: BeforeLifecycleHook; ctx: LifecycleContext },
|
|
3355
|
+
): Promise<{ rows: Record<string, unknown>[]; batchOf: Map<Record<string, unknown>, string> }> {
|
|
3356
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
3357
|
+
const pk = primaryKeyColumns(table);
|
|
3358
|
+
const key = pk.length === 1 ? columnPropertyName(table, pk[0].name) : undefined;
|
|
3359
|
+
if (!sdCol || key === undefined) {
|
|
3360
|
+
throw new Error(`[everystack/api] softDelete.lifecycle.${tableName}: the table needs a ${LIFECYCLE_DELETED_AT} column and a single-column primary key`);
|
|
3361
|
+
}
|
|
3362
|
+
const keyColumn = getTableColumns(table)[key];
|
|
3363
|
+
const batchOf = new Map<Record<string, unknown>, string>();
|
|
3364
|
+
const rows = await db.transaction(async (tx: DrizzleDb) => {
|
|
3365
|
+
// No row lock: FOR UPDATE needs UPDATE privilege, which a delete-only role does not hold.
|
|
3366
|
+
const matched: Record<string, unknown>[] = await tx.select().from(table).where(and(...conditions, isNull(sdCol)));
|
|
3367
|
+
if (matched.length === 0) return [];
|
|
3368
|
+
// beforeStamp: once per matched root, before the DELETE, inside this transaction. A veto or a
|
|
3369
|
+
// throw leaves it, so nothing is stamped.
|
|
3370
|
+
if (beforeStamp) {
|
|
3371
|
+
for (const row of matched) {
|
|
3372
|
+
let verdict: unknown;
|
|
3373
|
+
try { verdict = await beforeStamp.run(row, beforeStamp.ctx); } catch (err) { throw new BeforeHookThrew(err); }
|
|
3374
|
+
if (isVeto(verdict)) throw new VetoRefusal(verdict.reason);
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
// The transition appends one `<JSON key> <batch>` line per stamped root to this setting (a
|
|
3378
|
+
// caller that is not a manage role cannot read deleted_batch). Cleared first, so only this
|
|
3379
|
+
// DELETE's lines are read back; informational, the stamp columns stay the truth.
|
|
3380
|
+
await tx.execute(sql`SELECT pg_catalog.set_config('everystack.lifecycle_batch', '', true)`);
|
|
3381
|
+
// Exactly the rows the hook saw: a row that starts to match while a hook runs is not stamped.
|
|
3382
|
+
// The request filter stays, so a matched row edited out of it meanwhile is not stamped either.
|
|
3383
|
+
const matchedKeys = matched.map(row => row[key]);
|
|
3384
|
+
await tx.delete(table).where(and(...conditions, isNull(sdCol), inArray(keyColumn, matchedKeys)));
|
|
3385
|
+
const stillLive: Record<string, unknown>[] = await tx
|
|
3386
|
+
.select({ [key]: keyColumn })
|
|
3387
|
+
.from(table)
|
|
3388
|
+
.where(and(isNull(sdCol), inArray(keyColumn, matchedKeys)));
|
|
3389
|
+
const lines = await tx.execute(sql`SELECT pg_catalog.current_setting('everystack.lifecycle_batch', true) AS v`) as unknown as Array<{ v: string | null }>;
|
|
3390
|
+
const byKey = new Map<string, string>();
|
|
3391
|
+
for (const line of String(lines[0]?.v ?? '').split('\n')) {
|
|
3392
|
+
if (line.length < 38) continue;
|
|
3393
|
+
try { byKey.set(String(JSON.parse(line.slice(0, -37))), line.slice(-36)); } catch { /* not a line the transition wrote */ }
|
|
3394
|
+
}
|
|
3395
|
+
const live = new Set(stillLive.map(row => String(row[key])));
|
|
3396
|
+
const stamped = matched.filter(row => !live.has(String(row[key])));
|
|
3397
|
+
for (const row of stamped) {
|
|
3398
|
+
const batch = byKey.get(String(row[key]));
|
|
3399
|
+
if (batch) batchOf.set(row, batch);
|
|
3400
|
+
}
|
|
3401
|
+
return stamped;
|
|
3402
|
+
});
|
|
3403
|
+
return { rows, batchOf };
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
/**
|
|
3407
|
+
* POST {table}/restore { id } on a lifecycle table: `<schema>.<table>_restore(id)` inside the
|
|
3408
|
+
* request's pgSettings transaction, so the function sees the caller's role and claims and decides
|
|
3409
|
+
* on the table's delete rule. The id is bound and cast to the key's type, so an author's own
|
|
3410
|
+
* overload of the name is never chosen. 42501 (the function's one refusal) is 403 with its
|
|
3411
|
+
* message; 55000 (not READ COMMITTED) is 409; 22P02 (an id the key type cannot read) is 400.
|
|
3412
|
+
*/
|
|
3413
|
+
async function handleRestore(
|
|
3414
|
+
db: DrizzleDb,
|
|
3415
|
+
table: DrizzleTable,
|
|
3416
|
+
tableName: string,
|
|
3417
|
+
request: Request,
|
|
3418
|
+
options: HandlerOptions | undefined,
|
|
3419
|
+
user: Record<string, unknown> | null,
|
|
3420
|
+
client: Record<string, unknown> | null,
|
|
3421
|
+
requestId: string | undefined,
|
|
3422
|
+
): Promise<Response> {
|
|
3423
|
+
const body: unknown = await request.json().catch(() => undefined);
|
|
3424
|
+
const id = body !== null && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>).id : undefined;
|
|
3425
|
+
if (!(typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id)))) {
|
|
3426
|
+
return json({ message: 'restore requires a JSON body { "id": <id> }' }, 400);
|
|
3427
|
+
}
|
|
3428
|
+
const [key] = primaryKeyColumns(table);
|
|
3429
|
+
const keyType = key ? restoreArgType(key) : null;
|
|
3430
|
+
const name = sqlNameOf(table);
|
|
3431
|
+
if (!keyType || name === null || primaryKeyColumns(table).length !== 1) {
|
|
3432
|
+
const message = `[everystack/api] softDelete.lifecycle.${tableName}: restore needs a table with a single-column primary key of a plain type`;
|
|
3433
|
+
await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method: 'POST', status: 500, message });
|
|
3434
|
+
return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : message }, 500);
|
|
3435
|
+
}
|
|
3436
|
+
const fn = sql`${sql.identifier(getTableConfig(table).schema ?? 'public')}.${sql.identifier(`${name}_restore`)}`;
|
|
3437
|
+
try {
|
|
3438
|
+
const result = await executeWithSettings(db, options?.pgSettings?.(user, client), async (tx) => {
|
|
3439
|
+
const rows = await tx.execute(sql`SELECT ${fn}(CAST(${id} AS ${sql.raw(keyType)})) AS result`);
|
|
3440
|
+
return (rows as unknown as Array<{ result: unknown }>)[0]?.result;
|
|
3441
|
+
});
|
|
3442
|
+
// After the commit (executeWithSettings has returned). Best-effort: never changes the response.
|
|
3443
|
+
const hook = ownEntry(options?.hooks, tableName);
|
|
3444
|
+
const r = result as { restored?: unknown; skipped?: unknown; batch?: unknown } | null;
|
|
3445
|
+
if (hook?.afterRestore && r && typeof r.batch === 'string') {
|
|
3446
|
+
try {
|
|
3447
|
+
await hook.afterRestore([{ [columnPropertyName(table, key.name) ?? key.name]: id }], {
|
|
3448
|
+
phase: 'restore', source: 'api', batch: r.batch, user, client,
|
|
3449
|
+
counts: { restored: Number(r.restored), skipped: Number(r.skipped) },
|
|
3450
|
+
});
|
|
3451
|
+
} catch (err) {
|
|
3452
|
+
await reportHookFault(options?.onError, { requestId, name: `${tableName}.afterRestore`, method: 'POST' }, err);
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
return json(result, 200);
|
|
3456
|
+
} catch (error) {
|
|
3457
|
+
// Code and message from the one object that carries the code: a wrapper's message can quote the query.
|
|
3458
|
+
const carrier = pgErrorCarrier(error);
|
|
3459
|
+
const code = carrier && safeRead(carrier, 'code') as string;
|
|
3460
|
+
const text = carrier && safeRead(carrier, 'message');
|
|
3461
|
+
const message = typeof text === 'string' ? text : '';
|
|
3462
|
+
if (code === '42501') return sanitizeError(message || 'Permission denied', 403, options);
|
|
3463
|
+
if (code === '55000') return json({ message, code }, 409);
|
|
3464
|
+
if (code === '22P02') return json({ message: 'Invalid id', code }, 400);
|
|
3465
|
+
if (isServiceUnavailable(error)) return serviceUnavailableResponse();
|
|
3466
|
+
await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method: 'POST', status: 500, ...describeError(error) });
|
|
3467
|
+
return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : 'Restore failed' }, 500);
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
/** The first object on the error's cause chain with a string `code` (drivers and wrappers nest it). */
|
|
3472
|
+
function pgErrorCarrier(error: unknown): object | undefined {
|
|
3473
|
+
let current: unknown = error;
|
|
3474
|
+
for (let hop = 0; hop <= MAX_CAUSE_HOPS && current !== null && typeof current === 'object'; hop++) {
|
|
3475
|
+
if (typeof safeRead(current, 'code') === 'string') return current;
|
|
3476
|
+
const cause = safeRead(current, 'cause');
|
|
3477
|
+
if (cause === UNREADABLE || cause === current) return undefined;
|
|
3478
|
+
current = cause;
|
|
3479
|
+
}
|
|
3480
|
+
return undefined;
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
/**
|
|
3484
|
+
* The SQL type the restore id is cast to: the key column's declared type, a serial read as its
|
|
3485
|
+
* integer type. Null for a type text this function will not put in SQL.
|
|
3486
|
+
*/
|
|
3487
|
+
function restoreArgType(column: DrizzleColumn): string | null {
|
|
3488
|
+
const declared = String((column as { getSQLType?: () => string }).getSQLType?.() ?? '').trim().toLowerCase();
|
|
3489
|
+
const type = ({ serial: 'integer', smallserial: 'smallint', bigserial: 'bigint' } as Record<string, string>)[declared] ?? declared;
|
|
3490
|
+
return /^[a-z][a-z0-9_ ]*(\(\d+(, ?\d+)?\))?$/.test(type) ? type : null;
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
/** The restore endpoint's path, as a client calls it. */
|
|
3494
|
+
function restorePath(tableName: string, options?: HandlerOptions): string {
|
|
3495
|
+
return `${(options?.basePath ?? '').replace(/\/+$/, '')}/${tableName}/restore`;
|
|
3496
|
+
}
|
|
3497
|
+
|
|
2870
3498
|
interface JsonPath {
|
|
2871
3499
|
baseColumn: string;
|
|
2872
3500
|
steps: Array<{ key: string; asText: boolean }>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lifecycle hook contract. A structurally identical twin of `@everystack/model`'s
|
|
3
|
+
* lifecycle-hooks (this package has no runtime dependencies): a veto made by either is
|
|
4
|
+
* recognized by both, and the types are interchangeable. Keep the two in step.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const VETO_KIND = 'everystack.hookVeto';
|
|
8
|
+
|
|
9
|
+
/** What a before hook returns to refuse its operation. Make it with {@link veto}. */
|
|
10
|
+
export interface HookVeto {
|
|
11
|
+
readonly kind: typeof VETO_KIND;
|
|
12
|
+
readonly reason: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Refuse the operation a before hook guards. A throw is a failure; a veto is a decision. */
|
|
16
|
+
export function veto(reason: string): HookVeto {
|
|
17
|
+
if (typeof reason !== 'string' || reason.trim() === '') throw new TypeError('veto(reason) needs a non-empty reason string');
|
|
18
|
+
return Object.freeze({ kind: VETO_KIND, reason });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** True only for a value {@link veto} made (or its twin in `@everystack/model`). */
|
|
22
|
+
export function isVeto(value: unknown): value is HookVeto {
|
|
23
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
24
|
+
&& (value as { kind?: unknown }).kind === VETO_KIND && typeof (value as { reason?: unknown }).reason === 'string';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The one context every lifecycle hook receives. */
|
|
28
|
+
export interface LifecycleContext {
|
|
29
|
+
/** `stamp`: the row was marked deleted. `restore`: a batch came back. `destroy`: physical removal. */
|
|
30
|
+
phase: 'stamp' | 'restore' | 'destroy';
|
|
31
|
+
/** `api`: a request through createHandler. `purge`: purgeExpired. */
|
|
32
|
+
source: 'api' | 'purge';
|
|
33
|
+
/** The lifecycle batch, or null when there is none to report (before a stamp, a non-lifecycle table, a pre-batch row). Never invented. */
|
|
34
|
+
batch: string | null;
|
|
35
|
+
user: Record<string, unknown> | null;
|
|
36
|
+
client: Record<string, unknown> | null;
|
|
37
|
+
/** Restore only: what the restore function reported. */
|
|
38
|
+
counts?: { restored: number; skipped: number };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Context for a real removal. External effects must be idempotent across retries. */
|
|
42
|
+
export interface DestroyContext extends LifecycleContext {
|
|
43
|
+
phase: 'destroy';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** beforeDestroy may return {@link veto}; afterDestroy's return value is ignored. */
|
|
47
|
+
export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | HookVeto | Promise<void | HookVeto>;
|
|
48
|
+
|
|
49
|
+
/** A before hook of the lifecycle: called once per root row; returns nothing or a veto. */
|
|
50
|
+
export type BeforeLifecycleHook = (row: Record<string, unknown>, ctx: LifecycleContext) => void | HookVeto | Promise<void | HookVeto>;
|
|
51
|
+
|
|
52
|
+
/** An after hook of the lifecycle: called after commit with the root rows of one batch. Best-effort. */
|
|
53
|
+
export type AfterLifecycleHook = (rows: Record<string, unknown>[], ctx: LifecycleContext) => void | Promise<void>;
|