@everystack/api 0.4.13 → 0.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +3 -4
- package/package.json +1 -1
- package/src/client/index.ts +2 -2
- package/src/handler/index.ts +121 -156
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ All notable changes to `@everystack/api` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.15] - 2026-11-05
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- afterRestore runs for a row deleted before batches existed
|
|
12
|
+
|
|
13
|
+
## [0.4.14] - 2026-11-04
|
|
14
|
+
|
|
15
|
+
### BREAKING CHANGES
|
|
16
|
+
|
|
17
|
+
- softDelete is removed; lifecycle is the one soft delete
|
|
18
|
+
- createHandler refuses softDelete; lifecycle tables are the lifecycle option
|
|
19
|
+
|
|
20
|
+
### Removed
|
|
21
|
+
|
|
22
|
+
- remove the handler's plain soft-delete paths
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- like/ilike on uuid, enum and numeric columns compare their text form
|
|
27
|
+
- match number and enum search terms exactly
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- check filter values against column types
|
|
32
|
+
|
|
33
|
+
### Documentation
|
|
34
|
+
|
|
35
|
+
- softDelete is removed; lifecycle is the one soft delete
|
|
36
|
+
|
|
7
37
|
## [0.4.13] - 2026-10-30
|
|
8
38
|
|
|
9
39
|
### BREAKING CHANGES
|
package/README.md
CHANGED
|
@@ -83,10 +83,9 @@ createHandler(db, schema, {
|
|
|
83
83
|
},
|
|
84
84
|
},
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
},
|
|
86
|
+
// lifecycle: true tables, the one soft delete. deriveHandlerConfig(models) emits this list;
|
|
87
|
+
// do not write it by hand. Restore is POST {basePath}/{table}/restore, client .restore(id).
|
|
88
|
+
lifecycle: ['posts', 'comments'],
|
|
90
89
|
|
|
91
90
|
pgSettings: (user, client) => ({
|
|
92
91
|
'request.jwt.claims': JSON.stringify(user || { role: 'anon' }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.15",
|
|
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
|
@@ -74,8 +74,8 @@ export interface QueryBuilder {
|
|
|
74
74
|
order(column: string, direction?: 'asc' | 'desc'): QueryBuilder;
|
|
75
75
|
limit(count: number): QueryBuilder;
|
|
76
76
|
offset(count: number): QueryBuilder;
|
|
77
|
-
/**
|
|
78
|
-
*
|
|
77
|
+
/** Lifecycle tables: ?deleted=include|only on a read. An update with it is refused (400);
|
|
78
|
+
* restore a deleted row with `.restore(id)`. */
|
|
79
79
|
deleted(mode: DeletedMode): QueryBuilder;
|
|
80
80
|
execute(): Promise<ClientResponse<unknown[]>>;
|
|
81
81
|
executeWithCount(): Promise<CountResponse<unknown[]>>;
|
package/src/handler/index.ts
CHANGED
|
@@ -262,29 +262,6 @@ export interface RelationConfig {
|
|
|
262
262
|
where?: Record<string, string | number | boolean | null>;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
export interface SoftDeleteConfig {
|
|
266
|
-
/** Column name for soft-delete timestamp. Default: 'deletedAt' */
|
|
267
|
-
column?: string;
|
|
268
|
-
/** Tables with soft-delete enabled. If omitted, all tables with the column are treated as soft-delete. */
|
|
269
|
-
tables?: string[];
|
|
270
|
-
/** Who may restore through PATCH, per table: table name -> role names. Keyed like `tables`.
|
|
271
|
-
* PATCH honours `?deleted=include|only` only when the caller's role claim (`auth.roleField`,
|
|
272
|
-
* default `role`) is in the table's list: exact string match, no hierarchy, no case folding.
|
|
273
|
-
* Anyone else gets live rows only, as if the param were absent. A restore body must be
|
|
274
|
-
* exactly `{ <column>: null }`, or 400. Omitted: no one restores through the API.
|
|
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`. */
|
|
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[];
|
|
286
|
-
}
|
|
287
|
-
|
|
288
265
|
export interface HookConfig {
|
|
289
266
|
/** Called before INSERT. Can modify the body (e.g., inject userId). Throw to abort. */
|
|
290
267
|
beforeCreate?: (body: Record<string, unknown>, user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<Record<string, unknown>>;
|
|
@@ -295,15 +272,16 @@ export interface HookConfig {
|
|
|
295
272
|
/** Called after UPDATE with all returned rows. Fire-and-forget. */
|
|
296
273
|
afterUpdate?: (rows: Record<string, unknown>[], user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
|
|
297
274
|
/** Inside the transaction, once per matched root row. Return veto(reason) to roll back the
|
|
298
|
-
* request with 409. Lifecycle rows are pre-images; plain
|
|
275
|
+
* request with 409. Lifecycle rows are pre-images; plain rows come from
|
|
299
276
|
* RETURNING. Throws roll back too. Cascaded children run no hook: guard them with deleteWhen. */
|
|
300
277
|
beforeDelete?: BeforeLifecycleHook;
|
|
301
278
|
/** After commit, once per request with its root rows, best-effort. Empty deletes fire nothing.
|
|
302
279
|
* Each lifecycle row carries its own deletedBatch; ctx.batch is null for a delete request. */
|
|
303
280
|
afterDelete?: AfterLifecycleHook;
|
|
304
281
|
/** Lifecycle tables only: after `POST {table}/restore` COMMITS, with `[{ <key>: id }]` (the id as
|
|
305
|
-
* the request sent it), `ctx.batch` and `ctx.counts`.
|
|
306
|
-
* existed
|
|
282
|
+
* the request sent it), `ctx.batch` and `ctx.counts`. `ctx.batch` is null for a row deleted before
|
|
283
|
+
* batches existed: that row came back alone. Best-effort: a throw goes to onError and never changes
|
|
284
|
+
* the response. */
|
|
307
285
|
afterRestore?: AfterLifecycleHook;
|
|
308
286
|
/** Reserved for a future release: createHandler refuses it, so it is never silently ignored. */
|
|
309
287
|
beforeRestore?: never;
|
|
@@ -382,8 +360,16 @@ export interface HandlerOptions {
|
|
|
382
360
|
relations?: Record<string, Record<string, RelationConfig>>;
|
|
383
361
|
/** Base path to strip from request URL (e.g., '/api' when mounted at /api/[...path]) */
|
|
384
362
|
basePath?: string;
|
|
385
|
-
/**
|
|
386
|
-
softDelete?:
|
|
363
|
+
/** Removed: createHandler refuses it, so it is never silently ignored. Use `lifecycle`. */
|
|
364
|
+
softDelete?: never;
|
|
365
|
+
/** `lifecycle: true` tables (deriveHandlerConfig emits them). Each is a soft-delete table whose
|
|
366
|
+
* column is always `deletedAt`, soft-deleted by the database itself: DELETE runs a real SQL
|
|
367
|
+
* DELETE (a trigger stamps the row), reads and embeds hide deleted rows for every role unless
|
|
368
|
+
* `?deleted=include|only` (honoured for any role; RLS decides what each sees), and
|
|
369
|
+
* `POST {basePath}/{table}/restore {id}` calls `<schema>.<table>_restore(id)` as the caller.
|
|
370
|
+
* PATCH with `?deleted=include|only` is refused with 400. `?deleted=only` reaches embeds too,
|
|
371
|
+
* so a live parent embedded under a deleted child comes back null. */
|
|
372
|
+
lifecycle?: string[];
|
|
387
373
|
/** Drizzle schema relations — enables JOIN queries via db.query API instead of batch WHERE IN */
|
|
388
374
|
drizzleRelations?: Record<string, unknown>;
|
|
389
375
|
/** Compute PostgreSQL session variables from the authenticated user.
|
|
@@ -466,26 +452,23 @@ export interface HandlerOptions {
|
|
|
466
452
|
onError?: (fault: HandlerFault) => void | Promise<void>;
|
|
467
453
|
}
|
|
468
454
|
|
|
469
|
-
/** The lifecycle machinery's timestamp column: fixed
|
|
455
|
+
/** The lifecycle machinery's timestamp column: fixed. */
|
|
470
456
|
const LIFECYCLE_DELETED_AT = 'deletedAt';
|
|
471
457
|
/** The SQL name of the lifecycle batch column (columnPropertyName maps it to the table's key). */
|
|
472
458
|
const LIFECYCLE_DELETED_BATCH = 'deleted_batch';
|
|
473
459
|
|
|
474
460
|
function isLifecycleTable(tableName: string, options?: HandlerOptions): boolean {
|
|
475
|
-
return options?.
|
|
461
|
+
return options?.lifecycle?.includes(tableName) ?? false;
|
|
476
462
|
}
|
|
477
463
|
|
|
478
464
|
function getSoftDeleteColumn(tableName: string, table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
|
|
479
|
-
|
|
480
|
-
if (isLifecycleTable(tableName, options)) return getColumn(table, LIFECYCLE_DELETED_AT);
|
|
481
|
-
const colName = options.softDelete.column || 'deletedAt';
|
|
482
|
-
return getColumn(table, colName);
|
|
465
|
+
return isLifecycleTable(tableName, options) ? getColumn(table, LIFECYCLE_DELETED_AT) : null;
|
|
483
466
|
}
|
|
484
467
|
|
|
485
468
|
/**
|
|
486
|
-
* The
|
|
487
|
-
* matches deleted rows, anything else (absent or unknown) matches live rows. DELETE
|
|
488
|
-
* not use it:
|
|
469
|
+
* The lifecycle condition for a read or an embed: ?deleted=include adds none, ?deleted=only
|
|
470
|
+
* matches deleted rows, anything else (absent or unknown) matches live rows. PATCH and DELETE
|
|
471
|
+
* do not use it: they always match live rows only.
|
|
489
472
|
*/
|
|
490
473
|
function softDeleteCondition(sdCol: DrizzleColumn, params: URLSearchParams): SQL | null {
|
|
491
474
|
const mode = params.get('deleted');
|
|
@@ -493,20 +476,10 @@ function softDeleteCondition(sdCol: DrizzleColumn, params: URLSearchParams): SQL
|
|
|
493
476
|
return mode === 'only' ? isNotNull(sdCol) : isNull(sdCol);
|
|
494
477
|
}
|
|
495
478
|
|
|
496
|
-
function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: HandlerOptions): boolean {
|
|
497
|
-
if (!options?.softDelete) return false;
|
|
498
|
-
if (isLifecycleTable(tableName, options)) return true;
|
|
499
|
-
if (options.softDelete.tables) {
|
|
500
|
-
return options.softDelete.tables.includes(tableName);
|
|
501
|
-
}
|
|
502
|
-
// Auto-detect: table has the soft-delete column
|
|
503
|
-
return getSoftDeleteColumn(tableName, table, options) != null;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
479
|
/**
|
|
507
|
-
* The soft-delete guard on an embedded table.
|
|
508
|
-
*
|
|
509
|
-
*
|
|
480
|
+
* The soft-delete guard on an embedded lifecycle table. It honours the request's
|
|
481
|
+
* ?deleted=include|only like its top-level read: RLS decides what each role sees. Live rows
|
|
482
|
+
* only where no request applies (`params` absent: a pre-DB validation pass).
|
|
510
483
|
*/
|
|
511
484
|
function embedSoftDeleteCondition(
|
|
512
485
|
tableName: string,
|
|
@@ -514,10 +487,9 @@ function embedSoftDeleteCondition(
|
|
|
514
487
|
options: HandlerOptions | undefined,
|
|
515
488
|
params: URLSearchParams | undefined,
|
|
516
489
|
): SQL | null {
|
|
517
|
-
if (!isSoftDeleteTable(tableName, table, options)) return null;
|
|
518
490
|
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
519
491
|
if (!sdCol) return null;
|
|
520
|
-
return params
|
|
492
|
+
return params ? softDeleteCondition(sdCol, params) : isNull(sdCol);
|
|
521
493
|
}
|
|
522
494
|
|
|
523
495
|
/**
|
|
@@ -926,16 +898,7 @@ function expandTableKeyedOptions(
|
|
|
926
898
|
...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
|
|
927
899
|
...(options.relations ? { relations: expandMap(options.relations) } : {}),
|
|
928
900
|
...(options.hooks ? { hooks: expandMap(options.hooks) } : {}),
|
|
929
|
-
...(options.
|
|
930
|
-
? {
|
|
931
|
-
softDelete: {
|
|
932
|
-
...options.softDelete,
|
|
933
|
-
...(options.softDelete.tables ? { tables: expandList(options.softDelete.tables) } : {}),
|
|
934
|
-
...(options.softDelete.restore ? { restore: expandMap(options.softDelete.restore) } : {}),
|
|
935
|
-
...(options.softDelete.lifecycle ? { lifecycle: expandList(options.softDelete.lifecycle) } : {}),
|
|
936
|
-
},
|
|
937
|
-
}
|
|
938
|
-
: {}),
|
|
901
|
+
...(options.lifecycle ? { lifecycle: expandList(options.lifecycle) } : {}),
|
|
939
902
|
};
|
|
940
903
|
}
|
|
941
904
|
|
|
@@ -951,8 +914,7 @@ function declaredTables(options: HandlerOptions): Set<string> {
|
|
|
951
914
|
...Object.keys(options.hooks ?? {}),
|
|
952
915
|
...Object.keys(options.relations ?? {}),
|
|
953
916
|
...Object.values(options.relations ?? {}).flatMap(rels => Object.values(rels).map(rel => rel.table)),
|
|
954
|
-
...(options.
|
|
955
|
-
...(options.softDelete?.lifecycle ?? []),
|
|
917
|
+
...(options.lifecycle ?? []),
|
|
956
918
|
];
|
|
957
919
|
return new Set(names.map(toSnakeCase));
|
|
958
920
|
}
|
|
@@ -1002,7 +964,7 @@ function validateRowOwnership(schema: Schema, options: HandlerOptions | undefine
|
|
|
1002
964
|
if (!table) {
|
|
1003
965
|
if (declared.has(toSnakeCase(tableName))) continue;
|
|
1004
966
|
throw new Error(
|
|
1005
|
-
`[everystack/api] rowOwnership.${tableName}: table "${tableName}" is not in the schema and is not declared anywhere else in the handler options (exposedTables, relations, hiddenColumns, protectedFields,
|
|
967
|
+
`[everystack/api] rowOwnership.${tableName}: table "${tableName}" is not in the schema and is not declared anywhere else in the handler options (exposedTables, relations, hiddenColumns, protectedFields, lifecycle, hooks). Fix the name or remove the entry.`,
|
|
1006
968
|
);
|
|
1007
969
|
}
|
|
1008
970
|
if (getColumn(table, entry.column) === null) {
|
|
@@ -1018,53 +980,6 @@ function validateRowOwnership(schema: Schema, options: HandlerOptions | undefine
|
|
|
1018
980
|
}
|
|
1019
981
|
}
|
|
1020
982
|
|
|
1021
|
-
/**
|
|
1022
|
-
* Refuse to start on a softDelete.restore config the handler cannot honour. Every entry
|
|
1023
|
-
* must name a soft-delete table. A restorable table must not list the soft-delete column
|
|
1024
|
-
* in protectedFields: the strip would leave every restore body empty. Takes the expanded
|
|
1025
|
-
* options, so both spellings of a table are checked.
|
|
1026
|
-
*/
|
|
1027
|
-
function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | undefined): void {
|
|
1028
|
-
const softDelete = options?.softDelete;
|
|
1029
|
-
const colName = softDelete?.column || 'deletedAt';
|
|
1030
|
-
for (const [tableName, roles] of Object.entries(softDelete?.restore ?? {})) {
|
|
1031
|
-
const table = schema[tableName];
|
|
1032
|
-
if (isLifecycleTable(tableName, options)) {
|
|
1033
|
-
throw new Error(
|
|
1034
|
-
`[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.`,
|
|
1035
|
-
);
|
|
1036
|
-
}
|
|
1037
|
-
const isSoftDelete = table
|
|
1038
|
-
? isSoftDeleteTable(tableName, table, options)
|
|
1039
|
-
: (softDelete?.tables?.includes(tableName) ?? false);
|
|
1040
|
-
if (!isSoftDelete) {
|
|
1041
|
-
throw new Error(
|
|
1042
|
-
`[everystack/api] softDelete.restore.${tableName}: table "${tableName}" is not a soft-delete table. Add it to softDelete.tables or remove the entry.`,
|
|
1043
|
-
);
|
|
1044
|
-
}
|
|
1045
|
-
const sameColumn = (field: string): boolean => (table
|
|
1046
|
-
? getColumn(table, field) !== null && getColumn(table, field) === getColumn(table, colName)
|
|
1047
|
-
: toSnakeCase(field) === toSnakeCase(colName));
|
|
1048
|
-
if (roles.length > 0 && (ownEntry(options?.protectedFields, tableName) ?? []).some(sameColumn)) {
|
|
1049
|
-
throw new Error(
|
|
1050
|
-
`[everystack/api] softDelete.restore.${tableName}: the soft-delete column "${colName}" is in protectedFields.${tableName}, so every restore body is stripped to {}. Remove it from protectedFields.${tableName} or remove the restore entry.`,
|
|
1051
|
-
);
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
/**
|
|
1057
|
-
* True when the caller may restore on this table: its role claim is an exact member of
|
|
1058
|
-
* softDelete.restore[tableName]. Fail closed: no option, no list, no user, or a missing or
|
|
1059
|
-
* non-string role claim is not a member.
|
|
1060
|
-
*/
|
|
1061
|
-
function mayRestore(tableName: string, options: HandlerOptions | undefined, user: Record<string, unknown> | null): boolean {
|
|
1062
|
-
const roles = ownEntry(options?.softDelete?.restore, tableName);
|
|
1063
|
-
if (!roles?.length || !user) return false;
|
|
1064
|
-
const role = readRoleClaim(user, options?.auth?.roleField ?? 'role');
|
|
1065
|
-
return role !== '' && roles.includes(role);
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
983
|
/** Refuse a cleaning policy that cannot be enforced, including conflicting table aliases. */
|
|
1069
984
|
function validateSanitizeColumns(schema: Schema, options: HandlerOptions | undefined): void {
|
|
1070
985
|
const configured = new Map<DrizzleTable, Set<string>>();
|
|
@@ -1403,7 +1318,19 @@ function validateConstraintMessages(options?: HandlerOptions): void {
|
|
|
1403
1318
|
}
|
|
1404
1319
|
}
|
|
1405
1320
|
|
|
1321
|
+
/**
|
|
1322
|
+
* `softDelete` was removed. Refuse the key with any value, rather than ignore it: an ignored
|
|
1323
|
+
* option would hard-delete the rows the handler used to mark, and drop the restore path. This
|
|
1324
|
+
* also catches an app that spreads an old deriveHandlerConfig output.
|
|
1325
|
+
*/
|
|
1326
|
+
function refuseRemovedSoftDelete(options: HandlerOptions | undefined): void {
|
|
1327
|
+
if (options && 'softDelete' in options) {
|
|
1328
|
+
throw new Error('[everystack/api] softDelete was removed. Pass lifecycle tables as lifecycle: [...] (deriveHandlerConfig emits it). For a soft delete on a plain table, declare lifecycle: true on its model.');
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1406
1332
|
export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
|
|
1333
|
+
refuseRemovedSoftDelete(rawOptions);
|
|
1407
1334
|
refuseReservedHooks(rawOptions);
|
|
1408
1335
|
// The naming seam: URLs, exposedTables, and relation targets historically mixed
|
|
1409
1336
|
// drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
|
|
@@ -1415,7 +1342,6 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1415
1342
|
const configuredOptions = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
|
|
1416
1343
|
validateConstraintMessages(configuredOptions);
|
|
1417
1344
|
validateRowOwnership(schema, rawOptions);
|
|
1418
|
-
validateSoftDeleteRestore(schema, configuredOptions);
|
|
1419
1345
|
validateSanitizeColumns(schema, configuredOptions);
|
|
1420
1346
|
validateRelationDisplay(schema, configuredOptions);
|
|
1421
1347
|
validateDrizzleRelationNames(db, configuredOptions);
|
|
@@ -2108,13 +2034,11 @@ async function handleGet(
|
|
|
2108
2034
|
}
|
|
2109
2035
|
const conditions = filterResult.conditions;
|
|
2110
2036
|
|
|
2111
|
-
//
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
if (condition) conditions.push(condition);
|
|
2117
|
-
}
|
|
2037
|
+
// Lifecycle: filter deleted records unless ?deleted=include|only
|
|
2038
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
2039
|
+
if (sdCol) {
|
|
2040
|
+
const condition = softDeleteCondition(sdCol, params);
|
|
2041
|
+
if (condition) conditions.push(condition);
|
|
2118
2042
|
}
|
|
2119
2043
|
|
|
2120
2044
|
// Aggregate query path: SELECT count(*), sum(amount), ... GROUP BY platform
|
|
@@ -2984,23 +2908,6 @@ async function handlePatch(
|
|
|
2984
2908
|
throw err;
|
|
2985
2909
|
}
|
|
2986
2910
|
|
|
2987
|
-
// Restore: ?deleted=include|only is honoured only for a role in softDelete.restore for
|
|
2988
|
-
// this table. Anyone else is matched to live rows, as if the param were absent.
|
|
2989
|
-
const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(tableName, table, options) : null;
|
|
2990
|
-
const restoring = sdCol !== null && mayRestore(tableName, options, user);
|
|
2991
|
-
const deletedMode = params.get('deleted');
|
|
2992
|
-
if (restoring && (deletedMode === 'include' || deletedMode === 'only')) {
|
|
2993
|
-
// Checked after the protectedFields strip, so a body stripped to {} is refused here.
|
|
2994
|
-
const key = columnPropertyName(table, options?.softDelete?.column || 'deletedAt');
|
|
2995
|
-
const isRestoreBody = body !== null && typeof body === 'object' && !Array.isArray(body)
|
|
2996
|
-
&& Object.keys(body).length === 1 && key !== undefined && Object.hasOwn(body, key) && body[key] === null;
|
|
2997
|
-
if (!isRestoreBody) {
|
|
2998
|
-
const column = options?.softDelete?.column || 'deletedAt';
|
|
2999
|
-
const wire = options?.naming === 'snake_case' ? toSnakeCase(column) : column;
|
|
3000
|
-
return json({ message: `restore accepts only { ${wire}: null }` }, 400);
|
|
3001
|
-
}
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
2911
|
// With the ownership backstop lifted, an unfiltered PATCH would write every row the
|
|
3005
2912
|
// role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
|
|
3006
2913
|
const ownership = ownEntry(options?.rowOwnership, tableName);
|
|
@@ -3050,11 +2957,9 @@ async function handlePatch(
|
|
|
3050
2957
|
|
|
3051
2958
|
const conditions = filterResult.conditions;
|
|
3052
2959
|
|
|
3053
|
-
//
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
if (condition) conditions.push(condition);
|
|
3057
|
-
}
|
|
2960
|
+
// Lifecycle: update live records only (?deleted= was refused above)
|
|
2961
|
+
const sdCol = getSoftDeleteColumn(tableName, table, options);
|
|
2962
|
+
if (sdCol) conditions.push(isNull(sdCol));
|
|
3058
2963
|
|
|
3059
2964
|
// Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
|
|
3060
2965
|
if (scope.condition) conditions.push(scope.condition);
|
|
@@ -3232,12 +3137,7 @@ async function handleDelete(
|
|
|
3232
3137
|
const batchKey = columnPropertyName(table, LIFECYCLE_DELETED_BATCH);
|
|
3233
3138
|
if (batchKey) for (const [row, batch] of deleted.batchOf) row[batchKey] = batch;
|
|
3234
3139
|
} else {
|
|
3235
|
-
|
|
3236
|
-
if (sdCol) conditions.push(isNull(sdCol));
|
|
3237
|
-
const query: any = sdCol
|
|
3238
|
-
? db.update(table).set({ [options?.softDelete?.column || 'deletedAt']: new Date() })
|
|
3239
|
-
: db.delete(table);
|
|
3240
|
-
rows = await query.where(conditions.length === 1 ? conditions[0] : and(...conditions)).returning();
|
|
3140
|
+
rows = await db.delete(table).where(conditions.length === 1 ? conditions[0] : and(...conditions)).returning();
|
|
3241
3141
|
// RETURNING proves the caller's write authority. A veto or throw leaves the caller's
|
|
3242
3142
|
// transaction, so the statement rolls back before the response is made.
|
|
3243
3143
|
if (hook?.beforeDelete) for (const row of rows) {
|
|
@@ -3276,7 +3176,7 @@ async function lifecycleDelete(
|
|
|
3276
3176
|
const pk = primaryKeyColumns(table);
|
|
3277
3177
|
const key = pk.length === 1 ? columnPropertyName(table, pk[0].name) : undefined;
|
|
3278
3178
|
if (!sdCol || key === undefined) {
|
|
3279
|
-
throw new Error(`[everystack/api]
|
|
3179
|
+
throw new Error(`[everystack/api] lifecycle.${tableName}: the table needs a ${LIFECYCLE_DELETED_AT} column and a single-column primary key`);
|
|
3280
3180
|
}
|
|
3281
3181
|
const keyColumn = getTableColumns(table)[key];
|
|
3282
3182
|
const batchOf = new Map<Record<string, unknown>, string>();
|
|
@@ -3348,7 +3248,7 @@ async function handleRestore(
|
|
|
3348
3248
|
const keyType = key ? restoreArgType(key) : null;
|
|
3349
3249
|
const name = sqlNameOf(table);
|
|
3350
3250
|
if (!keyType || name === null || primaryKeyColumns(table).length !== 1) {
|
|
3351
|
-
const message = `[everystack/api]
|
|
3251
|
+
const message = `[everystack/api] lifecycle.${tableName}: restore needs a table with a single-column primary key of a plain type`;
|
|
3352
3252
|
await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method: 'POST', status: 500, message });
|
|
3353
3253
|
return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : message }, 500);
|
|
3354
3254
|
}
|
|
@@ -3361,10 +3261,10 @@ async function handleRestore(
|
|
|
3361
3261
|
// After the commit (executeWithSettings has returned). Best-effort: never changes the response.
|
|
3362
3262
|
const hook = ownEntry(options?.hooks, tableName);
|
|
3363
3263
|
const r = result as { restored?: unknown; skipped?: unknown; batch?: unknown } | null;
|
|
3364
|
-
if (hook?.afterRestore && r
|
|
3264
|
+
if (hook?.afterRestore && r) {
|
|
3365
3265
|
try {
|
|
3366
3266
|
await hook.afterRestore([{ [columnPropertyName(table, key.name) ?? key.name]: id }], {
|
|
3367
|
-
phase: 'restore', batch: r.batch, user, client,
|
|
3267
|
+
phase: 'restore', batch: typeof r.batch === 'string' ? r.batch : null, user, client,
|
|
3368
3268
|
counts: { restored: Number(r.restored), skipped: Number(r.skipped) },
|
|
3369
3269
|
});
|
|
3370
3270
|
} catch (err) {
|
|
@@ -3580,6 +3480,13 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
3580
3480
|
const ARRAY_OPERATORS = new Set(['cs', 'cd', 'ov']);
|
|
3581
3481
|
const VALID_OPERATORS = new Set(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'is', 'in', ...ARRAY_OPERATORS]);
|
|
3582
3482
|
const TEXT_ARRAY_ELEMENT_TYPES = new Set(['PgText', 'PgVarchar']);
|
|
3483
|
+
// These PostgreSQL types need text for pattern matching. Keep text/custom types
|
|
3484
|
+
// uncast so their operators (notably citext LIKE) and indexes keep their meaning.
|
|
3485
|
+
const LIKE_TEXT_CAST_TYPES = new Set([
|
|
3486
|
+
'PgUUID', 'PgEnumColumn', 'PgSmallInt', 'PgInteger', 'PgBigInt53', 'PgBigInt64',
|
|
3487
|
+
'PgSmallSerial', 'PgSerial', 'PgBigSerial53', 'PgBigSerial64',
|
|
3488
|
+
'PgNumeric', 'PgNumericNumber', 'PgNumericBigInt', 'PgReal', 'PgDoublePrecision',
|
|
3489
|
+
]);
|
|
3583
3490
|
|
|
3584
3491
|
function arrayOperatorRefusal(operator: string, column: string): { __filterError: string } {
|
|
3585
3492
|
return { __filterError: `Unknown filter operator: '${operator}' for column '${column}' (cs, cd and ov need a text[] or varchar[] column)` };
|
|
@@ -3634,7 +3541,47 @@ function dateFilterOperand(filter: Filter): Date | Date[] | { __filterError: str
|
|
|
3634
3541
|
return filter.operator === 'in' ? dates : dates[0];
|
|
3635
3542
|
}
|
|
3636
3543
|
|
|
3637
|
-
|
|
3544
|
+
const INTEGER_FILTER_BOUNDS: Readonly<Record<string, readonly [bigint, bigint]>> = {
|
|
3545
|
+
PgSmallInt: [-32768n, 32767n], PgSmallSerial: [-32768n, 32767n],
|
|
3546
|
+
PgInteger: [-2147483648n, 2147483647n], PgSerial: [-2147483648n, 2147483647n],
|
|
3547
|
+
PgBigInt53: [-9223372036854775808n, 9223372036854775807n],
|
|
3548
|
+
PgBigInt64: [-9223372036854775808n, 9223372036854775807n],
|
|
3549
|
+
PgBigSerial53: [-9223372036854775808n, 9223372036854775807n],
|
|
3550
|
+
PgBigSerial64: [-9223372036854775808n, 9223372036854775807n],
|
|
3551
|
+
};
|
|
3552
|
+
const NUMERIC_FILTER_TYPES = new Set(['PgNumeric', 'PgNumericNumber', 'PgNumericBigInt', 'PgReal', 'PgDoublePrecision']);
|
|
3553
|
+
|
|
3554
|
+
// PostgreSQL accepts unambiguous prefixes, but "o" could mean on or off.
|
|
3555
|
+
function booleanFilterValue(value: string): boolean | undefined {
|
|
3556
|
+
const literal = value.trim().toLowerCase();
|
|
3557
|
+
if (!literal || literal === 'o') return undefined;
|
|
3558
|
+
if (literal === '1' || ['true', 'yes', 'on'].some(word => word.startsWith(literal))) return true;
|
|
3559
|
+
if (literal === '0' || ['false', 'no', 'off'].some(word => word.startsWith(literal))) return false;
|
|
3560
|
+
return undefined;
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
function filterValueFitsColumn(column: DrizzleColumn, value: string): boolean {
|
|
3564
|
+
const bounds = INTEGER_FILTER_BOUNDS[column.columnType];
|
|
3565
|
+
if (bounds) {
|
|
3566
|
+
const literal = value.trim();
|
|
3567
|
+
// PG16 also permits one separator after a base prefix (0x_10).
|
|
3568
|
+
if (!/^[+-]?(?:[0-9](?:_?[0-9])*|0x_?[0-9a-f](?:_?[0-9a-f])*|0o_?[0-7](?:_?[0-7])*|0b_?[01](?:_?[01])*)$/i.test(literal)) return false;
|
|
3569
|
+
// BigInt does not accept a sign before a base prefix; apply it separately.
|
|
3570
|
+
const magnitude = BigInt(literal.replace(/^[+-]/, '').replace(/_/g, ''));
|
|
3571
|
+
const integer = literal.startsWith('-') ? -magnitude : magnitude;
|
|
3572
|
+
return integer >= bounds[0] && integer <= bounds[1];
|
|
3573
|
+
}
|
|
3574
|
+
if (NUMERIC_FILTER_TYPES.has(column.columnType)) {
|
|
3575
|
+
// Deliberately permissive: PostgreSQL remains the authority for numeric edges.
|
|
3576
|
+
const literal = value.trim();
|
|
3577
|
+
return literal !== '' && (!Number.isNaN(Number(literal.replace(/_/g, ''))) || /^[+-]?(?:inf(?:inity)?|nan)$/i.test(literal));
|
|
3578
|
+
}
|
|
3579
|
+
if (column.columnType === 'PgBoolean') return booleanFilterValue(value) !== undefined;
|
|
3580
|
+
if (column.columnType === 'PgEnumColumn') return column.enumValues?.includes(value) === true;
|
|
3581
|
+
return true;
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
function buildCondition(table: DrizzleTable, filter: Filter, positiveOr = false): any {
|
|
3638
3585
|
if (filter.jsonPath) {
|
|
3639
3586
|
return buildJsonCondition(table, filter);
|
|
3640
3587
|
}
|
|
@@ -3647,8 +3594,22 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
3647
3594
|
}
|
|
3648
3595
|
if (ARRAY_OPERATORS.has(filter.operator)) return buildArrayCondition(column, filter);
|
|
3649
3596
|
|
|
3597
|
+
const typedComparison = DATE_FILTER_OPERATORS.has(filter.operator);
|
|
3598
|
+
const members = filter.operator === 'in' ? parseInList(filter.value) : [filter.value];
|
|
3599
|
+
if (typedComparison && members.some(member => !filterValueFitsColumn(column, member))) {
|
|
3600
|
+
// Only a positive OR equality is impossible. Rewriting a range, inequality,
|
|
3601
|
+
// negation or AND member would change the request's meaning.
|
|
3602
|
+
if (positiveOr && filter.operator === 'eq' && !filter.negated) return sql`FALSE`;
|
|
3603
|
+
return { __filterError: `Invalid value for column '${filter.column}'` };
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3650
3606
|
let value: unknown = filter.value;
|
|
3651
3607
|
let values: unknown[] | null = null;
|
|
3608
|
+
if (typedComparison && column.columnType === 'PgBoolean') {
|
|
3609
|
+
// Bind booleans, not strings: the driver need not understand PG's literals.
|
|
3610
|
+
if (filter.operator === 'in') values = members.map(booleanFilterValue);
|
|
3611
|
+
else value = booleanFilterValue(filter.value);
|
|
3612
|
+
}
|
|
3652
3613
|
if (column.dataType === 'date' && filter.operator !== 'is') {
|
|
3653
3614
|
const dated = dateFilterOperand(filter);
|
|
3654
3615
|
if (isFilterError(dated)) return dated;
|
|
@@ -3679,10 +3640,14 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
3679
3640
|
break;
|
|
3680
3641
|
case 'like':
|
|
3681
3642
|
// PostgREST uses * for wildcard, SQL uses %
|
|
3682
|
-
condition =
|
|
3643
|
+
condition = LIKE_TEXT_CAST_TYPES.has(column.columnType)
|
|
3644
|
+
? sql`${column}::text LIKE ${filter.value.replace(/\*/g, '%')}`
|
|
3645
|
+
: like(column, filter.value.replace(/\*/g, '%'));
|
|
3683
3646
|
break;
|
|
3684
3647
|
case 'ilike':
|
|
3685
|
-
condition =
|
|
3648
|
+
condition = LIKE_TEXT_CAST_TYPES.has(column.columnType)
|
|
3649
|
+
? sql`${column}::text ILIKE ${filter.value.replace(/\*/g, '%')}`
|
|
3650
|
+
: ilike(column, filter.value.replace(/\*/g, '%'));
|
|
3686
3651
|
break;
|
|
3687
3652
|
case 'is':
|
|
3688
3653
|
if (filter.value === 'null') condition = isNull(column);
|
|
@@ -3909,7 +3874,7 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { con
|
|
|
3909
3874
|
for (const value of params.getAll(key)) {
|
|
3910
3875
|
const group = parseLogicalGroup(value);
|
|
3911
3876
|
if (group.error) return { error: group.error };
|
|
3912
|
-
const groupRawResults = group.filters.map(f => buildCondition(table, f));
|
|
3877
|
+
const groupRawResults = group.filters.map(f => buildCondition(table, f, key === 'or'));
|
|
3913
3878
|
for (const r of groupRawResults) {
|
|
3914
3879
|
if (isFilterError(r)) return { error: r.__filterError };
|
|
3915
3880
|
}
|