@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 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
- softDelete: {
87
- column: 'deletedAt',
88
- tables: ['posts', 'comments'],
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.13",
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>",
@@ -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
- /** Soft-delete tables: ?deleted=include|only on select and update. `.deleted('only').update({ deletedAt: null })` restores,
78
- * for a role the handler lists in `softDelete.restore`; for anyone else update ignores it. */
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[]>>;
@@ -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 and legacy softDelete rows come from
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`. Not called for a row deleted before batches
306
- * existed (batch null). Best-effort: a throw goes to onError and never changes the response. */
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
- /** Enable soft-delete: DELETE becomes UPDATE SET deleted_at, reads filter deleted rows */
386
- softDelete?: SoftDeleteConfig;
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, whatever softDelete.column says. */
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?.softDelete?.lifecycle?.includes(tableName) ?? false;
461
+ return options?.lifecycle?.includes(tableName) ?? false;
476
462
  }
477
463
 
478
464
  function getSoftDeleteColumn(tableName: string, table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
479
- if (!options?.softDelete) return null;
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 soft-delete condition for a GET or PATCH: ?deleted=include adds none, ?deleted=only
487
- * matches deleted rows, anything else (absent or unknown) matches live rows. DELETE does
488
- * not use it: it always matches live rows only.
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. Live rows only, except that a lifecycle table
508
- * honours the request's ?deleted=include|only like its top-level read: RLS decides what each
509
- * role sees. `params` is absent where no request applies (a pre-DB validation pass).
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 && isLifecycleTable(tableName, options) ? softDeleteCondition(sdCol, params) : isNull(sdCol);
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.softDelete
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.softDelete?.tables ?? []),
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, softDelete, hooks). Fix the name or remove the entry.`,
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
- // Soft-delete: filter deleted records unless ?deleted=include|only
2112
- if (isSoftDeleteTable(tableName, table, options)) {
2113
- const sdCol = getSoftDeleteColumn(tableName, table, options);
2114
- if (sdCol) {
2115
- const condition = softDeleteCondition(sdCol, params);
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
- // Soft-delete: update live records, unless a restore role sent ?deleted=include|only
3054
- if (sdCol) {
3055
- const condition = restoring ? softDeleteCondition(sdCol, params) : isNull(sdCol);
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
- const sdCol = isSoftDeleteTable(tableName, table, options) && getSoftDeleteColumn(tableName, table, options);
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] softDelete.lifecycle.${tableName}: the table needs a ${LIFECYCLE_DELETED_AT} column and a single-column primary key`);
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] softDelete.lifecycle.${tableName}: restore needs a table with a single-column primary key of a plain type`;
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 && typeof r.batch === 'string') {
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
- function buildCondition(table: DrizzleTable, filter: Filter): any {
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 = like(column, filter.value.replace(/\*/g, '%'));
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 = ilike(column, filter.value.replace(/\*/g, '%'));
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
  }