@rebasepro/common 0.17.3 → 0.18.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.
Files changed (58) hide show
  1. package/README.md +4 -0
  2. package/dist/collections/CollectionRegistry.d.ts +1 -1
  3. package/dist/collections/default-collections.d.ts +15 -84
  4. package/dist/data/buildRebaseData.d.ts +1 -1
  5. package/dist/data/filter-dialect.d.ts +11 -0
  6. package/dist/data/sort-dialect.d.ts +15 -3
  7. package/dist/index.es.js +375 -63
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/util/builders.d.ts +69 -24
  10. package/dist/util/callback-errors.d.ts +77 -0
  11. package/dist/util/callback-errors.test.d.ts +1 -0
  12. package/dist/util/index.d.ts +1 -0
  13. package/dist/util/policy/evaluatePolicy.d.ts +6 -0
  14. package/dist/util/relations.d.ts +41 -0
  15. package/dist/util/table-name.test.d.ts +1 -0
  16. package/package.json +26 -22
  17. package/src/collections/CollectionRegistry.ts +0 -485
  18. package/src/collections/default-collections.ts +0 -109
  19. package/src/collections/index.ts +0 -2
  20. package/src/data/buildRebaseData.ts +0 -816
  21. package/src/data/buildRoutedRebaseData.ts +0 -103
  22. package/src/data/filter-conditions.ts +0 -46
  23. package/src/data/filter-dialect.ts +0 -737
  24. package/src/data/paginate.ts +0 -334
  25. package/src/data/query_builder.ts +0 -176
  26. package/src/data/resolveDataSource.ts +0 -135
  27. package/src/data/sort-dialect.ts +0 -237
  28. package/src/index.ts +0 -11
  29. package/src/table-classification.ts +0 -109
  30. package/src/types/json-logic-js.d.ts +0 -8
  31. package/src/util/auth-default-policies.ts +0 -215
  32. package/src/util/builders.ts +0 -82
  33. package/src/util/callbacks.ts +0 -122
  34. package/src/util/collections.ts +0 -117
  35. package/src/util/common.ts +0 -2
  36. package/src/util/conditions.ts +0 -168
  37. package/src/util/email.ts +0 -32
  38. package/src/util/entities.ts +0 -282
  39. package/src/util/enums.ts +0 -26
  40. package/src/util/identity.ts +0 -202
  41. package/src/util/index.ts +0 -21
  42. package/src/util/internal-tables.test.ts +0 -188
  43. package/src/util/internal-tables.ts +0 -197
  44. package/src/util/junction-policies.ts +0 -355
  45. package/src/util/paths.ts +0 -27
  46. package/src/util/permissions.test.ts +0 -866
  47. package/src/util/permissions.ts +0 -206
  48. package/src/util/pg-column-to-property.ts +0 -377
  49. package/src/util/policy/evaluatePolicy.ts +0 -194
  50. package/src/util/policy/index.ts +0 -4
  51. package/src/util/policy/policyToPostgres.ts +0 -263
  52. package/src/util/policy/securityRuleToConditions.ts +0 -67
  53. package/src/util/policy/sqlToPolicy.ts +0 -422
  54. package/src/util/relations.ts +0 -236
  55. package/src/util/resolutions.ts +0 -534
  56. package/src/util/resolve-relation.ts +0 -243
  57. package/src/util/storage.ts +0 -177
  58. package/src/util/string-column-length.ts +0 -31
