@everystack/api 0.4.5 → 0.4.7
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 +39 -0
- package/package.json +6 -1
- package/src/client/index.ts +10 -1
- package/src/handler/array-literal.ts +58 -0
- package/src/handler/errors.ts +39 -0
- package/src/handler/index.ts +466 -102
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,45 @@ 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.7] - 2026-10-18
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- date filters no longer throw; refuse impossible calendar dates; map 22007 and 22008 to 422
|
|
12
|
+
- list fields sort by default; the API refuses an order it cannot apply (#92)
|
|
13
|
+
- datetimes show in the viewer's zone and edit round-trip in it (#94)
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- HandlerError carries a machine-readable code and column on RPC answers
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- hiddenColumns accepts the camelCase of a column's SQL name; an ambiguous alias refuses at init
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- move HandlerError to its own import-free module, @everystack/api/errors
|
|
26
|
+
|
|
27
|
+
## [0.4.6] - 2026-10-16
|
|
28
|
+
|
|
29
|
+
### BREAKING CHANGES
|
|
30
|
+
|
|
31
|
+
- an undeclared embed in select now answers 400 instead of a 200 with the embed dropped
|
|
32
|
+
- a JSON-path filter that cannot be built now answers 400 instead of being dropped; a DELETE or PATCH with such a filter used to run on the remaining filters alone
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- cs, cd and ov array filter operators on text[] and varchar[] columns
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- map integrity errors to safe client refusals
|
|
41
|
+
- preserve legacy errors alongside integrity mapping
|
|
42
|
+
- protect integrity diagnostics across failing tables
|
|
43
|
+
- show integrity errors under matching form fields
|
|
44
|
+
- the integrity mapper follows nested causes and keeps legacy hostile-value answers
|
|
45
|
+
|
|
7
46
|
## [0.4.5] - 2026-10-15
|
|
8
47
|
|
|
9
48
|
### BREAKING CHANGES
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
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>",
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
"types": "./src/handler/index.ts",
|
|
28
28
|
"default": "./src/handler/index.ts"
|
|
29
29
|
},
|
|
30
|
+
"./errors": {
|
|
31
|
+
"source": "./src/handler/errors.ts",
|
|
32
|
+
"types": "./src/handler/errors.ts",
|
|
33
|
+
"default": "./src/handler/errors.ts"
|
|
34
|
+
},
|
|
30
35
|
"./client": {
|
|
31
36
|
"source": "./src/client/index.ts",
|
|
32
37
|
"types": "./src/client/index.ts",
|
package/src/client/index.ts
CHANGED
|
@@ -27,6 +27,9 @@ export interface ClientOptions {
|
|
|
27
27
|
export interface ClientError {
|
|
28
28
|
status: number;
|
|
29
29
|
message: string;
|
|
30
|
+
code?: string;
|
|
31
|
+
constraint?: string;
|
|
32
|
+
column?: string;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export interface ClientResponse<T> {
|
|
@@ -337,7 +340,13 @@ function parseContentRange(header: string | null): number {
|
|
|
337
340
|
|
|
338
341
|
async function parseError(response: Response): Promise<ClientError> {
|
|
339
342
|
const body = await response.json().catch(() => ({}));
|
|
340
|
-
return {
|
|
343
|
+
return {
|
|
344
|
+
status: response.status,
|
|
345
|
+
message: body.message || response.statusText,
|
|
346
|
+
...(typeof body.code === 'string' ? { code: body.code } : {}),
|
|
347
|
+
...(typeof body.constraint === 'string' ? { constraint: body.constraint } : {}),
|
|
348
|
+
...(typeof body.column === 'string' ? { column: body.column } : {}),
|
|
349
|
+
};
|
|
341
350
|
}
|
|
342
351
|
|
|
343
352
|
function buildUrl(
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Most elements an array filter value may list. A longer list is refused. */
|
|
2
|
+
export const ARRAY_LITERAL_MAX_ELEMENTS = 100;
|
|
3
|
+
/** Most characters one element may hold, after unescaping. A longer one is refused. */
|
|
4
|
+
export const ARRAY_LITERAL_MAX_ELEMENT_LENGTH = 200;
|
|
5
|
+
|
|
6
|
+
const UNQUOTED_FORBIDDEN = new Set(['{', '}', '"', '\\']);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse the PostgREST array value of a cs/cd/ov filter: `{a,b}`, `{"a,b","c\"d"}`, `{}`.
|
|
10
|
+
*
|
|
11
|
+
* An element is either unquoted (non-empty; no comma, brace, double quote, backslash or
|
|
12
|
+
* edge whitespace) or double-quoted, where `\"` and `\\` are the only escapes. Returns
|
|
13
|
+
* null for anything else, or past the element and length caps. The caller binds the
|
|
14
|
+
* result as one parameter; this text never reaches SQL.
|
|
15
|
+
*/
|
|
16
|
+
export function parseArrayLiteral(value: string): string[] | null {
|
|
17
|
+
if (value.length < 2 || value[0] !== '{' || value[value.length - 1] !== '}') return null;
|
|
18
|
+
const body = value.slice(1, -1);
|
|
19
|
+
if (body === '') return [];
|
|
20
|
+
|
|
21
|
+
const elements: string[] = [];
|
|
22
|
+
let i = 0;
|
|
23
|
+
for (;;) {
|
|
24
|
+
if (elements.length === ARRAY_LITERAL_MAX_ELEMENTS) return null;
|
|
25
|
+
let element = '';
|
|
26
|
+
if (body[i] === '"') {
|
|
27
|
+
i++;
|
|
28
|
+
for (;;) {
|
|
29
|
+
if (i >= body.length) return null;
|
|
30
|
+
const char = body[i];
|
|
31
|
+
if (char === '"') { i++; break; }
|
|
32
|
+
if (char === '\\') {
|
|
33
|
+
const next = body[i + 1];
|
|
34
|
+
if (next !== '"' && next !== '\\') return null;
|
|
35
|
+
element += next;
|
|
36
|
+
i += 2;
|
|
37
|
+
} else {
|
|
38
|
+
element += char;
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
if (element.length > ARRAY_LITERAL_MAX_ELEMENT_LENGTH) return null;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
const start = i;
|
|
45
|
+
while (i < body.length && body[i] !== ',') {
|
|
46
|
+
if (UNQUOTED_FORBIDDEN.has(body[i])) return null;
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
element = body.slice(start, i);
|
|
50
|
+
if (element === '' || /^\s|\s$/.test(element)) return null;
|
|
51
|
+
if (element.length > ARRAY_LITERAL_MAX_ELEMENT_LENGTH) return null;
|
|
52
|
+
}
|
|
53
|
+
elements.push(element);
|
|
54
|
+
if (i === body.length) return elements;
|
|
55
|
+
if (body[i] !== ',') return null;
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HandlerError lives in its own module with no imports, so code that only needs to throw one
|
|
3
|
+
* (the admin's RPC factory) does not pull the whole server handler into its bundle.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** An application error code: lower-case words, never a Postgres SQLSTATE or a system errno. */
|
|
7
|
+
const APP_ERROR_CODE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
8
|
+
const isAppErrorCode = (value: unknown): value is string => typeof value === 'string' && APP_ERROR_CODE.test(value);
|
|
9
|
+
const isAppErrorColumn = (value: unknown): value is string =>
|
|
10
|
+
typeof value === 'string' && value.length >= 1 && value.length <= 128 && !/[\u0000-\u001f\u007f-\u009f]/.test(value);
|
|
11
|
+
|
|
12
|
+
const appErrorMetadata = new WeakMap<object, { readonly code?: string; readonly column?: string }>();
|
|
13
|
+
const EMPTY_METADATA: { readonly code?: string; readonly column?: string } = Object.freeze(Object.create(null));
|
|
14
|
+
|
|
15
|
+
/** Throw from hooks or RPCs to return a custom status and message; RPCs also expose optional metadata. */
|
|
16
|
+
export class HandlerError extends Error {
|
|
17
|
+
status: number;
|
|
18
|
+
constructor(status: number, message: string, options?: { code?: string; column?: string }) {
|
|
19
|
+
super(message);
|
|
20
|
+
const code = options?.code;
|
|
21
|
+
const column = options?.column;
|
|
22
|
+
if (code !== undefined && !isAppErrorCode(code)) {
|
|
23
|
+
throw new TypeError('HandlerError code must match /^[a-z][a-z0-9_]{0,63}$/');
|
|
24
|
+
}
|
|
25
|
+
if (column !== undefined && !isAppErrorColumn(column)) {
|
|
26
|
+
throw new TypeError('HandlerError column must be 1 to 128 characters with no control characters');
|
|
27
|
+
}
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.name = 'HandlerError';
|
|
30
|
+
// Held outside the instance: only what the author passed here is ever sent, and no property
|
|
31
|
+
// or getter on the error can change it afterwards.
|
|
32
|
+
appErrorMetadata.set(this, Object.freeze({ code, column }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The `code` and `column` given to the constructor. Anything assigned to the error later is not here. */
|
|
36
|
+
static metadataOf(error: HandlerError): { code?: string; column?: string } {
|
|
37
|
+
return appErrorMetadata.get(error) ?? EMPTY_METADATA;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/handler/index.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* // Mount as Expo Router API route or any Web Standard handler
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, and, or, not, asc, desc, sql, getTableColumns, type SQL } from 'drizzle-orm';
|
|
10
|
+
import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, arrayContains, arrayContained, arrayOverlaps, and, or, not, asc, desc, sql, getTableColumns, is, Param, type SQL } from 'drizzle-orm';
|
|
11
|
+
import { getTableConfig, PgTable } from 'drizzle-orm/pg-core';
|
|
12
|
+
import { parseArrayLiteral, ARRAY_LITERAL_MAX_ELEMENTS, ARRAY_LITERAL_MAX_ELEMENT_LENGTH } from './array-literal';
|
|
11
13
|
import { emitAudit, resolvePrincipal, type AuditConfig, type RequestContext } from './audit';
|
|
12
14
|
import { isServiceUnavailable, SERVICE_UNAVAILABLE_MESSAGE } from './service-unavailable';
|
|
13
15
|
import { isPermissionDenied, safeRead, UNREADABLE } from './thrown';
|
|
@@ -24,15 +26,8 @@ type Schema = Record<string, DrizzleTable>;
|
|
|
24
26
|
|
|
25
27
|
type Handler = (request: Request) => Promise<Response>;
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
status: number;
|
|
30
|
-
constructor(status: number, message: string) {
|
|
31
|
-
super(message);
|
|
32
|
-
this.status = status;
|
|
33
|
-
this.name = 'HandlerError';
|
|
34
|
-
}
|
|
35
|
-
}
|
|
29
|
+
export { HandlerError } from './errors';
|
|
30
|
+
import { HandlerError } from './errors';
|
|
36
31
|
|
|
37
32
|
/**
|
|
38
33
|
* A 5xx system fault surfaced to the observability sink. Distinct from a 4xx
|
|
@@ -176,13 +171,18 @@ function describeError(error: unknown): Pick<HandlerFault, 'message' | 'stack' |
|
|
|
176
171
|
}
|
|
177
172
|
|
|
178
173
|
/** Replace schema-revealing error details with a generic message when hideSchemaErrors is enabled. */
|
|
179
|
-
function sanitizeError(
|
|
174
|
+
function sanitizeError(
|
|
175
|
+
message: string,
|
|
176
|
+
status: number,
|
|
177
|
+
options?: HandlerOptions,
|
|
178
|
+
extra?: { code: string; column: string },
|
|
179
|
+
): Response {
|
|
180
180
|
if (options?.hideSchemaErrors) {
|
|
181
181
|
if (status === 404) return json({ message: 'Not found' }, 404);
|
|
182
182
|
if (status === 403) return json({ message: 'Forbidden' }, 403);
|
|
183
|
-
if (status === 400) return json({ message: 'Bad request' }, 400);
|
|
183
|
+
if (status === 400) return json({ message: 'Bad request', ...extra }, 400);
|
|
184
184
|
}
|
|
185
|
-
return json({ message }, status);
|
|
185
|
+
return json({ message, ...extra }, status);
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
export interface AuthConfig {
|
|
@@ -392,6 +392,9 @@ export interface HandlerOptions {
|
|
|
392
392
|
/** When true, error messages hide internal details (table names, column names, operators).
|
|
393
393
|
* Enable in production to prevent schema enumeration. Default: false. */
|
|
394
394
|
hideSchemaErrors?: boolean;
|
|
395
|
+
/** App-approved messages keyed by PostgreSQL constraint name. These may expose an
|
|
396
|
+
* optional column even with hideSchemaErrors; hiddenColumns always wins. */
|
|
397
|
+
constraintMessages?: Record<string, string | { message: string; column?: string }>;
|
|
395
398
|
/** Forensic audit logging (distinct from observability). When a sink is set,
|
|
396
399
|
* records every non-GET request (writes + RPCs) — the query, not the response —
|
|
397
400
|
* to an app-provided append-only sink, stamped with the trace-id (`x-amz-cf-id`).
|
|
@@ -714,6 +717,48 @@ function transformBodyKeys(body: unknown, table: DrizzleTable): unknown {
|
|
|
714
717
|
*/
|
|
715
718
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?$/;
|
|
716
719
|
|
|
720
|
+
/**
|
|
721
|
+
* The one date rule, shared by a write body and a query-string filter so the two cannot
|
|
722
|
+
* drift: the Date for `value`, or the 400 message naming `key`.
|
|
723
|
+
*/
|
|
724
|
+
function parseDateValue(key: string, value: string | number): Date | { error: string } {
|
|
725
|
+
if (typeof value === 'string' && !ISO_DATE_RE.test(value)) {
|
|
726
|
+
return { error: `Invalid date value for "${key}": expected ISO 8601, got ${JSON.stringify(value)}` };
|
|
727
|
+
}
|
|
728
|
+
const parsed = new Date(value);
|
|
729
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
730
|
+
return { error: `Invalid date value for "${key}": ${JSON.stringify(value)}` };
|
|
731
|
+
}
|
|
732
|
+
if (typeof value === 'string' && !isCalendarDate(value.slice(0, 10))) {
|
|
733
|
+
return { error: `Invalid date value for "${key}": ${value.slice(0, 10)} is not a calendar date` };
|
|
734
|
+
}
|
|
735
|
+
return parsed;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The YYYY-MM-DD digits as written name a real day. `new Date('2026-02-29')` rolls over to
|
|
740
|
+
* 1 March with no error, which would match or store the wrong day; PostgreSQL refuses it.
|
|
741
|
+
* Checked on the digits before any offset is applied.
|
|
742
|
+
*/
|
|
743
|
+
function isCalendarDate(ymd: string): boolean {
|
|
744
|
+
const [year, month, day] = ymd.split('-').map(Number);
|
|
745
|
+
const probe = new Date(0);
|
|
746
|
+
probe.setUTCFullYear(year, month - 1, day);
|
|
747
|
+
return probe.getUTCFullYear() === year && probe.getUTCMonth() === month - 1 && probe.getUTCDate() === day;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* A JSON boolean, array or plain object: never a date, and the date encoder throws on it.
|
|
752
|
+
* A Date or a drizzle SQL/Param a hook set has a class prototype and is not one of these.
|
|
753
|
+
*/
|
|
754
|
+
function isJsonShape(value: unknown): boolean {
|
|
755
|
+
if (typeof value === 'boolean' || Array.isArray(value)) return true;
|
|
756
|
+
if (value === null || typeof value !== 'object') return false;
|
|
757
|
+
// Plain from any realm: a parsed body's Object.prototype need not be this module's.
|
|
758
|
+
const proto = Object.getPrototypeOf(value);
|
|
759
|
+
return proto === null || Object.getPrototypeOf(proto) === null;
|
|
760
|
+
}
|
|
761
|
+
|
|
717
762
|
function coerceDateColumnValues(body: unknown, table: DrizzleTable): unknown {
|
|
718
763
|
if (Array.isArray(body)) return body.map(row => coerceDateColumnValues(row, table));
|
|
719
764
|
if (body === null || typeof body !== 'object') {
|
|
@@ -722,16 +767,14 @@ function coerceDateColumnValues(body: unknown, table: DrizzleTable): unknown {
|
|
|
722
767
|
|
|
723
768
|
const result: Record<string, unknown> = { ...(body as Record<string, unknown>) };
|
|
724
769
|
for (const [key, value] of Object.entries(result)) {
|
|
725
|
-
if (typeof value !== 'string' && typeof value !== 'number') continue;
|
|
770
|
+
if (typeof value !== 'string' && typeof value !== 'number' && !isJsonShape(value)) continue;
|
|
726
771
|
const column = getColumn(table, key);
|
|
727
772
|
if (!column || column.dataType !== 'date') continue;
|
|
728
|
-
if (
|
|
773
|
+
if (isJsonShape(value)) {
|
|
729
774
|
throw new HandlerError(400, `Invalid date value for "${key}": expected ISO 8601, got ${JSON.stringify(value)}`);
|
|
730
775
|
}
|
|
731
|
-
const parsed =
|
|
732
|
-
if (
|
|
733
|
-
throw new HandlerError(400, `Invalid date value for "${key}": ${JSON.stringify(value)}`);
|
|
734
|
-
}
|
|
776
|
+
const parsed = parseDateValue(key, value as string | number);
|
|
777
|
+
if (!(parsed instanceof Date)) throw new HandlerError(400, parsed.error);
|
|
735
778
|
result[key] = parsed;
|
|
736
779
|
}
|
|
737
780
|
return result;
|
|
@@ -948,7 +991,16 @@ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): Handler
|
|
|
948
991
|
const table = schema[tableName];
|
|
949
992
|
const hidden = byTable.get(table) ?? new Set<string>();
|
|
950
993
|
for (const name of names) {
|
|
951
|
-
|
|
994
|
+
let property = columnPropertyName(table, name);
|
|
995
|
+
if (property === undefined && table) {
|
|
996
|
+
const matches = Object.entries(getTableColumns(table))
|
|
997
|
+
.filter(([, column]) => toCamelCase((column as DrizzleColumn).name) === name);
|
|
998
|
+
if (matches.length > 1) {
|
|
999
|
+
const candidates = matches.map(([key]) => JSON.stringify(key)).join(' and ');
|
|
1000
|
+
throw new Error(`[everystack/api] hiddenColumns.${tableName}: "${name}" is ambiguous: it is the camelCase of columns ${candidates}; name one by its property or SQL name`);
|
|
1001
|
+
}
|
|
1002
|
+
property = matches[0]?.[0];
|
|
1003
|
+
}
|
|
952
1004
|
if (property === undefined) {
|
|
953
1005
|
throw new Error(`[everystack/api] hiddenColumns.${tableName}: "${name}" does not resolve to a column`);
|
|
954
1006
|
}
|
|
@@ -964,6 +1016,109 @@ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): Handler
|
|
|
964
1016
|
return { ...options, hiddenColumns };
|
|
965
1017
|
}
|
|
966
1018
|
|
|
1019
|
+
/** Safe, fixed client messages. Never derive these from PostgreSQL text or detail. */
|
|
1020
|
+
const INTEGRITY_ERRORS: Record<string, { status: number; message: string }> = {
|
|
1021
|
+
'23514': { status: 422, message: 'Value does not meet a constraint' },
|
|
1022
|
+
'23502': { status: 422, message: 'A required value is missing' },
|
|
1023
|
+
'23503': { status: 422, message: 'Related record does not exist' },
|
|
1024
|
+
'23000': { status: 422, message: 'Value violates an integrity constraint' },
|
|
1025
|
+
'23505': { status: 409, message: 'Value already exists' },
|
|
1026
|
+
'23P01': { status: 409, message: 'Value conflicts with an existing record' },
|
|
1027
|
+
'22P02': { status: 422, message: 'Invalid value format' },
|
|
1028
|
+
'22001': { status: 422, message: 'Value is too long' },
|
|
1029
|
+
'22003': { status: 422, message: 'Value is out of range' },
|
|
1030
|
+
'22007': { status: 422, message: 'Invalid date or time format' },
|
|
1031
|
+
'22008': { status: 422, message: 'Date or time value is out of range' },
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
type IntegrityAnswer = { status: number; body: {
|
|
1035
|
+
message: string; code: string; constraint?: string; column?: string;
|
|
1036
|
+
} };
|
|
1037
|
+
|
|
1038
|
+
/** How many `cause` links the integrity mapper follows from the thrown value. */
|
|
1039
|
+
const MAX_CAUSE_HOPS = 3;
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Both dispatch catches use this after their transaction has rolled back. A throwing
|
|
1043
|
+
* diagnostic getter invalidates the whole classification, including a wrapped cause.
|
|
1044
|
+
* undefined means unclassified; UNREADABLE preserves the caller's 500 fault path.
|
|
1045
|
+
*/
|
|
1046
|
+
function mapIntegrityError(
|
|
1047
|
+
error: unknown,
|
|
1048
|
+
tableName: string | undefined,
|
|
1049
|
+
options?: HandlerOptions,
|
|
1050
|
+
): IntegrityAnswer | typeof UNREADABLE | undefined {
|
|
1051
|
+
// Drizzle drivers may throw directly or wrap the driver's error as cause, and a
|
|
1052
|
+
// query wrapper can wrap again. The first string code on the chain decides.
|
|
1053
|
+
// Classify before touching diagnostics, so unrelated codes keep their old answer.
|
|
1054
|
+
const chain: unknown[] = [error];
|
|
1055
|
+
let code = safeRead(error, 'code');
|
|
1056
|
+
while (typeof code !== 'string' && chain.length <= MAX_CAUSE_HOPS) {
|
|
1057
|
+
if (code === UNREADABLE) return UNREADABLE;
|
|
1058
|
+
const cause = safeRead(chain[chain.length - 1], 'cause');
|
|
1059
|
+
if (cause === UNREADABLE) return UNREADABLE;
|
|
1060
|
+
if (cause === null || typeof cause !== 'object' || chain.includes(cause)) break;
|
|
1061
|
+
chain.push(cause);
|
|
1062
|
+
code = safeRead(cause, 'code');
|
|
1063
|
+
}
|
|
1064
|
+
if (code === UNREADABLE) return UNREADABLE;
|
|
1065
|
+
if (typeof code !== 'string' || !Object.hasOwn(INTEGRITY_ERRORS, code)) return undefined;
|
|
1066
|
+
const read = (value: unknown) => ({
|
|
1067
|
+
message: safeRead(value, 'message'), cause: safeRead(value, 'cause'),
|
|
1068
|
+
constraint: safeRead(value, 'constraint') ?? safeRead(value, 'constraint_name'),
|
|
1069
|
+
column: safeRead(value, 'column') ?? safeRead(value, 'column_name'),
|
|
1070
|
+
table: safeRead(value, 'table') ?? safeRead(value, 'table_name'),
|
|
1071
|
+
});
|
|
1072
|
+
// Diagnostics come only from the object that carried the code (the last link).
|
|
1073
|
+
// Wrapper messages and fields are read only to refuse a hostile value.
|
|
1074
|
+
let fields!: ReturnType<typeof read>;
|
|
1075
|
+
for (const value of chain) {
|
|
1076
|
+
fields = read(value);
|
|
1077
|
+
if (Object.values(fields).includes(UNREADABLE)) return UNREADABLE;
|
|
1078
|
+
}
|
|
1079
|
+
const mapped = INTEGRITY_ERRORS[code];
|
|
1080
|
+
const constraint = typeof fields.constraint === 'string' ? fields.constraint : undefined;
|
|
1081
|
+
const declared = constraint === undefined ? undefined : ownEntry(options?.constraintMessages, constraint);
|
|
1082
|
+
const body: IntegrityAnswer['body'] = { message: mapped.message, code };
|
|
1083
|
+
if (declared !== undefined) {
|
|
1084
|
+
body.message = typeof declared === 'string' ? declared : declared.message;
|
|
1085
|
+
body.constraint = constraint;
|
|
1086
|
+
if (typeof declared !== 'string' && declared.column !== undefined) body.column = declared.column;
|
|
1087
|
+
} else if (!options?.hideSchemaErrors) {
|
|
1088
|
+
if (constraint !== undefined) body.constraint = constraint;
|
|
1089
|
+
if (code === '23502' && typeof fields.column === 'string') body.column = fields.column;
|
|
1090
|
+
}
|
|
1091
|
+
// A failing trigger or FK action can name a different table than the URL.
|
|
1092
|
+
// Missing driver tables (and unknown RPC policies) use every configured list.
|
|
1093
|
+
const driverTable = typeof fields.table === 'string' ? fields.table : undefined;
|
|
1094
|
+
const hiddenColumns = options?.hiddenColumns ?? {};
|
|
1095
|
+
const driverHidden = driverTable === undefined
|
|
1096
|
+
|| (tableName === undefined && !Object.hasOwn(hiddenColumns, driverTable))
|
|
1097
|
+
? Object.values(hiddenColumns).flat() : ownEntry(hiddenColumns, driverTable) ?? [];
|
|
1098
|
+
const hidden = [
|
|
1099
|
+
...(tableName !== undefined ? ownEntry(hiddenColumns, tableName) ?? [] : []),
|
|
1100
|
+
...driverHidden,
|
|
1101
|
+
];
|
|
1102
|
+
if (body.column !== undefined && hidden.includes(body.column)) delete body.column;
|
|
1103
|
+
return { status: mapped.status, body };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function validateConstraintMessages(options?: HandlerOptions): void {
|
|
1107
|
+
if (options?.constraintMessages === undefined) return;
|
|
1108
|
+
if (options.constraintMessages === null || typeof options.constraintMessages !== 'object'
|
|
1109
|
+
|| Array.isArray(options.constraintMessages)) {
|
|
1110
|
+
throw new Error('[everystack/api] constraintMessages must be a record');
|
|
1111
|
+
}
|
|
1112
|
+
for (const [constraint, entry] of Object.entries(options.constraintMessages)) {
|
|
1113
|
+
const object = entry !== null && typeof entry === 'object' && !Array.isArray(entry);
|
|
1114
|
+
const message = typeof entry === 'string' ? entry : object ? entry.message : undefined;
|
|
1115
|
+
if (typeof message !== 'string' || message.trim().length === 0
|
|
1116
|
+
|| (object && entry.column !== undefined && typeof entry.column !== 'string')) {
|
|
1117
|
+
throw new Error(`[everystack/api] constraintMessages.${constraint}: expected a non-empty message and optional string column`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
967
1122
|
export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
|
|
968
1123
|
// The naming seam: URLs, exposedTables, and relation targets historically mixed
|
|
969
1124
|
// drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
|
|
@@ -973,6 +1128,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
973
1128
|
const namePairs = tableNamePairs(rawSchema);
|
|
974
1129
|
const schema = expandSchema(rawSchema, namePairs);
|
|
975
1130
|
const options = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
|
|
1131
|
+
validateConstraintMessages(options);
|
|
976
1132
|
validateRowOwnership(schema, rawOptions);
|
|
977
1133
|
validateSoftDeleteRestore(schema, options);
|
|
978
1134
|
validateSanitizeColumns(schema, options);
|
|
@@ -1156,20 +1312,22 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1156
1312
|
const rpcCacheControl = getCacheControl(options);
|
|
1157
1313
|
return json(result, 200, rpcCacheControl ? { 'Cache-Control': rpcCacheControl } : undefined);
|
|
1158
1314
|
} catch (error) {
|
|
1315
|
+
// App answers and outages take precedence over integrity classification.
|
|
1316
|
+
const unavailable = !(error instanceof HandlerError) && isServiceUnavailable(error);
|
|
1317
|
+
const integrity = error instanceof HandlerError || unavailable
|
|
1318
|
+
? undefined : mapIntegrityError(error, undefined, options);
|
|
1159
1319
|
const code = safeRead(error, 'code');
|
|
1160
1320
|
const text = safeRead(error, 'message');
|
|
1161
|
-
// A value whose getter throws is hostile: classify nothing from it
|
|
1162
|
-
//
|
|
1163
|
-
const hostile =
|
|
1321
|
+
// A value whose getter throws is hostile: classify nothing from it and echo
|
|
1322
|
+
// nothing of it, whatever its code (the mapper reads cause only for its codes).
|
|
1323
|
+
const hostile = integrity === UNREADABLE || code === UNREADABLE || text === UNREADABLE
|
|
1324
|
+
|| safeRead(error, 'cause') === UNREADABLE;
|
|
1164
1325
|
const pgCode = hostile ? undefined : code;
|
|
1165
|
-
|
|
1166
|
-
// carries host and port; it goes to onError only. A HandlerError's
|
|
1167
|
-
// own answer still wins: its return below runs first.
|
|
1168
|
-
const unavailable = isServiceUnavailable(error);
|
|
1326
|
+
const mapped = !hostile ? integrity : undefined;
|
|
1169
1327
|
let status = 500;
|
|
1170
1328
|
if (unavailable) status = 503;
|
|
1329
|
+
else if (mapped) status = mapped.status;
|
|
1171
1330
|
else if (pgCode === '42501') status = 403;
|
|
1172
|
-
else if (pgCode === '23505') status = 409;
|
|
1173
1331
|
else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
|
|
1174
1332
|
// Split the outcome: an authz/validation refusal (4xx) is `deny`; a
|
|
1175
1333
|
// system fault (5xx) is `error`. Conflating them blinds an investigation.
|
|
@@ -1189,9 +1347,16 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1189
1347
|
});
|
|
1190
1348
|
}
|
|
1191
1349
|
if (error instanceof HandlerError) {
|
|
1192
|
-
|
|
1350
|
+
const { code, column } = HandlerError.metadataOf(error);
|
|
1351
|
+
return json({
|
|
1352
|
+
message: error.message,
|
|
1353
|
+
...(code !== undefined ? { code } : {}),
|
|
1354
|
+
...(column !== undefined ? { column } : {}),
|
|
1355
|
+
}, error.status);
|
|
1193
1356
|
}
|
|
1194
1357
|
if (unavailable) return serviceUnavailableResponse();
|
|
1358
|
+
if (hostile) return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : 'RPC execution failed' }, 500);
|
|
1359
|
+
if (mapped) return json(mapped.body, mapped.status);
|
|
1195
1360
|
const message = !hostile && error instanceof Error ? text : 'RPC execution failed';
|
|
1196
1361
|
if (options?.hideSchemaErrors && status === 500) {
|
|
1197
1362
|
return json({ message: 'Internal Server Error' }, 500);
|
|
@@ -1270,6 +1435,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1270
1435
|
// Bound every select before the hidden-column guard walks it. The guard
|
|
1271
1436
|
// reads duplicates and runs for writes too, so a GET-only check is too late.
|
|
1272
1437
|
let queryError: string | undefined;
|
|
1438
|
+
let queryErrorEchoesInput = false;
|
|
1273
1439
|
let auditReadDenial = false;
|
|
1274
1440
|
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1275
1441
|
const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
|
|
@@ -1280,20 +1446,33 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1280
1446
|
}
|
|
1281
1447
|
}
|
|
1282
1448
|
|
|
1449
|
+
// A part with parentheses is an aggregate or a declared embed. Anything
|
|
1450
|
+
// else cannot be honored, so refuse it rather than drop it from the rows.
|
|
1451
|
+
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1452
|
+
if (!queryError) {
|
|
1453
|
+
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1454
|
+
const { undeclared } = parseSelectParam(selectParam, tableRelations, options?.relations);
|
|
1455
|
+
if (undeclared !== undefined) {
|
|
1456
|
+
queryError = undeclaredSelectPartMessage(undeclared);
|
|
1457
|
+
queryErrorEchoesInput = true;
|
|
1458
|
+
break;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1283
1463
|
// Refusals share the response audit below without opening a transaction.
|
|
1284
1464
|
// Response stripping cannot prevent value oracles or aggregate alias reads.
|
|
1285
1465
|
if (!queryError && requestUsesHiddenColumn(schema, tableName, url.searchParams, options)) {
|
|
1286
1466
|
queryError = 'Invalid query';
|
|
1287
1467
|
}
|
|
1288
|
-
// Refuse projections
|
|
1289
|
-
|
|
1290
|
-
if (!queryError && options?.drizzleRelations && tableRelations) {
|
|
1468
|
+
// Refuse projections neither read path can enforce, on the common pre-DB audit path.
|
|
1469
|
+
if (!queryError && tableRelations) {
|
|
1291
1470
|
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1292
|
-
const { embeds } = parseSelectParam(selectParam, tableRelations, options
|
|
1293
|
-
const
|
|
1294
|
-
if (
|
|
1471
|
+
const { embeds } = parseSelectParam(selectParam, tableRelations, options?.relations);
|
|
1472
|
+
const refused = refusedEmbedProjection(embeds, tableRelations, schema, options);
|
|
1473
|
+
if (refused !== undefined) {
|
|
1295
1474
|
queryError = 'Invalid query';
|
|
1296
|
-
auditReadDenial = method === 'GET' &&
|
|
1475
|
+
auditReadDenial = method === 'GET' && refused === 'unresolvedHiddenColumns';
|
|
1297
1476
|
break;
|
|
1298
1477
|
}
|
|
1299
1478
|
}
|
|
@@ -1317,7 +1496,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1317
1496
|
const deferred: Deferred[] = [];
|
|
1318
1497
|
let response: Response;
|
|
1319
1498
|
try {
|
|
1320
|
-
response = queryError ? json({ message: queryError }, 400)
|
|
1499
|
+
response = queryError ? (queryErrorEchoesInput ? sanitizeError(queryError, 400, options) : json({ message: queryError }, 400))
|
|
1321
1500
|
: unbacked ? json({ message: unbacked }, 500) : await executeWithSettings(routedDb, settings, async (dbOrTx) => {
|
|
1322
1501
|
if (method === 'GET') {
|
|
1323
1502
|
const cacheControl = getCacheControl(options);
|
|
@@ -1358,20 +1537,21 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1358
1537
|
}
|
|
1359
1538
|
return response;
|
|
1360
1539
|
} catch (error) {
|
|
1540
|
+
const appError = error instanceof HandlerError;
|
|
1541
|
+
const unavailable = !appError && isServiceUnavailable(error);
|
|
1542
|
+
const integrity = appError || unavailable ? undefined : mapIntegrityError(error, tableName, options);
|
|
1543
|
+
const hostile = integrity === UNREADABLE;
|
|
1544
|
+
const mapped = integrity !== UNREADABLE ? integrity : undefined;
|
|
1545
|
+
const permissionDenied = !appError && !unavailable && !hostile && isPermissionDenied(error);
|
|
1361
1546
|
if (isWrite) {
|
|
1362
1547
|
await emitAudit(options?.audit, {
|
|
1363
1548
|
kind: 'rest', name: tableName, user, client,
|
|
1364
|
-
result:
|
|
1549
|
+
result: mapped || permissionDenied ? 'deny' : 'error',
|
|
1365
1550
|
requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
|
|
1366
1551
|
});
|
|
1367
1552
|
}
|
|
1368
|
-
if (
|
|
1369
|
-
|
|
1370
|
-
}
|
|
1371
|
-
// An outage answers 503 with a fixed body; the driver's message (host and
|
|
1372
|
-
// port) goes to onError only. A HandlerError is the app's own answer and
|
|
1373
|
-
// never an outage.
|
|
1374
|
-
const unavailable = !(error instanceof HandlerError) && isServiceUnavailable(error);
|
|
1553
|
+
if (mapped) return json(mapped.body, mapped.status);
|
|
1554
|
+
if (permissionDenied) return sanitizeError('Permission denied', 403, options);
|
|
1375
1555
|
// Other table faults re-throw to the Lambda boundary (which logs to
|
|
1376
1556
|
// CloudWatch), but that path never reaches the S3 archive. Surface the
|
|
1377
1557
|
// cause here too so a write 5xx is in logs:errors. A HandlerError with a
|
|
@@ -1595,6 +1775,9 @@ async function handleGet(
|
|
|
1595
1775
|
}
|
|
1596
1776
|
|
|
1597
1777
|
const groupColumns = columns || [];
|
|
1778
|
+
const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, false, groupColumns);
|
|
1779
|
+
if ('error' in order) return sanitizeError(order.error, 400, options, { code: order.code, column: order.column });
|
|
1780
|
+
|
|
1598
1781
|
const selectObj = buildAggregateSelect(table, aggregates, groupColumns);
|
|
1599
1782
|
|
|
1600
1783
|
let query: any = db.select(selectObj).from(table);
|
|
@@ -1612,10 +1795,7 @@ async function handleGet(
|
|
|
1612
1795
|
}
|
|
1613
1796
|
}
|
|
1614
1797
|
|
|
1615
|
-
if (
|
|
1616
|
-
const orderExpr = buildOrderBy(table, orderParam);
|
|
1617
|
-
if (orderExpr) query = query.orderBy(orderExpr);
|
|
1618
|
-
}
|
|
1798
|
+
if (order.expressions.length > 0) query = query.orderBy(...order.expressions);
|
|
1619
1799
|
|
|
1620
1800
|
query = query.limit(limitVal).offset(offsetVal);
|
|
1621
1801
|
|
|
@@ -1650,8 +1830,12 @@ async function handleGet(
|
|
|
1650
1830
|
&& (db as any).query?.[tableName];
|
|
1651
1831
|
|
|
1652
1832
|
let rows: unknown[];
|
|
1833
|
+
const isHidden = (column: string): boolean => isHiddenColumn(schema, tableName, column, options);
|
|
1653
1834
|
|
|
1654
1835
|
if (useRelationalQuery) {
|
|
1836
|
+
const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, true, undefined, isHidden);
|
|
1837
|
+
if ('error' in order) return sanitizeError(order.error, 400, options, { code: order.code, column: order.column });
|
|
1838
|
+
|
|
1655
1839
|
// JOIN path — single query via Drizzle relational API (db.query.findMany)
|
|
1656
1840
|
const queryOptions: Record<string, any> = {
|
|
1657
1841
|
limit: limitVal,
|
|
@@ -1673,14 +1857,14 @@ async function handleGet(
|
|
|
1673
1857
|
queryOptions.where = and(...conditions);
|
|
1674
1858
|
}
|
|
1675
1859
|
|
|
1676
|
-
if (
|
|
1677
|
-
const orderExpr = buildOrderBy(table, orderParam);
|
|
1678
|
-
if (orderExpr) queryOptions.orderBy = orderExpr;
|
|
1679
|
-
}
|
|
1860
|
+
if (order.expressions.length > 0) queryOptions.orderBy = order.expressions;
|
|
1680
1861
|
|
|
1681
1862
|
rows = await (db as any).query[tableName].findMany(queryOptions);
|
|
1682
1863
|
} else {
|
|
1683
1864
|
// Batch path — standard select + separate relation queries
|
|
1865
|
+
const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, true, undefined, isHidden);
|
|
1866
|
+
if ('error' in order) return sanitizeError(order.error, 400, options, { code: order.code, column: order.column });
|
|
1867
|
+
|
|
1684
1868
|
let query: any = db.select().from(table);
|
|
1685
1869
|
|
|
1686
1870
|
if (conditions.length === 1) {
|
|
@@ -1689,12 +1873,7 @@ async function handleGet(
|
|
|
1689
1873
|
query = query.where(and(...conditions));
|
|
1690
1874
|
}
|
|
1691
1875
|
|
|
1692
|
-
if (
|
|
1693
|
-
const orderExpr = buildOrderBy(table, orderParam);
|
|
1694
|
-
if (orderExpr) {
|
|
1695
|
-
query = query.orderBy(orderExpr);
|
|
1696
|
-
}
|
|
1697
|
-
}
|
|
1876
|
+
if (order.expressions.length > 0) query = query.orderBy(...order.expressions);
|
|
1698
1877
|
|
|
1699
1878
|
query = query.limit(limitVal).offset(offsetVal);
|
|
1700
1879
|
rows = await query;
|
|
@@ -1799,7 +1978,7 @@ function parseSelectParam(
|
|
|
1799
1978
|
selectParam: string | null,
|
|
1800
1979
|
tableRelations?: Record<string, RelationConfig>,
|
|
1801
1980
|
allRelations?: Record<string, Record<string, RelationConfig>>,
|
|
1802
|
-
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[] } {
|
|
1981
|
+
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string } {
|
|
1803
1982
|
if (!selectParam) {
|
|
1804
1983
|
return { columns: null, embeds: [], aggregates: [] };
|
|
1805
1984
|
}
|
|
@@ -1808,6 +1987,9 @@ function parseSelectParam(
|
|
|
1808
1987
|
const columns: string[] = [];
|
|
1809
1988
|
const aggregates: AggregateField[] = [];
|
|
1810
1989
|
let hasWildcard = false;
|
|
1990
|
+
// The first part with parentheses that is neither an aggregate nor a declared
|
|
1991
|
+
// embed, at any depth. The caller refuses the select; it is never a column.
|
|
1992
|
+
let undeclared: string | undefined;
|
|
1811
1993
|
|
|
1812
1994
|
// Parse: split on commas, but respect parentheses
|
|
1813
1995
|
const parts = splitSelectParam(selectParam);
|
|
@@ -1831,11 +2013,14 @@ function parseSelectParam(
|
|
|
1831
2013
|
const targetTableName = tableRelations[name].table;
|
|
1832
2014
|
const targetRelations = ownEntry(allRelations, targetTableName);
|
|
1833
2015
|
const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
|
|
2016
|
+
undeclared ??= inner.undeclared;
|
|
1834
2017
|
embeds.push({
|
|
1835
2018
|
name,
|
|
1836
2019
|
columns: inner.columns,
|
|
1837
2020
|
embeds: inner.embeds,
|
|
1838
2021
|
});
|
|
2022
|
+
} else if (part.includes('(')) {
|
|
2023
|
+
undeclared ??= part;
|
|
1839
2024
|
} else if (part === '*') {
|
|
1840
2025
|
hasWildcard = true;
|
|
1841
2026
|
} else {
|
|
@@ -1847,9 +2032,39 @@ function parseSelectParam(
|
|
|
1847
2032
|
columns: hasWildcard ? null : (columns.length > 0 ? columns : null),
|
|
1848
2033
|
embeds,
|
|
1849
2034
|
aggregates,
|
|
2035
|
+
...(undeclared === undefined ? {} : { undeclared }),
|
|
1850
2036
|
};
|
|
1851
2037
|
}
|
|
1852
2038
|
|
|
2039
|
+
/** The 400 for an undeclared select part. It echoes client input only, capped. */
|
|
2040
|
+
function undeclaredSelectPartMessage(part: string): string {
|
|
2041
|
+
const chars = Array.from(part);
|
|
2042
|
+
const shown = chars.length > 200 ? `${chars.slice(0, 200).join('')}…` : part;
|
|
2043
|
+
return `Select part is not a declared embed or an aggregate: ${shown}`;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* An embed list left empty once unknown and hidden names are removed, at any
|
|
2048
|
+
* depth. Both read paths refuse it the same way, before any DB call.
|
|
2049
|
+
*/
|
|
2050
|
+
function refusedEmbedProjection(
|
|
2051
|
+
embeds: EmbedRequest[],
|
|
2052
|
+
tableRelations: Record<string, RelationConfig>,
|
|
2053
|
+
schema: Schema,
|
|
2054
|
+
options?: HandlerOptions,
|
|
2055
|
+
): null | 'unresolvedHiddenColumns' | undefined {
|
|
2056
|
+
const withClause = buildWithClause(embeds, tableRelations, schema, options);
|
|
2057
|
+
if (withClause === null || withClause === 'unresolvedHiddenColumns') return withClause;
|
|
2058
|
+
for (const embed of embeds) {
|
|
2059
|
+
const relation = ownEntry(tableRelations, embed.name);
|
|
2060
|
+
const targetRelations = relation && ownEntry(options?.relations, relation.table);
|
|
2061
|
+
if (embed.embeds.length === 0 || !targetRelations) continue;
|
|
2062
|
+
const nested = refusedEmbedProjection(embed.embeds, targetRelations, schema, options);
|
|
2063
|
+
if (nested !== undefined) return nested;
|
|
2064
|
+
}
|
|
2065
|
+
return undefined;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1853
2068
|
/**
|
|
1854
2069
|
* Split select param respecting parentheses:
|
|
1855
2070
|
* "id,title,author(name,email),comments(*)" → ["id", "title", "author(name,email)", "comments(*)"]
|
|
@@ -1881,6 +2096,34 @@ function splitSelectParam(param: string): string[] {
|
|
|
1881
2096
|
return parts;
|
|
1882
2097
|
}
|
|
1883
2098
|
|
|
2099
|
+
/** Check a reference against the resolved hidden policy, including related tables. */
|
|
2100
|
+
function isHiddenColumn(
|
|
2101
|
+
schema: Schema,
|
|
2102
|
+
sourceTable: string,
|
|
2103
|
+
reference: string,
|
|
2104
|
+
options?: HandlerOptions,
|
|
2105
|
+
): boolean {
|
|
2106
|
+
if (!options?.hiddenColumns) return false;
|
|
2107
|
+
|
|
2108
|
+
// A resolved SQL name can contain path syntax; match the literal name first.
|
|
2109
|
+
if (ownEntry(options.hiddenColumns, sourceTable)?.includes(reference)) return true;
|
|
2110
|
+
|
|
2111
|
+
let targetName = sourceTable;
|
|
2112
|
+
let columnName = parseJsonPath(reference)?.baseColumn ?? reference;
|
|
2113
|
+
// Prefer a real source column. The relation fallback only rejects hidden
|
|
2114
|
+
// references; the filter and order builders do not support relation paths.
|
|
2115
|
+
if (!getColumn(schema[targetName], columnName)) {
|
|
2116
|
+
const parts = columnName.split('.');
|
|
2117
|
+
while (parts.length > 1) {
|
|
2118
|
+
const relation = ownEntry(ownEntry(options.relations, targetName), parts.shift()!);
|
|
2119
|
+
if (!relation || !schema[relation.table]) return false;
|
|
2120
|
+
targetName = relation.table;
|
|
2121
|
+
}
|
|
2122
|
+
columnName = parts[0];
|
|
2123
|
+
}
|
|
2124
|
+
return (ownEntry(options.hiddenColumns, targetName) ?? []).includes(columnName);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
1884
2127
|
/** Check explicit query references, including each related table's own policy. */
|
|
1885
2128
|
function requestUsesHiddenColumn(
|
|
1886
2129
|
schema: Schema,
|
|
@@ -1890,22 +2133,7 @@ function requestUsesHiddenColumn(
|
|
|
1890
2133
|
): boolean {
|
|
1891
2134
|
if (!options?.hiddenColumns) return false;
|
|
1892
2135
|
|
|
1893
|
-
const isHidden = (sourceTable: string, reference: string): boolean =>
|
|
1894
|
-
let targetName = sourceTable;
|
|
1895
|
-
let columnName = parseJsonPath(reference)?.baseColumn ?? reference;
|
|
1896
|
-
// Prefer a real source column. The relation fallback only rejects hidden
|
|
1897
|
-
// references; the filter and order builders do not support relation paths.
|
|
1898
|
-
if (!getColumn(schema[targetName], columnName)) {
|
|
1899
|
-
const parts = columnName.split('.');
|
|
1900
|
-
while (parts.length > 1) {
|
|
1901
|
-
const relation = ownEntry(ownEntry(options.relations, targetName), parts.shift()!);
|
|
1902
|
-
if (!relation || !schema[relation.table]) return false;
|
|
1903
|
-
targetName = relation.table;
|
|
1904
|
-
}
|
|
1905
|
-
columnName = parts[0];
|
|
1906
|
-
}
|
|
1907
|
-
return (options.hiddenColumns?.[targetName] ?? []).includes(columnName);
|
|
1908
|
-
};
|
|
2136
|
+
const isHidden = (sourceTable: string, reference: string): boolean => isHiddenColumn(schema, sourceTable, reference, options);
|
|
1909
2137
|
|
|
1910
2138
|
const selectUsesHidden = (sourceTable: string, select: string): boolean => {
|
|
1911
2139
|
// Walk the raw selection: parseSelectParam collapses `*,secret` to a
|
|
@@ -2681,6 +2909,7 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2681
2909
|
const jp = filter.jsonPath!;
|
|
2682
2910
|
const baseCol = getColumn(table, jp.baseColumn);
|
|
2683
2911
|
if (!baseCol) return { __filterError: `Unknown column: '${jp.baseColumn}'` };
|
|
2912
|
+
if (ARRAY_OPERATORS.has(filter.operator)) return arrayOperatorRefusal(filter.operator, jp.baseColumn);
|
|
2684
2913
|
|
|
2685
2914
|
// Validate all JSON path keys before interpolation
|
|
2686
2915
|
for (const step of jp.steps) {
|
|
@@ -2727,6 +2956,7 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2727
2956
|
case 'is':
|
|
2728
2957
|
if (filter.value === 'null') condition = sql`${expr} IS NULL`;
|
|
2729
2958
|
else if (filter.value === 'not.null') condition = sql`${expr} IS NOT NULL`;
|
|
2959
|
+
else return { __filterError: `Invalid value for 'is': expected null or not.null` };
|
|
2730
2960
|
break;
|
|
2731
2961
|
case 'in': {
|
|
2732
2962
|
const values = filter.value.replace(/^\(/, '').replace(/\)$/, '').split(',');
|
|
@@ -2735,7 +2965,8 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2735
2965
|
break;
|
|
2736
2966
|
}
|
|
2737
2967
|
default:
|
|
2738
|
-
|
|
2968
|
+
// Refused, never dropped: a dropped filter widens the answer.
|
|
2969
|
+
return { __filterError: `Unknown filter operator: '${filter.operator.slice(0, 100)}'` };
|
|
2739
2970
|
}
|
|
2740
2971
|
|
|
2741
2972
|
if (condition && filter.negated) {
|
|
@@ -2748,7 +2979,62 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2748
2979
|
return condition;
|
|
2749
2980
|
}
|
|
2750
2981
|
|
|
2751
|
-
const
|
|
2982
|
+
const ARRAY_OPERATORS = new Set(['cs', 'cd', 'ov']);
|
|
2983
|
+
const VALID_OPERATORS = new Set(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'is', 'in', ...ARRAY_OPERATORS]);
|
|
2984
|
+
const TEXT_ARRAY_ELEMENT_TYPES = new Set(['PgText', 'PgVarchar']);
|
|
2985
|
+
|
|
2986
|
+
function arrayOperatorRefusal(operator: string, column: string): { __filterError: string } {
|
|
2987
|
+
return { __filterError: `Unknown filter operator: '${operator}' for column '${column}' (cs, cd and ov need a text[] or varchar[] column)` };
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
/**
|
|
2991
|
+
* cs (@>), cd (<@) and ov (&&) on a text[] or varchar[] column. The parsed list is
|
|
2992
|
+
* bound as ONE parameter through the column's own encoder. Passing a Param, not the
|
|
2993
|
+
* bare array, keeps the empty set: drizzle's helpers throw on an empty JS array, and
|
|
2994
|
+
* `{}` must keep PostgreSQL's own meaning.
|
|
2995
|
+
*/
|
|
2996
|
+
function buildArrayCondition(column: DrizzleColumn, filter: Filter): any {
|
|
2997
|
+
const element = (column as { baseColumn?: { columnType?: string } }).baseColumn?.columnType;
|
|
2998
|
+
if (column.dataType !== 'array' || !element || !TEXT_ARRAY_ELEMENT_TYPES.has(element)) {
|
|
2999
|
+
return arrayOperatorRefusal(filter.operator, filter.column);
|
|
3000
|
+
}
|
|
3001
|
+
const values = parseArrayLiteral(filter.value);
|
|
3002
|
+
if (!values) {
|
|
3003
|
+
return { __filterError: `Invalid array value for '${filter.operator}' on column '${filter.column}': expected {a,b} with at most ${ARRAY_LITERAL_MAX_ELEMENTS} elements of at most ${ARRAY_LITERAL_MAX_ELEMENT_LENGTH} characters` };
|
|
3004
|
+
}
|
|
3005
|
+
const list = new Param(values, column);
|
|
3006
|
+
const condition = filter.operator === 'cs' ? arrayContains(column, list)
|
|
3007
|
+
: filter.operator === 'cd' ? arrayContained(column, list)
|
|
3008
|
+
: arrayOverlaps(column, list);
|
|
3009
|
+
return filter.negated ? not(condition) : condition;
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
/** PostgREST format: in.(val1,val2,val3) */
|
|
3013
|
+
function parseInList(value: string): string[] {
|
|
3014
|
+
return value.replace(/^\(/, '').replace(/\)$/, '').split(',');
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
const DATE_FILTER_OPERATORS = new Set(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in']);
|
|
3018
|
+
|
|
3019
|
+
/**
|
|
3020
|
+
* A date-mode column's driver encoder calls `value.toISOString()`, so a filter value must
|
|
3021
|
+
* reach drizzle as a Date: a string throws a TypeError out of the handler. Each value goes
|
|
3022
|
+
* through the write path's rule (parseDateValue); `in` yields one Date per member. An
|
|
3023
|
+
* operator with no date meaning (like, ilike) is refused, never handed a Date.
|
|
3024
|
+
*/
|
|
3025
|
+
function dateFilterOperand(filter: Filter): Date | Date[] | { __filterError: string } {
|
|
3026
|
+
if (!DATE_FILTER_OPERATORS.has(filter.operator)) {
|
|
3027
|
+
return { __filterError: `Operator '${filter.operator}' is not supported on date column "${filter.column}"` };
|
|
3028
|
+
}
|
|
3029
|
+
const raw = filter.operator === 'in' ? parseInList(filter.value) : [filter.value];
|
|
3030
|
+
const dates: Date[] = [];
|
|
3031
|
+
for (const member of raw) {
|
|
3032
|
+
const parsed = parseDateValue(filter.column, member);
|
|
3033
|
+
if (!(parsed instanceof Date)) return { __filterError: parsed.error };
|
|
3034
|
+
dates.push(parsed);
|
|
3035
|
+
}
|
|
3036
|
+
return filter.operator === 'in' ? dates : dates[0];
|
|
3037
|
+
}
|
|
2752
3038
|
|
|
2753
3039
|
function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
2754
3040
|
if (filter.jsonPath) {
|
|
@@ -2761,27 +3047,37 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2761
3047
|
if (!VALID_OPERATORS.has(filter.operator)) {
|
|
2762
3048
|
return { __filterError: `Unknown filter operator: '${filter.operator}'` };
|
|
2763
3049
|
}
|
|
3050
|
+
if (ARRAY_OPERATORS.has(filter.operator)) return buildArrayCondition(column, filter);
|
|
3051
|
+
|
|
3052
|
+
let value: unknown = filter.value;
|
|
3053
|
+
let values: unknown[] | null = null;
|
|
3054
|
+
if (column.dataType === 'date' && filter.operator !== 'is') {
|
|
3055
|
+
const dated = dateFilterOperand(filter);
|
|
3056
|
+
if (isFilterError(dated)) return dated;
|
|
3057
|
+
if (Array.isArray(dated)) values = dated;
|
|
3058
|
+
else value = dated;
|
|
3059
|
+
}
|
|
2764
3060
|
|
|
2765
3061
|
let condition: any = null;
|
|
2766
3062
|
|
|
2767
3063
|
switch (filter.operator) {
|
|
2768
3064
|
case 'eq':
|
|
2769
|
-
condition = eq(column,
|
|
3065
|
+
condition = eq(column, value);
|
|
2770
3066
|
break;
|
|
2771
3067
|
case 'neq':
|
|
2772
|
-
condition = ne(column,
|
|
3068
|
+
condition = ne(column, value);
|
|
2773
3069
|
break;
|
|
2774
3070
|
case 'gt':
|
|
2775
|
-
condition = gt(column,
|
|
3071
|
+
condition = gt(column, value);
|
|
2776
3072
|
break;
|
|
2777
3073
|
case 'gte':
|
|
2778
|
-
condition = gte(column,
|
|
3074
|
+
condition = gte(column, value);
|
|
2779
3075
|
break;
|
|
2780
3076
|
case 'lt':
|
|
2781
|
-
condition = lt(column,
|
|
3077
|
+
condition = lt(column, value);
|
|
2782
3078
|
break;
|
|
2783
3079
|
case 'lte':
|
|
2784
|
-
condition = lte(column,
|
|
3080
|
+
condition = lte(column, value);
|
|
2785
3081
|
break;
|
|
2786
3082
|
case 'like':
|
|
2787
3083
|
// PostgREST uses * for wildcard, SQL uses %
|
|
@@ -2794,12 +3090,9 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2794
3090
|
if (filter.value === 'null') condition = isNull(column);
|
|
2795
3091
|
else if (filter.value === 'not.null') condition = isNotNull(column);
|
|
2796
3092
|
break;
|
|
2797
|
-
case 'in':
|
|
2798
|
-
|
|
2799
|
-
const values = filter.value.replace(/^\(/, '').replace(/\)$/, '').split(',');
|
|
2800
|
-
condition = inArray(column, values);
|
|
3093
|
+
case 'in':
|
|
3094
|
+
condition = inArray(column, values ?? parseInList(filter.value));
|
|
2801
3095
|
break;
|
|
2802
|
-
}
|
|
2803
3096
|
default:
|
|
2804
3097
|
return null;
|
|
2805
3098
|
}
|
|
@@ -2816,18 +3109,51 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2816
3109
|
return condition;
|
|
2817
3110
|
}
|
|
2818
3111
|
|
|
2819
|
-
function buildOrderBy(
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
const
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
3112
|
+
function buildOrderBy(
|
|
3113
|
+
table: DrizzleTable,
|
|
3114
|
+
orderParam: string,
|
|
3115
|
+
tieBreak: boolean,
|
|
3116
|
+
groupColumns?: string[],
|
|
3117
|
+
isHidden?: (column: string) => boolean,
|
|
3118
|
+
): { expressions: SQL[] } | { error: string; code: 'invalid_order'; column: string } {
|
|
3119
|
+
const columns = Object.values(getTableColumns(table)) as DrizzleColumn[];
|
|
3120
|
+
const expressions: SQL[] = [];
|
|
3121
|
+
const named = new Set<string>();
|
|
3122
|
+
|
|
3123
|
+
for (const part of orderParam.split(',')) {
|
|
3124
|
+
const dotIndex = part.lastIndexOf('.');
|
|
3125
|
+
const columnName = (dotIndex < 0 ? part : part.slice(0, dotIndex)).trim();
|
|
3126
|
+
const direction = dotIndex < 0 ? '' : part.slice(dotIndex + 1).trim();
|
|
3127
|
+
const column = getColumn(table, columnName);
|
|
3128
|
+
// getColumn also sees table properties and inherited names; only real columns can order rows.
|
|
3129
|
+
if (!column || !columns.includes(column)) {
|
|
3130
|
+
return { error: `Unknown order column: ${columnName}`, code: 'invalid_order', column: columnName };
|
|
3131
|
+
}
|
|
3132
|
+
if (direction !== 'asc' && direction !== 'desc') {
|
|
3133
|
+
return { error: `Invalid order direction for ${columnName}: expected asc or desc`, code: 'invalid_order', column: columnName };
|
|
3134
|
+
}
|
|
3135
|
+
if (groupColumns && !groupColumns.some(name => getColumn(table, name) === column)) {
|
|
3136
|
+
return { error: `Order column must be grouped: ${columnName}`, code: 'invalid_order', column: columnName };
|
|
3137
|
+
}
|
|
3138
|
+
expressions.push(direction === 'desc' ? desc(column) : asc(column));
|
|
3139
|
+
named.add(column.name);
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
// Aggregate groups do not represent individual rows, so their order cannot use the row key.
|
|
3143
|
+
if (tieBreak && is(table, PgTable)) {
|
|
3144
|
+
const config = getTableConfig(table);
|
|
3145
|
+
const primaryColumns = [
|
|
3146
|
+
...config.columns.filter(column => column.primary),
|
|
3147
|
+
...config.primaryKeys.flatMap(key => key.columns),
|
|
3148
|
+
];
|
|
3149
|
+
for (const column of primaryColumns) {
|
|
3150
|
+
if (!named.has(column.name) && !isHidden?.(column.name)) {
|
|
3151
|
+
expressions.push(asc(column));
|
|
3152
|
+
named.add(column.name);
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
return { expressions };
|
|
2831
3157
|
}
|
|
2832
3158
|
|
|
2833
3159
|
const RESERVED_QUERY_PARAMS = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
|
|
@@ -2866,6 +3192,44 @@ function parseFilters(params: URLSearchParams): Filter[] {
|
|
|
2866
3192
|
return filters;
|
|
2867
3193
|
}
|
|
2868
3194
|
|
|
3195
|
+
/** A group part reading `column.[not.]cs.` so far: a `{` here opens an array value. */
|
|
3196
|
+
const ARRAY_VALUE_OPENING = /^[^.]+\.(?:not\.)?(?:cs|cd|ov)\.$/;
|
|
3197
|
+
|
|
3198
|
+
/**
|
|
3199
|
+
* Split a group's content on commas, except inside the `{...}` value of a cs/cd/ov
|
|
3200
|
+
* filter, where a comma, or a brace inside a double-quoted element, is data. Every
|
|
3201
|
+
* other operator splits on every comma, as it always has (`in.(a,b)` included).
|
|
3202
|
+
*/
|
|
3203
|
+
function splitLogicalGroup(content: string): string[] {
|
|
3204
|
+
const parts: string[] = [];
|
|
3205
|
+
let current = '';
|
|
3206
|
+
let inArray = false;
|
|
3207
|
+
let inQuote = false;
|
|
3208
|
+
for (let i = 0; i < content.length; i++) {
|
|
3209
|
+
const char = content[i];
|
|
3210
|
+
if (inArray) {
|
|
3211
|
+
if (inQuote && char === '\\') {
|
|
3212
|
+
current += char + (content[i + 1] ?? '');
|
|
3213
|
+
i++;
|
|
3214
|
+
continue;
|
|
3215
|
+
}
|
|
3216
|
+
if (char === '"') inQuote = !inQuote;
|
|
3217
|
+
else if (char === '}' && !inQuote) inArray = false;
|
|
3218
|
+
current += char;
|
|
3219
|
+
continue;
|
|
3220
|
+
}
|
|
3221
|
+
if (char === ',') {
|
|
3222
|
+
parts.push(current);
|
|
3223
|
+
current = '';
|
|
3224
|
+
continue;
|
|
3225
|
+
}
|
|
3226
|
+
if (char === '{' && ARRAY_VALUE_OPENING.test(current)) inArray = true;
|
|
3227
|
+
current += char;
|
|
3228
|
+
}
|
|
3229
|
+
parts.push(current);
|
|
3230
|
+
return parts;
|
|
3231
|
+
}
|
|
3232
|
+
|
|
2869
3233
|
/**
|
|
2870
3234
|
* Parse a logical group value into individual filters.
|
|
2871
3235
|
* Input: "(status.eq.draft,status.eq.pending)" → [{column:'status', operator:'eq', value:'draft'}, ...]
|
|
@@ -2877,7 +3241,7 @@ function parseLogicalGroup(value: string): Filter[] {
|
|
|
2877
3241
|
}
|
|
2878
3242
|
|
|
2879
3243
|
const filters: Filter[] = [];
|
|
2880
|
-
for (const part of content
|
|
3244
|
+
for (const part of splitLogicalGroup(content)) {
|
|
2881
3245
|
const dotIndex = part.indexOf('.');
|
|
2882
3246
|
if (dotIndex === -1) continue;
|
|
2883
3247
|
|