@rebasepro/server 0.21.2-canary.g1ea48be → 0.22.0
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/dist/{auth-DZXQRmYD.js → auth-CCDpk2rn.js} +7 -5
- package/dist/{auth-DZXQRmYD.js.map → auth-CCDpk2rn.js.map} +1 -1
- package/dist/{cron-routes-cv0hSd-O.js → cron-routes-B7CRGfiq.js} +2 -2
- package/dist/{cron-routes-cv0hSd-O.js.map → cron-routes-B7CRGfiq.js.map} +1 -1
- package/dist/{cron-store-Bdm7JqFB.js → cron-store-BYGZFNWk.js} +2 -2
- package/dist/{cron-store-Bdm7JqFB.js.map → cron-store-BYGZFNWk.js.map} +1 -1
- package/dist/ddl-bootstrap-CfNvxMuK.js.map +1 -1
- package/dist/{history-recorder-CYfkqP2X.js → history-recorder-BQmB0P_j.js} +2 -2
- package/dist/{history-recorder-CYfkqP2X.js.map → history-recorder-BQmB0P_j.js.map} +1 -1
- package/dist/{history-store-B1CBwLfi.js → history-store-CetkrBBD.js} +2 -2
- package/dist/{history-store-B1CBwLfi.js.map → history-store-CetkrBBD.js.map} +1 -1
- package/dist/index.es.js +60 -20
- package/dist/index.es.js.map +1 -1
- package/dist/init/global-callbacks.d.ts +21 -0
- package/dist/{jobs-2fO2BI8P.js → jobs-Bjr8DZAi.js} +2 -2
- package/dist/{jobs-2fO2BI8P.js.map → jobs-Bjr8DZAi.js.map} +1 -1
- package/dist/{openapi-generator-D8ZIN_ss.js → openapi-generator-D8uFz-LW.js} +2 -2
- package/dist/{openapi-generator-D8ZIN_ss.js.map → openapi-generator-D8uFz-LW.js.map} +1 -1
- package/dist/{query-parser-CoIOmflr.js → query-parser-BleZmY18.js} +2 -2
- package/dist/{query-parser-CoIOmflr.js.map → query-parser-BleZmY18.js.map} +1 -1
- package/dist/{src-BYax9_rm.js → src-1vL-I1Po.js} +69 -2
- package/dist/src-1vL-I1Po.js.map +1 -0
- package/package.json +5 -5
- package/dist/src-BYax9_rm.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query-parser-CoIOmflr.js","names":[],"sources":["../src/api/rest/soft-delete-params.ts","../src/api/rest/field-access-query.ts","../src/api/rest/query-parser.ts"],"sourcesContent":["import { ApiError } from \"../errors\";\n\n/**\n * The two query parameters soft delete adds to the REST surface.\n *\n * Kept in their own module so the call sites in `query-parser.ts` and the\n * delete routes are a single line each: the parsing rules belong to soft\n * delete, not to the parser, and a rule spread across the two files that read\n * it is a rule that drifts.\n */\n\n/** `?deleted=` — what to do about rows a soft delete has stamped. */\nexport const DELETED_QUERY_PARAM = \"deleted\";\n/** `?hard=` — ask for a real `DELETE` on a soft-delete collection. */\nexport const HARD_DELETE_QUERY_PARAM = \"hard\";\n\n/**\n * `?deleted=include|only` → the driver's `withDeleted`.\n *\n * Spelled `deleted` on the wire and `withDeleted` in the driver, deliberately:\n * the URL reads as a question about the rows (`?deleted=only` — \"only the\n * deleted ones\"), and the driver option reads as an instruction about the query.\n *\n * A value neither word is a 400 rather than a silent fallback to the default.\n * `?deleted=true` quietly hiding every deleted row is the worst of both: it\n * looks like it worked and answers the opposite question. Absent is the\n * default, which is \"hide them\".\n */\nexport function parseWithDeleted(raw: unknown): boolean | \"only\" | undefined {\n if (raw === undefined || raw === null || raw === \"\") return undefined;\n const value = String(raw).trim().toLowerCase();\n if (value === \"include\") return true;\n if (value === \"only\") return \"only\";\n throw ApiError.badRequest(\n `Invalid \\`?${DELETED_QUERY_PARAM}=${String(raw)}\\`. It takes 'include' (live rows and deleted ones) ` +\n \"or 'only' (deleted rows alone). Omit it to see only the live rows.\",\n \"INVALID_DELETED_PARAM\"\n );\n}\n\n/**\n * `?hard=true` → a real `DELETE` on a collection that soft-deletes.\n *\n * Needs no permission beyond the delete it replaces: it is the same verb, and a\n * second access-control surface for one operation is a second thing to get\n * wrong. What it changes is whether the row can be restored.\n *\n * Only the exact words `true` and `1` mean yes. Anything else is a 400, not a\n * \"no\" — a typo that silently soft-deletes when the caller asked to purge is a\n * caller who believes the data is gone.\n */\nexport function parseHardDelete(raw: unknown): boolean {\n if (raw === undefined || raw === null || raw === \"\") return false;\n const value = String(raw).trim().toLowerCase();\n if (value === \"true\" || value === \"1\") return true;\n if (value === \"false\" || value === \"0\") return false;\n throw ApiError.badRequest(\n `Invalid \\`?${HARD_DELETE_QUERY_PARAM}=${String(raw)}\\`. It takes 'true' or 'false'.`,\n \"INVALID_HARD_PARAM\"\n );\n}\n","import type { CollectionConfig } from \"@rebasepro/types\";\nimport type { FilterCondition, LogicalCondition } from \"@rebasepro/types\";\nimport { type FieldViewer, restrictedFieldNames } from \"@rebasepro/common\";\nimport { ApiError } from \"../errors\";\n\n/**\n * A read may not name a field the caller cannot read.\n *\n * The strip in the row pipeline is what keeps the *value* off the wire. This is\n * the other half, and without it the value is still readable one bit at a time:\n * `?salary=gt.100000` returns the rows whose withheld salary is above 100k, and\n * `?orderBy=salary` returns them in order of it. A column no response can carry\n * has to be a column no query can interrogate, or the read rule is decoration.\n *\n * The refusal names the field. That is deliberate and it is not a leak: the\n * published OpenAPI lists every property of every collection, including the ones\n * a given caller cannot read, because the document is one document and is served\n * off the app rather than off the authenticated data router. Hiding the name\n * here would protect nothing and would answer a caller's genuine typo with\n * \"unknown field\", sending them to look for a spelling mistake that is not\n * there. Field *names* are public; field *values* are not.\n *\n * @module\n */\n\n/**\n * The roles behind a request, as a viewer a field rule can judge.\n *\n * Never `undefined`, and that is the point. `undefined` means the trusted server\n * plane in {@link FieldViewer}, which satisfies every non-empty role list — so\n * returning it for a request that merely has no `user` on the context would\n * hand an unauthenticated caller every field in the database. The auth\n * middleware scopes such a request's driver as `roles: [\"anon\"]` but sets no\n * `user`, so the fallback here has to be the same list the driver was scoped\n * with, not nothing.\n *\n * @param c anything carrying the Hono context's `get` — the batch route passes a\n * shim rather than the context itself, exactly as the API-key\n * permission check does.\n */\nexport function requestViewer(c: { get: (key: never) => unknown }): FieldViewer {\n const user = c.get(\"user\" as never) as { roles?: readonly string[] } | undefined;\n return { roles: user?.roles ?? ANON_ROLES };\n}\n\n/** What the auth middleware scopes an unauthenticated request's driver with. */\nconst ANON_ROLES: readonly string[] = Object.freeze([\"anon\"]);\n\n/** Which query parameter a refused field arrived in, for the message. */\ntype Where = \"filter\" | \"orderBy\" | \"fields\" | \"select\" | \"groupBy\";\n\nconst WHERE_LABEL: Record<Where, string> = {\n filter: \"a filter\",\n orderBy: \"`orderBy`\",\n fields: \"`fields`\",\n select: \"`select`\",\n groupBy: \"`groupBy`\"\n};\n\n/** Every column a logical group compares, however deeply nested. */\nfunction logicalColumns(logical: LogicalCondition | undefined, into: string[]): void {\n if (!logical?.conditions) return;\n for (const condition of logical.conditions) {\n if (\"conditions\" in condition) logicalColumns(condition as LogicalCondition, into);\n else if ((condition as FilterCondition).column) into.push((condition as FilterCondition).column);\n }\n}\n\n/**\n * The bare column an `orderBy` key names, or `undefined` for one that is not a\n * column at all.\n *\n * A sort key may be a relation aggregate (`comments.count()`) or one of the\n * computed keys a search adds (`_score`, `_distance`). Neither is a property of\n * this collection, so neither is a field this rule has anything to say about;\n * the field it *would* have named is checked by the same walk one level down\n * when the driver resolves the relation.\n */\nfunction orderByColumn(field: string): string | undefined {\n if (field.startsWith(\"_\")) return undefined;\n if (field.includes(\"(\") || field.includes(\".\")) return undefined;\n return field;\n}\n\n/**\n * Refuse the request when any of `names` is a field this caller cannot read.\n *\n * Exported so the aggregate route — whose `select` and `groupBy` are parsed\n * outside `parseQueryOptions` — applies the identical rule. `count(*)` over a\n * withheld column is the same disclosure as reading it, one predicate at a time.\n */\nexport function assertReadableFields(\n names: readonly (string | undefined)[],\n collection: CollectionConfig,\n viewer: FieldViewer | undefined,\n where: Where\n): void {\n if (names.length === 0) return;\n const { refused } = restrictedFieldNames(collection, viewer, \"read\");\n if (refused.size === 0) return;\n\n const named = [...new Set(names.filter((n): n is string => n !== undefined && refused.has(n)))];\n if (named.length === 0) return;\n\n throw ApiError.badRequest(\n `${named.map(f => `'${f}'`).join(\", \")} ${named.length > 1 ? \"are\" : \"is\"} not readable ` +\n `on '${collection.slug}' with your roles, so ${named.length > 1 ? \"they\" : \"it\"} cannot be used in ` +\n `${WHERE_LABEL[where]}.`,\n \"FIELD_NOT_READABLE\",\n {\n collection: collection.slug,\n fields: named,\n violations: named.map(field => ({\n field,\n code: \"access\",\n message: `'${field}' is not readable with your roles.`\n }))\n }\n );\n}\n\n/**\n * The whole of a parsed read request, checked in one pass.\n *\n * One call rather than five, because five call sites is five chances to add a\n * sixth query parameter and forget it — which is exactly how `?or=` came to be\n * parsed and then dropped by the list route.\n */\nexport function assertQueryFieldsReadable(\n options: {\n where?: Record<string, unknown>;\n logical?: LogicalCondition;\n orderBy?: { field: string }[];\n fields?: string[];\n },\n collection: CollectionConfig,\n viewer: FieldViewer | undefined\n): void {\n const filtered: string[] = [];\n if (options.where) filtered.push(...Object.keys(options.where));\n logicalColumns(options.logical, filtered);\n assertReadableFields(filtered, collection, viewer, \"filter\");\n\n assertReadableFields(\n (options.orderBy ?? []).map(entry => orderByColumn(entry.field)),\n collection, viewer, \"orderBy\"\n );\n\n assertReadableFields(options.fields ?? [], collection, viewer, \"fields\");\n}\n","import type { CollectionConfig, FilterValues, ListLimitBounds, LogicalCondition, NullsPlacement, OrderByTuple, VectorSearchParams } from \"@rebasepro/types\";\nimport { toCanonicalOp, resolveClientListLimit, ListLimitError, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT } from \"@rebasepro/types\";\nimport type { DecodedCursor } from \"@rebasepro/common\";\nimport {\n CursorError,\n CursorMismatchError,\n type FieldViewer,\n IncludeSpecError,\n OrderBySpecError,\n decodeCursor,\n deserializeFilter,\n deserializeInclude,\n deserializeLogicalCondition,\n normalizeInclude,\n reconcileCursorOrder,\n UnknownFilterOperatorError\n} from \"@rebasepro/common\";\nimport { QueryOptions } from \"../types\";\nimport { ApiError } from \"../errors\";\nimport { DELETED_QUERY_PARAM, HARD_DELETE_QUERY_PARAM, parseWithDeleted } from \"./soft-delete-params\";\nimport { assertQueryFieldsReadable } from \"./field-access-query\";\n\nexport const mapOperator = (op: string) => toCanonicalOp(op) ?? null;\n\n/**\n * A malformed query parameter, refused with a 400.\n *\n * Every rejection in this file is one of these, and every one of them is\n * `expected` — the flag `errorHandler` reads to log a routine outcome at debug\n * instead of warn. A client that mistypes an operator, a sort direction or a\n * limit is not an incident: nothing on the server is wrong, the request never\n * reached the database, and the caller has already been told what to fix in the\n * response body. Left at warn, a single frontend holding a stale field name\n * writes a `⚠️` line per request forever, and the warn level stops meaning\n * anything — which is why \"routine 4xx logs at WARN\" is a standing finding\n * against this API.\n *\n * Not a factory on `ApiError`: the class's members are part of the tracked\n * runtime surface (`api-surface/server.api.txt`), and this needs no addition to\n * it. `ApiError.unauthenticated` is the same idea one status code up.\n */\nfunction invalidParam(message: string, code: string, details?: unknown): ApiError {\n return new ApiError(400, code, message, details, true);\n}\n\n/**\n * Decode a filter, turning the shared codec's operator rejection into a 400.\n *\n * `deserializeFilter` lives in `@rebasepro/common`, which cannot throw an\n * `ApiError` — it does not depend on this package, and the browser SDK decodes\n * through the same function and has nothing to render one with. So it throws\n * `UnknownFilterOperatorError`, and the HTTP boundary is where that becomes a\n * status code. Same seam `parseLogicalGroup` uses for the nesting bound.\n *\n * Without this the operator string became a *value*: `?where={\"title\":\n * [\"!!\",\"Hello\"]}` compiled to `title IN ('!!','Hello')` and answered 200 with\n * the row the caller was filtering out, and `{\"id\":[\">>\",0]}` reached Postgres\n * and came back a 500 quoting `invalid input syntax for type integer`. Both are\n * malformed requests and now say so.\n */\nfunction decodeFilter(query: Record<string, unknown>): FilterValues<string> {\n try {\n return deserializeFilter(query);\n } catch (e) {\n if (e instanceof UnknownFilterOperatorError) {\n throw invalidParam(e.message, e.code, e.details);\n }\n throw e;\n }\n}\n\nfunction getLastValue(val: unknown): unknown {\n if (Array.isArray(val)) {\n return val[val.length - 1];\n }\n return val;\n}\n\n/**\n * Parse an `or(...)` / `and(...)` logical group from its wire form.\n *\n * The wire carries the inner conditions wrapped in parens (e.g.\n * `(status.eq.active,age.gte.18)`); we re-attach the `or`/`and` prefix and\n * delegate to the canonical filter dialect (`@rebasepro/common`). Values are\n * preserved as strings — type coercion is the schema-aware driver's job, so\n * this path stays byte-for-byte consistent with the SDK/admin path (which\n * also parses via the shared dialect).\n */\nfunction parseLogicalGroup(type: \"or\" | \"and\" | \"not\", raw: unknown): LogicalCondition | undefined {\n let inner = String(raw).trim();\n if (inner.startsWith(\"(\") && inner.endsWith(\")\")) {\n inner = inner.slice(1, -1);\n }\n inner = inner.trim();\n if (!inner) return undefined;\n let parsed;\n try {\n parsed = deserializeLogicalCondition(`${type}(${inner})`);\n } catch (e) {\n // The parser refuses a nesting depth no real filter reaches. That is a\n // request problem, and without this it surfaced as a 500 — the\n // unbounded version reached `RangeError: Maximum call stack size\n // exceeded`, which tells the caller nothing about their filter.\n throw invalidParam(\n `Invalid \\`${type}\\` parameter: ${e instanceof Error ? e.message : String(e)}`,\n \"INVALID_LOGICAL_GROUP\"\n );\n }\n return \"type\" in parsed ? parsed : undefined;\n}\n\n/**\n * Parse the `?where=` JSON filter object.\n *\n * This is the dialect the OpenAPI document publishes on every\n * `GET /api/data/{slug}` — `{\"status\":[\"==\",\"active\"]}`: field → canonical\n * `[WhereFilterOp, value]` tuple. It is normalized through the same\n * `deserializeFilter` as the `?field=op.value` params below, so a value that\n * arrives as a PostgREST dot-string (`{\"status\":\"eq.active\"}`) or as a bare\n * scalar (`{\"status\":\"active\"}`) compiles to the same condition. Unlike the\n * querystring dialect, JSON carries types — a number stays a number.\n *\n * A malformed value is a 400 rather than a silent drop: dropping the filter\n * would run the read unfiltered and return everything RLS happens to allow.\n */\nfunction parseWhereParam(raw: unknown): FilterValues<string> | undefined {\n const str = String(raw).trim();\n if (!str) return undefined;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(str);\n } catch {\n throw invalidParam(\n \"Invalid `where` parameter: expected a JSON object, e.g. {\\\"status\\\":[\\\"==\\\",\\\"active\\\"]}\",\n \"INVALID_WHERE\"\n );\n }\n if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) {\n throw invalidParam(\n \"Invalid `where` parameter: expected a JSON object mapping fields to conditions, \"\n + \"e.g. {\\\"status\\\":[\\\"==\\\",\\\"active\\\"]}\",\n \"INVALID_WHERE\"\n );\n }\n\n const filter = decodeFilter(parsed as Record<string, unknown>);\n return Object.keys(filter).length > 0 ? filter : undefined;\n}\n\ntype OrderByEntry = { field: string; direction: \"asc\" | \"desc\"; nulls?: NullsPlacement };\n\n/**\n * The parsed entries as the driver contract spells them: `[field, direction]`\n * tuples in order of significance.\n *\n * The REST layer used to hand the driver `orderBy[0].field` and drop the rest,\n * so `?orderBy=[{\"field\":\"roles\"},{\"field\":\"created_at\",\"direction\":\"desc\"}]`\n * — a shape this parser has always accepted and validated in full — sorted by\n * `roles` alone and returned the ties in whatever order Postgres pleased.\n */\nexport function orderByEntriesToTuples(entries?: OrderByEntry[]): OrderByTuple[] | undefined {\n if (!entries || entries.length === 0) return undefined;\n return entries.map(({ field, direction, nulls }) => (nulls\n ? [field, direction, nulls]\n : [field, direction]) as OrderByTuple);\n}\n\nfunction invalidOrderBy(detail: string): never {\n throw invalidParam(\n `Invalid \\`orderBy\\` parameter: ${detail}. Expected \\`field\\`, \\`field:desc\\`, `\n + \"`field:desc:last`, or a JSON array like \"\n + \"[{\\\"field\\\":\\\"created_at\\\",\\\"direction\\\":\\\"desc\\\",\\\"nulls\\\":\\\"last\\\"}]\",\n \"INVALID_ORDER_BY\"\n );\n}\n\n/**\n * The `nulls` slot: `first`/`last`, or a refusal naming the entry.\n *\n * Refused rather than defaulted, for the reason every other parameter here is:\n * a sort quietly ordered by a convention the caller did not ask for reads as\n * though it obeyed them. See {@link NullsPlacement} for what the default is\n * when the slot is simply absent.\n */\nfunction toNulls(raw: unknown, context: string): NullsPlacement | undefined {\n if (raw === undefined || raw === null || raw === \"\") return undefined;\n if (raw !== \"first\" && raw !== \"last\") {\n invalidOrderBy(`${context} has nulls '${String(raw)}'`);\n }\n return raw;\n}\n\n/** The aggregate functions `?select=` accepts. */\nconst AGGREGATE_FUNCTIONS = new Set([\"count\", \"sum\", \"avg\", \"min\", \"max\"]);\n\nexport interface ParsedAggregate {\n fn: \"count\" | \"sum\" | \"avg\" | \"min\" | \"max\";\n /** Absent only for `count()`, which counts rows rather than values. */\n field?: string;\n /** The key this appears under in the response. */\n alias: string;\n}\n\n/**\n * Parse `?select=count(),sum(total),avg(total)`.\n *\n * The spelling is SQL's, because whoever writes it is thinking in SQL and\n * because any other spelling has to be learned first. `count()` with no field\n * counts rows; every other function names a column.\n *\n * Aliases are derived rather than accepted: `sum(total)` returns as\n * `sum_total`, `count()` as `count`. Letting a caller choose would mean\n * checking their alias is not also a `groupBy` field — a rule nobody would\n * guess, and a silently overwritten value if it went unchecked.\n */\nexport function parseAggregateSelect(raw: unknown): ParsedAggregate[] | undefined {\n const value = getLastValue(raw);\n if (!value) return undefined;\n\n const entries = String(value).split(\",\").map(s => s.trim()).filter(Boolean);\n if (entries.length === 0) return undefined;\n\n return entries.map((entry) => {\n const match = /^([a-z]+)\\(\\s*([A-Za-z0-9_]*)\\s*\\)$/i.exec(entry);\n if (!match) {\n throw invalidParam(\n `Invalid \\`select\\` entry \"${entry}\". Expected \\`fn(field)\\`, e.g. \\`sum(total)\\` or \\`count()\\`.`,\n \"INVALID_AGGREGATE_SELECT\"\n );\n }\n\n const fn = match[1].toLowerCase();\n const field = match[2] || undefined;\n\n if (!AGGREGATE_FUNCTIONS.has(fn)) {\n throw invalidParam(\n `Unknown aggregate function \"${fn}\". Expected: ${[...AGGREGATE_FUNCTIONS].join(\", \")}.`,\n \"INVALID_AGGREGATE_FUNCTION\"\n );\n }\n if (fn !== \"count\" && !field) {\n // `sum()` has no sensible reading, and guessing one would be\n // inventing a column on the caller's behalf.\n throw invalidParam(\n `\\`${fn}()\\` needs a field, e.g. \\`${fn}(total)\\`. Only \\`count()\\` may be empty.`,\n \"INVALID_AGGREGATE_SELECT\"\n );\n }\n\n return {\n fn: fn as ParsedAggregate[\"fn\"],\n field,\n alias: field ? `${fn}_${field}` : fn\n };\n });\n}\n\n/** Parse `?groupBy=status,country`. */\nexport function parseGroupBy(raw: unknown): string[] | undefined {\n const value = getLastValue(raw);\n if (!value) return undefined;\n const fields = String(value).split(\",\").map(s => s.trim()).filter(Boolean);\n return fields.length > 0 ? fields : undefined;\n}\n\n/** `asc`/`desc`, in any case. Anything else is a request to sort in a way that does not exist. */\nfunction toDirection(raw: unknown, context: string): \"asc\" | \"desc\" {\n if (raw === undefined || raw === null) return \"asc\";\n if (typeof raw !== \"string\") invalidOrderBy(`${context} has a non-string \\`direction\\``);\n const lowered = raw.toLowerCase();\n if (lowered !== \"asc\" && lowered !== \"desc\") {\n invalidOrderBy(`${context} has direction '${raw}'`);\n }\n return lowered;\n}\n\n/** One entry: the canonical `{field, direction}`, or the `field:direction` shorthand as a string. */\nfunction toOrderByEntry(raw: unknown, index: number): OrderByEntry {\n const context = `entry ${index}`;\n if (typeof raw === \"string\") {\n // Split here rather than through `deserializeOrderBy`, which is the\n // *client* end of the codec and normalises anything that is not\n // literally \"desc\" to \"asc\". Routed through it, `?orderBy=x:DESC`\n // reached `toDirection` already collapsed to \"asc\" and answered 200\n // with the rows in the opposite order — a newest-first list showing\n // the oldest rows — and `x:sideways` did the same. The direction token\n // has to arrive here raw for `toDirection` to have anything to refuse.\n const idx = raw.indexOf(\":\");\n const field = (idx === -1 ? raw : raw.slice(0, idx)).trim();\n if (!field) invalidOrderBy(`${context} is an empty field name`);\n if (idx === -1) return { field, direction: \"asc\" };\n // `field:direction:nulls`. The third segment is optional, so every\n // `field:desc` written before it existed parses exactly as it did.\n const rest = raw.slice(idx + 1);\n const nullsIdx = rest.indexOf(\":\");\n const direction = toDirection(nullsIdx === -1 ? rest : rest.slice(0, nullsIdx), context);\n const nulls = nullsIdx === -1 ? undefined : toNulls(rest.slice(nullsIdx + 1), context);\n return nulls ? { field, direction, nulls } : { field, direction };\n }\n if (typeof raw !== \"object\" || raw === null || Array.isArray(raw)) {\n invalidOrderBy(`${context} is not a field name or a {field, direction} object`);\n }\n const entry = raw as Record<string, unknown>;\n if (typeof entry.field !== \"string\" || entry.field.trim() === \"\") {\n invalidOrderBy(`${context} has no \\`field\\``);\n }\n const nulls = toNulls(entry.nulls, context);\n const direction = toDirection(entry.direction, context);\n return nulls ? { field: entry.field, direction, nulls } : { field: entry.field, direction };\n}\n\n/**\n * Parse the `orderBy` query parameter.\n *\n * The field *name* has been validated against the schema for a while — an\n * `?orderBy=titel` is a 400 rather than 200 with unsorted rows, on the grounds\n * that silently dropping the sort leaves the caller believing in an order that\n * is not there. The parameter's *shape* was never checked the same way, and it\n * failed in exactly the same silent manner one layer earlier: whatever\n * `JSON.parse` returned was assigned to an option declared as an array of\n * `{field, direction}`, and the REST layer reads only `orderBy[0].field`. So\n * `?orderBy={\"field\":\"name\"}` — an object rather than an array, and the most\n * natural thing for a client to try — read `undefined`, dropped the ORDER BY,\n * and answered 200. So did a number, a boolean, `null`, and `[\"name\"]`.\n *\n * This refuses those, the way `parseWhereParam` above already refuses a\n * malformed filter and for the same reason. What it keeps working is every\n * shape that worked before: the `field` and `field:desc` shorthands, and the\n * canonical JSON array.\n */\nfunction parseOrderByParam(raw: unknown): OrderByEntry[] | undefined {\n if (Array.isArray(raw)) {\n // A repeated query parameter arrives pre-split; treat it as the list.\n return raw.length === 0 ? undefined : raw.map(toOrderByEntry);\n }\n\n const str = String(raw).trim();\n if (!str) return undefined;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(str);\n } catch {\n // Not JSON at all, so it is the `field:direction` shorthand.\n return [toOrderByEntry(str, 0)];\n }\n\n // `JSON.parse` succeeding says nothing about the shape being usable.\n if (Array.isArray(parsed)) {\n if (parsed.length === 0) return undefined;\n return parsed.map(toOrderByEntry);\n }\n if (typeof parsed === \"string\") return [toOrderByEntry(parsed, 0)];\n if (typeof parsed === \"object\" && parsed !== null) {\n // A bare `{field, direction}` is a near miss rather than nonsense, but\n // accepting it would leave two spellings of one parameter. Name it.\n invalidOrderBy(\"a single object was given where a JSON array was expected\");\n }\n invalidOrderBy(`${typeof parsed} is not a field name or a list of them`);\n}\n\n// Re-exported for callers/tests that reference the REST list bounds. The\n// numbers and the rule live in `@rebasepro/types` so the REST parser and the\n// WebSocket ingress enforce ONE shared guarantee. See `resolveClientListLimit`.\nexport { DEFAULT_LIST_LIMIT, DEFAULT_VECTOR_LIST_LIMIT, MAX_LIST_LIMIT } from \"@rebasepro/types\";\n\n/**\n * Overridable list-pagination bounds for {@link parseQueryOptions}. Without\n * these, `GET /<collection>` with no `?limit` would buffer the ENTIRE table\n * into a JS array + JSON response (a trivial OOM/DoS), and `?limit=100000000`\n * would be honoured verbatim.\n */\nexport interface ListLimitOptions {\n /**\n * Page size used when the client sends no `?limit`. Applied to plain and\n * text-search reads — a vector search falls back to its own default (10).\n */\n defaultLimit?: number;\n /** Largest `?limit` a client may ask for. A larger one is a 400, not a clamp. */\n maxLimit?: number;\n}\n\n/**\n * {@link resolveClientListLimit} for an HTTP route: the same bounds, answered\n * with a 400 rather than a 500.\n *\n * The shared resolver throws a `ListLimitError`, which carries `status` — but\n * the Hono error handler discriminates on `statusCode`, so an unconverted one\n * reaches the client as `INTERNAL_ERROR` with its message stripped, telling the\n * caller nothing about the parameter it got wrong. Every REST list ingress\n * routes its `limit` through here so all of them name the ceiling the same way.\n */\nexport function resolveListLimitParam(\n rawLimit: number | string | null | undefined,\n opts: ListLimitBounds & { vectorSearch?: boolean } = {}\n): number {\n try {\n return resolveClientListLimit(rawLimit, opts);\n } catch (e) {\n if (e instanceof ListLimitError) {\n throw invalidParam(e.message, \"INVALID_LIMIT\");\n }\n throw e;\n }\n}\n\n/**\n * A whole number at or above `minimum`, or a 400 naming the parameter.\n *\n * `parseInt` was the whole of the validation, and it answers `NaN` for\n * `?offset=abc` and a negative for `?offset=-5`. Neither was checked:\n *\n * - `NaN` reached the driver, where `OFFSET NaN` is a 500 about a syntax error\n * in a query the caller never wrote;\n * - `?page=0` computed `offset = -limit`, a negative offset, which Postgres\n * also refuses — and `?page=-3` refused deeper;\n * - `?offset=1.5` truncated silently to `1`, so the caller paged a window they\n * had not asked for.\n *\n * Every one of those is the caller's parameter, so every one is a 400 named\n * after the parameter — the shape `INVALID_LIMIT` already had, and the reason a\n * limit is *rejected* rather than clamped: a window quietly different from the\n * one asked for cannot be told apart from having reached the end.\n *\n * `expected: true` on the error (via {@link invalidParam}): a mistyped query\n * parameter never reached the database and the response body already says what\n * to fix, so it logs at debug rather than putting a warning in production logs\n * on every request from a client holding a stale link.\n */\nfunction parseWindowParam(raw: unknown, name: string, minimum: number, code: string): number {\n const text = String(raw).trim();\n const value = Number(text);\n if (text === \"\" || !Number.isFinite(value) || !Number.isInteger(value) || value < minimum) {\n throw invalidParam(\n `Invalid \\`${name}\\` parameter: expected a whole number ${minimum === 0 ? \"of 0 or more\" : `of ${minimum} or more`}, got ${JSON.stringify(text)}.`,\n code\n );\n }\n return value;\n}\n\n/**\n * Parse query parameters into QueryOptions\n */\nexport function parseQueryOptions(\n query: Record<string, unknown>,\n limits: ListLimitOptions = {},\n /**\n * The collection being read and who is reading it. Optional so the parser\n * stays a pure parser for the callers that have neither (tests, the WS\n * ingress, anything parsing a query it is not about to run); when present,\n * a `where`, `orderBy` or `fields` naming a field the caller cannot read is\n * a 400 rather than a query the driver would happily answer. See\n * {@link assertQueryFieldsReadable}.\n */\n access?: { collection: CollectionConfig; viewer?: FieldViewer }\n): QueryOptions {\n const options: QueryOptions = {};\n const rawLimit = getLastValue(query.limit) as number | string | null | undefined;\n\n // `?deleted=include|only` — soft delete. See `soft-delete-params.ts`.\n const withDeleted = parseWithDeleted(getLastValue(query[DELETED_QUERY_PARAM]));\n if (withDeleted !== undefined) options.withDeleted = withDeleted;\n\n const offsetVal = getLastValue(query.offset);\n if (offsetVal) options.offset = parseWindowParam(offsetVal, \"offset\", 0, \"INVALID_OFFSET\");\n\n const pageVal = getLastValue(query.page);\n if (pageVal) {\n const page = parseWindowParam(pageVal, \"page\", 1, \"INVALID_PAGE\");\n // Page stride uses the same bounded page size the read will use, so\n // pages neither overlap nor gap. (Vector search never paginates by\n // page, so the plain/text default is correct here.)\n const limit = resolveListLimitParam(rawLimit, {\n defaultLimit: limits.defaultLimit,\n maxLimit: limits.maxLimit\n });\n options.offset = (page - 1) * limit;\n }\n\n // ── Logical conditions (or / and / not) ────────────────────────────\n //\n // `?not=(status.eq.draft,views.gte.10)` negates the **conjunction** of its\n // conditions: `not(a)` is `NOT a`, `not(a,b)` is `NOT (a AND b)`. The rule\n // lives on `LogicalCondition` and is applied identically by this parser,\n // the shared wire codec and every driver compiler — one negation, one\n // meaning. A group nests, so `?not=(or(a,b))` is the De Morgan case.\n //\n // Three parameters and one slot, so exactly one applies. `or` wins over\n // `and`, and both over `not` — the precedence `or`/`and` already had, with\n // the third added at the end rather than in the middle, where it would have\n // silently changed which of two existing parameters was honoured.\n const orVal = getLastValue(query.or);\n const andVal = getLastValue(query.and);\n const notVal = getLastValue(query.not);\n if (orVal) {\n const logical = parseLogicalGroup(\"or\", orVal);\n if (logical) options.logical = logical;\n } else if (andVal) {\n const logical = parseLogicalGroup(\"and\", andVal);\n if (logical) options.logical = logical;\n } else if (notVal) {\n const logical = parseLogicalGroup(\"not\", notVal);\n if (logical) options.logical = logical;\n }\n\n // ── PostgREST-style field filters: ?field=op.value ─────────────────\n // Delegate to the canonical filter dialect (the single source of truth\n // for the wire grammar: operator codes, list/escape handling, implicit\n // eq). Values stay strings; the schema-aware driver coerces them to\n // column types. This keeps the REST path byte-for-byte consistent with\n // the SDK/admin path, which parses through the same `deserializeFilter`.\n //\n // `where` is reserved: it is the JSON filter dialect (see\n // `parseWhereParam`), not a column named \"where\". Leaving it out of this\n // list made the documented `?where={...}` compile as a filter on a\n // nonexistent field — which used to be dropped, widening the read to the\n // whole table, and is now a 400 `UNKNOWN_FILTER_FIELD`.\n //\n // `select` and `groupBy` are reserved for the same reason: on\n // `/aggregate` they are the request, and left out of this list\n // `?select=sum(total)` compiles into the filter as a comparison on a\n // column named \"select\" — a 400 on the one endpoint that requires it.\n // `not`, `after` and `distinct` join the list for the reason the comment\n // above gives: a reserved key left out of it compiles as a filter on a\n // column of that name, which is a 400 `UNKNOWN_FILTER_FIELD` on the one\n // request that needs the parameter. So do `?deleted=` and `?hard=`, which\n // ask about the soft-delete stamp rather than name a column.\n const reservedQueryKeys = [\"limit\", \"offset\", \"page\", \"after\", \"orderBy\", \"include\", \"fields\", \"distinct\", \"searchString\", \"searchExplain\", \"vector_search\", \"vector\", \"vector_distance\", \"vector_threshold\", \"or\", \"and\", \"not\", \"where\", \"select\", \"groupBy\", DELETED_QUERY_PARAM, HARD_DELETE_QUERY_PARAM];\n const filterDict: Record<string, unknown> = {};\n for (const [key, rawValue] of Object.entries(query)) {\n if (reservedQueryKeys.includes(key)) continue;\n filterDict[key] = rawValue;\n }\n // Both dialects may be sent together; an explicit `?field=op.value` wins\n // over the same field inside `where`, being the more specific request.\n const whereVal = getLastValue(query.where);\n const where = {\n ...(whereVal !== undefined && whereVal !== null ? parseWhereParam(whereVal) : undefined),\n ...decodeFilter(filterDict)\n };\n if (Object.keys(where).length > 0) {\n options.where = where;\n }\n\n // Sorting\n const orderByVal = getLastValue(query.orderBy);\n if (orderByVal) {\n options.orderBy = parseOrderByParam(orderByVal);\n }\n\n // ── Relation includes ──────────────────────────────────────────────\n //\n // Two spellings on one parameter, told apart by a leading `{`:\n //\n // ?include=author,comments.author — names and dotted paths\n // ?include={\"comments\":{\"limit\":5,\"include\":{\"author\":true}}}\n //\n // The flat form is what a human types and what every existing client\n // sends; the JSON form exists because the flat one has nowhere to put a\n // per-relation `limit`/`where`/`orderBy`/`fields`, and inventing a\n // punctuation for those (`comments(limit:5)`) would be a third grammar to\n // learn beside the two this API already has. Both compile to the same\n // request — `deserializeInclude` in `@rebasepro/common` is the codec, and\n // the SDK serialises through its inverse.\n const includeVal = getLastValue(query.include);\n if (includeVal !== undefined && includeVal !== null) {\n try {\n const include = deserializeInclude(String(includeVal));\n // Normalized for its *checks* — the depth bound and the shape of a\n // per-relation options object — and then discarded: what travels on\n // is the caller's own spelling, which the driver normalizes again\n // (idempotently) when it reads it. Validating here is what makes a\n // malformed include a 400 at the boundary rather than an\n // `IncludeSpecError` escaping from the driver as a 500, which is\n // what `?include=a.b.c.d` used to answer.\n normalizeInclude(include);\n options.include = include;\n } catch (e) {\n if (e instanceof IncludeSpecError) throw invalidParam(e.message, e.code);\n if (e instanceof OrderBySpecError) {\n throw invalidParam(`Invalid \\`include\\`: ${e.message}`, \"INVALID_INCLUDE\");\n }\n throw e;\n }\n }\n\n // Field selection. A projection at the driver, not a trim of the response:\n // the columns named here are the columns read.\n const fieldsVal = getLastValue(query.fields);\n if (fieldsVal) {\n const fieldsStr = String(fieldsVal).trim();\n options.fields = fieldsStr.split(\",\").map(s => s.trim()).filter(Boolean);\n }\n\n // `?distinct=true` — `SELECT DISTINCT` over the projection. Only `true`\n // and `1` mean yes; anything else is refused rather than read as \"no\",\n // because a `?distinct=1&` typo'd into `?distinct=ture` would otherwise\n // return duplicate rows while looking exactly like it had worked.\n const distinctVal = getLastValue(query.distinct);\n if (distinctVal !== undefined && distinctVal !== null && String(distinctVal) !== \"\") {\n const text = String(distinctVal).trim().toLowerCase();\n if (text !== \"true\" && text !== \"1\" && text !== \"false\" && text !== \"0\") {\n throw invalidParam(\n `Invalid \\`distinct\\` parameter: expected \\`true\\` or \\`false\\`, got ${JSON.stringify(String(distinctVal))}.`,\n \"INVALID_DISTINCT\"\n );\n }\n options.distinct = text === \"true\" || text === \"1\";\n }\n\n // ── Keyset cursor ──────────────────────────────────────────────────\n //\n // `?after=<meta.nextCursor>`. Decoded here so a malformed cursor is one\n // 400 in one place, and so the sort a cursor implies is settled before any\n // route reads `orderBy`: a request that names no sort adopts the cursor's,\n // and one that names a different sort is refused rather than seeked in an\n // order nobody asked for.\n const afterVal = getLastValue(query.after);\n if (afterVal !== undefined && afterVal !== null && String(afterVal).trim() !== \"\") {\n let cursor: DecodedCursor;\n try {\n cursor = decodeCursor(String(afterVal));\n } catch (e) {\n if (e instanceof CursorError) throw invalidParam(e.message, e.code);\n throw e;\n }\n try {\n const reconciled = reconcileCursorOrder(cursor, orderByEntriesToTuples(options.orderBy));\n options.orderBy = reconciled.map(([field, direction, nulls]) =>\n (nulls ? { field, direction, nulls } : { field, direction }));\n } catch (e) {\n if (e instanceof CursorMismatchError) throw invalidParam(e.message, e.code);\n throw e;\n }\n options.cursor = cursor;\n // A cursor and an offset describe the same window two incompatible\n // ways, and honouring both would start the page `offset` rows past\n // where the cursor pointed — a gap the caller cannot see.\n if (options.offset !== undefined) {\n throw invalidParam(\n \"`after` and `offset`/`page` cannot be combined: a cursor already says where the page \"\n + \"starts, and an offset on top of it skips rows. Use one or the other.\",\n \"CURSOR_WITH_OFFSET\"\n );\n }\n }\n\n // ── Vector similarity search ───────────────────────────────────────\n // Every rejection here is a malformed *request*, so it must carry a 400.\n // A bare `Error` reaches the handler with no `statusCode` and no known\n // `code`, which makes it a 500 — logged with a full stack as an incident,\n // and answered with \"An unexpected error occurred\", because the handler\n // only forwards a message to the client below 500. The caller was told\n // nothing about what it got wrong.\n const vectorSearchVal = getLastValue(query.vector_search);\n const vectorVal = getLastValue(query.vector);\n if (vectorSearchVal && vectorVal) {\n const vectorStr = String(vectorVal);\n let decoded: unknown;\n try {\n decoded = JSON.parse(vectorStr);\n } catch {\n decoded = undefined;\n }\n // Validated outside the `try` on purpose: inside it, the thrown\n // ApiError would be caught by its own `catch` and re-thrown as\n // something else.\n if (!Array.isArray(decoded) || !decoded.every(v => typeof v === \"number\")) {\n throw invalidParam(\n \"Invalid `vector` format. Expected a JSON array of numbers, e.g. [0.1,0.2,0.3]\",\n \"INVALID_VECTOR\"\n );\n }\n const queryVector = decoded as number[];\n\n const distanceParamVal = getLastValue(query.vector_distance);\n const distanceParam = distanceParamVal ? String(distanceParamVal) : \"cosine\";\n if (distanceParam !== \"cosine\" && distanceParam !== \"l2\" && distanceParam !== \"inner_product\") {\n throw invalidParam(\n `Invalid \\`vector_distance\\`: ${distanceParam}. Expected: cosine, l2, or inner_product`,\n \"INVALID_VECTOR_DISTANCE\"\n );\n }\n\n const vectorSearch: VectorSearchParams = {\n property: String(vectorSearchVal),\n vector: queryVector,\n distance: distanceParam\n };\n\n const thresholdVal = getLastValue(query.vector_threshold);\n if (thresholdVal) {\n const threshold = parseFloat(String(thresholdVal));\n if (isNaN(threshold)) {\n throw invalidParam(\n \"Invalid `vector_threshold`. Expected a number.\",\n \"INVALID_VECTOR_THRESHOLD\"\n );\n }\n vectorSearch.threshold = threshold;\n }\n\n options.vectorSearch = vectorSearch;\n }\n\n // Resolve the limit LAST — once we know whether this is a vector search —\n // so a client-supplied limit above the ceiling is refused with a 400 and an\n // absent one falls back to the correct mode default (plain/text =\n // defaultLimit, vector = 10). Without this a bare `GET /<collection>` would\n // return the whole table. Shared with the WebSocket ingress via\n // `resolveClientListLimit`.\n options.limit = resolveListLimitParam(rawLimit, {\n vectorSearch: !!options.vectorSearch,\n defaultLimit: limits.defaultLimit,\n maxLimit: limits.maxLimit\n });\n\n // Every field the request named, against what this caller may read. Last,\n // so a malformed parameter is still answered as malformed rather than as a\n // permission problem.\n if (access) assertQueryFieldsReadable(options, access.collection, access.viewer);\n\n return options;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"query-parser-BleZmY18.js","names":[],"sources":["../src/api/rest/soft-delete-params.ts","../src/api/rest/field-access-query.ts","../src/api/rest/query-parser.ts"],"sourcesContent":["import { ApiError } from \"../errors\";\n\n/**\n * The two query parameters soft delete adds to the REST surface.\n *\n * Kept in their own module so the call sites in `query-parser.ts` and the\n * delete routes are a single line each: the parsing rules belong to soft\n * delete, not to the parser, and a rule spread across the two files that read\n * it is a rule that drifts.\n */\n\n/** `?deleted=` — what to do about rows a soft delete has stamped. */\nexport const DELETED_QUERY_PARAM = \"deleted\";\n/** `?hard=` — ask for a real `DELETE` on a soft-delete collection. */\nexport const HARD_DELETE_QUERY_PARAM = \"hard\";\n\n/**\n * `?deleted=include|only` → the driver's `withDeleted`.\n *\n * Spelled `deleted` on the wire and `withDeleted` in the driver, deliberately:\n * the URL reads as a question about the rows (`?deleted=only` — \"only the\n * deleted ones\"), and the driver option reads as an instruction about the query.\n *\n * A value neither word is a 400 rather than a silent fallback to the default.\n * `?deleted=true` quietly hiding every deleted row is the worst of both: it\n * looks like it worked and answers the opposite question. Absent is the\n * default, which is \"hide them\".\n */\nexport function parseWithDeleted(raw: unknown): boolean | \"only\" | undefined {\n if (raw === undefined || raw === null || raw === \"\") return undefined;\n const value = String(raw).trim().toLowerCase();\n if (value === \"include\") return true;\n if (value === \"only\") return \"only\";\n throw ApiError.badRequest(\n `Invalid \\`?${DELETED_QUERY_PARAM}=${String(raw)}\\`. It takes 'include' (live rows and deleted ones) ` +\n \"or 'only' (deleted rows alone). Omit it to see only the live rows.\",\n \"INVALID_DELETED_PARAM\"\n );\n}\n\n/**\n * `?hard=true` → a real `DELETE` on a collection that soft-deletes.\n *\n * Needs no permission beyond the delete it replaces: it is the same verb, and a\n * second access-control surface for one operation is a second thing to get\n * wrong. What it changes is whether the row can be restored.\n *\n * Only the exact words `true` and `1` mean yes. Anything else is a 400, not a\n * \"no\" — a typo that silently soft-deletes when the caller asked to purge is a\n * caller who believes the data is gone.\n */\nexport function parseHardDelete(raw: unknown): boolean {\n if (raw === undefined || raw === null || raw === \"\") return false;\n const value = String(raw).trim().toLowerCase();\n if (value === \"true\" || value === \"1\") return true;\n if (value === \"false\" || value === \"0\") return false;\n throw ApiError.badRequest(\n `Invalid \\`?${HARD_DELETE_QUERY_PARAM}=${String(raw)}\\`. It takes 'true' or 'false'.`,\n \"INVALID_HARD_PARAM\"\n );\n}\n","import type { CollectionConfig } from \"@rebasepro/types\";\nimport type { FilterCondition, LogicalCondition } from \"@rebasepro/types\";\nimport { type FieldViewer, restrictedFieldNames } from \"@rebasepro/common\";\nimport { ApiError } from \"../errors\";\n\n/**\n * A read may not name a field the caller cannot read.\n *\n * The strip in the row pipeline is what keeps the *value* off the wire. This is\n * the other half, and without it the value is still readable one bit at a time:\n * `?salary=gt.100000` returns the rows whose withheld salary is above 100k, and\n * `?orderBy=salary` returns them in order of it. A column no response can carry\n * has to be a column no query can interrogate, or the read rule is decoration.\n *\n * The refusal names the field. That is deliberate and it is not a leak: the\n * published OpenAPI lists every property of every collection, including the ones\n * a given caller cannot read, because the document is one document and is served\n * off the app rather than off the authenticated data router. Hiding the name\n * here would protect nothing and would answer a caller's genuine typo with\n * \"unknown field\", sending them to look for a spelling mistake that is not\n * there. Field *names* are public; field *values* are not.\n *\n * @module\n */\n\n/**\n * The roles behind a request, as a viewer a field rule can judge.\n *\n * Never `undefined`, and that is the point. `undefined` means the trusted server\n * plane in {@link FieldViewer}, which satisfies every non-empty role list — so\n * returning it for a request that merely has no `user` on the context would\n * hand an unauthenticated caller every field in the database. The auth\n * middleware scopes such a request's driver as `roles: [\"anon\"]` but sets no\n * `user`, so the fallback here has to be the same list the driver was scoped\n * with, not nothing.\n *\n * @param c anything carrying the Hono context's `get` — the batch route passes a\n * shim rather than the context itself, exactly as the API-key\n * permission check does.\n */\nexport function requestViewer(c: { get: (key: never) => unknown }): FieldViewer {\n const user = c.get(\"user\" as never) as { roles?: readonly string[] } | undefined;\n return { roles: user?.roles ?? ANON_ROLES };\n}\n\n/** What the auth middleware scopes an unauthenticated request's driver with. */\nconst ANON_ROLES: readonly string[] = Object.freeze([\"anon\"]);\n\n/** Which query parameter a refused field arrived in, for the message. */\ntype Where = \"filter\" | \"orderBy\" | \"fields\" | \"select\" | \"groupBy\";\n\nconst WHERE_LABEL: Record<Where, string> = {\n filter: \"a filter\",\n orderBy: \"`orderBy`\",\n fields: \"`fields`\",\n select: \"`select`\",\n groupBy: \"`groupBy`\"\n};\n\n/** Every column a logical group compares, however deeply nested. */\nfunction logicalColumns(logical: LogicalCondition | undefined, into: string[]): void {\n if (!logical?.conditions) return;\n for (const condition of logical.conditions) {\n if (\"conditions\" in condition) logicalColumns(condition as LogicalCondition, into);\n else if ((condition as FilterCondition).column) into.push((condition as FilterCondition).column);\n }\n}\n\n/**\n * The bare column an `orderBy` key names, or `undefined` for one that is not a\n * column at all.\n *\n * A sort key may be a relation aggregate (`comments.count()`) or one of the\n * computed keys a search adds (`_score`, `_distance`). Neither is a property of\n * this collection, so neither is a field this rule has anything to say about;\n * the field it *would* have named is checked by the same walk one level down\n * when the driver resolves the relation.\n */\nfunction orderByColumn(field: string): string | undefined {\n if (field.startsWith(\"_\")) return undefined;\n if (field.includes(\"(\") || field.includes(\".\")) return undefined;\n return field;\n}\n\n/**\n * Refuse the request when any of `names` is a field this caller cannot read.\n *\n * Exported so the aggregate route — whose `select` and `groupBy` are parsed\n * outside `parseQueryOptions` — applies the identical rule. `count(*)` over a\n * withheld column is the same disclosure as reading it, one predicate at a time.\n */\nexport function assertReadableFields(\n names: readonly (string | undefined)[],\n collection: CollectionConfig,\n viewer: FieldViewer | undefined,\n where: Where\n): void {\n if (names.length === 0) return;\n const { refused } = restrictedFieldNames(collection, viewer, \"read\");\n if (refused.size === 0) return;\n\n const named = [...new Set(names.filter((n): n is string => n !== undefined && refused.has(n)))];\n if (named.length === 0) return;\n\n throw ApiError.badRequest(\n `${named.map(f => `'${f}'`).join(\", \")} ${named.length > 1 ? \"are\" : \"is\"} not readable ` +\n `on '${collection.slug}' with your roles, so ${named.length > 1 ? \"they\" : \"it\"} cannot be used in ` +\n `${WHERE_LABEL[where]}.`,\n \"FIELD_NOT_READABLE\",\n {\n collection: collection.slug,\n fields: named,\n violations: named.map(field => ({\n field,\n code: \"access\",\n message: `'${field}' is not readable with your roles.`\n }))\n }\n );\n}\n\n/**\n * The whole of a parsed read request, checked in one pass.\n *\n * One call rather than five, because five call sites is five chances to add a\n * sixth query parameter and forget it — which is exactly how `?or=` came to be\n * parsed and then dropped by the list route.\n */\nexport function assertQueryFieldsReadable(\n options: {\n where?: Record<string, unknown>;\n logical?: LogicalCondition;\n orderBy?: { field: string }[];\n fields?: string[];\n },\n collection: CollectionConfig,\n viewer: FieldViewer | undefined\n): void {\n const filtered: string[] = [];\n if (options.where) filtered.push(...Object.keys(options.where));\n logicalColumns(options.logical, filtered);\n assertReadableFields(filtered, collection, viewer, \"filter\");\n\n assertReadableFields(\n (options.orderBy ?? []).map(entry => orderByColumn(entry.field)),\n collection, viewer, \"orderBy\"\n );\n\n assertReadableFields(options.fields ?? [], collection, viewer, \"fields\");\n}\n","import type { CollectionConfig, FilterValues, ListLimitBounds, LogicalCondition, NullsPlacement, OrderByTuple, VectorSearchParams } from \"@rebasepro/types\";\nimport { toCanonicalOp, resolveClientListLimit, ListLimitError, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT } from \"@rebasepro/types\";\nimport type { DecodedCursor } from \"@rebasepro/common\";\nimport {\n CursorError,\n CursorMismatchError,\n type FieldViewer,\n IncludeSpecError,\n OrderBySpecError,\n decodeCursor,\n deserializeFilter,\n deserializeInclude,\n deserializeLogicalCondition,\n normalizeInclude,\n reconcileCursorOrder,\n UnknownFilterOperatorError\n} from \"@rebasepro/common\";\nimport { QueryOptions } from \"../types\";\nimport { ApiError } from \"../errors\";\nimport { DELETED_QUERY_PARAM, HARD_DELETE_QUERY_PARAM, parseWithDeleted } from \"./soft-delete-params\";\nimport { assertQueryFieldsReadable } from \"./field-access-query\";\n\nexport const mapOperator = (op: string) => toCanonicalOp(op) ?? null;\n\n/**\n * A malformed query parameter, refused with a 400.\n *\n * Every rejection in this file is one of these, and every one of them is\n * `expected` — the flag `errorHandler` reads to log a routine outcome at debug\n * instead of warn. A client that mistypes an operator, a sort direction or a\n * limit is not an incident: nothing on the server is wrong, the request never\n * reached the database, and the caller has already been told what to fix in the\n * response body. Left at warn, a single frontend holding a stale field name\n * writes a `⚠️` line per request forever, and the warn level stops meaning\n * anything — which is why \"routine 4xx logs at WARN\" is a standing finding\n * against this API.\n *\n * Not a factory on `ApiError`: the class's members are part of the tracked\n * runtime surface (`api-surface/server.api.txt`), and this needs no addition to\n * it. `ApiError.unauthenticated` is the same idea one status code up.\n */\nfunction invalidParam(message: string, code: string, details?: unknown): ApiError {\n return new ApiError(400, code, message, details, true);\n}\n\n/**\n * Decode a filter, turning the shared codec's operator rejection into a 400.\n *\n * `deserializeFilter` lives in `@rebasepro/common`, which cannot throw an\n * `ApiError` — it does not depend on this package, and the browser SDK decodes\n * through the same function and has nothing to render one with. So it throws\n * `UnknownFilterOperatorError`, and the HTTP boundary is where that becomes a\n * status code. Same seam `parseLogicalGroup` uses for the nesting bound.\n *\n * Without this the operator string became a *value*: `?where={\"title\":\n * [\"!!\",\"Hello\"]}` compiled to `title IN ('!!','Hello')` and answered 200 with\n * the row the caller was filtering out, and `{\"id\":[\">>\",0]}` reached Postgres\n * and came back a 500 quoting `invalid input syntax for type integer`. Both are\n * malformed requests and now say so.\n */\nfunction decodeFilter(query: Record<string, unknown>): FilterValues<string> {\n try {\n return deserializeFilter(query);\n } catch (e) {\n if (e instanceof UnknownFilterOperatorError) {\n throw invalidParam(e.message, e.code, e.details);\n }\n throw e;\n }\n}\n\nfunction getLastValue(val: unknown): unknown {\n if (Array.isArray(val)) {\n return val[val.length - 1];\n }\n return val;\n}\n\n/**\n * Parse an `or(...)` / `and(...)` logical group from its wire form.\n *\n * The wire carries the inner conditions wrapped in parens (e.g.\n * `(status.eq.active,age.gte.18)`); we re-attach the `or`/`and` prefix and\n * delegate to the canonical filter dialect (`@rebasepro/common`). Values are\n * preserved as strings — type coercion is the schema-aware driver's job, so\n * this path stays byte-for-byte consistent with the SDK/admin path (which\n * also parses via the shared dialect).\n */\nfunction parseLogicalGroup(type: \"or\" | \"and\" | \"not\", raw: unknown): LogicalCondition | undefined {\n let inner = String(raw).trim();\n if (inner.startsWith(\"(\") && inner.endsWith(\")\")) {\n inner = inner.slice(1, -1);\n }\n inner = inner.trim();\n if (!inner) return undefined;\n let parsed;\n try {\n parsed = deserializeLogicalCondition(`${type}(${inner})`);\n } catch (e) {\n // The parser refuses a nesting depth no real filter reaches. That is a\n // request problem, and without this it surfaced as a 500 — the\n // unbounded version reached `RangeError: Maximum call stack size\n // exceeded`, which tells the caller nothing about their filter.\n throw invalidParam(\n `Invalid \\`${type}\\` parameter: ${e instanceof Error ? e.message : String(e)}`,\n \"INVALID_LOGICAL_GROUP\"\n );\n }\n return \"type\" in parsed ? parsed : undefined;\n}\n\n/**\n * Parse the `?where=` JSON filter object.\n *\n * This is the dialect the OpenAPI document publishes on every\n * `GET /api/data/{slug}` — `{\"status\":[\"==\",\"active\"]}`: field → canonical\n * `[WhereFilterOp, value]` tuple. It is normalized through the same\n * `deserializeFilter` as the `?field=op.value` params below, so a value that\n * arrives as a PostgREST dot-string (`{\"status\":\"eq.active\"}`) or as a bare\n * scalar (`{\"status\":\"active\"}`) compiles to the same condition. Unlike the\n * querystring dialect, JSON carries types — a number stays a number.\n *\n * A malformed value is a 400 rather than a silent drop: dropping the filter\n * would run the read unfiltered and return everything RLS happens to allow.\n */\nfunction parseWhereParam(raw: unknown): FilterValues<string> | undefined {\n const str = String(raw).trim();\n if (!str) return undefined;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(str);\n } catch {\n throw invalidParam(\n \"Invalid `where` parameter: expected a JSON object, e.g. {\\\"status\\\":[\\\"==\\\",\\\"active\\\"]}\",\n \"INVALID_WHERE\"\n );\n }\n if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) {\n throw invalidParam(\n \"Invalid `where` parameter: expected a JSON object mapping fields to conditions, \"\n + \"e.g. {\\\"status\\\":[\\\"==\\\",\\\"active\\\"]}\",\n \"INVALID_WHERE\"\n );\n }\n\n const filter = decodeFilter(parsed as Record<string, unknown>);\n return Object.keys(filter).length > 0 ? filter : undefined;\n}\n\ntype OrderByEntry = { field: string; direction: \"asc\" | \"desc\"; nulls?: NullsPlacement };\n\n/**\n * The parsed entries as the driver contract spells them: `[field, direction]`\n * tuples in order of significance.\n *\n * The REST layer used to hand the driver `orderBy[0].field` and drop the rest,\n * so `?orderBy=[{\"field\":\"roles\"},{\"field\":\"created_at\",\"direction\":\"desc\"}]`\n * — a shape this parser has always accepted and validated in full — sorted by\n * `roles` alone and returned the ties in whatever order Postgres pleased.\n */\nexport function orderByEntriesToTuples(entries?: OrderByEntry[]): OrderByTuple[] | undefined {\n if (!entries || entries.length === 0) return undefined;\n return entries.map(({ field, direction, nulls }) => (nulls\n ? [field, direction, nulls]\n : [field, direction]) as OrderByTuple);\n}\n\nfunction invalidOrderBy(detail: string): never {\n throw invalidParam(\n `Invalid \\`orderBy\\` parameter: ${detail}. Expected \\`field\\`, \\`field:desc\\`, `\n + \"`field:desc:last`, or a JSON array like \"\n + \"[{\\\"field\\\":\\\"created_at\\\",\\\"direction\\\":\\\"desc\\\",\\\"nulls\\\":\\\"last\\\"}]\",\n \"INVALID_ORDER_BY\"\n );\n}\n\n/**\n * The `nulls` slot: `first`/`last`, or a refusal naming the entry.\n *\n * Refused rather than defaulted, for the reason every other parameter here is:\n * a sort quietly ordered by a convention the caller did not ask for reads as\n * though it obeyed them. See {@link NullsPlacement} for what the default is\n * when the slot is simply absent.\n */\nfunction toNulls(raw: unknown, context: string): NullsPlacement | undefined {\n if (raw === undefined || raw === null || raw === \"\") return undefined;\n if (raw !== \"first\" && raw !== \"last\") {\n invalidOrderBy(`${context} has nulls '${String(raw)}'`);\n }\n return raw;\n}\n\n/** The aggregate functions `?select=` accepts. */\nconst AGGREGATE_FUNCTIONS = new Set([\"count\", \"sum\", \"avg\", \"min\", \"max\"]);\n\nexport interface ParsedAggregate {\n fn: \"count\" | \"sum\" | \"avg\" | \"min\" | \"max\";\n /** Absent only for `count()`, which counts rows rather than values. */\n field?: string;\n /** The key this appears under in the response. */\n alias: string;\n}\n\n/**\n * Parse `?select=count(),sum(total),avg(total)`.\n *\n * The spelling is SQL's, because whoever writes it is thinking in SQL and\n * because any other spelling has to be learned first. `count()` with no field\n * counts rows; every other function names a column.\n *\n * Aliases are derived rather than accepted: `sum(total)` returns as\n * `sum_total`, `count()` as `count`. Letting a caller choose would mean\n * checking their alias is not also a `groupBy` field — a rule nobody would\n * guess, and a silently overwritten value if it went unchecked.\n */\nexport function parseAggregateSelect(raw: unknown): ParsedAggregate[] | undefined {\n const value = getLastValue(raw);\n if (!value) return undefined;\n\n const entries = String(value).split(\",\").map(s => s.trim()).filter(Boolean);\n if (entries.length === 0) return undefined;\n\n return entries.map((entry) => {\n const match = /^([a-z]+)\\(\\s*([A-Za-z0-9_]*)\\s*\\)$/i.exec(entry);\n if (!match) {\n throw invalidParam(\n `Invalid \\`select\\` entry \"${entry}\". Expected \\`fn(field)\\`, e.g. \\`sum(total)\\` or \\`count()\\`.`,\n \"INVALID_AGGREGATE_SELECT\"\n );\n }\n\n const fn = match[1].toLowerCase();\n const field = match[2] || undefined;\n\n if (!AGGREGATE_FUNCTIONS.has(fn)) {\n throw invalidParam(\n `Unknown aggregate function \"${fn}\". Expected: ${[...AGGREGATE_FUNCTIONS].join(\", \")}.`,\n \"INVALID_AGGREGATE_FUNCTION\"\n );\n }\n if (fn !== \"count\" && !field) {\n // `sum()` has no sensible reading, and guessing one would be\n // inventing a column on the caller's behalf.\n throw invalidParam(\n `\\`${fn}()\\` needs a field, e.g. \\`${fn}(total)\\`. Only \\`count()\\` may be empty.`,\n \"INVALID_AGGREGATE_SELECT\"\n );\n }\n\n return {\n fn: fn as ParsedAggregate[\"fn\"],\n field,\n alias: field ? `${fn}_${field}` : fn\n };\n });\n}\n\n/** Parse `?groupBy=status,country`. */\nexport function parseGroupBy(raw: unknown): string[] | undefined {\n const value = getLastValue(raw);\n if (!value) return undefined;\n const fields = String(value).split(\",\").map(s => s.trim()).filter(Boolean);\n return fields.length > 0 ? fields : undefined;\n}\n\n/** `asc`/`desc`, in any case. Anything else is a request to sort in a way that does not exist. */\nfunction toDirection(raw: unknown, context: string): \"asc\" | \"desc\" {\n if (raw === undefined || raw === null) return \"asc\";\n if (typeof raw !== \"string\") invalidOrderBy(`${context} has a non-string \\`direction\\``);\n const lowered = raw.toLowerCase();\n if (lowered !== \"asc\" && lowered !== \"desc\") {\n invalidOrderBy(`${context} has direction '${raw}'`);\n }\n return lowered;\n}\n\n/** One entry: the canonical `{field, direction}`, or the `field:direction` shorthand as a string. */\nfunction toOrderByEntry(raw: unknown, index: number): OrderByEntry {\n const context = `entry ${index}`;\n if (typeof raw === \"string\") {\n // Split here rather than through `deserializeOrderBy`, which is the\n // *client* end of the codec and normalises anything that is not\n // literally \"desc\" to \"asc\". Routed through it, `?orderBy=x:DESC`\n // reached `toDirection` already collapsed to \"asc\" and answered 200\n // with the rows in the opposite order — a newest-first list showing\n // the oldest rows — and `x:sideways` did the same. The direction token\n // has to arrive here raw for `toDirection` to have anything to refuse.\n const idx = raw.indexOf(\":\");\n const field = (idx === -1 ? raw : raw.slice(0, idx)).trim();\n if (!field) invalidOrderBy(`${context} is an empty field name`);\n if (idx === -1) return { field, direction: \"asc\" };\n // `field:direction:nulls`. The third segment is optional, so every\n // `field:desc` written before it existed parses exactly as it did.\n const rest = raw.slice(idx + 1);\n const nullsIdx = rest.indexOf(\":\");\n const direction = toDirection(nullsIdx === -1 ? rest : rest.slice(0, nullsIdx), context);\n const nulls = nullsIdx === -1 ? undefined : toNulls(rest.slice(nullsIdx + 1), context);\n return nulls ? { field, direction, nulls } : { field, direction };\n }\n if (typeof raw !== \"object\" || raw === null || Array.isArray(raw)) {\n invalidOrderBy(`${context} is not a field name or a {field, direction} object`);\n }\n const entry = raw as Record<string, unknown>;\n if (typeof entry.field !== \"string\" || entry.field.trim() === \"\") {\n invalidOrderBy(`${context} has no \\`field\\``);\n }\n const nulls = toNulls(entry.nulls, context);\n const direction = toDirection(entry.direction, context);\n return nulls ? { field: entry.field, direction, nulls } : { field: entry.field, direction };\n}\n\n/**\n * Parse the `orderBy` query parameter.\n *\n * The field *name* has been validated against the schema for a while — an\n * `?orderBy=titel` is a 400 rather than 200 with unsorted rows, on the grounds\n * that silently dropping the sort leaves the caller believing in an order that\n * is not there. The parameter's *shape* was never checked the same way, and it\n * failed in exactly the same silent manner one layer earlier: whatever\n * `JSON.parse` returned was assigned to an option declared as an array of\n * `{field, direction}`, and the REST layer reads only `orderBy[0].field`. So\n * `?orderBy={\"field\":\"name\"}` — an object rather than an array, and the most\n * natural thing for a client to try — read `undefined`, dropped the ORDER BY,\n * and answered 200. So did a number, a boolean, `null`, and `[\"name\"]`.\n *\n * This refuses those, the way `parseWhereParam` above already refuses a\n * malformed filter and for the same reason. What it keeps working is every\n * shape that worked before: the `field` and `field:desc` shorthands, and the\n * canonical JSON array.\n */\nfunction parseOrderByParam(raw: unknown): OrderByEntry[] | undefined {\n if (Array.isArray(raw)) {\n // A repeated query parameter arrives pre-split; treat it as the list.\n return raw.length === 0 ? undefined : raw.map(toOrderByEntry);\n }\n\n const str = String(raw).trim();\n if (!str) return undefined;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(str);\n } catch {\n // Not JSON at all, so it is the `field:direction` shorthand.\n return [toOrderByEntry(str, 0)];\n }\n\n // `JSON.parse` succeeding says nothing about the shape being usable.\n if (Array.isArray(parsed)) {\n if (parsed.length === 0) return undefined;\n return parsed.map(toOrderByEntry);\n }\n if (typeof parsed === \"string\") return [toOrderByEntry(parsed, 0)];\n if (typeof parsed === \"object\" && parsed !== null) {\n // A bare `{field, direction}` is a near miss rather than nonsense, but\n // accepting it would leave two spellings of one parameter. Name it.\n invalidOrderBy(\"a single object was given where a JSON array was expected\");\n }\n invalidOrderBy(`${typeof parsed} is not a field name or a list of them`);\n}\n\n// Re-exported for callers/tests that reference the REST list bounds. The\n// numbers and the rule live in `@rebasepro/types` so the REST parser and the\n// WebSocket ingress enforce ONE shared guarantee. See `resolveClientListLimit`.\nexport { DEFAULT_LIST_LIMIT, DEFAULT_VECTOR_LIST_LIMIT, MAX_LIST_LIMIT } from \"@rebasepro/types\";\n\n/**\n * Overridable list-pagination bounds for {@link parseQueryOptions}. Without\n * these, `GET /<collection>` with no `?limit` would buffer the ENTIRE table\n * into a JS array + JSON response (a trivial OOM/DoS), and `?limit=100000000`\n * would be honoured verbatim.\n */\nexport interface ListLimitOptions {\n /**\n * Page size used when the client sends no `?limit`. Applied to plain and\n * text-search reads — a vector search falls back to its own default (10).\n */\n defaultLimit?: number;\n /** Largest `?limit` a client may ask for. A larger one is a 400, not a clamp. */\n maxLimit?: number;\n}\n\n/**\n * {@link resolveClientListLimit} for an HTTP route: the same bounds, answered\n * with a 400 rather than a 500.\n *\n * The shared resolver throws a `ListLimitError`, which carries `status` — but\n * the Hono error handler discriminates on `statusCode`, so an unconverted one\n * reaches the client as `INTERNAL_ERROR` with its message stripped, telling the\n * caller nothing about the parameter it got wrong. Every REST list ingress\n * routes its `limit` through here so all of them name the ceiling the same way.\n */\nexport function resolveListLimitParam(\n rawLimit: number | string | null | undefined,\n opts: ListLimitBounds & { vectorSearch?: boolean } = {}\n): number {\n try {\n return resolveClientListLimit(rawLimit, opts);\n } catch (e) {\n if (e instanceof ListLimitError) {\n throw invalidParam(e.message, \"INVALID_LIMIT\");\n }\n throw e;\n }\n}\n\n/**\n * A whole number at or above `minimum`, or a 400 naming the parameter.\n *\n * `parseInt` was the whole of the validation, and it answers `NaN` for\n * `?offset=abc` and a negative for `?offset=-5`. Neither was checked:\n *\n * - `NaN` reached the driver, where `OFFSET NaN` is a 500 about a syntax error\n * in a query the caller never wrote;\n * - `?page=0` computed `offset = -limit`, a negative offset, which Postgres\n * also refuses — and `?page=-3` refused deeper;\n * - `?offset=1.5` truncated silently to `1`, so the caller paged a window they\n * had not asked for.\n *\n * Every one of those is the caller's parameter, so every one is a 400 named\n * after the parameter — the shape `INVALID_LIMIT` already had, and the reason a\n * limit is *rejected* rather than clamped: a window quietly different from the\n * one asked for cannot be told apart from having reached the end.\n *\n * `expected: true` on the error (via {@link invalidParam}): a mistyped query\n * parameter never reached the database and the response body already says what\n * to fix, so it logs at debug rather than putting a warning in production logs\n * on every request from a client holding a stale link.\n */\nfunction parseWindowParam(raw: unknown, name: string, minimum: number, code: string): number {\n const text = String(raw).trim();\n const value = Number(text);\n if (text === \"\" || !Number.isFinite(value) || !Number.isInteger(value) || value < minimum) {\n throw invalidParam(\n `Invalid \\`${name}\\` parameter: expected a whole number ${minimum === 0 ? \"of 0 or more\" : `of ${minimum} or more`}, got ${JSON.stringify(text)}.`,\n code\n );\n }\n return value;\n}\n\n/**\n * Parse query parameters into QueryOptions\n */\nexport function parseQueryOptions(\n query: Record<string, unknown>,\n limits: ListLimitOptions = {},\n /**\n * The collection being read and who is reading it. Optional so the parser\n * stays a pure parser for the callers that have neither (tests, the WS\n * ingress, anything parsing a query it is not about to run); when present,\n * a `where`, `orderBy` or `fields` naming a field the caller cannot read is\n * a 400 rather than a query the driver would happily answer. See\n * {@link assertQueryFieldsReadable}.\n */\n access?: { collection: CollectionConfig; viewer?: FieldViewer }\n): QueryOptions {\n const options: QueryOptions = {};\n const rawLimit = getLastValue(query.limit) as number | string | null | undefined;\n\n // `?deleted=include|only` — soft delete. See `soft-delete-params.ts`.\n const withDeleted = parseWithDeleted(getLastValue(query[DELETED_QUERY_PARAM]));\n if (withDeleted !== undefined) options.withDeleted = withDeleted;\n\n const offsetVal = getLastValue(query.offset);\n if (offsetVal) options.offset = parseWindowParam(offsetVal, \"offset\", 0, \"INVALID_OFFSET\");\n\n const pageVal = getLastValue(query.page);\n if (pageVal) {\n const page = parseWindowParam(pageVal, \"page\", 1, \"INVALID_PAGE\");\n // Page stride uses the same bounded page size the read will use, so\n // pages neither overlap nor gap. (Vector search never paginates by\n // page, so the plain/text default is correct here.)\n const limit = resolveListLimitParam(rawLimit, {\n defaultLimit: limits.defaultLimit,\n maxLimit: limits.maxLimit\n });\n options.offset = (page - 1) * limit;\n }\n\n // ── Logical conditions (or / and / not) ────────────────────────────\n //\n // `?not=(status.eq.draft,views.gte.10)` negates the **conjunction** of its\n // conditions: `not(a)` is `NOT a`, `not(a,b)` is `NOT (a AND b)`. The rule\n // lives on `LogicalCondition` and is applied identically by this parser,\n // the shared wire codec and every driver compiler — one negation, one\n // meaning. A group nests, so `?not=(or(a,b))` is the De Morgan case.\n //\n // Three parameters and one slot, so exactly one applies. `or` wins over\n // `and`, and both over `not` — the precedence `or`/`and` already had, with\n // the third added at the end rather than in the middle, where it would have\n // silently changed which of two existing parameters was honoured.\n const orVal = getLastValue(query.or);\n const andVal = getLastValue(query.and);\n const notVal = getLastValue(query.not);\n if (orVal) {\n const logical = parseLogicalGroup(\"or\", orVal);\n if (logical) options.logical = logical;\n } else if (andVal) {\n const logical = parseLogicalGroup(\"and\", andVal);\n if (logical) options.logical = logical;\n } else if (notVal) {\n const logical = parseLogicalGroup(\"not\", notVal);\n if (logical) options.logical = logical;\n }\n\n // ── PostgREST-style field filters: ?field=op.value ─────────────────\n // Delegate to the canonical filter dialect (the single source of truth\n // for the wire grammar: operator codes, list/escape handling, implicit\n // eq). Values stay strings; the schema-aware driver coerces them to\n // column types. This keeps the REST path byte-for-byte consistent with\n // the SDK/admin path, which parses through the same `deserializeFilter`.\n //\n // `where` is reserved: it is the JSON filter dialect (see\n // `parseWhereParam`), not a column named \"where\". Leaving it out of this\n // list made the documented `?where={...}` compile as a filter on a\n // nonexistent field — which used to be dropped, widening the read to the\n // whole table, and is now a 400 `UNKNOWN_FILTER_FIELD`.\n //\n // `select` and `groupBy` are reserved for the same reason: on\n // `/aggregate` they are the request, and left out of this list\n // `?select=sum(total)` compiles into the filter as a comparison on a\n // column named \"select\" — a 400 on the one endpoint that requires it.\n // `not`, `after` and `distinct` join the list for the reason the comment\n // above gives: a reserved key left out of it compiles as a filter on a\n // column of that name, which is a 400 `UNKNOWN_FILTER_FIELD` on the one\n // request that needs the parameter. So do `?deleted=` and `?hard=`, which\n // ask about the soft-delete stamp rather than name a column.\n const reservedQueryKeys = [\"limit\", \"offset\", \"page\", \"after\", \"orderBy\", \"include\", \"fields\", \"distinct\", \"searchString\", \"searchExplain\", \"vector_search\", \"vector\", \"vector_distance\", \"vector_threshold\", \"or\", \"and\", \"not\", \"where\", \"select\", \"groupBy\", DELETED_QUERY_PARAM, HARD_DELETE_QUERY_PARAM];\n const filterDict: Record<string, unknown> = {};\n for (const [key, rawValue] of Object.entries(query)) {\n if (reservedQueryKeys.includes(key)) continue;\n filterDict[key] = rawValue;\n }\n // Both dialects may be sent together; an explicit `?field=op.value` wins\n // over the same field inside `where`, being the more specific request.\n const whereVal = getLastValue(query.where);\n const where = {\n ...(whereVal !== undefined && whereVal !== null ? parseWhereParam(whereVal) : undefined),\n ...decodeFilter(filterDict)\n };\n if (Object.keys(where).length > 0) {\n options.where = where;\n }\n\n // Sorting\n const orderByVal = getLastValue(query.orderBy);\n if (orderByVal) {\n options.orderBy = parseOrderByParam(orderByVal);\n }\n\n // ── Relation includes ──────────────────────────────────────────────\n //\n // Two spellings on one parameter, told apart by a leading `{`:\n //\n // ?include=author,comments.author — names and dotted paths\n // ?include={\"comments\":{\"limit\":5,\"include\":{\"author\":true}}}\n //\n // The flat form is what a human types and what every existing client\n // sends; the JSON form exists because the flat one has nowhere to put a\n // per-relation `limit`/`where`/`orderBy`/`fields`, and inventing a\n // punctuation for those (`comments(limit:5)`) would be a third grammar to\n // learn beside the two this API already has. Both compile to the same\n // request — `deserializeInclude` in `@rebasepro/common` is the codec, and\n // the SDK serialises through its inverse.\n const includeVal = getLastValue(query.include);\n if (includeVal !== undefined && includeVal !== null) {\n try {\n const include = deserializeInclude(String(includeVal));\n // Normalized for its *checks* — the depth bound and the shape of a\n // per-relation options object — and then discarded: what travels on\n // is the caller's own spelling, which the driver normalizes again\n // (idempotently) when it reads it. Validating here is what makes a\n // malformed include a 400 at the boundary rather than an\n // `IncludeSpecError` escaping from the driver as a 500, which is\n // what `?include=a.b.c.d` used to answer.\n normalizeInclude(include);\n options.include = include;\n } catch (e) {\n if (e instanceof IncludeSpecError) throw invalidParam(e.message, e.code);\n if (e instanceof OrderBySpecError) {\n throw invalidParam(`Invalid \\`include\\`: ${e.message}`, \"INVALID_INCLUDE\");\n }\n throw e;\n }\n }\n\n // Field selection. A projection at the driver, not a trim of the response:\n // the columns named here are the columns read.\n const fieldsVal = getLastValue(query.fields);\n if (fieldsVal) {\n const fieldsStr = String(fieldsVal).trim();\n options.fields = fieldsStr.split(\",\").map(s => s.trim()).filter(Boolean);\n }\n\n // `?distinct=true` — `SELECT DISTINCT` over the projection. Only `true`\n // and `1` mean yes; anything else is refused rather than read as \"no\",\n // because a `?distinct=1&` typo'd into `?distinct=ture` would otherwise\n // return duplicate rows while looking exactly like it had worked.\n const distinctVal = getLastValue(query.distinct);\n if (distinctVal !== undefined && distinctVal !== null && String(distinctVal) !== \"\") {\n const text = String(distinctVal).trim().toLowerCase();\n if (text !== \"true\" && text !== \"1\" && text !== \"false\" && text !== \"0\") {\n throw invalidParam(\n `Invalid \\`distinct\\` parameter: expected \\`true\\` or \\`false\\`, got ${JSON.stringify(String(distinctVal))}.`,\n \"INVALID_DISTINCT\"\n );\n }\n options.distinct = text === \"true\" || text === \"1\";\n }\n\n // ── Keyset cursor ──────────────────────────────────────────────────\n //\n // `?after=<meta.nextCursor>`. Decoded here so a malformed cursor is one\n // 400 in one place, and so the sort a cursor implies is settled before any\n // route reads `orderBy`: a request that names no sort adopts the cursor's,\n // and one that names a different sort is refused rather than seeked in an\n // order nobody asked for.\n const afterVal = getLastValue(query.after);\n if (afterVal !== undefined && afterVal !== null && String(afterVal).trim() !== \"\") {\n let cursor: DecodedCursor;\n try {\n cursor = decodeCursor(String(afterVal));\n } catch (e) {\n if (e instanceof CursorError) throw invalidParam(e.message, e.code);\n throw e;\n }\n try {\n const reconciled = reconcileCursorOrder(cursor, orderByEntriesToTuples(options.orderBy));\n options.orderBy = reconciled.map(([field, direction, nulls]) =>\n (nulls ? { field, direction, nulls } : { field, direction }));\n } catch (e) {\n if (e instanceof CursorMismatchError) throw invalidParam(e.message, e.code);\n throw e;\n }\n options.cursor = cursor;\n // A cursor and an offset describe the same window two incompatible\n // ways, and honouring both would start the page `offset` rows past\n // where the cursor pointed — a gap the caller cannot see.\n if (options.offset !== undefined) {\n throw invalidParam(\n \"`after` and `offset`/`page` cannot be combined: a cursor already says where the page \"\n + \"starts, and an offset on top of it skips rows. Use one or the other.\",\n \"CURSOR_WITH_OFFSET\"\n );\n }\n }\n\n // ── Vector similarity search ───────────────────────────────────────\n // Every rejection here is a malformed *request*, so it must carry a 400.\n // A bare `Error` reaches the handler with no `statusCode` and no known\n // `code`, which makes it a 500 — logged with a full stack as an incident,\n // and answered with \"An unexpected error occurred\", because the handler\n // only forwards a message to the client below 500. The caller was told\n // nothing about what it got wrong.\n const vectorSearchVal = getLastValue(query.vector_search);\n const vectorVal = getLastValue(query.vector);\n if (vectorSearchVal && vectorVal) {\n const vectorStr = String(vectorVal);\n let decoded: unknown;\n try {\n decoded = JSON.parse(vectorStr);\n } catch {\n decoded = undefined;\n }\n // Validated outside the `try` on purpose: inside it, the thrown\n // ApiError would be caught by its own `catch` and re-thrown as\n // something else.\n if (!Array.isArray(decoded) || !decoded.every(v => typeof v === \"number\")) {\n throw invalidParam(\n \"Invalid `vector` format. Expected a JSON array of numbers, e.g. [0.1,0.2,0.3]\",\n \"INVALID_VECTOR\"\n );\n }\n const queryVector = decoded as number[];\n\n const distanceParamVal = getLastValue(query.vector_distance);\n const distanceParam = distanceParamVal ? String(distanceParamVal) : \"cosine\";\n if (distanceParam !== \"cosine\" && distanceParam !== \"l2\" && distanceParam !== \"inner_product\") {\n throw invalidParam(\n `Invalid \\`vector_distance\\`: ${distanceParam}. Expected: cosine, l2, or inner_product`,\n \"INVALID_VECTOR_DISTANCE\"\n );\n }\n\n const vectorSearch: VectorSearchParams = {\n property: String(vectorSearchVal),\n vector: queryVector,\n distance: distanceParam\n };\n\n const thresholdVal = getLastValue(query.vector_threshold);\n if (thresholdVal) {\n const threshold = parseFloat(String(thresholdVal));\n if (isNaN(threshold)) {\n throw invalidParam(\n \"Invalid `vector_threshold`. Expected a number.\",\n \"INVALID_VECTOR_THRESHOLD\"\n );\n }\n vectorSearch.threshold = threshold;\n }\n\n options.vectorSearch = vectorSearch;\n }\n\n // Resolve the limit LAST — once we know whether this is a vector search —\n // so a client-supplied limit above the ceiling is refused with a 400 and an\n // absent one falls back to the correct mode default (plain/text =\n // defaultLimit, vector = 10). Without this a bare `GET /<collection>` would\n // return the whole table. Shared with the WebSocket ingress via\n // `resolveClientListLimit`.\n options.limit = resolveListLimitParam(rawLimit, {\n vectorSearch: !!options.vectorSearch,\n defaultLimit: limits.defaultLimit,\n maxLimit: limits.maxLimit\n });\n\n // Every field the request named, against what this caller may read. Last,\n // so a malformed parameter is still answered as malformed rather than as a\n // permission problem.\n if (access) assertQueryFieldsReadable(options, access.collection, access.viewer);\n\n return options;\n}\n"],"mappings":"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"}
|
|
@@ -1528,6 +1528,42 @@ function suggestNearMiss(known, key) {
|
|
|
1528
1528
|
return known.find((candidate) => isNearMiss(candidate, key));
|
|
1529
1529
|
}
|
|
1530
1530
|
/**
|
|
1531
|
+
* The terms a user meant when they typed into a search box.
|
|
1532
|
+
*
|
|
1533
|
+
* A search box is not a substring box. Typing `sebastian melendez` on a
|
|
1534
|
+
* collection whose name is split across `first_name` and `last_name` has to
|
|
1535
|
+
* find the row, and matching the whole string against each field separately
|
|
1536
|
+
* cannot: no single column holds both words. Splitting on whitespace and
|
|
1537
|
+
* requiring *every* term to match *some* field is what does, and it is what
|
|
1538
|
+
* `websearch_to_tsquery` already does for collections that declared a `search`
|
|
1539
|
+
* block — so this is also what keeps the two search paths answering the same
|
|
1540
|
+
* question.
|
|
1541
|
+
*
|
|
1542
|
+
* It fixes the trailing space too: `sebastian ` matched nothing at all, because
|
|
1543
|
+
* the space was part of the pattern.
|
|
1544
|
+
*
|
|
1545
|
+
* A double-quoted run is one term, again matching the full-text path, so a
|
|
1546
|
+
* caller who really does want the phrase can ask for it. An unbalanced quote is
|
|
1547
|
+
* stripped rather than honoured — the alternative is that everything after a
|
|
1548
|
+
* stray `"` silently becomes one long term that matches nothing.
|
|
1549
|
+
*
|
|
1550
|
+
* Returns an empty array for a string with no terms in it at all (whitespace,
|
|
1551
|
+
* or nothing but quotes); the caller decides what an empty search means, since
|
|
1552
|
+
* only it knows whether "no constraint" or "no rows" is the safe reading.
|
|
1553
|
+
*/
|
|
1554
|
+
function splitSearchTerms(searchString, maxTerms = 8) {
|
|
1555
|
+
const terms = [];
|
|
1556
|
+
const pattern = /"([^"]*)"|(\S+)/g;
|
|
1557
|
+
let match;
|
|
1558
|
+
while ((match = pattern.exec(searchString)) !== null) {
|
|
1559
|
+
const term = (match[1] ?? match[2].replace(/"/g, "")).trim();
|
|
1560
|
+
if (!term) continue;
|
|
1561
|
+
terms.push(term);
|
|
1562
|
+
if (terms.length >= maxTerms) break;
|
|
1563
|
+
}
|
|
1564
|
+
return terms;
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1531
1567
|
* Deep clone a value, preserving function references and class instances.
|
|
1532
1568
|
* Unlike structuredClone, this handles objects that contain functions
|
|
1533
1569
|
* (e.g. CollectionConfig with target(), childCollections(), callbacks).
|
|
@@ -4190,6 +4226,37 @@ var CollectionRegistry = class {
|
|
|
4190
4226
|
};
|
|
4191
4227
|
}
|
|
4192
4228
|
};
|
|
4229
|
+
//#endregion
|
|
4230
|
+
//#region ../common/src/collections/engine-support.ts
|
|
4231
|
+
/**
|
|
4232
|
+
* Refuse a `beforeQuery` hook on a collection Postgres does not serve.
|
|
4233
|
+
*
|
|
4234
|
+
* `beforeQuery` narrows a read *before it is compiled*, which means compiling
|
|
4235
|
+
* a declarative filter into the query the engine is about to run. Only
|
|
4236
|
+
* `@rebasepro/server-postgres` does that today. On any other engine the hook
|
|
4237
|
+
* would look configured and do nothing — and for a hook whose whole purpose is
|
|
4238
|
+
* to restrict which rows come back, "nothing" means every row served to
|
|
4239
|
+
* everybody.
|
|
4240
|
+
*
|
|
4241
|
+
* So it is a boot failure, named. The alternative — declaring the hook only on
|
|
4242
|
+
* `PostgresCollectionConfig`, the way `search` is — is not available:
|
|
4243
|
+
* `callbacks` is one engine-agnostic type shared by every collection, and
|
|
4244
|
+
* splitting it would fork `afterRead` and the five write hooks along with it.
|
|
4245
|
+
*
|
|
4246
|
+
* Lives here rather than in either driver because both have to run it: the
|
|
4247
|
+
* Postgres schema planner sees every collection in a mixed app, and a
|
|
4248
|
+
* Mongo-only app never boots a Postgres planner at all.
|
|
4249
|
+
*
|
|
4250
|
+
* Called with *every* collection, before an engine filters any out.
|
|
4251
|
+
*/
|
|
4252
|
+
var assertBeforeQueryIsPostgresOnly = (collections) => {
|
|
4253
|
+
for (const collection of collections) {
|
|
4254
|
+
if (isPostgresCollectionConfig(collection)) continue;
|
|
4255
|
+
if (!collection.callbacks?.beforeQuery) continue;
|
|
4256
|
+
const engine = collection.engine ?? "non-postgres";
|
|
4257
|
+
throw new Error(`${collection.slug}.callbacks.beforeQuery: narrowing a read before it is compiled is a Postgres feature, and this collection is served by \`${engine}\`. Remove the hook — it would otherwise look configured while every read kept returning every row the engine allows. Redaction that works on every engine is \`afterRead\`.`);
|
|
4258
|
+
}
|
|
4259
|
+
};
|
|
4193
4260
|
/**
|
|
4194
4261
|
* What a property's access rules actually are, with `excludeFromApi` expanded.
|
|
4195
4262
|
*
|
|
@@ -6508,6 +6575,6 @@ function buildRoutedRebaseData({ defaultData, sources, resolveKey }) {
|
|
|
6508
6575
|
}
|
|
6509
6576
|
new Set(REBASE_INTERNAL_TABLES);
|
|
6510
6577
|
//#endregion
|
|
6511
|
-
export {
|
|
6578
|
+
export { ListLimitError as $, createDataSourceRegistry as A, fieldKeyForColumn as B, decodeCursor as C, restrictedFieldNames as D, effectiveAccess as E, revokeInternalTableSql as F, enumToObjectEntries as G, getTableName as H, getJunctionConfigForRelation as I, hydrateRegExp as J, buildCompositeId as K, getEffectiveSecurityRules as L, resolveDataSource as M, authSecretsMissingExclusion as N, assertBeforeQueryIsPostgresOnly as O, REBASE_USER_ROLE as P, unref as Q, getTenantConfig as R, cursorToStartAfter as S, canWriteField as T, isRelationRequired as U, findRelation as V, resolveCollectionRelations as W, suggestNearMiss as X, splitSearchTerms as Y, toSnakeCase as Z, OrderBySpecError as _, isUnsupported as _t, deserializeLogicalCondition as a, isFieldOperation as at, CursorError as b, collectAllPages as c, JUNCTION_PIVOT_KEY as ct, IncludeSpecError as d, EntityReference as dt, MAX_LIST_LIMIT as et, deserializeInclude as f, EntityRelation as ft, topLevelIncludeNames as g, RebaseClientError as gt, serializeInclude as h, RebaseApiError as ht, deserializeFilter as i, hasFieldOperation as it, isRelationalCollection as j, CollectionRegistry as k, paginateFind as l, isManyToMany as lt, normalizeInclude as m, Vector as mt, buildSdkData as n, BATCH_REF_KEY as nt, serializeFilter as o, ANONYMOUS_USER_ID as ot, mergeIncludeSpecs as p, GeoPoint as pt, resolvePrimaryKeys as q, UnknownFilterOperatorError as r, FIELD_OPERATORS as rt, serializeLogicalCondition as s, isAnonymousUid as st, buildRoutedRebaseData as t, resolveClientListLimit as tt, resolveFindWindow as u, isToMany as ut, normalizeOrderBy as v, unsupportedMethod as vt, reconcileCursorOrder as w, CursorMismatchError as x, serializeOrderBy as y, securityRuleToConditions as z };
|
|
6512
6579
|
|
|
6513
|
-
//# sourceMappingURL=src-
|
|
6580
|
+
//# sourceMappingURL=src-1vL-I1Po.js.map
|