@@ -1,737 +0,0 @@
1
- /**
2
- * REST wire-format adapter for the unified filter system.
3
- *
4
- * This module is the ONLY code in the entire codebase that knows about
5
- * PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).
6
- * Everything else speaks `FilterValues` exclusively.
7
- *
8
- * Wire-format values are always strings — the wire format carries no type
9
- * metadata, so type coercion is the responsibility of the server-side data
10
- * driver which has access to the collection schema.
11
- *
12
- * Structural characters inside a value are backslash-escaped: `,` → `\,`,
13
- * `(` → `\(`, `)` → `\)`, and a literal backslash as `\\`. Decoding is
14
- * deliberately conservative — only those four sequences are decoded, so a
15
- * backslash that arrives unescaped from an older client survives intact.
16
- *
17
- * @module
18
- */
19
-
20
- import {
21
- WhereFilterOp,
22
- FilterValues,
23
- ALL_WHERE_FILTER_OPS,
24
- CANONICAL_TO_REST,
25
- REST_TO_CANONICAL,
26
- RestFilterOp,
27
- toCanonicalOp,
28
- LogicalCondition,
29
- FilterCondition,
30
- NULL_OPS
31
- } from "@rebasepro/types";
32
- import { normalizeToEntityRelation } from "../util/entities";
33
-
34
- // ---------------------------------------------------------------------------
35
- // Value stringification
36
- // ---------------------------------------------------------------------------
37
-
38
- /**
39
- * Serialize a JS value to its querystring representation.
40
- * `null` is serialized as the literal string `"null"`.
41
- * Relation values (`EntityRelation` instances or `{ __type: "relation", id, path }`
42
- * objects) are serialized as their raw id — the wire format only carries the
43
- * value to compare against the FK column.
44
- */
45
- function stringifyValue(value: unknown): string {
46
- if (value === null) return "null";
47
- const relation = normalizeToEntityRelation(value);
48
- if (relation) return String(relation.id);
49
- return String(value);
50
- }
51
-
52
- // ---------------------------------------------------------------------------
53
- // Comma escaping for list values
54
- // ---------------------------------------------------------------------------
55
-
56
- /**
57
- * Characters that carry structure in the wire format and must therefore be
58
- * escaped inside a value: the separator, the group delimiters, and the escape
59
- * character itself.
60
- *
61
- * Parentheses are here because `and(...)`/`or(...)` groups are parsed by
62
- * tracking paren depth. A value containing one is not merely ambiguous, it
63
- * moves where the parser thinks the group ends.
64
- */
65
- const WIRE_SPECIALS = /[\\,()]/g;
66
-
67
- /**
68
- * Escape a value for the wire format: `\` → `\\`, `,` → `\,`, `(` → `\(`,
69
- * `)` → `\)`.
70
- */
71
- /**
72
- * The wire spelling of an empty list.
73
- *
74
- * A lone backslash: unproducible by {@link escapeWireValue}, which doubles
75
- * every backslash it emits, so it cannot collide with any real item.
76
- */
77
- const EMPTY_LIST_TOKEN = "\\";
78
-
79
- function escapeWireValue(value: string): string {
80
- return value.replace(WIRE_SPECIALS, ch => `\\${ch}`);
81
- }
82
-
83
- /**
84
- * Unescape a wire-format value.
85
- *
86
- * **Conservative**, and deliberately so: only the four sequences
87
- * {@link escapeWireValue} actually produces are decoded. A backslash followed
88
- * by anything else is left exactly as it is.
89
- *
90
- * This used to consume the backslash before *any* character, which is
91
- * indistinguishable for anything this codec emitted — it only ever emits those
92
- * four — but not for input arriving from elsewhere. A client on an older
93
- * release sends a Windows path or a LIKE pattern with a literal `C:\x`
94
- * unescaped, and greedy unescaping silently turned it into `C:x`, changing
95
- * which rows matched. Decoding only what the encoder can produce makes the two
96
- * directions agree across versions.
97
- */
98
- function unescapeWireValue(value: string): string {
99
- let result = "";
100
- for (let i = 0; i < value.length; i++) {
101
- const next = value[i + 1];
102
- if (value[i] === "\\" && (next === "\\" || next === "," || next === "(" || next === ")")) {
103
- result += next;
104
- i++;
105
- continue;
106
- }
107
- result += value[i];
108
- }
109
- return result;
110
- }
111
-
112
- /**
113
- * Split a parenthesized list string on unescaped commas.
114
- * Input is the content between `(` and `)`.
115
- *
116
- * @example
117
- * splitListItems("admin,editor") // ["admin", "editor"]
118
- * splitListItems("hello\\, world,foo") // ["hello, world", "foo"]
119
- */
120
- function splitListItems(inner: string): string[] {
121
- const items: string[] = [];
122
- let current = "";
123
- for (let i = 0; i < inner.length; i++) {
124
- if (inner[i] === "\\" && i + 1 < inner.length) {
125
- // Escaped pair — consume both chars so the comma in `\,` is not
126
- // read as a separator. Kept verbatim; decoding happens once, below.
127
- current += inner[i] + inner[i + 1];
128
- i++;
129
- } else if (inner[i] === ",") {
130
- items.push(unescapeWireValue(current));
131
- current = "";
132
- } else {
133
- current += inner[i];
134
- }
135
- }
136
- items.push(unescapeWireValue(current));
137
- return items;
138
- }
139
-
140
- /**
141
- * Split a group body on commas at paren depth 0, honouring escapes.
142
- *
143
- * The escape-awareness is the point. The splitter used to track only paren
144
- * depth, so a comma inside a scalar value ended a condition:
145
- * `or(name.eq.Doe, John,age.gte.18)` parsed as *three* conditions, the middle
146
- * one a fabricated `" John" == true`. On an `or` that widens the result set,
147
- * and nothing anywhere reports an error — the query simply stops meaning what
148
- * the caller wrote.
149
- */
150
- function splitGroupItems(inner: string): string[] {
151
- const parts: string[] = [];
152
- let depth = 0;
153
- let start = 0;
154
- for (let i = 0; i < inner.length; i++) {
155
- const ch = inner[i];
156
- if (ch === "\\" && i + 1 < inner.length) { i++; continue; }
157
- if (ch === "(") depth++;
158
- else if (ch === ")") depth--;
159
- else if (ch === "," && depth === 0) {
160
- parts.push(inner.slice(start, i));
161
- start = i + 1;
162
- }
163
- }
164
- parts.push(inner.slice(start));
165
- return parts;
166
- }
167
-
168
- // ---------------------------------------------------------------------------
169
- // Typed operator map lookups (no `as any`)
170
- // ---------------------------------------------------------------------------
171
-
172
- /**
173
- * Operator tables as `Map`s, because the key comes off the wire.
174
- *
175
- * Indexed as plain objects, every `Object.prototype` member answered: a query
176
- * string of `?f=valueOf.x` found a truthy "operator" — the inherited function —
177
- * and `deserializeTuple` returned it *as the operator*, so a function object
178
- * travelled on into the compilers in place of a `WhereFilterOp`. The guard one
179
- * line below (`if (!canonicalOp)`) reads as though it rejects anything unknown,
180
- * and does not: `Object.prototype` is not unknown to a plain object.
181
- *
182
- * Same shape as the prototype-key defects swept out of `setIn`, `getIn`,
183
- * `mergeDeep`, `unflattenObject` and `FOREIGN_CONVENTION_UIDS`.
184
- */
185
- const REST_OP_LOOKUP = new Map<string, WhereFilterOp>(
186
- Object.entries(REST_TO_CANONICAL) as [string, WhereFilterOp][]
187
- );
188
- const CANONICAL_OP_LOOKUP = new Map<string, RestFilterOp>(
189
- Object.entries(CANONICAL_TO_REST) as [string, RestFilterOp][]
190
- );
191
-
192
- // ---------------------------------------------------------------------------
193
- // Unknown operators
194
- // ---------------------------------------------------------------------------
195
-
196
- /** The operator spellings a rejection lists back to the caller. */
197
- const VALID_OPERATOR_LIST = ALL_WHERE_FILTER_OPS.join(", ");
198
-
199
- /**
200
- * A filter condition named an operator this dialect does not have.
201
- *
202
- * ## Why this throws, rather than returning a typed rejection
203
- *
204
- * `deserializeFilter` is the *shared* codec: the REST ingress
205
- * (`packages/server/src/api/rest/query-parser.ts`), the browser SDK and the
206
- * admin panel (`buildRebaseData.ts`) all decode through it. Two constraints
207
- * follow.
208
- *
209
- * - It cannot throw the server's `ApiError`. `@rebasepro/common` does not
210
- * depend on `@rebasepro/server` (the dependency runs the other way), and a
211
- * browser client has no error handler to render an `ApiError` with. So the
212
- * rejection is this plain `Error` subclass, whose `message` reads correctly
213
- * wherever it surfaces — a rejected promise in an app, a 400 body over HTTP.
214
- * - It cannot be a returned rejection *value*. Every caller assigns the result
215
- * straight into a query it is about to run; a sentinel that none of them
216
- * check would be ignored, which is exactly the silently-wrong-filter failure
217
- * this exists to stop. Throwing is also what this file already does for the
218
- * sibling cases — `serializeTuple` on an unknown canonical operator,
219
- * `deserializeLogicalCondition` past the nesting bound — and the REST parser
220
- * already converts the latter into a 400.
221
- *
222
- * `statusCode`, `code` and `details` are carried as fields because the server's
223
- * Hono error handler duck-types those off any thrown error: a decode path that
224
- * forgets to convert still answers 400 with the canonical envelope instead of a
225
- * 500 that says "An unexpected error occurred". `query-parser.ts` converts
226
- * explicitly all the same — that is the path the contract is stated on, and an
227
- * incidental 400 is not a contract.
228
- */
229
- export class UnknownFilterOperatorError extends Error {
230
- /** The field the condition was written against. */
231
- public readonly field: string;
232
- /** The operator string as it arrived, verbatim. */
233
- public readonly operator: string;
234
- /** Every operator this dialect accepts, in canonical spelling. */
235
- public readonly validOperators: readonly WhereFilterOp[] = ALL_WHERE_FILTER_OPS;
236
- /** See the class docblock: read by the server's error handler. */
237
- public readonly statusCode = 400;
238
- public readonly code = "UNKNOWN_FILTER_OPERATOR";
239
- public readonly details: { field: string; operator: string; validOperators: readonly WhereFilterOp[] };
240
-
241
- constructor(field: string, operator: string) {
242
- super(
243
- `Unknown filter operator '${operator}' on field '${field}'. `
244
- + `Valid operators: ${VALID_OPERATOR_LIST}`
245
- );
246
- this.name = "UnknownFilterOperatorError";
247
- this.field = field;
248
- this.operator = operator;
249
- this.details = { field, operator, validOperators: ALL_WHERE_FILTER_OPS };
250
- }
251
- }
252
-
253
- /**
254
- * Two to three characters of ASCII punctuation and nothing else — the shape
255
- * every symbolic operator has (`==`, `>=`, `<>`, `~~`, `!!`, `>>`, `===`), and
256
- * one a column value effectively never has.
257
- *
258
- * Two characters minimum on purpose. A *single* punctuation character is a
259
- * perfectly ordinary value — `{ grade: ["-", "+"] }` is a two-item list, not a
260
- * condition — and the only single-character operator anyone actually mistypes
261
- * is `=`, which is named separately below. `<` and `>` need no special case:
262
- * they are real operators and resolve.
263
- */
264
- const SYMBOLIC_OPERATOR = /^[^\p{L}\p{N}\s]{2,3}$/u;
265
-
266
- /** Lowercase, strip everything that is not a letter or digit. */
267
- function normalizeOperatorName(op: string): string {
268
- return op.toLowerCase().replace(/[^a-z0-9]/g, "");
269
- }
270
-
271
- /**
272
- * Every real operator name with its case and separators removed, so a
273
- * respelling of one — `arrayContains`, `not_in`, `NOT-LIKE`, `isNull` — is
274
- * recognised as an attempt at an operator rather than read as a value.
275
- *
276
- * These are rejected rather than accepted: admitting a second spelling of an
277
- * operator would leave two wire spellings of one thing, and the rejection
278
- * message names the one that works.
279
- */
280
- const RESPELLED_OPERATORS: ReadonlySet<string> = new Set(
281
- [...ALL_WHERE_FILTER_OPS, ...Object.keys(REST_TO_CANONICAL)].map(normalizeOperatorName)
282
- );
283
-
284
- /**
285
- * Operator names *other* query dialects use, which this one does not have.
286
- *
287
- * This list is curated, and deliberately so. For a word-shaped string there is
288
- * no rule that separates "an operator the caller guessed" from "a value that
289
- * happens to be a word": `{ tags: ["a", "b"] }` has to keep meaning a two-item
290
- * `in` list, so the codec cannot simply refuse every unrecognised word in
291
- * position 0. The line is therefore drawn by name, and only around names whose
292
- * use as an operator is far more likely than their use as one of two sibling
293
- * values. `contains` is the motivating case — the first thing a developer
294
- * reaches for, and until now it compiled to `title IN ('contains', 'Hell')`.
295
- *
296
- * Genuinely ambiguous single words (`any`, `all`, `exists`, `search`, `not`)
297
- * are left off: as operators they are rare, and as enum values they are common.
298
- * Everywhere else the tie goes to *rejecting*, because a 400 naming the
299
- * supported set costs the caller one round trip, and the alternative — which is
300
- * what every name on this list used to produce — is a query that runs, returns
301
- * rows, and is wrong.
302
- */
303
- const NEAR_MISS_OPERATORS: ReadonlySet<string> = new Set([
304
- "contains", "notcontains", "doesnotcontain", "doesnotcontains",
305
- "includes", "notincludes",
306
- "startswith", "notstartswith", "beginswith", "startingwith",
307
- "endswith", "notendswith",
308
- "matches", "notmatches", "regex", "regexp",
309
- "between", "notbetween",
310
- "equals", "notequals", "equalto", "isequalto", "isnotequalto",
311
- "greaterthan", "greaterthanorequal", "greaterthanorequalto",
312
- "lessthan", "lessthanorequal", "lessthanorequalto",
313
- "isempty", "isnotempty",
314
- "oneof", "noneof", "anyof", "allof",
315
- "null", "isnullorempty"
316
- ]);
317
-
318
- /**
319
- * Was this string *meant* as an operator?
320
- *
321
- * Only consulted after {@link toCanonicalOp} has already failed to resolve it,
322
- * so a `true` here is always a rejection.
323
- */
324
- function isOperatorShaped(op: string): boolean {
325
- if (op === "=") return true;
326
- if (SYMBOLIC_OPERATOR.test(op)) return true;
327
- const normalized = normalizeOperatorName(op);
328
- if (!normalized) return false;
329
- return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);
330
- }
331
-
332
- /**
333
- * Read a `[op, value]` tuple, if that is what this is.
334
- *
335
- * Three outcomes, and the middle one is the defect this function exists for:
336
- *
337
- * - the operator resolves (canonical *or* REST spelling) → the canonical tuple;
338
- * - the operator does not resolve but was plainly meant as one → throw;
339
- * - it does not look like an operator at all → `undefined`, and the caller
340
- * falls back to reading the array as a list of values.
341
- *
342
- * The old test was `toCanonicalOp(raw[0]) === raw[0]`, i.e. canonical spelling
343
- * only, with *everything else* — including every REST short-code — dropping
344
- * through to `["in", raw]`. So the operator string itself became a value in a
345
- * membership test: `["!!", "Hello"]` compiled to `title IN ('!!','Hello')`,
346
- * which matches, and the caller got back rows their filter was written to
347
- * exclude. `["eq", "active"]` had the same shape of failure.
348
- */
349
- function readTuple(field: string, raw: unknown): [WhereFilterOp, unknown] | undefined {
350
- if (!Array.isArray(raw) || raw.length !== 2) return undefined;
351
- const [op, value] = raw;
352
- if (typeof op !== "string") return undefined;
353
-
354
- const canonical = toCanonicalOp(op);
355
- if (canonical) return [canonical, value];
356
-
357
- // A dot means this is a *wire* string, not an operator token: two repeated
358
- // query params arrive as `["gte.18", "lt.65"]`, which is a two-element array
359
- // of strings and therefore tuple-shaped. Deferred with exactly the test the
360
- // repeated-dot-string branch below uses, so the two cannot disagree.
361
- //
362
- // The property test found this: `["ilike", ""]` serializes to `"ilike."`,
363
- // whose normalized form is a real operator name, so a well-formed
364
- // round-trip was being rejected as a bad operator.
365
- if (op.includes(".")) return undefined;
366
-
367
- if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);
368
-
369
- return undefined;
370
- }
371
-
372
- // ---------------------------------------------------------------------------
373
- // Serialize: FilterValues → REST querystring
374
- // ---------------------------------------------------------------------------
375
-
376
- /**
377
- * Serialize a single canonical condition tuple to a PostgREST dot-string.
378
- *
379
- * Throws `TypeError` if the input is not a valid `[WhereFilterOp, unknown]` tuple.
380
- *
381
- * @example
382
- * serializeTuple(["==", "active"]) // "eq.active"
383
- * serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
384
- * serializeTuple([">=", 18]) // "gte.18"
385
- */
386
- function serializeTuple(tuple: [WhereFilterOp, unknown]): string {
387
- if (!Array.isArray(tuple) || tuple.length !== 2) {
388
- throw new TypeError(
389
- `serializeTuple: expected a [WhereFilterOp, value] tuple, got ${JSON.stringify(tuple)}`
390
- );
391
- }
392
-
393
- const [op, value] = tuple;
394
-
395
- if (typeof op !== "string") {
396
- throw new TypeError(
397
- `serializeTuple: operator must be a string, got ${typeof op}`
398
- );
399
- }
400
-
401
- const restOp = CANONICAL_OP_LOOKUP.get(op);
402
- if (!restOp) {
403
- throw new TypeError(
404
- `serializeTuple: unknown operator "${op}". Valid operators: ${Object.keys(CANONICAL_TO_REST).join(", ")}`
405
- );
406
- }
407
-
408
- // `== null` and `!= null` go out as the null-testing operators.
409
- //
410
- // They used to serialize as `eq.null`, and `deserializeTuple` had no way to
411
- // tell that from a search for the four-character string "null" — so it
412
- // returned the string, and `.where("deleted_at", "==", null)` compiled to
413
- // `deleted_at = 'null'` over HTTP. The typed builder allows it, the Postgres
414
- // compiler implements it as IS NULL, and only the wire trip broke it.
415
- //
416
- // These are the same query: SQL `= NULL` is never true, so `== null` can
417
- // only mean IS NULL. Emitting it as such is unambiguous in both directions
418
- // and leaves `eq.null` free to mean the literal string, which it now does.
419
- if (value === null && (op === "==" || op === "!=")) {
420
- return op === "==" ? "isnull.null" : "notnull.null";
421
- }
422
-
423
- if (Array.isArray(value)) {
424
- // The empty list needs a spelling of its own.
425
- //
426
- // A comma-joined format has no way to write "zero items": `()` is the
427
- // empty string between the parens, which splits to `[""]`. So
428
- // `.where("id", "in", [])` — which matches nothing — used to arrive as
429
- // a search for the empty string: a 500 on a uuid column, silently the
430
- // wrong rows on a text one.
431
- //
432
- // `EMPTY_LIST_TOKEN` is a single unescaped backslash, which no real
433
- // value can produce: `escapeWireValue` doubles every backslash, so a
434
- // one-item list holding `\` serializes as `(\\)`. That keeps both
435
- // directions exact — `[]` and `[""]` stay distinct — rather than
436
- // trading one lossy reading for another.
437
- if (value.length === 0) return `${restOp}.(${EMPTY_LIST_TOKEN})`;
438
- const items = value.map(v => escapeWireValue(stringifyValue(v))).join(",");
439
- return `${restOp}.(${items})`;
440
- }
441
-
442
- return `${restOp}.${stringifyValue(value)}`;
443
- }
444
-
445
- /**
446
- * Convert `FilterValues` (or `WireFilterValues`) to a PostgREST-style
447
- * querystring record.
448
- *
449
- * - Canonical `[WhereFilterOp, value]` tuples are serialized strictly.
450
- * - Pre-serialized PostgREST strings (e.g. `"eq.published"`) are passed through.
451
- * - Single conditions produce a string value.
452
- * - Multiple conditions on the same field produce a string array (repeated params).
453
- *
454
- * @example
455
- * serializeFilter({ status: ["==", "active"] })
456
- * // → { status: "eq.active" }
457
- *
458
- * serializeFilter({ age: [[">=", 18], ["<", 65]] })
459
- * // → { age: ["gte.18", "lt.65"] }
460
- *
461
- * // Pre-serialized strings pass through unchanged:
462
- * serializeFilter({ status: "eq.published" })
463
- * // → { status: "eq.published" }
464
- */
465
- export function serializeFilter(
466
- filter: FilterValues<string> | Record<string, unknown>
467
- ): Record<string, string | string[]> {
468
- const result: Record<string, string | string[]> = {};
469
-
470
- for (const [field, condition] of Object.entries(filter)) {
471
- if (condition === undefined) continue;
472
-
473
- // Pre-serialized PostgREST string — pass through unchanged.
474
- // This supports WireFilterValues where values may already be
475
- // serialized dot-strings like "eq.active" or raw strings like "true".
476
- if (typeof condition === "string") {
477
- result[field] = condition;
478
- continue;
479
- }
480
-
481
- // Multiple conditions on the same field: array of tuples
482
- // We detect this by checking if the first element is also an array.
483
- if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) {
484
- result[field] = (condition as [WhereFilterOp, unknown][]).map(serializeTuple);
485
- } else {
486
- // Single condition — must be a [WhereFilterOp, value] tuple
487
- result[field] = serializeTuple(condition as [WhereFilterOp, unknown]);
488
- }
489
- }
490
-
491
- return result;
492
- }
493
-
494
- // ---------------------------------------------------------------------------
495
- // Deserialize: REST querystring → FilterValues
496
- // ---------------------------------------------------------------------------
497
-
498
- /**
499
- * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
500
- *
501
- * All values are returned as strings — the wire format carries no type
502
- * metadata, so coercion is the data driver's responsibility.
503
- *
504
- * If the string doesn't match a known operator prefix, it falls back to
505
- * `["==", originalString]` (treating the whole string as an equality value).
506
- * This intentional defense handles values like `"user@host.com"` or
507
- * `"1.2.3"` that happen to contain dots.
508
- */
509
- function deserializeSingle(raw: string): [WhereFilterOp, unknown] {
510
- const dotIndex = raw.indexOf(".");
511
- if (dotIndex === -1) {
512
- // No dot → equality on the raw value (kept as string)
513
- return ["==", raw];
514
- }
515
-
516
- const prefix = raw.substring(0, dotIndex);
517
- const rest = raw.substring(dotIndex + 1);
518
-
519
- // Check if the prefix is a known REST operator.
520
- // This is the key defense against values like "eq.something" or "gt.foo"
521
- // being misinterpreted — only known REST short-codes are treated as operators.
522
- const canonicalOp = REST_OP_LOOKUP.get(prefix);
523
- if (!canonicalOp) {
524
- // Not a known operator (e.g., email "user@host.com" or version "1.2.3")
525
- // Treat the entire string as an equality value
526
- return ["==", raw];
527
- }
528
-
529
- // Null-testing operators ignore their serialized value — normalize to null
530
- // so the tuple round-trips stably (`isnull.null` → ["is-null", null]).
531
- if (NULL_OPS.has(canonicalOp)) {
532
- return [canonicalOp, null];
533
- }
534
-
535
- // Parse list values: "(admin,editor)" → ["admin", "editor"]
536
- if (rest.startsWith("(") && rest.endsWith(")")) {
537
- const inner = rest.slice(1, -1);
538
- // See EMPTY_LIST_TOKEN: `(\)` is the empty list. `()` remains a list
539
- // holding one empty string, which is what splitting it yields anyway.
540
- const items = inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner);
541
- return [canonicalOp, items];
542
- }
543
-
544
- return [canonicalOp, rest];
545
- }
546
-
547
- /**
548
- * Convert a PostgREST-style querystring record to `FilterValues`.
549
- *
550
- * - String values are parsed as single conditions.
551
- * - String arrays (repeated query params) become multiple conditions on the same field.
552
- *
553
- * @example
554
- * deserializeFilter({ status: "eq.active" })
555
- * // → { status: ["==", "active"] }
556
- *
557
- * deserializeFilter({ age: ["gte.18", "lt.65"] })
558
- * // → { age: [[">=", "18"], ["<", "65"]] }
559
- *
560
- * @throws {UnknownFilterOperatorError} when a condition names an operator this
561
- * dialect does not have. See that class for why a rejection here is a throw.
562
- */
563
- export function deserializeFilter(
564
- query: Record<string, unknown>
565
- ): FilterValues<string> {
566
- const result: FilterValues<string> = {};
567
-
568
- for (const [field, raw] of Object.entries(query)) {
569
- if (raw === undefined) continue;
570
-
571
- // A single `[op, value]` condition.
572
- const tuple = readTuple(field, raw);
573
- if (tuple) {
574
- result[field] = tuple;
575
- continue;
576
- }
577
-
578
- if (Array.isArray(raw)) {
579
- if (raw.length === 0) continue;
580
-
581
- // An array of tuples: several conditions on the same field. Every
582
- // element is checked, not just the first — the old test read
583
- // `raw[0]` and cast the whole array, so one bad operator among
584
- // several travelled on untouched.
585
- if (Array.isArray(raw[0])) {
586
- const tuples = raw.map(item => readTuple(field, item));
587
- if (tuples.every((t): t is [WhereFilterOp, unknown] => t !== undefined)) {
588
- result[field] = tuples;
589
- continue;
590
- }
591
- }
592
-
593
- if (raw.length === 1) {
594
- result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
595
- } else {
596
- // If the elements are strings, they might be PostgREST dot-strings (repeated params)
597
- if (typeof raw[0] === "string" && raw[0].includes(".")) {
598
- result[field] = raw.map(r => typeof r === "string" ? deserializeSingle(r) : (["==", r] as [WhereFilterOp, unknown])) as [WhereFilterOp, unknown][];
599
- } else {
600
- // Otherwise assume it's a list of values for an implicit
601
- // "in" — `{ tags: ["a","b"] }`, and `?tags=a&tags=b`, which
602
- // arrives here identically.
603
- //
604
- // A two-element array reaches this line only after
605
- // `readTuple` has decided its first element was not meant
606
- // as an operator. Everything longer never had the
607
- // ambiguity: an operator tuple has exactly two slots.
608
- result[field] = ["in", raw];
609
- }
610
- }
611
- } else if (typeof raw === "string") {
612
- result[field] = deserializeSingle(raw);
613
- } else {
614
- result[field] = ["==", raw];
615
- }
616
- }
617
-
618
- return result;
619
- }
620
-
621
- // ---------------------------------------------------------------------------
622
- // Logical conditions: serialize / deserialize
623
- // ---------------------------------------------------------------------------
624
-
625
- /**
626
- * Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
627
- *
628
- * @example
629
- * serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
630
- * // → "status.eq.active"
631
- *
632
- * serializeLogicalCondition({ type: "or", conditions: [...] })
633
- * // → "or(status.eq.active,status.eq.pending)"
634
- */
635
- export function serializeLogicalCondition(
636
- cond: LogicalCondition | FilterCondition
637
- ): string {
638
- if ("type" in cond) {
639
- // LogicalCondition (and/or)
640
- const inner = (cond.conditions ?? [])
641
- .map(serializeLogicalCondition)
642
- .join(",");
643
- return `${cond.type}(${inner})`;
644
- }
645
-
646
- // FilterCondition
647
- const restOp = CANONICAL_OP_LOOKUP.get(cond.operator) ?? "eq";
648
- if (Array.isArray(cond.value)) {
649
- const items = cond.value.map(v => escapeWireValue(stringifyValue(v))).join(",");
650
- return `${cond.column}.${restOp}.(${items})`;
651
- }
652
- // Escaped, like a list item. A scalar inside a group sits between the same
653
- // delimiters a list item does, so leaving it raw let a comma in the value
654
- // end the condition early — see `splitGroupItems`.
655
- return `${cond.column}.${restOp}.${escapeWireValue(stringifyValue(cond.value))}`;
656
- }
657
-
658
- /**
659
- * Parse a logical condition wire-format string back into a
660
- * `LogicalCondition` or `FilterCondition`.
661
- *
662
- * @example
663
- * deserializeLogicalCondition("status.eq.active")
664
- * // → { column: "status", operator: "==", value: "active" }
665
- *
666
- * deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
667
- * // → { type: "or", conditions: [...] }
668
- */
669
- /**
670
- * How deeply `or(...)`/`and(...)` groups may nest.
671
- *
672
- * This parser recurses once per level, on a value that arrives in a query
673
- * string. Unbounded, twenty thousand levels reached `RangeError: Maximum call
674
- * stack size exceeded`, which a caller sees as a 500 about the call stack
675
- * rather than a 400 about their filter. Node's 16 KB header cap keeps a GET
676
- * below that in practice, but "the HTTP layer happens to stop it" is not a
677
- * bound this parser should rely on.
678
- *
679
- * Thirty-two is far past anything a real filter expresses; the deepest in this
680
- * repository's own tests is three.
681
- */
682
- export const MAX_LOGICAL_NESTING_DEPTH = 32;
683
-
684
- export function deserializeLogicalCondition(
685
- str: string,
686
- // Not `depth`: the body already uses that name for paren tracking, inside a
687
- // block that shadows a parameter of the same name — so the recursion
688
- // counter silently became the paren counter and never grew.
689
- nesting = 0
690
- ): LogicalCondition | FilterCondition {
691
- if (nesting > MAX_LOGICAL_NESTING_DEPTH) {
692
- throw new Error(
693
- `Filter groups nest more than ${MAX_LOGICAL_NESTING_DEPTH} levels deep. ` +
694
- "Flatten the condition — `or(a,or(b,c))` is `or(a,b,c)`."
695
- );
696
- }
697
- // Check for logical group: "and(...)" or "or(...)"
698
- const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
699
- if (logicalMatch) {
700
- const type = logicalMatch[1] as "and" | "or";
701
- const innerStr = logicalMatch[2];
702
-
703
- const conditions = splitGroupItems(innerStr)
704
- .map(part => deserializeLogicalCondition(part, nesting + 1));
705
-
706
- return { type, conditions };
707
- }
708
-
709
- // FilterCondition: "column.op.value"
710
- const firstDot = str.indexOf(".");
711
- if (firstDot === -1) {
712
- return { column: str, operator: "==", value: true };
713
- }
714
-
715
- const column = str.substring(0, firstDot);
716
- const rest = str.substring(firstDot + 1);
717
-
718
- const secondDot = rest.indexOf(".");
719
- if (secondDot === -1) {
720
- // "column.value" — treat as equality (value kept as string)
721
- return { column, operator: "==", value: unescapeWireValue(rest) };
722
- }
723
-
724
- const opStr = rest.substring(0, secondDot);
725
- const valueStr = rest.substring(secondDot + 1);
726
- const operator = toCanonicalOp(opStr) ?? "==";
727
-
728
- // Parse list values with escape-aware splitting. The wrapping parens are
729
- // written by the serializer *after* the items are escaped, so an escaped
730
- // paren inside an item can never be mistaken for them.
731
- if (valueStr.startsWith("(") && valueStr.endsWith(")")) {
732
- const items = splitListItems(valueStr.slice(1, -1));
733
- return { column, operator, value: items };
734
- }
735
-
736
- return { column, operator, value: unescapeWireValue(valueStr) };
737
- }