@rebasepro/common 0.28.2 → 0.29.0-canary.g0e3a0e7

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.
@@ -235,8 +235,14 @@ var UnknownFilterOperatorError = class extends Error {
235
235
  statusCode = 400;
236
236
  code = "UNKNOWN_FILTER_OPERATOR";
237
237
  details;
238
- constructor(field, operator) {
239
- super(`Unknown filter operator '${operator}' on field '${field}'. Valid operators: ${VALID_OPERATOR_LIST}`);
238
+ /**
239
+ * @param hint - One sentence naming the spelling that works, when the
240
+ * rejected one is recognisably another dialect's. It leads the message:
241
+ * the operator list is in canonical spelling, which is not what a wire
242
+ * string takes, so on its own it would send the caller to `is-null.null`.
243
+ */
244
+ constructor(field, operator, hint) {
245
+ super(`Unknown filter operator '${operator}' on field '${field}'. ` + (hint ? `${hint} ` : "") + `Valid operators: ${VALID_OPERATOR_LIST}`);
240
246
  this.name = "UnknownFilterOperatorError";
241
247
  this.field = field;
242
248
  this.operator = operator;
@@ -344,6 +350,72 @@ function isOperatorShaped(op) {
344
350
  if (!normalized) return false;
345
351
  return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);
346
352
  }
353
+ /** The negated twin of each REST operator that has one. */
354
+ var NEGATED_REST_OP = {
355
+ eq: "neq",
356
+ neq: "eq",
357
+ in: "nin",
358
+ nin: "in",
359
+ like: "nlike",
360
+ nlike: "like",
361
+ ilike: "nilike",
362
+ nilike: "ilike",
363
+ isnull: "notnull",
364
+ notnull: "isnull"
365
+ };
366
+ /**
367
+ * A wire condition written in PostgREST's spelling, where this dialect has a
368
+ * different one: the rejection to raise, or `undefined` when it is not one.
369
+ *
370
+ * Three forms, and all used to be read as an equality on the whole string:
371
+ *
372
+ * - **The null test.** PostgREST writes `is.null` and `not.is.null`; this
373
+ * dialect writes `isnull.null` and `notnull.null`. `is` names no operator
374
+ * here, so `?difficulty=is.null` became `difficulty = 'is.null'` — "not a
375
+ * valid smallint" on a number column, and no rows at all on a text one. The
376
+ * respellings `is-null.null`, `isNull.null`, `not-null.null` failed the
377
+ * same way.
378
+ * - **The `not.` prefix.** `?status=not.eq.draft` became
379
+ * `status = 'not.eq.draft'`: no rows, with nothing to say why. Negation here
380
+ * is `neq`/`nin`/`nlike`/`nilike`/`notnull`, or a `not=(…)` group.
381
+ * - **A canonical name in a query parameter.** `?age=>=.18`: the tuple and
382
+ * group forms read `>=`, a parameter reads only `gte`.
383
+ *
384
+ * Narrow on purpose: the operand of a null test must be one it has
385
+ * ({@link NULL_OPERANDS}), and the segment after `not.` must be a real
386
+ * operator. So `is.gd` and `not.a.value` stay values.
387
+ */
388
+ function foreignWireSpelling(field, operand, position) {
389
+ const segments = operand.split(".");
390
+ const write = (condition) => position === "param" ? `${field}=${condition}` : `${field}.${condition}`;
391
+ const head = segments[0].toLowerCase();
392
+ const nullTest = (negated, operator, valueAt) => {
393
+ if (segments.length !== valueAt + 1 || !NULL_OPERANDS.has(segments[valueAt].toLowerCase())) return void 0;
394
+ return new UnknownFilterOperatorError(field, operator, `Write \`${write(negated ? "notnull.null" : "isnull.null")}\` to match rows where it is ${negated ? "set" : "empty"} (\`["${negated ? "is-not-null" : "is-null"}", null]\` as a JSON tuple).`);
395
+ };
396
+ const canonical = toCanonicalOp(segments[0]);
397
+ if (canonical) {
398
+ if (position === "group" || REST_OP_LOOKUP.has(segments[0])) return void 0;
399
+ if (NULL_OPS.has(canonical)) return nullTest(canonical === "is-not-null", segments[0], 1);
400
+ const restOp = CANONICAL_TO_REST[canonical];
401
+ return new UnknownFilterOperatorError(field, segments[0], `A query parameter spells it \`${restOp}\`: write \`${write(`${restOp}.${segments.slice(1).join(".")}`)}\`.`);
402
+ }
403
+ if (head === "is") {
404
+ if (segments[1]?.toLowerCase() === "not") return nullTest(true, "is.not", 2);
405
+ return nullTest(false, "is", 1);
406
+ }
407
+ if (head === "not" && segments.length >= 3) {
408
+ const next = segments[1].toLowerCase();
409
+ if (next === "is") return nullTest(true, "not.is", 2);
410
+ if (!REST_OP_LOOKUP.has(next)) return void 0;
411
+ const value = segments.slice(2).join(".");
412
+ const twin = NEGATED_REST_OP[next];
413
+ return new UnknownFilterOperatorError(field, `not.${next}`, twin ? `There is no \`not.\` prefix: write \`${write(`${twin}.${value}`)}\`.` : `There is no \`not.\` prefix: negate with a group, \`not=(${field}.${next}.${value})\`.`);
414
+ }
415
+ const normalized = normalizeOperatorName(head);
416
+ if (normalized === "isnull") return nullTest(false, segments[0], 1);
417
+ if (normalized === "notnull" || normalized === "isnotnull") return nullTest(true, segments[0], 1);
418
+ }
347
419
  /**
348
420
  * Read a `[op, value]` tuple, if that is what this is.
349
421
  *
@@ -483,6 +555,10 @@ function serializeFilter(filter) {
483
555
  result[field] = condition;
484
556
  continue;
485
557
  }
558
+ if (condition === null || typeof condition === "number" || typeof condition === "boolean") {
559
+ result[field] = serializeTuple(["==", condition]);
560
+ continue;
561
+ }
486
562
  if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) result[field] = condition.map(serializeTuple);
487
563
  else result[field] = serializeTuple(condition);
488
564
  }
@@ -553,13 +629,17 @@ var NULL_OPERANDS = /* @__PURE__ */ new Set([
553
629
  * Values that merely *contain* dots (`user@host.com`, `1.2.3`) were never
554
630
  * ambiguous: their first segment names no operator to begin with.
555
631
  */
556
- function deserializeSingle(raw) {
632
+ function deserializeSingle(field, raw) {
557
633
  const dotIndex = raw.indexOf(".");
558
634
  if (dotIndex === -1) return ["==", raw];
559
635
  const prefix = raw.substring(0, dotIndex);
560
636
  const rest = raw.substring(dotIndex + 1);
561
637
  const canonicalOp = REST_OP_LOOKUP.get(prefix);
562
- if (!canonicalOp) return ["==", raw];
638
+ if (!canonicalOp) {
639
+ const foreign = foreignWireSpelling(field, raw, "param");
640
+ if (foreign) throw foreign;
641
+ return ["==", raw];
642
+ }
563
643
  if (NULL_OPS.has(canonicalOp)) {
564
644
  if (!NULL_OPERANDS.has(rest)) return ["==", raw];
565
645
  return [canonicalOp, null];
@@ -605,10 +685,10 @@ function deserializeFilter(query) {
605
685
  continue;
606
686
  }
607
687
  }
608
- if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
609
- else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
688
+ if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(field, raw[0]) : ["==", raw[0]];
689
+ else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(field, r) : ["==", r]);
610
690
  else result[field] = ["in", raw];
611
- } else if (typeof raw === "string") result[field] = deserializeSingle(raw);
691
+ } else if (typeof raw === "string") result[field] = deserializeSingle(field, raw);
612
692
  else result[field] = ["==", raw];
613
693
  }
614
694
  return result;
@@ -706,6 +786,26 @@ function splitLeafCondition(str) {
706
786
  }
707
787
  }
708
788
  /**
789
+ * Refuse a group leaf written in PostgREST's spelling — `d.is.null`,
790
+ * `d.not.is.null`, `status.not.eq.draft` — see {@link foreignWireSpelling}.
791
+ *
792
+ * Every split point is a candidate column, so a relation path
793
+ * (`author.name.is.null`) is caught too. The scan stops at the first segment
794
+ * that resolves as an operator: everything after it is the value, so
795
+ * `note.eq.not.is.null` stays an equality on the string `"not.is.null"`.
796
+ *
797
+ * Without this, `d.is.null` was `d == "is.null"`, and `status.not.eq.draft`
798
+ * found its `eq` and filtered a column named `status.not`.
799
+ */
800
+ function rejectForeignLeaf(str) {
801
+ const dots = leafSeparators(str);
802
+ for (let i = 0; i < dots.length; i++) {
803
+ const foreign = foreignWireSpelling(unescapeWireValue(str.substring(0, dots[i])), str.substring(dots[i] + 1), "group");
804
+ if (foreign) throw foreign;
805
+ if (i + 1 < dots.length && toCanonicalOp(str.substring(dots[i] + 1, dots[i + 1]))) return;
806
+ }
807
+ }
808
+ /**
709
809
  * Parse a logical condition wire-format string back into a
710
810
  * `LogicalCondition` or `FilterCondition`.
711
811
  *
@@ -741,6 +841,7 @@ function deserializeLogicalCondition(str, nesting = 0) {
741
841
  conditions: splitGroupItems(innerStr).map((part) => deserializeLogicalCondition(part, nesting + 1))
742
842
  };
743
843
  }
844
+ rejectForeignLeaf(str);
744
845
  const leaf = splitLeafCondition(str);
745
846
  if (!leaf) {
746
847
  const firstDot = str.indexOf(".");
@@ -1 +1 @@
1
- {"version":3,"file":"filter-dialect.es.js","names":[],"sources":["../../src/data/filter-dialect.ts"],"sourcesContent":["/**\n * REST wire-format adapter for the unified filter system.\n *\n * This module is the ONLY code in the entire codebase that knows about\n * PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).\n * Everything else speaks `FilterValues` exclusively.\n *\n * Wire-format values are always strings — the wire format carries no type\n * metadata, so type coercion is the responsibility of the server-side data\n * driver which has access to the collection schema.\n *\n * Structural characters inside a value are backslash-escaped: `,` → `\\,`,\n * `(` → `\\(`, `)` → `\\)`, and a literal backslash as `\\\\`. Decoding is\n * deliberately conservative — only those four sequences are decoded, so a\n * backslash that arrives unescaped from an older client survives intact.\n *\n * @module\n */\n\nimport {\n WhereFilterOp,\n FilterValues,\n ALL_WHERE_FILTER_OPS,\n CANONICAL_TO_REST,\n REST_TO_CANONICAL,\n RestFilterOp,\n toCanonicalOp,\n LogicalCondition,\n FilterCondition,\n LIST_OPS,\n NULL_OPS\n} from \"@rebasepro/types\";\nimport { normalizeToEntityRelation } from \"../util/entities\";\n\n// ---------------------------------------------------------------------------\n// Value stringification\n// ---------------------------------------------------------------------------\n\n/**\n * Serialize a JS value to its querystring representation.\n * `null` is serialized as the literal string `\"null\"`.\n * Relation values (`EntityRelation` instances or `{ __type: \"relation\", id, path }`\n * objects) are serialized as their raw id — the wire format only carries the\n * value to compare against the FK column.\n *\n * A `Date` is its ISO 8601 instant — what JSON makes of it on the socket and\n * in-process, so one filter names one instant through every door. `String()`\n * gave the viewer's local zone, no milliseconds and a parenthesised zone name\n * Postgres cannot parse. An invalid date keeps its \"Invalid Date\" spelling for\n * the server to refuse, rather than throwing a `RangeError` from here.\n */\nfunction stringifyValue(value: unknown): string {\n if (value === null) return \"null\";\n if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();\n const relation = normalizeToEntityRelation(value);\n if (relation) return String(relation.id);\n return String(value);\n}\n\n// ---------------------------------------------------------------------------\n// Comma escaping for list values\n// ---------------------------------------------------------------------------\n\n/**\n * Characters that carry structure in the wire format and must therefore be\n * escaped inside a value: the separator, the group delimiters, and the escape\n * character itself.\n *\n * Parentheses are here because `and(...)`/`or(...)` groups are parsed by\n * tracking paren depth. A value containing one is not merely ambiguous, it\n * moves where the parser thinks the group ends.\n */\nconst WIRE_SPECIALS = /[\\\\,()]/g;\n\n/**\n * Escape a value for the wire format: `\\` → `\\\\`, `,` → `\\,`, `(` → `\\(`,\n * `)` → `\\)`.\n */\n/**\n * The wire spelling of an empty list.\n *\n * A lone backslash: unproducible by {@link escapeWireValue}, which doubles\n * every backslash it emits, so it cannot collide with any real item.\n */\nconst EMPTY_LIST_TOKEN = \"\\\\\";\n\nfunction escapeWireValue(value: string): string {\n return value.replace(WIRE_SPECIALS, ch => `\\\\${ch}`);\n}\n\n/**\n * Unescape a wire-format value.\n *\n * **Conservative**, and deliberately so: only the four sequences\n * {@link escapeWireValue} actually produces are decoded. A backslash followed\n * by anything else is left exactly as it is.\n *\n * This used to consume the backslash before *any* character, which is\n * indistinguishable for anything this codec emitted — it only ever emits those\n * four — but not for input arriving from elsewhere. A client on an older\n * release sends a Windows path or a LIKE pattern with a literal `C:\\x`\n * unescaped, and greedy unescaping silently turned it into `C:x`, changing\n * which rows matched. Decoding only what the encoder can produce makes the two\n * directions agree across versions.\n */\nfunction unescapeWireValue(value: string): string {\n let result = \"\";\n for (let i = 0; i < value.length; i++) {\n const next = value[i + 1];\n if (value[i] === \"\\\\\" && (next === \"\\\\\" || next === \",\" || next === \"(\" || next === \")\")) {\n result += next;\n i++;\n continue;\n }\n result += value[i];\n }\n return result;\n}\n\n/**\n * Whether a scalar, sent raw as a query parameter's operand, would be read\n * back as something other than itself.\n *\n * A query parameter owns its whole value, so its scalar goes out unescaped —\n * `eq.a,b`, `eq.C:\\x` — and the decoder returns it verbatim. Two shapes break\n * that. A parenthesised operand is a list for every operator (so that\n * `[\"==\", [\"a\",\"b\"]]` on an array column stays a list), so raw, the scalar\n * `\"(deleted)\"` would arrive as `[\"deleted\"]`: an `==` matching nothing, a\n * `!=` returning the very rows it excludes. Such a scalar is sent escaped,\n * `eq.\\(deleted\\)`. And that spelling has to be told apart from a raw value\n * that looks the same, so a scalar of *that* shape is escaped in turn.\n *\n * Everything else stays raw, so a value an older client sends decodes as it\n * always has — unless it is exactly the escaped spelling of such a scalar,\n * which decodes to the scalar it spells.\n */\nfunction scalarNeedsEscape(value: string): boolean {\n if (value.startsWith(\"(\") && value.endsWith(\")\")) return true;\n return decodeEscapedScalar(value) !== undefined;\n}\n\n/**\n * The scalar an operand is the escaped spelling of, when it is one.\n *\n * Exact on purpose: only an operand that {@link escapeWireValue} produces from\n * a scalar that {@link scalarNeedsEscape} is decoded. Any other operand —\n * including a raw backslash an older client sent, like `\\\\server\\share` — is\n * left verbatim. Terminates because an operand that matches contains an\n * escape pair, so the value is strictly shorter than it.\n */\nfunction decodeEscapedScalar(operand: string): string | undefined {\n if (!operand.startsWith(\"\\\\\")) return undefined;\n const value = unescapeWireValue(operand);\n return escapeWireValue(value) === operand && scalarNeedsEscape(value) ? value : undefined;\n}\n\n/**\n * Split a parenthesized list string on unescaped commas.\n * Input is the content between `(` and `)`.\n *\n * @example\n * splitListItems(\"admin,editor\") // [\"admin\", \"editor\"]\n * splitListItems(\"hello\\\\, world,foo\") // [\"hello, world\", \"foo\"]\n */\nfunction splitListItems(inner: string): string[] {\n const items: string[] = [];\n let current = \"\";\n for (let i = 0; i < inner.length; i++) {\n if (inner[i] === \"\\\\\" && i + 1 < inner.length) {\n // Escaped pair — consume both chars so the comma in `\\,` is not\n // read as a separator. Kept verbatim; decoding happens once, below.\n current += inner[i] + inner[i + 1];\n i++;\n } else if (inner[i] === \",\") {\n items.push(unescapeWireValue(current));\n current = \"\";\n } else {\n current += inner[i];\n }\n }\n items.push(unescapeWireValue(current));\n return items;\n}\n\n/**\n * Split a group body on commas at paren depth 0, honouring escapes.\n *\n * The escape-awareness is the point. The splitter used to track only paren\n * depth, so a comma inside a scalar value ended a condition:\n * `or(name.eq.Doe, John,age.gte.18)` parsed as *three* conditions, the middle\n * one a fabricated `\" John\" == true`. On an `or` that widens the result set,\n * and nothing anywhere reports an error — the query simply stops meaning what\n * the caller wrote.\n */\nfunction splitGroupItems(inner: string): string[] {\n const parts: string[] = [];\n let depth = 0;\n let start = 0;\n for (let i = 0; i < inner.length; i++) {\n const ch = inner[i];\n if (ch === \"\\\\\" && i + 1 < inner.length) { i++; continue; }\n if (ch === \"(\") depth++;\n else if (ch === \")\") depth--;\n else if (ch === \",\" && depth === 0) {\n parts.push(inner.slice(start, i));\n start = i + 1;\n }\n }\n parts.push(inner.slice(start));\n return parts;\n}\n\n// ---------------------------------------------------------------------------\n// Typed operator map lookups (no `as any`)\n// ---------------------------------------------------------------------------\n\n/**\n * Operator tables as `Map`s, because the key comes off the wire.\n *\n * Indexed as plain objects, every `Object.prototype` member answered: a query\n * string of `?f=valueOf.x` found a truthy \"operator\" — the inherited function —\n * and `deserializeTuple` returned it *as the operator*, so a function object\n * travelled on into the compilers in place of a `WhereFilterOp`. The guard one\n * line below (`if (!canonicalOp)`) reads as though it rejects anything unknown,\n * and does not: `Object.prototype` is not unknown to a plain object.\n *\n * Same shape as the prototype-key defects swept out of `setIn`, `getIn`,\n * `mergeDeep`, `unflattenObject` and `FOREIGN_CONVENTION_UIDS`.\n */\nconst REST_OP_LOOKUP = new Map<string, WhereFilterOp>(\n Object.entries(REST_TO_CANONICAL) as [string, WhereFilterOp][]\n);\nconst CANONICAL_OP_LOOKUP = new Map<string, RestFilterOp>(\n Object.entries(CANONICAL_TO_REST) as [string, RestFilterOp][]\n);\n\n// ---------------------------------------------------------------------------\n// Unknown operators\n// ---------------------------------------------------------------------------\n\n/** The operator spellings a rejection lists back to the caller. */\nconst VALID_OPERATOR_LIST = ALL_WHERE_FILTER_OPS.join(\", \");\n\n/**\n * A filter condition named an operator this dialect does not have.\n *\n * ## Why this throws, rather than returning a typed rejection\n *\n * `deserializeFilter` is the *shared* codec: the REST ingress\n * (`packages/server/src/api/rest/query-parser.ts`), the browser SDK and the\n * admin panel (`buildRebaseData.ts`) all decode through it. Two constraints\n * follow.\n *\n * - It cannot throw the server's `ApiError`. `@rebasepro/common` does not\n * depend on `@rebasepro/server` (the dependency runs the other way), and a\n * browser client has no error handler to render an `ApiError` with. So the\n * rejection is this plain `Error` subclass, whose `message` reads correctly\n * wherever it surfaces — a rejected promise in an app, a 400 body over HTTP.\n * - It cannot be a returned rejection *value*. Every caller assigns the result\n * straight into a query it is about to run; a sentinel that none of them\n * check would be ignored, which is exactly the silently-wrong-filter failure\n * this exists to stop. Throwing is also what this file already does for the\n * sibling cases — `serializeTuple` on an unknown canonical operator,\n * `deserializeLogicalCondition` past the nesting bound — and the REST parser\n * already converts the latter into a 400.\n *\n * `statusCode`, `code` and `details` are carried as fields because the server's\n * Hono error handler duck-types those off any thrown error: a decode path that\n * forgets to convert still answers 400 with the canonical envelope instead of a\n * 500 that says \"An unexpected error occurred\". `query-parser.ts` converts\n * explicitly all the same — that is the path the contract is stated on, and an\n * incidental 400 is not a contract.\n */\nexport class UnknownFilterOperatorError extends Error {\n /** The field the condition was written against. */\n public readonly field: string;\n /** The operator string as it arrived, verbatim. */\n public readonly operator: string;\n /** Every operator this dialect accepts, in canonical spelling. */\n public readonly validOperators: readonly WhereFilterOp[] = ALL_WHERE_FILTER_OPS;\n /** See the class docblock: read by the server's error handler. */\n public readonly statusCode = 400;\n public readonly code = \"UNKNOWN_FILTER_OPERATOR\";\n public readonly details: { field: string; operator: string; validOperators: readonly WhereFilterOp[] };\n\n constructor(field: string, operator: string) {\n super(\n `Unknown filter operator '${operator}' on field '${field}'. `\n + `Valid operators: ${VALID_OPERATOR_LIST}`\n );\n this.name = \"UnknownFilterOperatorError\";\n this.field = field;\n this.operator = operator;\n this.details = { field, operator, validOperators: ALL_WHERE_FILTER_OPS };\n }\n}\n\n/**\n * Two to three characters of ASCII punctuation and nothing else — the shape\n * every symbolic operator has (`==`, `>=`, `<>`, `~~`, `!!`, `>>`, `===`), and\n * one a column value effectively never has.\n *\n * Two characters minimum on purpose. A *single* punctuation character is a\n * perfectly ordinary value — `{ grade: [\"-\", \"+\"] }` is a two-item list, not a\n * condition — and the only single-character operator anyone actually mistypes\n * is `=`, which is named separately below. `<` and `>` need no special case:\n * they are real operators and resolve.\n */\nconst SYMBOLIC_OPERATOR = /^[^\\p{L}\\p{N}\\s]{2,3}$/u;\n\n/** Lowercase, strip everything that is not a letter or digit. */\nfunction normalizeOperatorName(op: string): string {\n return op.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n}\n\n/**\n * Every real operator name with its case and separators removed, so a\n * respelling of one — `arrayContains`, `not_in`, `NOT-LIKE`, `isNull` — is\n * recognised as an attempt at an operator rather than read as a value.\n *\n * These are rejected rather than accepted: admitting a second spelling of an\n * operator would leave two wire spellings of one thing, and the rejection\n * message names the one that works.\n */\nconst RESPELLED_OPERATORS: ReadonlySet<string> = new Set(\n [...ALL_WHERE_FILTER_OPS, ...Object.keys(REST_TO_CANONICAL)].map(normalizeOperatorName)\n);\n\n/**\n * Operator names *other* query dialects use, which this one does not have.\n *\n * This list is curated, and deliberately so. For a word-shaped string there is\n * no rule that separates \"an operator the caller guessed\" from \"a value that\n * happens to be a word\": `{ tags: [\"a\", \"b\"] }` has to keep meaning a two-item\n * `in` list, so the codec cannot simply refuse every unrecognised word in\n * position 0. The line is therefore drawn by name, and only around names whose\n * use as an operator is far more likely than their use as one of two sibling\n * values. `contains` is the motivating case — the first thing a developer\n * reaches for, and until now it compiled to `title IN ('contains', 'Hell')`.\n *\n * Genuinely ambiguous single words (`any`, `all`, `exists`, `search`, `not`)\n * are left off: as operators they are rare, and as enum values they are common.\n * Everywhere else the tie goes to *rejecting*, because a 400 naming the\n * supported set costs the caller one round trip, and the alternative — which is\n * what every name on this list used to produce — is a query that runs, returns\n * rows, and is wrong.\n */\nconst NEAR_MISS_OPERATORS: ReadonlySet<string> = new Set([\n \"contains\", \"notcontains\", \"doesnotcontain\", \"doesnotcontains\",\n \"includes\", \"notincludes\",\n \"startswith\", \"notstartswith\", \"beginswith\", \"startingwith\",\n \"endswith\", \"notendswith\",\n \"matches\", \"notmatches\", \"regex\", \"regexp\",\n \"between\", \"notbetween\",\n \"equals\", \"notequals\", \"equalto\", \"isequalto\", \"isnotequalto\",\n \"greaterthan\", \"greaterthanorequal\", \"greaterthanorequalto\",\n \"lessthan\", \"lessthanorequal\", \"lessthanorequalto\",\n \"isempty\", \"isnotempty\",\n \"oneof\", \"noneof\", \"anyof\", \"allof\",\n \"null\", \"isnullorempty\"\n]);\n\n/**\n * Was this string *meant* as an operator?\n *\n * Only consulted after {@link toCanonicalOp} has already failed to resolve it,\n * so a `true` here is always a rejection.\n */\nfunction isOperatorShaped(op: string): boolean {\n if (op === \"=\") return true;\n if (SYMBOLIC_OPERATOR.test(op)) return true;\n const normalized = normalizeOperatorName(op);\n if (!normalized) return false;\n return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);\n}\n\n/**\n * Read a `[op, value]` tuple, if that is what this is.\n *\n * Three outcomes, and the middle one is the defect this function exists for:\n *\n * - the operator resolves (canonical *or* REST spelling) → the canonical tuple;\n * - the operator does not resolve but was plainly meant as one → throw;\n * - it does not look like an operator at all → `undefined`, and the caller\n * falls back to reading the array as a list of values.\n *\n * The old test was `toCanonicalOp(raw[0]) === raw[0]`, i.e. canonical spelling\n * only, with *everything else* — including every REST short-code — dropping\n * through to `[\"in\", raw]`. So the operator string itself became a value in a\n * membership test: `[\"!!\", \"Hello\"]` compiled to `title IN ('!!','Hello')`,\n * which matches, and the caller got back rows their filter was written to\n * exclude. `[\"eq\", \"active\"]` had the same shape of failure.\n */\nfunction readTuple(field: string, raw: unknown): [WhereFilterOp, unknown] | undefined {\n if (!Array.isArray(raw) || raw.length !== 2) return undefined;\n const [op, value] = raw;\n if (typeof op !== \"string\") return undefined;\n\n const canonical = toCanonicalOp(op);\n if (canonical) return [canonical, value];\n\n // A dot means this is a *wire* string, not an operator token: two repeated\n // query params arrive as `[\"gte.18\", \"lt.65\"]`, which is a two-element array\n // of strings and therefore tuple-shaped. Deferred with exactly the test the\n // repeated-dot-string branch below uses, so the two cannot disagree.\n //\n // The property test found this: `[\"ilike\", \"\"]` serializes to `\"ilike.\"`,\n // whose normalized form is a real operator name, so a well-formed\n // round-trip was being rejected as a bad operator.\n if (op.includes(\".\")) return undefined;\n\n if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);\n\n return undefined;\n}\n\n// ---------------------------------------------------------------------------\n// Reserved query-parameter names\n// ---------------------------------------------------------------------------\n\n/**\n * The query-parameter names a REST listing reads as something other than a\n * field filter: pagination and whether to count, sorting, includes,\n * projection, search, the logical groups, the JSON `where` dialect, an\n * aggregate's `select`/`groupBy`, and the soft-delete switches.\n *\n * One list for both ends. The server skips these names when it collects\n * `?<field>=<op>.<value>` filters, and the SDK sends a filter on a column with\n * one of these names inside `?where=` instead — a collection is free to have a\n * `page`, `select` or `hard` column, and a filter on it sent as its own\n * parameter was read as the parameter and never applied.\n */\nexport const RESERVED_QUERY_KEYS: ReadonlySet<string> = new Set([\n \"limit\", \"offset\", \"page\", \"after\", \"orderBy\", \"include\", \"fields\", \"distinct\", \"count\",\n \"searchString\", \"searchExplain\", \"vector_search\", \"vector\", \"vector_distance\", \"vector_threshold\",\n \"or\", \"and\", \"not\", \"where\", \"select\", \"groupBy\", \"deleted\", \"hard\"\n]);\n\n// ---------------------------------------------------------------------------\n// Serialize: FilterValues → REST querystring\n// ---------------------------------------------------------------------------\n\n/**\n * Encode the `<op>.<value>` half of a wire condition.\n *\n * This is the single leaf encoder. Both wire positions that carry a condition\n * — a top-level query parameter (`?status=eq.active`) and a leaf inside an\n * `and(...)`/`or(...)` group (`or(status.eq.active,…)`) — go through it, so a\n * rule expressed here holds in both. The group serializer used to carry its\n * own copy, and the copy had drifted on every rule that matters: `null` went\n * out as the four-character string, the empty list as `()`, and an operator\n * this dialect does not have was silently rewritten to `eq` — a filter that\n * ran, returned rows, and answered a different question than the one asked.\n *\n * `escapeScalar` is the one thing the two positions legitimately disagree\n * about. A scalar in a query parameter owns the whole value and needs no\n * escaping; a scalar inside a group sits between the same commas a list item\n * does, so a comma in it would end the condition early.\n */\nfunction serializeOperatorAndValue(\n op: WhereFilterOp,\n value: unknown,\n { escapeScalar, where }: { escapeScalar: boolean; where: string }\n): string {\n if (typeof op !== \"string\") {\n throw new TypeError(\n `${where}: operator must be a string, got ${typeof op}`\n );\n }\n\n // Canonical spellings only, on purpose: this codec parses liberally and\n // emits strictly. `deserializeFilter` accepts a REST short-code because one\n // arrives off the wire; a *caller* handing one to the serializer has a\n // condition object built by hand, and the spelling it wants is the one the\n // types name.\n //\n // The throw is the fix. `serializeLogicalCondition` used to end this lookup\n // with `?? \"eq\"`, so `{ operator: \"gte\" }` — the spelling the wire uses, and\n // therefore the one most often guessed — was sent as `age.eq.18`: a query\n // that ran, returned rows, and answered a different question.\n const restOp = CANONICAL_OP_LOOKUP.get(op);\n if (!restOp) {\n throw new TypeError(\n `${where}: unknown operator \"${op}\". Valid operators: ${Object.keys(CANONICAL_TO_REST).join(\", \")}`\n );\n }\n\n // `== null` and `!= null` go out as the null-testing operators.\n //\n // They used to serialize as `eq.null`, and `deserializeTuple` had no way to\n // tell that from a search for the four-character string \"null\" — so it\n // returned the string, and `.where(\"deleted_at\", \"==\", null)` compiled to\n // `deleted_at = 'null'` over HTTP. The typed builder allows it, the Postgres\n // compiler implements it as IS NULL, and only the wire trip broke it.\n //\n // These are the same query: SQL `= NULL` is never true, so `== null` can\n // only mean IS NULL. Emitting it as such is unambiguous in both directions\n // and leaves `eq.null` free to mean the literal string, which it now does.\n if (value === null && (op === \"==\" || op === \"!=\")) {\n return op === \"==\" ? \"isnull.null\" : \"notnull.null\";\n }\n\n // A null test has no operand. Whatever was parked in `value` is dropped\n // here rather than on the way back, so the encoding is stable: both\n // deserializers normalize `isnull.<anything>` to `null`, and re-encoding\n // that must land on the same string it came from.\n if (NULL_OPS.has(op)) return `${restOp}.null`;\n\n if (Array.isArray(value)) {\n // The empty list needs a spelling of its own.\n //\n // A comma-joined format has no way to write \"zero items\": `()` is the\n // empty string between the parens, which splits to `[\"\"]`. So\n // `.where(\"id\", \"in\", [])` — which matches nothing — used to arrive as\n // a search for the empty string: a 500 on a uuid column, silently the\n // wrong rows on a text one.\n //\n // `EMPTY_LIST_TOKEN` is a single unescaped backslash, which no real\n // value can produce: `escapeWireValue` doubles every backslash, so a\n // one-item list holding `\\` serializes as `(\\\\)`. That keeps both\n // directions exact — `[]` and `[\"\"]` stay distinct — rather than\n // trading one lossy reading for another.\n if (value.length === 0) return `${restOp}.(${EMPTY_LIST_TOKEN})`;\n const items = value.map(v => escapeWireValue(stringifyValue(v))).join(\",\");\n return `${restOp}.(${items})`;\n }\n\n const scalar = stringifyValue(value);\n // A list operator's operand is a list, so a scalar there has no spelling\n // to protect; see `scalarNeedsEscape` for the others.\n const escape = escapeScalar || (!LIST_OPS.has(op) && scalarNeedsEscape(scalar));\n return `${restOp}.${escape ? escapeWireValue(scalar) : scalar}`;\n}\n\n/**\n * Serialize a single canonical condition tuple to a PostgREST dot-string.\n *\n * Throws `TypeError` if the input is not a valid `[WhereFilterOp, unknown]` tuple.\n *\n * @example\n * serializeTuple([\"==\", \"active\"]) // \"eq.active\"\n * serializeTuple([\"in\", [\"admin\",\"editor\"]]) // \"in.(admin,editor)\"\n * serializeTuple([\">=\", 18]) // \"gte.18\"\n */\nfunction serializeTuple(tuple: [WhereFilterOp, unknown]): string {\n if (!Array.isArray(tuple) || tuple.length !== 2) {\n throw new TypeError(\n `serializeTuple: expected a [WhereFilterOp, value] tuple, got ${JSON.stringify(tuple)}`\n );\n }\n\n const [op, value] = tuple;\n return serializeOperatorAndValue(op, value, {\n escapeScalar: false,\n where: \"serializeTuple\"\n });\n}\n\n/**\n * Convert `FilterValues` (or `WireFilterValues`) to a PostgREST-style\n * querystring record.\n *\n * - Canonical `[WhereFilterOp, value]` tuples are serialized strictly.\n * - Pre-serialized PostgREST strings (e.g. `\"eq.published\"`) are passed through.\n * - Single conditions produce a string value.\n * - Multiple conditions on the same field produce a string array (repeated params).\n *\n * @example\n * serializeFilter({ status: [\"==\", \"active\"] })\n * // → { status: \"eq.active\" }\n *\n * serializeFilter({ age: [[\">=\", 18], [\"<\", 65]] })\n * // → { age: [\"gte.18\", \"lt.65\"] }\n *\n * // Pre-serialized strings pass through unchanged:\n * serializeFilter({ status: \"eq.published\" })\n * // → { status: \"eq.published\" }\n */\nexport function serializeFilter(\n filter: FilterValues<string> | Record<string, unknown>\n): Record<string, string | string[]> {\n const result: Record<string, string | string[]> = {};\n\n for (const [field, condition] of Object.entries(filter)) {\n if (condition === undefined) continue;\n\n // Pre-serialized PostgREST string — pass through unchanged.\n // This supports WireFilterValues where values may already be\n // serialized dot-strings like \"eq.active\" or raw strings like \"true\".\n if (typeof condition === \"string\") {\n result[field] = condition;\n continue;\n }\n\n // Multiple conditions on the same field: array of tuples\n // We detect this by checking if the first element is also an array.\n if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) {\n result[field] = (condition as [WhereFilterOp, unknown][]).map(serializeTuple);\n } else {\n // Single condition — must be a [WhereFilterOp, value] tuple\n result[field] = serializeTuple(condition as [WhereFilterOp, unknown]);\n }\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Deserialize: REST querystring → FilterValues\n// ---------------------------------------------------------------------------\n\n/**\n * The spellings a null-testing operator's operand may take.\n *\n * The serializer writes `isnull.null`; a hand-written `isnull.true` means the\n * same thing and has always been accepted. Anything else after the operator is\n * not an operand it has — `notnull.reason` is a *value* — see\n * {@link deserializeSingle}.\n */\nconst NULL_OPERANDS: ReadonlySet<string> = new Set([\"null\", \"true\", \"false\", \"\"]);\n\n/**\n * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.\n *\n * All values are returned as strings — the wire format carries no type\n * metadata, so coercion is the data driver's responsibility.\n *\n * ## When a leading segment is an operator, and when it is part of the value\n *\n * `?status=in.progress` and `?status=in.(a,b)` differ by one character and mean\n * entirely different things, and the reading here decides which. The rule, in\n * full:\n *\n * > A dot-string is read as `operator.operand` **only** when its first segment\n * > names a known REST operator **and** what follows is a well-formed operand\n * > *for that operator's arity*. Otherwise the whole string is the value.\n *\n * Arity, per operator family:\n *\n * - **List** operators (`in`, `nin`, `csa` — `LIST_OPS`) take a parenthesised\n * list and nothing else. `in.(draft,review)` is the operator; `in.progress`\n * is the *value* `\"in.progress\"`, because there is no list there and so no\n * `in` filter that could have been written. That case used to compile to\n * `status IN ('progress')` — a filter the caller never wrote, quietly\n * matching the wrong rows and, on a status field, hiding every row they were\n * looking for.\n * - **Null** operators (`isnull`, `notnull` — {@link NULL_OPS}) take no\n * operand: only {@link NULL_OPERANDS}. `notnull.reason` is the value\n * `\"notnull.reason\"`, not \"reason is not null\".\n * - **Everything else** takes one scalar, and any remainder is one — including\n * the empty string, so `eq.` really is \"equals the empty string\". A\n * parenthesised remainder is a list for these too (`eq.(a,b)` is array\n * equality); a scalar that is itself parenthesised arrives escaped,\n * `eq.\\(none\\)`, and is decoded here — see {@link scalarNeedsEscape}.\n *\n * ### The one ambiguity that remains, and how to write past it\n *\n * A scalar operator's operand is unconstrained, so `?status=like.that` is a\n * `LIKE 'that'` and no rule at this layer can tell it from the literal value\n * `\"like.that\"` — both are well-formed encodings, and picking either by guess\n * would break the other. Two spellings say \"value\" unambiguously, and both\n * round-trip:\n *\n * - `?status=eq.like.that` — name the operator. The *first* segment is consumed\n * as the operator and everything after it is the value, dots and all. This is\n * what `serializeFilter` emits, which is why the SDK never meets the\n * ambiguity at all.\n * - `?where={\"status\":[\"==\",\"like.that\"]}` — the JSON dialect's tuple form.\n *\n * Values that merely *contain* dots (`user@host.com`, `1.2.3`) were never\n * ambiguous: their first segment names no operator to begin with.\n */\nfunction deserializeSingle(raw: string): [WhereFilterOp, unknown] {\n const dotIndex = raw.indexOf(\".\");\n if (dotIndex === -1) {\n // No dot → equality on the raw value (kept as string)\n return [\"==\", raw];\n }\n\n const prefix = raw.substring(0, dotIndex);\n const rest = raw.substring(dotIndex + 1);\n\n // Check if the prefix is a known REST operator.\n // This is the key defense against values like \"eq.something\" or \"gt.foo\"\n // being misinterpreted — only known REST short-codes are treated as operators.\n const canonicalOp = REST_OP_LOOKUP.get(prefix);\n if (!canonicalOp) {\n // Not a known operator (e.g., email \"user@host.com\" or version \"1.2.3\")\n // Treat the entire string as an equality value\n return [\"==\", raw];\n }\n\n // Null-testing operators ignore their serialized value — normalize to null\n // so the tuple round-trips stably (`isnull.null` → [\"is-null\", null]).\n if (NULL_OPS.has(canonicalOp)) {\n // ...but only when what follows is an operand this operator has. See\n // the docblock: `notnull.reason` names no null test, so it is a value.\n if (!NULL_OPERANDS.has(rest)) return [\"==\", raw];\n return [canonicalOp, null];\n }\n\n // Parse list values: \"(admin,editor)\" → [\"admin\", \"editor\"]\n if (rest.startsWith(\"(\") && rest.endsWith(\")\")) {\n const inner = rest.slice(1, -1);\n // See EMPTY_LIST_TOKEN: `(\\)` is the empty list. `()` remains a list\n // holding one empty string, which is what splitting it yields anyway.\n const items = inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner);\n return [canonicalOp, items];\n }\n\n // A list operator with no list is not that operator — see the docblock.\n // `?status=in.progress` is the value \"in.progress\"; the `in` filter it used\n // to compile to was never written by anyone.\n if (LIST_OPS.has(canonicalOp)) return [\"==\", raw];\n\n // A scalar the serializer had to escape, because raw it would have read\n // as a list; see `scalarNeedsEscape`.\n return [canonicalOp, decodeEscapedScalar(rest) ?? rest];\n}\n\n/**\n * Convert a PostgREST-style querystring record to `FilterValues`.\n *\n * - String values are parsed as single conditions.\n * - String arrays (repeated query params) become multiple conditions on the same field.\n *\n * @example\n * deserializeFilter({ status: \"eq.active\" })\n * // → { status: [\"==\", \"active\"] }\n *\n * deserializeFilter({ age: [\"gte.18\", \"lt.65\"] })\n * // → { age: [[\">=\", \"18\"], [\"<\", \"65\"]] }\n *\n * @throws {UnknownFilterOperatorError} when a condition names an operator this\n * dialect does not have. See that class for why a rejection here is a throw.\n */\nexport function deserializeFilter(\n query: Record<string, unknown>\n): FilterValues<string> {\n const result: FilterValues<string> = {};\n\n for (const [field, raw] of Object.entries(query)) {\n if (raw === undefined) continue;\n\n // A single `[op, value]` condition.\n const tuple = readTuple(field, raw);\n if (tuple) {\n result[field] = tuple;\n continue;\n }\n\n if (Array.isArray(raw)) {\n if (raw.length === 0) continue;\n\n // An array of tuples: several conditions on the same field. Every\n // element is checked, not just the first — the old test read\n // `raw[0]` and cast the whole array, so one bad operator among\n // several travelled on untouched.\n if (Array.isArray(raw[0])) {\n const tuples = raw.map(item => readTuple(field, item));\n if (tuples.every((t): t is [WhereFilterOp, unknown] => t !== undefined)) {\n result[field] = tuples;\n continue;\n }\n }\n\n if (raw.length === 1) {\n result[field] = typeof raw[0] === \"string\" ? deserializeSingle(raw[0]) : [\"==\", raw[0]];\n } else {\n // If the elements are strings, they might be PostgREST dot-strings (repeated params)\n if (typeof raw[0] === \"string\" && raw[0].includes(\".\")) {\n result[field] = raw.map(r => typeof r === \"string\" ? deserializeSingle(r) : ([\"==\", r] as [WhereFilterOp, unknown])) as [WhereFilterOp, unknown][];\n } else {\n // Otherwise assume it's a list of values for an implicit\n // \"in\" — `{ tags: [\"a\",\"b\"] }`, and `?tags=a&tags=b`, which\n // arrives here identically.\n //\n // A two-element array reaches this line only after\n // `readTuple` has decided its first element was not meant\n // as an operator. Everything longer never had the\n // ambiguity: an operator tuple has exactly two slots.\n result[field] = [\"in\", raw];\n }\n }\n } else if (typeof raw === \"string\") {\n result[field] = deserializeSingle(raw);\n } else {\n result[field] = [\"==\", raw];\n }\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Logical conditions: serialize / deserialize\n// ---------------------------------------------------------------------------\n\n/**\n * Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.\n *\n * Leaf encoding is {@link serializeOperatorAndValue}, the same function\n * `serializeTuple` uses, so `null`, the empty list and an unknown operator\n * behave identically inside a group and in a query parameter.\n *\n * @throws {TypeError} when a leaf names an operator this dialect does not have.\n * It used to fall back to `eq`, which turned `age >= 18` into `age = 18` with\n * no diagnostic anywhere.\n *\n * @example\n * serializeLogicalCondition({ column: \"status\", operator: \"==\", value: \"active\" })\n * // → \"status.eq.active\"\n *\n * serializeLogicalCondition({ column: \"deleted_at\", operator: \"==\", value: null })\n * // → \"deleted_at.isnull.null\"\n *\n * serializeLogicalCondition({ type: \"or\", conditions: [...] })\n * // → \"or(status.eq.active,status.eq.pending)\"\n */\nexport function serializeLogicalCondition(\n cond: LogicalCondition | FilterCondition\n): string {\n if (\"type\" in cond) {\n // LogicalCondition (and/or)\n const inner = (cond.conditions ?? [])\n .map(serializeLogicalCondition)\n .join(\",\");\n return `${cond.type}(${inner})`;\n }\n\n // FilterCondition. The leaf goes through the shared encoder, so a group\n // condition and a query parameter agree on nulls, empty lists and unknown\n // operators — see `serializeOperatorAndValue`.\n //\n // The column is escaped like a value: it is not one, but it shares the\n // delimiters, and a comma or paren in it would move where the group parser\n // thinks the condition ends. Dots are deliberately *not* escaped — a\n // relation path is `author.name` on the wire, and the parser below finds\n // the operator rather than assuming it is the second segment.\n return `${escapeWireValue(cond.column)}.${serializeOperatorAndValue(cond.operator, cond.value, {\n escapeScalar: true,\n where: \"serializeLogicalCondition\"\n })}`;\n}\n\n/**\n * The positions of the dots that can separate a leaf's segments.\n *\n * Dots inside a list value (`in.(1.5,2.5)`) are not separators. The value\n * always follows the operator, so the search only needs the region before the\n * first unescaped paren.\n */\nfunction leafSeparators(str: string): number[] {\n let limit = str.length;\n for (let i = 0; i < str.length; i++) {\n if (str[i] === \"\\\\\") { i++; continue; }\n if (str[i] === \"(\") { limit = i; break; }\n }\n\n const dots: number[] = [];\n for (let i = 0; i < limit; i++) {\n if (str[i] === \"\\\\\") { i++; continue; }\n if (str[i] === \".\") dots.push(i);\n }\n return dots;\n}\n\n/**\n * Split a leaf condition into `column`, operator token and value.\n *\n * The naive reading — column is everything before the first dot, operator is\n * everything up to the second — cannot express a relation path. A filter on\n * `author.name` serializes to `author.name.eq.bob` and came back as the column\n * `author` with the operator `name`, which resolves to nothing, so the\n * fallback made it `author == \"eq.bob\"`: a condition that runs and matches\n * nothing, on a column the caller never named.\n *\n * So the operator is found rather than assumed: it is the first dot-separated\n * segment after the column that resolves to a real operator. Everything before\n * it is the column, everything after is the value. `version.eq.1.2.3` reads as\n * `version == \"1.2.3\"` because the scan stops at the first match — the `eq` at\n * offset 1, not a later segment — and `metadata->>x.eq.5` never had dots in the\n * column to begin with.\n *\n * Returns `undefined` when no segment resolves — `status.active`, an equality\n * written without an operator, which the caller handles.\n */\nfunction splitLeafCondition(str: string): { column: string; operator: WhereFilterOp; value: string } | undefined {\n const dots = leafSeparators(str);\n\n // Segment 0 is always the column, and an operator needs a value after it,\n // so a candidate is bounded on both sides by a dot.\n for (let i = 1; i < dots.length; i++) {\n const operator = toCanonicalOp(str.substring(dots[i - 1] + 1, dots[i]));\n if (!operator) continue;\n return {\n column: unescapeWireValue(str.substring(0, dots[i - 1])),\n operator,\n value: str.substring(dots[i] + 1)\n };\n }\n\n return undefined;\n}\n\n/**\n * Parse a logical condition wire-format string back into a\n * `LogicalCondition` or `FilterCondition`.\n *\n * @example\n * deserializeLogicalCondition(\"status.eq.active\")\n * // → { column: \"status\", operator: \"==\", value: \"active\" }\n *\n * deserializeLogicalCondition(\"or(status.eq.active,age.gte.18)\")\n * // → { type: \"or\", conditions: [...] }\n */\n/**\n * How deeply `or(...)`/`and(...)` groups may nest.\n *\n * This parser recurses once per level, on a value that arrives in a query\n * string. Unbounded, twenty thousand levels reached `RangeError: Maximum call\n * stack size exceeded`, which a caller sees as a 500 about the call stack\n * rather than a 400 about their filter. Node's 16 KB header cap keeps a GET\n * below that in practice, but \"the HTTP layer happens to stop it\" is not a\n * bound this parser should rely on.\n *\n * Thirty-two is far past anything a real filter expresses; the deepest in this\n * repository's own tests is three.\n */\nexport const MAX_LOGICAL_NESTING_DEPTH = 32;\n\nexport function deserializeLogicalCondition(\n str: string,\n // Not `depth`: the body already uses that name for paren tracking, inside a\n // block that shadows a parameter of the same name — so the recursion\n // counter silently became the paren counter and never grew.\n nesting = 0\n): LogicalCondition | FilterCondition {\n if (nesting > MAX_LOGICAL_NESTING_DEPTH) {\n throw new Error(\n `Filter groups nest more than ${MAX_LOGICAL_NESTING_DEPTH} levels deep. ` +\n \"Flatten the condition — `or(a,or(b,c))` is `or(a,b,c)`.\"\n );\n }\n // Check for logical group: \"and(...)\", \"or(...)\" or \"not(...)\"\n const logicalMatch = str.match(/^(and|or|not)\\((.+)\\)$/);\n if (logicalMatch) {\n const type = logicalMatch[1] as \"and\" | \"or\" | \"not\";\n const innerStr = logicalMatch[2];\n\n const conditions = splitGroupItems(innerStr)\n .map(part => deserializeLogicalCondition(part, nesting + 1));\n\n return { type, conditions };\n }\n\n // FilterCondition: \"column.op.value\"\n const leaf = splitLeafCondition(str);\n if (!leaf) {\n const firstDot = str.indexOf(\".\");\n if (firstDot === -1) {\n return { column: unescapeWireValue(str), operator: \"==\", value: true };\n }\n // \"column.value\" — no segment resolved as an operator, so this is an\n // equality written without one. The value keeps its dots.\n //\n // Unless a segment was plainly meant as an operator: `title.contains.x`\n // read as `title == \"contains.x\"` matches nothing, and inside `not(…)`\n // its negation matched every row — the widened read\n // `parseWhereParam` exists to avoid. Refused by the same test the\n // tuple reader applies, so the two spellings of one filter agree.\n const dots = leafSeparators(str);\n for (let i = 1; i < dots.length; i++) {\n const segment = str.substring(dots[i - 1] + 1, dots[i]);\n if (isOperatorShaped(segment)) {\n throw new UnknownFilterOperatorError(unescapeWireValue(str.substring(0, dots[i - 1])), segment);\n }\n }\n return {\n column: unescapeWireValue(str.substring(0, firstDot)),\n operator: \"==\",\n value: unescapeWireValue(str.substring(firstDot + 1))\n };\n }\n\n const { column, operator, value: valueStr } = leaf;\n\n // A null test has no operand: `isnull.null` is what the serializer writes,\n // but a hand-written `isnull.true` means the same thing. Normalizing here\n // is what makes the tuple stable through a re-encode, and it matches\n // `deserializeSingle`, which has done it for query parameters all along.\n if (NULL_OPS.has(operator)) {\n return { column, operator, value: null };\n }\n\n // Parse list values with escape-aware splitting. The wrapping parens are\n // written by the serializer *after* the items are escaped, so an escaped\n // paren inside an item can never be mistaken for them.\n if (valueStr.startsWith(\"(\") && valueStr.endsWith(\")\")) {\n const inner = valueStr.slice(1, -1);\n // See EMPTY_LIST_TOKEN: `(\\)` is the empty list, which is not the same\n // query as a search for the empty string.\n const items = inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner);\n return { column, operator, value: items };\n }\n\n return { column, operator, value: unescapeWireValue(valueStr) };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"filter-dialect.es.js","names":[],"sources":["../../src/data/filter-dialect.ts"],"sourcesContent":["/**\n * REST wire-format adapter for the unified filter system.\n *\n * This module is the ONLY code in the entire codebase that knows about\n * PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).\n * Everything else speaks `FilterValues` exclusively.\n *\n * Wire-format values are always strings — the wire format carries no type\n * metadata, so type coercion is the responsibility of the server-side data\n * driver which has access to the collection schema.\n *\n * Structural characters inside a value are backslash-escaped: `,` → `\\,`,\n * `(` → `\\(`, `)` → `\\)`, and a literal backslash as `\\\\`. Decoding is\n * deliberately conservative — only those four sequences are decoded, so a\n * backslash that arrives unescaped from an older client survives intact.\n *\n * @module\n */\n\nimport {\n WhereFilterOp,\n FilterValues,\n ALL_WHERE_FILTER_OPS,\n CANONICAL_TO_REST,\n REST_TO_CANONICAL,\n RestFilterOp,\n toCanonicalOp,\n LogicalCondition,\n FilterCondition,\n LIST_OPS,\n NULL_OPS\n} from \"@rebasepro/types\";\nimport { normalizeToEntityRelation } from \"../util/entities\";\n\n// ---------------------------------------------------------------------------\n// Value stringification\n// ---------------------------------------------------------------------------\n\n/**\n * Serialize a JS value to its querystring representation.\n * `null` is serialized as the literal string `\"null\"`.\n * Relation values (`EntityRelation` instances or `{ __type: \"relation\", id, path }`\n * objects) are serialized as their raw id — the wire format only carries the\n * value to compare against the FK column.\n *\n * A `Date` is its ISO 8601 instant — what JSON makes of it on the socket and\n * in-process, so one filter names one instant through every door. `String()`\n * gave the viewer's local zone, no milliseconds and a parenthesised zone name\n * Postgres cannot parse. An invalid date keeps its \"Invalid Date\" spelling for\n * the server to refuse, rather than throwing a `RangeError` from here.\n */\nfunction stringifyValue(value: unknown): string {\n if (value === null) return \"null\";\n if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();\n const relation = normalizeToEntityRelation(value);\n if (relation) return String(relation.id);\n return String(value);\n}\n\n// ---------------------------------------------------------------------------\n// Comma escaping for list values\n// ---------------------------------------------------------------------------\n\n/**\n * Characters that carry structure in the wire format and must therefore be\n * escaped inside a value: the separator, the group delimiters, and the escape\n * character itself.\n *\n * Parentheses are here because `and(...)`/`or(...)` groups are parsed by\n * tracking paren depth. A value containing one is not merely ambiguous, it\n * moves where the parser thinks the group ends.\n */\nconst WIRE_SPECIALS = /[\\\\,()]/g;\n\n/**\n * Escape a value for the wire format: `\\` → `\\\\`, `,` → `\\,`, `(` → `\\(`,\n * `)` → `\\)`.\n */\n/**\n * The wire spelling of an empty list.\n *\n * A lone backslash: unproducible by {@link escapeWireValue}, which doubles\n * every backslash it emits, so it cannot collide with any real item.\n */\nconst EMPTY_LIST_TOKEN = \"\\\\\";\n\nfunction escapeWireValue(value: string): string {\n return value.replace(WIRE_SPECIALS, ch => `\\\\${ch}`);\n}\n\n/**\n * Unescape a wire-format value.\n *\n * **Conservative**, and deliberately so: only the four sequences\n * {@link escapeWireValue} actually produces are decoded. A backslash followed\n * by anything else is left exactly as it is.\n *\n * This used to consume the backslash before *any* character, which is\n * indistinguishable for anything this codec emitted — it only ever emits those\n * four — but not for input arriving from elsewhere. A client on an older\n * release sends a Windows path or a LIKE pattern with a literal `C:\\x`\n * unescaped, and greedy unescaping silently turned it into `C:x`, changing\n * which rows matched. Decoding only what the encoder can produce makes the two\n * directions agree across versions.\n */\nfunction unescapeWireValue(value: string): string {\n let result = \"\";\n for (let i = 0; i < value.length; i++) {\n const next = value[i + 1];\n if (value[i] === \"\\\\\" && (next === \"\\\\\" || next === \",\" || next === \"(\" || next === \")\")) {\n result += next;\n i++;\n continue;\n }\n result += value[i];\n }\n return result;\n}\n\n/**\n * Whether a scalar, sent raw as a query parameter's operand, would be read\n * back as something other than itself.\n *\n * A query parameter owns its whole value, so its scalar goes out unescaped —\n * `eq.a,b`, `eq.C:\\x` — and the decoder returns it verbatim. Two shapes break\n * that. A parenthesised operand is a list for every operator (so that\n * `[\"==\", [\"a\",\"b\"]]` on an array column stays a list), so raw, the scalar\n * `\"(deleted)\"` would arrive as `[\"deleted\"]`: an `==` matching nothing, a\n * `!=` returning the very rows it excludes. Such a scalar is sent escaped,\n * `eq.\\(deleted\\)`. And that spelling has to be told apart from a raw value\n * that looks the same, so a scalar of *that* shape is escaped in turn.\n *\n * Everything else stays raw, so a value an older client sends decodes as it\n * always has — unless it is exactly the escaped spelling of such a scalar,\n * which decodes to the scalar it spells.\n */\nfunction scalarNeedsEscape(value: string): boolean {\n if (value.startsWith(\"(\") && value.endsWith(\")\")) return true;\n return decodeEscapedScalar(value) !== undefined;\n}\n\n/**\n * The scalar an operand is the escaped spelling of, when it is one.\n *\n * Exact on purpose: only an operand that {@link escapeWireValue} produces from\n * a scalar that {@link scalarNeedsEscape} is decoded. Any other operand —\n * including a raw backslash an older client sent, like `\\\\server\\share` — is\n * left verbatim. Terminates because an operand that matches contains an\n * escape pair, so the value is strictly shorter than it.\n */\nfunction decodeEscapedScalar(operand: string): string | undefined {\n if (!operand.startsWith(\"\\\\\")) return undefined;\n const value = unescapeWireValue(operand);\n return escapeWireValue(value) === operand && scalarNeedsEscape(value) ? value : undefined;\n}\n\n/**\n * Split a parenthesized list string on unescaped commas.\n * Input is the content between `(` and `)`.\n *\n * @example\n * splitListItems(\"admin,editor\") // [\"admin\", \"editor\"]\n * splitListItems(\"hello\\\\, world,foo\") // [\"hello, world\", \"foo\"]\n */\nfunction splitListItems(inner: string): string[] {\n const items: string[] = [];\n let current = \"\";\n for (let i = 0; i < inner.length; i++) {\n if (inner[i] === \"\\\\\" && i + 1 < inner.length) {\n // Escaped pair — consume both chars so the comma in `\\,` is not\n // read as a separator. Kept verbatim; decoding happens once, below.\n current += inner[i] + inner[i + 1];\n i++;\n } else if (inner[i] === \",\") {\n items.push(unescapeWireValue(current));\n current = \"\";\n } else {\n current += inner[i];\n }\n }\n items.push(unescapeWireValue(current));\n return items;\n}\n\n/**\n * Split a group body on commas at paren depth 0, honouring escapes.\n *\n * The escape-awareness is the point. The splitter used to track only paren\n * depth, so a comma inside a scalar value ended a condition:\n * `or(name.eq.Doe, John,age.gte.18)` parsed as *three* conditions, the middle\n * one a fabricated `\" John\" == true`. On an `or` that widens the result set,\n * and nothing anywhere reports an error — the query simply stops meaning what\n * the caller wrote.\n */\nfunction splitGroupItems(inner: string): string[] {\n const parts: string[] = [];\n let depth = 0;\n let start = 0;\n for (let i = 0; i < inner.length; i++) {\n const ch = inner[i];\n if (ch === \"\\\\\" && i + 1 < inner.length) { i++; continue; }\n if (ch === \"(\") depth++;\n else if (ch === \")\") depth--;\n else if (ch === \",\" && depth === 0) {\n parts.push(inner.slice(start, i));\n start = i + 1;\n }\n }\n parts.push(inner.slice(start));\n return parts;\n}\n\n// ---------------------------------------------------------------------------\n// Typed operator map lookups (no `as any`)\n// ---------------------------------------------------------------------------\n\n/**\n * Operator tables as `Map`s, because the key comes off the wire.\n *\n * Indexed as plain objects, every `Object.prototype` member answered: a query\n * string of `?f=valueOf.x` found a truthy \"operator\" — the inherited function —\n * and `deserializeTuple` returned it *as the operator*, so a function object\n * travelled on into the compilers in place of a `WhereFilterOp`. The guard one\n * line below (`if (!canonicalOp)`) reads as though it rejects anything unknown,\n * and does not: `Object.prototype` is not unknown to a plain object.\n *\n * Same shape as the prototype-key defects swept out of `setIn`, `getIn`,\n * `mergeDeep`, `unflattenObject` and `FOREIGN_CONVENTION_UIDS`.\n */\nconst REST_OP_LOOKUP = new Map<string, WhereFilterOp>(\n Object.entries(REST_TO_CANONICAL) as [string, WhereFilterOp][]\n);\nconst CANONICAL_OP_LOOKUP = new Map<string, RestFilterOp>(\n Object.entries(CANONICAL_TO_REST) as [string, RestFilterOp][]\n);\n\n// ---------------------------------------------------------------------------\n// Unknown operators\n// ---------------------------------------------------------------------------\n\n/** The operator spellings a rejection lists back to the caller. */\nconst VALID_OPERATOR_LIST = ALL_WHERE_FILTER_OPS.join(\", \");\n\n/**\n * A filter condition named an operator this dialect does not have.\n *\n * ## Why this throws, rather than returning a typed rejection\n *\n * `deserializeFilter` is the *shared* codec: the REST ingress\n * (`packages/server/src/api/rest/query-parser.ts`), the browser SDK and the\n * admin panel (`buildRebaseData.ts`) all decode through it. Two constraints\n * follow.\n *\n * - It cannot throw the server's `ApiError`. `@rebasepro/common` does not\n * depend on `@rebasepro/server` (the dependency runs the other way), and a\n * browser client has no error handler to render an `ApiError` with. So the\n * rejection is this plain `Error` subclass, whose `message` reads correctly\n * wherever it surfaces — a rejected promise in an app, a 400 body over HTTP.\n * - It cannot be a returned rejection *value*. Every caller assigns the result\n * straight into a query it is about to run; a sentinel that none of them\n * check would be ignored, which is exactly the silently-wrong-filter failure\n * this exists to stop. Throwing is also what this file already does for the\n * sibling cases — `serializeTuple` on an unknown canonical operator,\n * `deserializeLogicalCondition` past the nesting bound — and the REST parser\n * already converts the latter into a 400.\n *\n * `statusCode`, `code` and `details` are carried as fields because the server's\n * Hono error handler duck-types those off any thrown error: a decode path that\n * forgets to convert still answers 400 with the canonical envelope instead of a\n * 500 that says \"An unexpected error occurred\". `query-parser.ts` converts\n * explicitly all the same — that is the path the contract is stated on, and an\n * incidental 400 is not a contract.\n */\nexport class UnknownFilterOperatorError extends Error {\n /** The field the condition was written against. */\n public readonly field: string;\n /** The operator string as it arrived, verbatim. */\n public readonly operator: string;\n /** Every operator this dialect accepts, in canonical spelling. */\n public readonly validOperators: readonly WhereFilterOp[] = ALL_WHERE_FILTER_OPS;\n /** See the class docblock: read by the server's error handler. */\n public readonly statusCode = 400;\n public readonly code = \"UNKNOWN_FILTER_OPERATOR\";\n public readonly details: { field: string; operator: string; validOperators: readonly WhereFilterOp[] };\n\n /**\n * @param hint - One sentence naming the spelling that works, when the\n * rejected one is recognisably another dialect's. It leads the message:\n * the operator list is in canonical spelling, which is not what a wire\n * string takes, so on its own it would send the caller to `is-null.null`.\n */\n constructor(field: string, operator: string, hint?: string) {\n super(\n `Unknown filter operator '${operator}' on field '${field}'. `\n + (hint ? `${hint} ` : \"\")\n + `Valid operators: ${VALID_OPERATOR_LIST}`\n );\n this.name = \"UnknownFilterOperatorError\";\n this.field = field;\n this.operator = operator;\n this.details = { field, operator, validOperators: ALL_WHERE_FILTER_OPS };\n }\n}\n\n/**\n * Two to three characters of ASCII punctuation and nothing else — the shape\n * every symbolic operator has (`==`, `>=`, `<>`, `~~`, `!!`, `>>`, `===`), and\n * one a column value effectively never has.\n *\n * Two characters minimum on purpose. A *single* punctuation character is a\n * perfectly ordinary value — `{ grade: [\"-\", \"+\"] }` is a two-item list, not a\n * condition — and the only single-character operator anyone actually mistypes\n * is `=`, which is named separately below. `<` and `>` need no special case:\n * they are real operators and resolve.\n */\nconst SYMBOLIC_OPERATOR = /^[^\\p{L}\\p{N}\\s]{2,3}$/u;\n\n/** Lowercase, strip everything that is not a letter or digit. */\nfunction normalizeOperatorName(op: string): string {\n return op.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n}\n\n/**\n * Every real operator name with its case and separators removed, so a\n * respelling of one — `arrayContains`, `not_in`, `NOT-LIKE`, `isNull` — is\n * recognised as an attempt at an operator rather than read as a value.\n *\n * These are rejected rather than accepted: admitting a second spelling of an\n * operator would leave two wire spellings of one thing, and the rejection\n * message names the one that works.\n */\nconst RESPELLED_OPERATORS: ReadonlySet<string> = new Set(\n [...ALL_WHERE_FILTER_OPS, ...Object.keys(REST_TO_CANONICAL)].map(normalizeOperatorName)\n);\n\n/**\n * Operator names *other* query dialects use, which this one does not have.\n *\n * This list is curated, and deliberately so. For a word-shaped string there is\n * no rule that separates \"an operator the caller guessed\" from \"a value that\n * happens to be a word\": `{ tags: [\"a\", \"b\"] }` has to keep meaning a two-item\n * `in` list, so the codec cannot simply refuse every unrecognised word in\n * position 0. The line is therefore drawn by name, and only around names whose\n * use as an operator is far more likely than their use as one of two sibling\n * values. `contains` is the motivating case — the first thing a developer\n * reaches for, and until now it compiled to `title IN ('contains', 'Hell')`.\n *\n * Genuinely ambiguous single words (`any`, `all`, `exists`, `search`, `not`)\n * are left off: as operators they are rare, and as enum values they are common.\n * Everywhere else the tie goes to *rejecting*, because a 400 naming the\n * supported set costs the caller one round trip, and the alternative — which is\n * what every name on this list used to produce — is a query that runs, returns\n * rows, and is wrong.\n */\nconst NEAR_MISS_OPERATORS: ReadonlySet<string> = new Set([\n \"contains\", \"notcontains\", \"doesnotcontain\", \"doesnotcontains\",\n \"includes\", \"notincludes\",\n \"startswith\", \"notstartswith\", \"beginswith\", \"startingwith\",\n \"endswith\", \"notendswith\",\n \"matches\", \"notmatches\", \"regex\", \"regexp\",\n \"between\", \"notbetween\",\n \"equals\", \"notequals\", \"equalto\", \"isequalto\", \"isnotequalto\",\n \"greaterthan\", \"greaterthanorequal\", \"greaterthanorequalto\",\n \"lessthan\", \"lessthanorequal\", \"lessthanorequalto\",\n \"isempty\", \"isnotempty\",\n \"oneof\", \"noneof\", \"anyof\", \"allof\",\n \"null\", \"isnullorempty\"\n]);\n\n/**\n * Was this string *meant* as an operator?\n *\n * Only consulted after {@link toCanonicalOp} has already failed to resolve it,\n * so a `true` here is always a rejection.\n */\nfunction isOperatorShaped(op: string): boolean {\n if (op === \"=\") return true;\n if (SYMBOLIC_OPERATOR.test(op)) return true;\n const normalized = normalizeOperatorName(op);\n if (!normalized) return false;\n return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);\n}\n\n/** The negated twin of each REST operator that has one. */\nconst NEGATED_REST_OP: Readonly<Partial<Record<string, RestFilterOp>>> = {\n eq: \"neq\", neq: \"eq\",\n in: \"nin\", nin: \"in\",\n like: \"nlike\", nlike: \"like\",\n ilike: \"nilike\", nilike: \"ilike\",\n isnull: \"notnull\", notnull: \"isnull\"\n};\n\n/**\n * A wire condition written in PostgREST's spelling, where this dialect has a\n * different one: the rejection to raise, or `undefined` when it is not one.\n *\n * Three forms, and all used to be read as an equality on the whole string:\n *\n * - **The null test.** PostgREST writes `is.null` and `not.is.null`; this\n * dialect writes `isnull.null` and `notnull.null`. `is` names no operator\n * here, so `?difficulty=is.null` became `difficulty = 'is.null'` — \"not a\n * valid smallint\" on a number column, and no rows at all on a text one. The\n * respellings `is-null.null`, `isNull.null`, `not-null.null` failed the\n * same way.\n * - **The `not.` prefix.** `?status=not.eq.draft` became\n * `status = 'not.eq.draft'`: no rows, with nothing to say why. Negation here\n * is `neq`/`nin`/`nlike`/`nilike`/`notnull`, or a `not=(…)` group.\n * - **A canonical name in a query parameter.** `?age=>=.18`: the tuple and\n * group forms read `>=`, a parameter reads only `gte`.\n *\n * Narrow on purpose: the operand of a null test must be one it has\n * ({@link NULL_OPERANDS}), and the segment after `not.` must be a real\n * operator. So `is.gd` and `not.a.value` stay values.\n */\nfunction foreignWireSpelling(\n field: string,\n operand: string,\n position: \"param\" | \"group\"\n): UnknownFilterOperatorError | undefined {\n const segments = operand.split(\".\");\n // How the corrected condition is written where this one was.\n const write = (condition: string) => position === \"param\" ? `${field}=${condition}` : `${field}.${condition}`;\n const head = segments[0].toLowerCase();\n\n const nullTest = (negated: boolean, operator: string, valueAt: number) => {\n if (segments.length !== valueAt + 1 || !NULL_OPERANDS.has(segments[valueAt].toLowerCase())) return undefined;\n const spelling = negated ? \"notnull.null\" : \"isnull.null\";\n return new UnknownFilterOperatorError(\n field,\n operator,\n `Write \\`${write(spelling)}\\` to match rows where it is ${negated ? \"set\" : \"empty\"} `\n + `(\\`[\"${negated ? \"is-not-null\" : \"is-null\"}\", null]\\` as a JSON tuple).`\n );\n };\n\n // A group leaf reads either spelling of a real operator; a query parameter\n // reads only the REST one, so `?age=>=.18` and `?d=is-null.null` were\n // equalities on the whole string.\n const canonical = toCanonicalOp(segments[0]);\n if (canonical) {\n if (position === \"group\" || REST_OP_LOOKUP.has(segments[0])) return undefined;\n if (NULL_OPS.has(canonical)) return nullTest(canonical === \"is-not-null\", segments[0], 1);\n const restOp = CANONICAL_TO_REST[canonical];\n return new UnknownFilterOperatorError(\n field,\n segments[0],\n `A query parameter spells it \\`${restOp}\\`: write \\`${write(`${restOp}.${segments.slice(1).join(\".\")}`)}\\`.`\n );\n }\n\n if (head === \"is\") {\n if (segments[1]?.toLowerCase() === \"not\") return nullTest(true, \"is.not\", 2);\n return nullTest(false, \"is\", 1);\n }\n\n if (head === \"not\" && segments.length >= 3) {\n const next = segments[1].toLowerCase();\n if (next === \"is\") return nullTest(true, \"not.is\", 2);\n if (!REST_OP_LOOKUP.has(next)) return undefined;\n const value = segments.slice(2).join(\".\");\n const twin = NEGATED_REST_OP[next];\n return new UnknownFilterOperatorError(\n field,\n `not.${next}`,\n twin\n ? `There is no \\`not.\\` prefix: write \\`${write(`${twin}.${value}`)}\\`.`\n : `There is no \\`not.\\` prefix: negate with a group, \\`not=(${field}.${next}.${value})\\`.`\n );\n }\n\n // `is-null.null`, `isNull.null`, `not_null.true`: a respelled null test.\n const normalized = normalizeOperatorName(head);\n if (normalized === \"isnull\") return nullTest(false, segments[0], 1);\n if (normalized === \"notnull\" || normalized === \"isnotnull\") return nullTest(true, segments[0], 1);\n\n return undefined;\n}\n\n/**\n * Read a `[op, value]` tuple, if that is what this is.\n *\n * Three outcomes, and the middle one is the defect this function exists for:\n *\n * - the operator resolves (canonical *or* REST spelling) → the canonical tuple;\n * - the operator does not resolve but was plainly meant as one → throw;\n * - it does not look like an operator at all → `undefined`, and the caller\n * falls back to reading the array as a list of values.\n *\n * The old test was `toCanonicalOp(raw[0]) === raw[0]`, i.e. canonical spelling\n * only, with *everything else* — including every REST short-code — dropping\n * through to `[\"in\", raw]`. So the operator string itself became a value in a\n * membership test: `[\"!!\", \"Hello\"]` compiled to `title IN ('!!','Hello')`,\n * which matches, and the caller got back rows their filter was written to\n * exclude. `[\"eq\", \"active\"]` had the same shape of failure.\n */\nfunction readTuple(field: string, raw: unknown): [WhereFilterOp, unknown] | undefined {\n if (!Array.isArray(raw) || raw.length !== 2) return undefined;\n const [op, value] = raw;\n if (typeof op !== \"string\") return undefined;\n\n const canonical = toCanonicalOp(op);\n if (canonical) return [canonical, value];\n\n // A dot means this is a *wire* string, not an operator token: two repeated\n // query params arrive as `[\"gte.18\", \"lt.65\"]`, which is a two-element array\n // of strings and therefore tuple-shaped. Deferred with exactly the test the\n // repeated-dot-string branch below uses, so the two cannot disagree.\n //\n // The property test found this: `[\"ilike\", \"\"]` serializes to `\"ilike.\"`,\n // whose normalized form is a real operator name, so a well-formed\n // round-trip was being rejected as a bad operator.\n if (op.includes(\".\")) return undefined;\n\n if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);\n\n return undefined;\n}\n\n// ---------------------------------------------------------------------------\n// Reserved query-parameter names\n// ---------------------------------------------------------------------------\n\n/**\n * The query-parameter names a REST listing reads as something other than a\n * field filter: pagination and whether to count, sorting, includes,\n * projection, search, the logical groups, the JSON `where` dialect, an\n * aggregate's `select`/`groupBy`, and the soft-delete switches.\n *\n * One list for both ends. The server skips these names when it collects\n * `?<field>=<op>.<value>` filters, and the SDK sends a filter on a column with\n * one of these names inside `?where=` instead — a collection is free to have a\n * `page`, `select` or `hard` column, and a filter on it sent as its own\n * parameter was read as the parameter and never applied.\n */\nexport const RESERVED_QUERY_KEYS: ReadonlySet<string> = new Set([\n \"limit\", \"offset\", \"page\", \"after\", \"orderBy\", \"include\", \"fields\", \"distinct\", \"count\",\n \"searchString\", \"searchExplain\", \"vector_search\", \"vector\", \"vector_distance\", \"vector_threshold\",\n \"or\", \"and\", \"not\", \"where\", \"select\", \"groupBy\", \"deleted\", \"hard\"\n]);\n\n// ---------------------------------------------------------------------------\n// Serialize: FilterValues → REST querystring\n// ---------------------------------------------------------------------------\n\n/**\n * Encode the `<op>.<value>` half of a wire condition.\n *\n * This is the single leaf encoder. Both wire positions that carry a condition\n * — a top-level query parameter (`?status=eq.active`) and a leaf inside an\n * `and(...)`/`or(...)` group (`or(status.eq.active,…)`) — go through it, so a\n * rule expressed here holds in both. The group serializer used to carry its\n * own copy, and the copy had drifted on every rule that matters: `null` went\n * out as the four-character string, the empty list as `()`, and an operator\n * this dialect does not have was silently rewritten to `eq` — a filter that\n * ran, returned rows, and answered a different question than the one asked.\n *\n * `escapeScalar` is the one thing the two positions legitimately disagree\n * about. A scalar in a query parameter owns the whole value and needs no\n * escaping; a scalar inside a group sits between the same commas a list item\n * does, so a comma in it would end the condition early.\n */\nfunction serializeOperatorAndValue(\n op: WhereFilterOp,\n value: unknown,\n { escapeScalar, where }: { escapeScalar: boolean; where: string }\n): string {\n if (typeof op !== \"string\") {\n throw new TypeError(\n `${where}: operator must be a string, got ${typeof op}`\n );\n }\n\n // Canonical spellings only, on purpose: this codec parses liberally and\n // emits strictly. `deserializeFilter` accepts a REST short-code because one\n // arrives off the wire; a *caller* handing one to the serializer has a\n // condition object built by hand, and the spelling it wants is the one the\n // types name.\n //\n // The throw is the fix. `serializeLogicalCondition` used to end this lookup\n // with `?? \"eq\"`, so `{ operator: \"gte\" }` — the spelling the wire uses, and\n // therefore the one most often guessed — was sent as `age.eq.18`: a query\n // that ran, returned rows, and answered a different question.\n const restOp = CANONICAL_OP_LOOKUP.get(op);\n if (!restOp) {\n throw new TypeError(\n `${where}: unknown operator \"${op}\". Valid operators: ${Object.keys(CANONICAL_TO_REST).join(\", \")}`\n );\n }\n\n // `== null` and `!= null` go out as the null-testing operators.\n //\n // They used to serialize as `eq.null`, and `deserializeTuple` had no way to\n // tell that from a search for the four-character string \"null\" — so it\n // returned the string, and `.where(\"deleted_at\", \"==\", null)` compiled to\n // `deleted_at = 'null'` over HTTP. The typed builder allows it, the Postgres\n // compiler implements it as IS NULL, and only the wire trip broke it.\n //\n // These are the same query: SQL `= NULL` is never true, so `== null` can\n // only mean IS NULL. Emitting it as such is unambiguous in both directions\n // and leaves `eq.null` free to mean the literal string, which it now does.\n if (value === null && (op === \"==\" || op === \"!=\")) {\n return op === \"==\" ? \"isnull.null\" : \"notnull.null\";\n }\n\n // A null test has no operand. Whatever was parked in `value` is dropped\n // here rather than on the way back, so the encoding is stable: both\n // deserializers normalize `isnull.<anything>` to `null`, and re-encoding\n // that must land on the same string it came from.\n if (NULL_OPS.has(op)) return `${restOp}.null`;\n\n if (Array.isArray(value)) {\n // The empty list needs a spelling of its own.\n //\n // A comma-joined format has no way to write \"zero items\": `()` is the\n // empty string between the parens, which splits to `[\"\"]`. So\n // `.where(\"id\", \"in\", [])` — which matches nothing — used to arrive as\n // a search for the empty string: a 500 on a uuid column, silently the\n // wrong rows on a text one.\n //\n // `EMPTY_LIST_TOKEN` is a single unescaped backslash, which no real\n // value can produce: `escapeWireValue` doubles every backslash, so a\n // one-item list holding `\\` serializes as `(\\\\)`. That keeps both\n // directions exact — `[]` and `[\"\"]` stay distinct — rather than\n // trading one lossy reading for another.\n if (value.length === 0) return `${restOp}.(${EMPTY_LIST_TOKEN})`;\n const items = value.map(v => escapeWireValue(stringifyValue(v))).join(\",\");\n return `${restOp}.(${items})`;\n }\n\n const scalar = stringifyValue(value);\n // A list operator's operand is a list, so a scalar there has no spelling\n // to protect; see `scalarNeedsEscape` for the others.\n const escape = escapeScalar || (!LIST_OPS.has(op) && scalarNeedsEscape(scalar));\n return `${restOp}.${escape ? escapeWireValue(scalar) : scalar}`;\n}\n\n/**\n * Serialize a single canonical condition tuple to a PostgREST dot-string.\n *\n * Throws `TypeError` if the input is not a valid `[WhereFilterOp, unknown]` tuple.\n *\n * @example\n * serializeTuple([\"==\", \"active\"]) // \"eq.active\"\n * serializeTuple([\"in\", [\"admin\",\"editor\"]]) // \"in.(admin,editor)\"\n * serializeTuple([\">=\", 18]) // \"gte.18\"\n */\nfunction serializeTuple(tuple: [WhereFilterOp, unknown]): string {\n if (!Array.isArray(tuple) || tuple.length !== 2) {\n throw new TypeError(\n `serializeTuple: expected a [WhereFilterOp, value] tuple, got ${JSON.stringify(tuple)}`\n );\n }\n\n const [op, value] = tuple;\n return serializeOperatorAndValue(op, value, {\n escapeScalar: false,\n where: \"serializeTuple\"\n });\n}\n\n/**\n * Convert `FilterValues` (or `WireFilterValues`) to a PostgREST-style\n * querystring record.\n *\n * - Canonical `[WhereFilterOp, value]` tuples are serialized strictly.\n * - Pre-serialized PostgREST strings (e.g. `\"eq.published\"`) are passed through.\n * - Single conditions produce a string value.\n * - Multiple conditions on the same field produce a string array (repeated params).\n *\n * @example\n * serializeFilter({ status: [\"==\", \"active\"] })\n * // → { status: \"eq.active\" }\n *\n * serializeFilter({ age: [[\">=\", 18], [\"<\", 65]] })\n * // → { age: [\"gte.18\", \"lt.65\"] }\n *\n * // Pre-serialized strings pass through unchanged:\n * serializeFilter({ status: \"eq.published\" })\n * // → { status: \"eq.published\" }\n */\nexport function serializeFilter(\n filter: FilterValues<string> | Record<string, unknown>\n): Record<string, string | string[]> {\n const result: Record<string, string | string[]> = {};\n\n for (const [field, condition] of Object.entries(filter)) {\n if (condition === undefined) continue;\n\n // Pre-serialized PostgREST string — pass through unchanged.\n // This supports WireFilterValues where values may already be\n // serialized dot-strings like \"eq.active\" or raw strings like \"true\".\n if (typeof condition === \"string\") {\n result[field] = condition;\n continue;\n }\n\n // A bare value is an equality, as `deserializeFilter` reads one on the\n // server: `{ difficulty: null }` is IS NULL, `{ level: 3 }` is `eq.3`.\n // Untyped callers — JavaScript, an MCP tool's JSON — write it this way\n // first, and it used to throw \"expected a [WhereFilterOp, value] tuple\".\n if (condition === null || typeof condition === \"number\" || typeof condition === \"boolean\") {\n result[field] = serializeTuple([\"==\", condition]);\n continue;\n }\n\n // Multiple conditions on the same field: array of tuples\n // We detect this by checking if the first element is also an array.\n if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) {\n result[field] = (condition as [WhereFilterOp, unknown][]).map(serializeTuple);\n } else {\n // Single condition — must be a [WhereFilterOp, value] tuple\n result[field] = serializeTuple(condition as [WhereFilterOp, unknown]);\n }\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Deserialize: REST querystring → FilterValues\n// ---------------------------------------------------------------------------\n\n/**\n * The spellings a null-testing operator's operand may take.\n *\n * The serializer writes `isnull.null`; a hand-written `isnull.true` means the\n * same thing and has always been accepted. Anything else after the operator is\n * not an operand it has — `notnull.reason` is a *value* — see\n * {@link deserializeSingle}.\n */\nconst NULL_OPERANDS: ReadonlySet<string> = new Set([\"null\", \"true\", \"false\", \"\"]);\n\n/**\n * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.\n *\n * All values are returned as strings — the wire format carries no type\n * metadata, so coercion is the data driver's responsibility.\n *\n * ## When a leading segment is an operator, and when it is part of the value\n *\n * `?status=in.progress` and `?status=in.(a,b)` differ by one character and mean\n * entirely different things, and the reading here decides which. The rule, in\n * full:\n *\n * > A dot-string is read as `operator.operand` **only** when its first segment\n * > names a known REST operator **and** what follows is a well-formed operand\n * > *for that operator's arity*. Otherwise the whole string is the value.\n *\n * Arity, per operator family:\n *\n * - **List** operators (`in`, `nin`, `csa` — `LIST_OPS`) take a parenthesised\n * list and nothing else. `in.(draft,review)` is the operator; `in.progress`\n * is the *value* `\"in.progress\"`, because there is no list there and so no\n * `in` filter that could have been written. That case used to compile to\n * `status IN ('progress')` — a filter the caller never wrote, quietly\n * matching the wrong rows and, on a status field, hiding every row they were\n * looking for.\n * - **Null** operators (`isnull`, `notnull` — {@link NULL_OPS}) take no\n * operand: only {@link NULL_OPERANDS}. `notnull.reason` is the value\n * `\"notnull.reason\"`, not \"reason is not null\".\n * - **Everything else** takes one scalar, and any remainder is one — including\n * the empty string, so `eq.` really is \"equals the empty string\". A\n * parenthesised remainder is a list for these too (`eq.(a,b)` is array\n * equality); a scalar that is itself parenthesised arrives escaped,\n * `eq.\\(none\\)`, and is decoded here — see {@link scalarNeedsEscape}.\n *\n * ### The one ambiguity that remains, and how to write past it\n *\n * A scalar operator's operand is unconstrained, so `?status=like.that` is a\n * `LIKE 'that'` and no rule at this layer can tell it from the literal value\n * `\"like.that\"` — both are well-formed encodings, and picking either by guess\n * would break the other. Two spellings say \"value\" unambiguously, and both\n * round-trip:\n *\n * - `?status=eq.like.that` — name the operator. The *first* segment is consumed\n * as the operator and everything after it is the value, dots and all. This is\n * what `serializeFilter` emits, which is why the SDK never meets the\n * ambiguity at all.\n * - `?where={\"status\":[\"==\",\"like.that\"]}` — the JSON dialect's tuple form.\n *\n * Values that merely *contain* dots (`user@host.com`, `1.2.3`) were never\n * ambiguous: their first segment names no operator to begin with.\n */\nfunction deserializeSingle(field: string, raw: string): [WhereFilterOp, unknown] {\n const dotIndex = raw.indexOf(\".\");\n if (dotIndex === -1) {\n // No dot → equality on the raw value (kept as string)\n return [\"==\", raw];\n }\n\n const prefix = raw.substring(0, dotIndex);\n const rest = raw.substring(dotIndex + 1);\n\n // Check if the prefix is a known REST operator.\n // This is the key defense against values like \"eq.something\" or \"gt.foo\"\n // being misinterpreted — only known REST short-codes are treated as operators.\n const canonicalOp = REST_OP_LOOKUP.get(prefix);\n if (!canonicalOp) {\n const foreign = foreignWireSpelling(field, raw, \"param\");\n if (foreign) throw foreign;\n // Not a known operator (e.g., email \"user@host.com\" or version \"1.2.3\")\n // Treat the entire string as an equality value\n return [\"==\", raw];\n }\n\n // Null-testing operators ignore their serialized value — normalize to null\n // so the tuple round-trips stably (`isnull.null` → [\"is-null\", null]).\n if (NULL_OPS.has(canonicalOp)) {\n // ...but only when what follows is an operand this operator has. See\n // the docblock: `notnull.reason` names no null test, so it is a value.\n if (!NULL_OPERANDS.has(rest)) return [\"==\", raw];\n return [canonicalOp, null];\n }\n\n // Parse list values: \"(admin,editor)\" → [\"admin\", \"editor\"]\n if (rest.startsWith(\"(\") && rest.endsWith(\")\")) {\n const inner = rest.slice(1, -1);\n // See EMPTY_LIST_TOKEN: `(\\)` is the empty list. `()` remains a list\n // holding one empty string, which is what splitting it yields anyway.\n const items = inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner);\n return [canonicalOp, items];\n }\n\n // A list operator with no list is not that operator — see the docblock.\n // `?status=in.progress` is the value \"in.progress\"; the `in` filter it used\n // to compile to was never written by anyone.\n if (LIST_OPS.has(canonicalOp)) return [\"==\", raw];\n\n // A scalar the serializer had to escape, because raw it would have read\n // as a list; see `scalarNeedsEscape`.\n return [canonicalOp, decodeEscapedScalar(rest) ?? rest];\n}\n\n/**\n * Convert a PostgREST-style querystring record to `FilterValues`.\n *\n * - String values are parsed as single conditions.\n * - String arrays (repeated query params) become multiple conditions on the same field.\n *\n * @example\n * deserializeFilter({ status: \"eq.active\" })\n * // → { status: [\"==\", \"active\"] }\n *\n * deserializeFilter({ age: [\"gte.18\", \"lt.65\"] })\n * // → { age: [[\">=\", \"18\"], [\"<\", \"65\"]] }\n *\n * @throws {UnknownFilterOperatorError} when a condition names an operator this\n * dialect does not have. See that class for why a rejection here is a throw.\n */\nexport function deserializeFilter(\n query: Record<string, unknown>\n): FilterValues<string> {\n const result: FilterValues<string> = {};\n\n for (const [field, raw] of Object.entries(query)) {\n if (raw === undefined) continue;\n\n // A single `[op, value]` condition.\n const tuple = readTuple(field, raw);\n if (tuple) {\n result[field] = tuple;\n continue;\n }\n\n if (Array.isArray(raw)) {\n if (raw.length === 0) continue;\n\n // An array of tuples: several conditions on the same field. Every\n // element is checked, not just the first — the old test read\n // `raw[0]` and cast the whole array, so one bad operator among\n // several travelled on untouched.\n if (Array.isArray(raw[0])) {\n const tuples = raw.map(item => readTuple(field, item));\n if (tuples.every((t): t is [WhereFilterOp, unknown] => t !== undefined)) {\n result[field] = tuples;\n continue;\n }\n }\n\n if (raw.length === 1) {\n result[field] = typeof raw[0] === \"string\" ? deserializeSingle(field, raw[0]) : [\"==\", raw[0]];\n } else {\n // If the elements are strings, they might be PostgREST dot-strings (repeated params)\n if (typeof raw[0] === \"string\" && raw[0].includes(\".\")) {\n result[field] = raw.map(r => typeof r === \"string\" ? deserializeSingle(field, r) : ([\"==\", r] as [WhereFilterOp, unknown])) as [WhereFilterOp, unknown][];\n } else {\n // Otherwise assume it's a list of values for an implicit\n // \"in\" — `{ tags: [\"a\",\"b\"] }`, and `?tags=a&tags=b`, which\n // arrives here identically.\n //\n // A two-element array reaches this line only after\n // `readTuple` has decided its first element was not meant\n // as an operator. Everything longer never had the\n // ambiguity: an operator tuple has exactly two slots.\n result[field] = [\"in\", raw];\n }\n }\n } else if (typeof raw === \"string\") {\n result[field] = deserializeSingle(field, raw);\n } else {\n result[field] = [\"==\", raw];\n }\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Logical conditions: serialize / deserialize\n// ---------------------------------------------------------------------------\n\n/**\n * Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.\n *\n * Leaf encoding is {@link serializeOperatorAndValue}, the same function\n * `serializeTuple` uses, so `null`, the empty list and an unknown operator\n * behave identically inside a group and in a query parameter.\n *\n * @throws {TypeError} when a leaf names an operator this dialect does not have.\n * It used to fall back to `eq`, which turned `age >= 18` into `age = 18` with\n * no diagnostic anywhere.\n *\n * @example\n * serializeLogicalCondition({ column: \"status\", operator: \"==\", value: \"active\" })\n * // → \"status.eq.active\"\n *\n * serializeLogicalCondition({ column: \"deleted_at\", operator: \"==\", value: null })\n * // → \"deleted_at.isnull.null\"\n *\n * serializeLogicalCondition({ type: \"or\", conditions: [...] })\n * // → \"or(status.eq.active,status.eq.pending)\"\n */\nexport function serializeLogicalCondition(\n cond: LogicalCondition | FilterCondition\n): string {\n if (\"type\" in cond) {\n // LogicalCondition (and/or)\n const inner = (cond.conditions ?? [])\n .map(serializeLogicalCondition)\n .join(\",\");\n return `${cond.type}(${inner})`;\n }\n\n // FilterCondition. The leaf goes through the shared encoder, so a group\n // condition and a query parameter agree on nulls, empty lists and unknown\n // operators — see `serializeOperatorAndValue`.\n //\n // The column is escaped like a value: it is not one, but it shares the\n // delimiters, and a comma or paren in it would move where the group parser\n // thinks the condition ends. Dots are deliberately *not* escaped — a\n // relation path is `author.name` on the wire, and the parser below finds\n // the operator rather than assuming it is the second segment.\n return `${escapeWireValue(cond.column)}.${serializeOperatorAndValue(cond.operator, cond.value, {\n escapeScalar: true,\n where: \"serializeLogicalCondition\"\n })}`;\n}\n\n/**\n * The positions of the dots that can separate a leaf's segments.\n *\n * Dots inside a list value (`in.(1.5,2.5)`) are not separators. The value\n * always follows the operator, so the search only needs the region before the\n * first unescaped paren.\n */\nfunction leafSeparators(str: string): number[] {\n let limit = str.length;\n for (let i = 0; i < str.length; i++) {\n if (str[i] === \"\\\\\") { i++; continue; }\n if (str[i] === \"(\") { limit = i; break; }\n }\n\n const dots: number[] = [];\n for (let i = 0; i < limit; i++) {\n if (str[i] === \"\\\\\") { i++; continue; }\n if (str[i] === \".\") dots.push(i);\n }\n return dots;\n}\n\n/**\n * Split a leaf condition into `column`, operator token and value.\n *\n * The naive reading — column is everything before the first dot, operator is\n * everything up to the second — cannot express a relation path. A filter on\n * `author.name` serializes to `author.name.eq.bob` and came back as the column\n * `author` with the operator `name`, which resolves to nothing, so the\n * fallback made it `author == \"eq.bob\"`: a condition that runs and matches\n * nothing, on a column the caller never named.\n *\n * So the operator is found rather than assumed: it is the first dot-separated\n * segment after the column that resolves to a real operator. Everything before\n * it is the column, everything after is the value. `version.eq.1.2.3` reads as\n * `version == \"1.2.3\"` because the scan stops at the first match — the `eq` at\n * offset 1, not a later segment — and `metadata->>x.eq.5` never had dots in the\n * column to begin with.\n *\n * Returns `undefined` when no segment resolves — `status.active`, an equality\n * written without an operator, which the caller handles.\n */\nfunction splitLeafCondition(str: string): { column: string; operator: WhereFilterOp; value: string } | undefined {\n const dots = leafSeparators(str);\n\n // Segment 0 is always the column, and an operator needs a value after it,\n // so a candidate is bounded on both sides by a dot.\n for (let i = 1; i < dots.length; i++) {\n const operator = toCanonicalOp(str.substring(dots[i - 1] + 1, dots[i]));\n if (!operator) continue;\n return {\n column: unescapeWireValue(str.substring(0, dots[i - 1])),\n operator,\n value: str.substring(dots[i] + 1)\n };\n }\n\n return undefined;\n}\n\n/**\n * Refuse a group leaf written in PostgREST's spelling — `d.is.null`,\n * `d.not.is.null`, `status.not.eq.draft` — see {@link foreignWireSpelling}.\n *\n * Every split point is a candidate column, so a relation path\n * (`author.name.is.null`) is caught too. The scan stops at the first segment\n * that resolves as an operator: everything after it is the value, so\n * `note.eq.not.is.null` stays an equality on the string `\"not.is.null\"`.\n *\n * Without this, `d.is.null` was `d == \"is.null\"`, and `status.not.eq.draft`\n * found its `eq` and filtered a column named `status.not`.\n */\nfunction rejectForeignLeaf(str: string): void {\n const dots = leafSeparators(str);\n for (let i = 0; i < dots.length; i++) {\n const column = unescapeWireValue(str.substring(0, dots[i]));\n const foreign = foreignWireSpelling(column, str.substring(dots[i] + 1), \"group\");\n if (foreign) throw foreign;\n if (i + 1 < dots.length && toCanonicalOp(str.substring(dots[i] + 1, dots[i + 1]))) return;\n }\n}\n\n/**\n * Parse a logical condition wire-format string back into a\n * `LogicalCondition` or `FilterCondition`.\n *\n * @example\n * deserializeLogicalCondition(\"status.eq.active\")\n * // → { column: \"status\", operator: \"==\", value: \"active\" }\n *\n * deserializeLogicalCondition(\"or(status.eq.active,age.gte.18)\")\n * // → { type: \"or\", conditions: [...] }\n */\n/**\n * How deeply `or(...)`/`and(...)` groups may nest.\n *\n * This parser recurses once per level, on a value that arrives in a query\n * string. Unbounded, twenty thousand levels reached `RangeError: Maximum call\n * stack size exceeded`, which a caller sees as a 500 about the call stack\n * rather than a 400 about their filter. Node's 16 KB header cap keeps a GET\n * below that in practice, but \"the HTTP layer happens to stop it\" is not a\n * bound this parser should rely on.\n *\n * Thirty-two is far past anything a real filter expresses; the deepest in this\n * repository's own tests is three.\n */\nexport const MAX_LOGICAL_NESTING_DEPTH = 32;\n\nexport function deserializeLogicalCondition(\n str: string,\n // Not `depth`: the body already uses that name for paren tracking, inside a\n // block that shadows a parameter of the same name — so the recursion\n // counter silently became the paren counter and never grew.\n nesting = 0\n): LogicalCondition | FilterCondition {\n if (nesting > MAX_LOGICAL_NESTING_DEPTH) {\n throw new Error(\n `Filter groups nest more than ${MAX_LOGICAL_NESTING_DEPTH} levels deep. ` +\n \"Flatten the condition — `or(a,or(b,c))` is `or(a,b,c)`.\"\n );\n }\n // Check for logical group: \"and(...)\", \"or(...)\" or \"not(...)\"\n const logicalMatch = str.match(/^(and|or|not)\\((.+)\\)$/);\n if (logicalMatch) {\n const type = logicalMatch[1] as \"and\" | \"or\" | \"not\";\n const innerStr = logicalMatch[2];\n\n const conditions = splitGroupItems(innerStr)\n .map(part => deserializeLogicalCondition(part, nesting + 1));\n\n return { type, conditions };\n }\n\n // FilterCondition: \"column.op.value\"\n rejectForeignLeaf(str);\n const leaf = splitLeafCondition(str);\n if (!leaf) {\n const firstDot = str.indexOf(\".\");\n if (firstDot === -1) {\n return { column: unescapeWireValue(str), operator: \"==\", value: true };\n }\n // \"column.value\" — no segment resolved as an operator, so this is an\n // equality written without one. The value keeps its dots.\n //\n // Unless a segment was plainly meant as an operator: `title.contains.x`\n // read as `title == \"contains.x\"` matches nothing, and inside `not(…)`\n // its negation matched every row — the widened read\n // `parseWhereParam` exists to avoid. Refused by the same test the\n // tuple reader applies, so the two spellings of one filter agree.\n const dots = leafSeparators(str);\n for (let i = 1; i < dots.length; i++) {\n const segment = str.substring(dots[i - 1] + 1, dots[i]);\n if (isOperatorShaped(segment)) {\n throw new UnknownFilterOperatorError(unescapeWireValue(str.substring(0, dots[i - 1])), segment);\n }\n }\n return {\n column: unescapeWireValue(str.substring(0, firstDot)),\n operator: \"==\",\n value: unescapeWireValue(str.substring(firstDot + 1))\n };\n }\n\n const { column, operator, value: valueStr } = leaf;\n\n // A null test has no operand: `isnull.null` is what the serializer writes,\n // but a hand-written `isnull.true` means the same thing. Normalizing here\n // is what makes the tuple stable through a re-encode, and it matches\n // `deserializeSingle`, which has done it for query parameters all along.\n if (NULL_OPS.has(operator)) {\n return { column, operator, value: null };\n }\n\n // Parse list values with escape-aware splitting. The wrapping parens are\n // written by the serializer *after* the items are escaped, so an escaped\n // paren inside an item can never be mistaken for them.\n if (valueStr.startsWith(\"(\") && valueStr.endsWith(\")\")) {\n const inner = valueStr.slice(1, -1);\n // See EMPTY_LIST_TOKEN: `(\\)` is the empty list, which is not the same\n // query as a search for the empty string.\n const items = inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner);\n return { column, operator, value: items };\n }\n\n return { column, operator, value: unescapeWireValue(valueStr) };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmDA,SAAS,eAAe,OAAwB;CAC5C,IAAI,UAAU,MAAM,OAAO;CAC3B,IAAI,iBAAiB,QAAQ,CAAC,OAAO,MAAM,MAAM,QAAQ,CAAC,GAAG,OAAO,MAAM,YAAY;CACtF,MAAM,WAAW,0BAA0B,KAAK;CAChD,IAAI,UAAU,OAAO,OAAO,SAAS,EAAE;CACvC,OAAO,OAAO,KAAK;AACvB;;;;;;;;;;AAeA,IAAM,gBAAgB;;;;;;;;;;;AAYtB,IAAM,mBAAmB;AAEzB,SAAS,gBAAgB,OAAuB;CAC5C,OAAO,MAAM,QAAQ,gBAAe,OAAM,KAAK,IAAI;AACvD;;;;;;;;;;;;;;;;AAiBA,SAAS,kBAAkB,OAAuB;CAC9C,IAAI,SAAS;CACb,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;EACnC,MAAM,OAAO,MAAM,IAAI;EACvB,IAAI,MAAM,OAAO,SAAS,SAAS,QAAQ,SAAS,OAAO,SAAS,OAAO,SAAS,MAAM;GACtF,UAAU;GACV;GACA;EACJ;EACA,UAAU,MAAM;CACpB;CACA,OAAO;AACX;;;;;;;;;;;;;;;;;;AAmBA,SAAS,kBAAkB,OAAwB;CAC/C,IAAI,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,GAAG,OAAO;CACzD,OAAO,oBAAoB,KAAK,MAAM,KAAA;AAC1C;;;;;;;;;;AAWA,SAAS,oBAAoB,SAAqC;CAC9D,IAAI,CAAC,QAAQ,WAAW,IAAI,GAAG,OAAO,KAAA;CACtC,MAAM,QAAQ,kBAAkB,OAAO;CACvC,OAAO,gBAAgB,KAAK,MAAM,WAAW,kBAAkB,KAAK,IAAI,QAAQ,KAAA;AACpF;;;;;;;;;AAUA,SAAS,eAAe,OAAyB;CAC7C,MAAM,QAAkB,CAAC;CACzB,IAAI,UAAU;CACd,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAC9B,IAAI,MAAM,OAAO,QAAQ,IAAI,IAAI,MAAM,QAAQ;EAG3C,WAAW,MAAM,KAAK,MAAM,IAAI;EAChC;CACJ,OAAO,IAAI,MAAM,OAAO,KAAK;EACzB,MAAM,KAAK,kBAAkB,OAAO,CAAC;EACrC,UAAU;CACd,OACI,WAAW,MAAM;CAGzB,MAAM,KAAK,kBAAkB,OAAO,CAAC;CACrC,OAAO;AACX;;;;;;;;;;;AAYA,SAAS,gBAAgB,OAAyB;CAC9C,MAAM,QAAkB,CAAC;CACzB,IAAI,QAAQ;CACZ,IAAI,QAAQ;CACZ,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;EACnC,MAAM,KAAK,MAAM;EACjB,IAAI,OAAO,QAAQ,IAAI,IAAI,MAAM,QAAQ;GAAE;GAAK;EAAU;EAC1D,IAAI,OAAO,KAAK;OACX,IAAI,OAAO,KAAK;OAChB,IAAI,OAAO,OAAO,UAAU,GAAG;GAChC,MAAM,KAAK,MAAM,MAAM,OAAO,CAAC,CAAC;GAChC,QAAQ,IAAI;EAChB;CACJ;CACA,MAAM,KAAK,MAAM,MAAM,KAAK,CAAC;CAC7B,OAAO;AACX;;;;;;;;;;;;;;AAmBA,IAAM,iBAAiB,IAAI,IACvB,OAAO,QAAQ,iBAAiB,CACpC;AACA,IAAM,sBAAsB,IAAI,IAC5B,OAAO,QAAQ,iBAAiB,CACpC;;AAOA,IAAM,sBAAsB,qBAAqB,KAAK,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgC1D,IAAa,6BAAb,cAAgD,MAAM;;CAElD;;CAEA;;CAEA,iBAA2D;;CAE3D,aAA6B;CAC7B,OAAuB;CACvB;;;;;;;CAQA,YAAY,OAAe,UAAkB,MAAe;EACxD,MACI,4BAA4B,SAAS,cAAc,MAAM,QACtD,OAAO,GAAG,KAAK,KAAK,MACrB,oBAAoB,qBAC1B;EACA,KAAK,OAAO;EACZ,KAAK,QAAQ;EACb,KAAK,WAAW;EAChB,KAAK,UAAU;GAAE;GAAO;GAAU,gBAAgB;EAAqB;CAC3E;AACJ;;;;;;;;;;;;AAaA,IAAM,oBAAoB;;AAG1B,SAAS,sBAAsB,IAAoB;CAC/C,OAAO,GAAG,YAAY,CAAC,CAAC,QAAQ,cAAc,EAAE;AACpD;;;;;;;;;;AAWA,IAAM,sBAA2C,IAAI,IACjD,CAAC,GAAG,sBAAsB,GAAG,OAAO,KAAK,iBAAiB,CAAC,CAAC,CAAC,IAAI,qBAAqB,CAC1F;;;;;;;;;;;;;;;;;;;;AAqBA,IAAM,sCAA2C,IAAI,IAAI;CACrD;CAAY;CAAe;CAAkB;CAC7C;CAAY;CACZ;CAAc;CAAiB;CAAc;CAC7C;CAAY;CACZ;CAAW;CAAc;CAAS;CAClC;CAAW;CACX;CAAU;CAAa;CAAW;CAAa;CAC/C;CAAe;CAAsB;CACrC;CAAY;CAAmB;CAC/B;CAAW;CACX;CAAS;CAAU;CAAS;CAC5B;CAAQ;AACZ,CAAC;;;;;;;AAQD,SAAS,iBAAiB,IAAqB;CAC3C,IAAI,OAAO,KAAK,OAAO;CACvB,IAAI,kBAAkB,KAAK,EAAE,GAAG,OAAO;CACvC,MAAM,aAAa,sBAAsB,EAAE;CAC3C,IAAI,CAAC,YAAY,OAAO;CACxB,OAAO,oBAAoB,IAAI,UAAU,KAAK,oBAAoB,IAAI,UAAU;AACpF;;AAGA,IAAM,kBAAmE;CACrE,IAAI;CAAO,KAAK;CAChB,IAAI;CAAO,KAAK;CAChB,MAAM;CAAS,OAAO;CACtB,OAAO;CAAU,QAAQ;CACzB,QAAQ;CAAW,SAAS;AAChC;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAS,oBACL,OACA,SACA,UACsC;CACtC,MAAM,WAAW,QAAQ,MAAM,GAAG;CAElC,MAAM,SAAS,cAAsB,aAAa,UAAU,GAAG,MAAM,GAAG,cAAc,GAAG,MAAM,GAAG;CAClG,MAAM,OAAO,SAAS,EAAE,CAAC,YAAY;CAErC,MAAM,YAAY,SAAkB,UAAkB,YAAoB;EACtE,IAAI,SAAS,WAAW,UAAU,KAAK,CAAC,cAAc,IAAI,SAAS,QAAQ,CAAC,YAAY,CAAC,GAAG,OAAO,KAAA;EAEnG,OAAO,IAAI,2BACP,OACA,UACA,WAAW,MAJE,UAAU,iBAAiB,aAIf,EAAE,+BAA+B,UAAU,QAAQ,QAAQ,QAC1E,UAAU,gBAAgB,UAAU,6BAClD;CACJ;CAKA,MAAM,YAAY,cAAc,SAAS,EAAE;CAC3C,IAAI,WAAW;EACX,IAAI,aAAa,WAAW,eAAe,IAAI,SAAS,EAAE,GAAG,OAAO,KAAA;EACpE,IAAI,SAAS,IAAI,SAAS,GAAG,OAAO,SAAS,cAAc,eAAe,SAAS,IAAI,CAAC;EACxF,MAAM,SAAS,kBAAkB;EACjC,OAAO,IAAI,2BACP,OACA,SAAS,IACT,iCAAiC,OAAO,cAAc,MAAM,GAAG,OAAO,GAAG,SAAS,MAAM,CAAC,CAAC,CAAC,KAAK,GAAG,GAAG,EAAE,IAC5G;CACJ;CAEA,IAAI,SAAS,MAAM;EACf,IAAI,SAAS,EAAE,EAAE,YAAY,MAAM,OAAO,OAAO,SAAS,MAAM,UAAU,CAAC;EAC3E,OAAO,SAAS,OAAO,MAAM,CAAC;CAClC;CAEA,IAAI,SAAS,SAAS,SAAS,UAAU,GAAG;EACxC,MAAM,OAAO,SAAS,EAAE,CAAC,YAAY;EACrC,IAAI,SAAS,MAAM,OAAO,SAAS,MAAM,UAAU,CAAC;EACpD,IAAI,CAAC,eAAe,IAAI,IAAI,GAAG,OAAO,KAAA;EACtC,MAAM,QAAQ,SAAS,MAAM,CAAC,CAAC,CAAC,KAAK,GAAG;EACxC,MAAM,OAAO,gBAAgB;EAC7B,OAAO,IAAI,2BACP,OACA,OAAO,QACP,OACM,wCAAwC,MAAM,GAAG,KAAK,GAAG,OAAO,EAAE,OAClE,4DAA4D,MAAM,GAAG,KAAK,GAAG,MAAM,KAC7F;CACJ;CAGA,MAAM,aAAa,sBAAsB,IAAI;CAC7C,IAAI,eAAe,UAAU,OAAO,SAAS,OAAO,SAAS,IAAI,CAAC;CAClE,IAAI,eAAe,aAAa,eAAe,aAAa,OAAO,SAAS,MAAM,SAAS,IAAI,CAAC;AAGpG;;;;;;;;;;;;;;;;;;AAmBA,SAAS,UAAU,OAAe,KAAoD;CAClF,IAAI,CAAC,MAAM,QAAQ,GAAG,KAAK,IAAI,WAAW,GAAG,OAAO,KAAA;CACpD,MAAM,CAAC,IAAI,SAAS;CACpB,IAAI,OAAO,OAAO,UAAU,OAAO,KAAA;CAEnC,MAAM,YAAY,cAAc,EAAE;CAClC,IAAI,WAAW,OAAO,CAAC,WAAW,KAAK;CAUvC,IAAI,GAAG,SAAS,GAAG,GAAG,OAAO,KAAA;CAE7B,IAAI,iBAAiB,EAAE,GAAG,MAAM,IAAI,2BAA2B,OAAO,EAAE;AAG5E;;;;;;;;;;;;;AAkBA,IAAa,sCAA2C,IAAI,IAAI;CAC5D;CAAS;CAAU;CAAQ;CAAS;CAAW;CAAW;CAAU;CAAY;CAChF;CAAgB;CAAiB;CAAiB;CAAU;CAAmB;CAC/E;CAAM;CAAO;CAAO;CAAS;CAAU;CAAW;CAAW;AACjE,CAAC;;;;;;;;;;;;;;;;;;AAuBD,SAAS,0BACL,IACA,OACA,EAAE,cAAc,SACV;CACN,IAAI,OAAO,OAAO,UACd,MAAM,IAAI,UACN,GAAG,MAAM,mCAAmC,OAAO,IACvD;CAaJ,MAAM,SAAS,oBAAoB,IAAI,EAAE;CACzC,IAAI,CAAC,QACD,MAAM,IAAI,UACN,GAAG,MAAM,sBAAsB,GAAG,sBAAsB,OAAO,KAAK,iBAAiB,CAAC,CAAC,KAAK,IAAI,GACpG;CAcJ,IAAI,UAAU,SAAS,OAAO,QAAQ,OAAO,OACzC,OAAO,OAAO,OAAO,gBAAgB;CAOzC,IAAI,SAAS,IAAI,EAAE,GAAG,OAAO,GAAG,OAAO;CAEvC,IAAI,MAAM,QAAQ,KAAK,GAAG;EActB,IAAI,MAAM,WAAW,GAAG,OAAO,GAAG,OAAO,IAAI,iBAAiB;EAE9D,OAAO,GAAG,OAAO,IADH,MAAM,KAAI,MAAK,gBAAgB,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,GACjD,EAAM;CAC/B;CAEA,MAAM,SAAS,eAAe,KAAK;CAInC,OAAO,GAAG,OAAO,GADF,gBAAiB,CAAC,SAAS,IAAI,EAAE,KAAK,kBAAkB,MAAM,IAChD,gBAAgB,MAAM,IAAI;AAC3D;;;;;;;;;;;AAYA,SAAS,eAAe,OAAyC;CAC7D,IAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,GAC1C,MAAM,IAAI,UACN,gEAAgE,KAAK,UAAU,KAAK,GACxF;CAGJ,MAAM,CAAC,IAAI,SAAS;CACpB,OAAO,0BAA0B,IAAI,OAAO;EACxC,cAAc;EACd,OAAO;CACX,CAAC;AACL;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,gBACZ,QACiC;CACjC,MAAM,SAA4C,CAAC;CAEnD,KAAK,MAAM,CAAC,OAAO,cAAc,OAAO,QAAQ,MAAM,GAAG;EACrD,IAAI,cAAc,KAAA,GAAW;EAK7B,IAAI,OAAO,cAAc,UAAU;GAC/B,OAAO,SAAS;GAChB;EACJ;EAMA,IAAI,cAAc,QAAQ,OAAO,cAAc,YAAY,OAAO,cAAc,WAAW;GACvF,OAAO,SAAS,eAAe,CAAC,MAAM,SAAS,CAAC;GAChD;EACJ;EAIA,IAAI,MAAM,QAAQ,SAAS,KAAK,UAAU,SAAS,KAAK,MAAM,QAAQ,UAAU,EAAE,GAC9E,OAAO,SAAU,UAAyC,IAAI,cAAc;OAG5E,OAAO,SAAS,eAAe,SAAqC;CAE5E;CAEA,OAAO;AACX;;;;;;;;;AAcA,IAAM,gCAAqC,IAAI,IAAI;CAAC;CAAQ;CAAQ;CAAS;AAAE,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqDhF,SAAS,kBAAkB,OAAe,KAAuC;CAC7E,MAAM,WAAW,IAAI,QAAQ,GAAG;CAChC,IAAI,aAAa,IAEb,OAAO,CAAC,MAAM,GAAG;CAGrB,MAAM,SAAS,IAAI,UAAU,GAAG,QAAQ;CACxC,MAAM,OAAO,IAAI,UAAU,WAAW,CAAC;CAKvC,MAAM,cAAc,eAAe,IAAI,MAAM;CAC7C,IAAI,CAAC,aAAa;EACd,MAAM,UAAU,oBAAoB,OAAO,KAAK,OAAO;EACvD,IAAI,SAAS,MAAM;EAGnB,OAAO,CAAC,MAAM,GAAG;CACrB;CAIA,IAAI,SAAS,IAAI,WAAW,GAAG;EAG3B,IAAI,CAAC,cAAc,IAAI,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG;EAC/C,OAAO,CAAC,aAAa,IAAI;CAC7B;CAGA,IAAI,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,GAAG;EAC5C,MAAM,QAAQ,KAAK,MAAM,GAAG,EAAE;EAI9B,OAAO,CAAC,aADM,UAAU,mBAAmB,CAAC,IAAI,eAAe,KAAK,CAC1C;CAC9B;CAKA,IAAI,SAAS,IAAI,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG;CAIhD,OAAO,CAAC,aAAa,oBAAoB,IAAI,KAAK,IAAI;AAC1D;;;;;;;;;;;;;;;;;AAkBA,SAAgB,kBACZ,OACoB;CACpB,MAAM,SAA+B,CAAC;CAEtC,KAAK,MAAM,CAAC,OAAO,QAAQ,OAAO,QAAQ,KAAK,GAAG;EAC9C,IAAI,QAAQ,KAAA,GAAW;EAGvB,MAAM,QAAQ,UAAU,OAAO,GAAG;EAClC,IAAI,OAAO;GACP,OAAO,SAAS;GAChB;EACJ;EAEA,IAAI,MAAM,QAAQ,GAAG,GAAG;GACpB,IAAI,IAAI,WAAW,GAAG;GAMtB,IAAI,MAAM,QAAQ,IAAI,EAAE,GAAG;IACvB,MAAM,SAAS,IAAI,KAAI,SAAQ,UAAU,OAAO,IAAI,CAAC;IACrD,IAAI,OAAO,OAAO,MAAqC,MAAM,KAAA,CAAS,GAAG;KACrE,OAAO,SAAS;KAChB;IACJ;GACJ;GAEA,IAAI,IAAI,WAAW,GACf,OAAO,SAAS,OAAO,IAAI,OAAO,WAAW,kBAAkB,OAAO,IAAI,EAAE,IAAI,CAAC,MAAM,IAAI,EAAE;QAG7F,IAAI,OAAO,IAAI,OAAO,YAAY,IAAI,EAAE,CAAC,SAAS,GAAG,GACjD,OAAO,SAAS,IAAI,KAAI,MAAK,OAAO,MAAM,WAAW,kBAAkB,OAAO,CAAC,IAAK,CAAC,MAAM,CAAC,CAA8B;QAU1H,OAAO,SAAS,CAAC,MAAM,GAAG;EAGtC,OAAO,IAAI,OAAO,QAAQ,UACtB,OAAO,SAAS,kBAAkB,OAAO,GAAG;OAE5C,OAAO,SAAS,CAAC,MAAM,GAAG;CAElC;CAEA,OAAO;AACX;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,0BACZ,MACM;CACN,IAAI,UAAU,MAAM;EAEhB,MAAM,SAAS,KAAK,cAAc,CAAC,EAAA,CAC9B,IAAI,yBAAyB,CAAC,CAC9B,KAAK,GAAG;EACb,OAAO,GAAG,KAAK,KAAK,GAAG,MAAM;CACjC;CAWA,OAAO,GAAG,gBAAgB,KAAK,MAAM,EAAE,GAAG,0BAA0B,KAAK,UAAU,KAAK,OAAO;EAC3F,cAAc;EACd,OAAO;CACX,CAAC;AACL;;;;;;;;AASA,SAAS,eAAe,KAAuB;CAC3C,IAAI,QAAQ,IAAI;CAChB,KAAK,IAAI,IAAI,GAAG,IAAI,IAAI,QAAQ,KAAK;EACjC,IAAI,IAAI,OAAO,MAAM;GAAE;GAAK;EAAU;EACtC,IAAI,IAAI,OAAO,KAAK;GAAE,QAAQ;GAAG;EAAO;CAC5C;CAEA,MAAM,OAAiB,CAAC;CACxB,KAAK,IAAI,IAAI,GAAG,IAAI,OAAO,KAAK;EAC5B,IAAI,IAAI,OAAO,MAAM;GAAE;GAAK;EAAU;EACtC,IAAI,IAAI,OAAO,KAAK,KAAK,KAAK,CAAC;CACnC;CACA,OAAO;AACX;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAS,mBAAmB,KAAqF;CAC7G,MAAM,OAAO,eAAe,GAAG;CAI/B,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;EAClC,MAAM,WAAW,cAAc,IAAI,UAAU,KAAK,IAAI,KAAK,GAAG,KAAK,EAAE,CAAC;EACtE,IAAI,CAAC,UAAU;EACf,OAAO;GACH,QAAQ,kBAAkB,IAAI,UAAU,GAAG,KAAK,IAAI,EAAE,CAAC;GACvD;GACA,OAAO,IAAI,UAAU,KAAK,KAAK,CAAC;EACpC;CACJ;AAGJ;;;;;;;;;;;;;AAcA,SAAS,kBAAkB,KAAmB;CAC1C,MAAM,OAAO,eAAe,GAAG;CAC/B,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;EAElC,MAAM,UAAU,oBADD,kBAAkB,IAAI,UAAU,GAAG,KAAK,EAAE,CACrB,GAAQ,IAAI,UAAU,KAAK,KAAK,CAAC,GAAG,OAAO;EAC/E,IAAI,SAAS,MAAM;EACnB,IAAI,IAAI,IAAI,KAAK,UAAU,cAAc,IAAI,UAAU,KAAK,KAAK,GAAG,KAAK,IAAI,EAAE,CAAC,GAAG;CACvF;AACJ;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,IAAa,4BAA4B;AAEzC,SAAgB,4BACZ,KAIA,UAAU,GACwB;CAClC,IAAI,UAAU,2BACV,MAAM,IAAI,MACN,gCAAgC,0BAA0B,0EAE9D;CAGJ,MAAM,eAAe,IAAI,MAAM,wBAAwB;CACvD,IAAI,cAAc;EACd,MAAM,OAAO,aAAa;EAC1B,MAAM,WAAW,aAAa;EAK9B,OAAO;GAAE;GAAM,YAHI,gBAAgB,QAAQ,CAAC,CACvC,KAAI,SAAQ,4BAA4B,MAAM,UAAU,CAAC,CAE/C;EAAW;CAC9B;CAGA,kBAAkB,GAAG;CACrB,MAAM,OAAO,mBAAmB,GAAG;CACnC,IAAI,CAAC,MAAM;EACP,MAAM,WAAW,IAAI,QAAQ,GAAG;EAChC,IAAI,aAAa,IACb,OAAO;GAAE,QAAQ,kBAAkB,GAAG;GAAG,UAAU;GAAM,OAAO;EAAK;EAUzE,MAAM,OAAO,eAAe,GAAG;EAC/B,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;GAClC,MAAM,UAAU,IAAI,UAAU,KAAK,IAAI,KAAK,GAAG,KAAK,EAAE;GACtD,IAAI,iBAAiB,OAAO,GACxB,MAAM,IAAI,2BAA2B,kBAAkB,IAAI,UAAU,GAAG,KAAK,IAAI,EAAE,CAAC,GAAG,OAAO;EAEtG;EACA,OAAO;GACH,QAAQ,kBAAkB,IAAI,UAAU,GAAG,QAAQ,CAAC;GACpD,UAAU;GACV,OAAO,kBAAkB,IAAI,UAAU,WAAW,CAAC,CAAC;EACxD;CACJ;CAEA,MAAM,EAAE,QAAQ,UAAU,OAAO,aAAa;CAM9C,IAAI,SAAS,IAAI,QAAQ,GACrB,OAAO;EAAE;EAAQ;EAAU,OAAO;CAAK;CAM3C,IAAI,SAAS,WAAW,GAAG,KAAK,SAAS,SAAS,GAAG,GAAG;EACpD,MAAM,QAAQ,SAAS,MAAM,GAAG,EAAE;EAIlC,OAAO;GAAE;GAAQ;GAAU,OADb,UAAU,mBAAmB,CAAC,IAAI,eAAe,KAAK;EAC5B;CAC5C;CAEA,OAAO;EAAE;EAAQ;EAAU,OAAO,kBAAkB,QAAQ;CAAE;AAClE"}
package/dist/index.es.js CHANGED
@@ -53,8 +53,8 @@ import { ETAG_KEY, etagOf, withETag } from "./data/row-etag.es.js";
53
53
  import { QueryBuilder, and, cond, not, or } from "./data/query_builder.es.js";
54
54
  import { DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_PAGE_SIZE, PAGE_WITH_OFFSET_MESSAGE, RebasePaginationError, collectAllPages, createPaginationHelpers, paginateFind, resolveFindWindow } from "./data/paginate.es.js";
55
55
  import { MAX_LOGICAL_NESTING_DEPTH, RESERVED_QUERY_KEYS, UnknownFilterOperatorError, deserializeFilter, deserializeLogicalCondition, serializeFilter, serializeLogicalCondition } from "./data/filter-dialect.es.js";
56
- import { aggregateAlias, buildRebaseData, buildSdkData, wrapAsEntityData, wrapAsSdkData } from "./data/buildRebaseData.es.js";
56
+ import { aggregateAlias, buildRebaseData, buildSdkData, rowToViewModel, wrapAsEntityData, wrapAsSdkData } from "./data/buildRebaseData.es.js";
57
57
  import { createCallContext } from "./data/call-context.es.js";
58
58
  import { createAccessorSlugResolver, toCamelCase, toCollectionAccessor, toPascalCase, toSafeIdentifier } from "./data/collection-accessor.es.js";
59
59
  import { buildRoutedRebaseData } from "./data/buildRoutedRebaseData.es.js";
60
- export { ADMIN_ROLE, CALLBACK_REJECTED, COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, CursorError, CursorMismatchError, DEFAULT_DOWNLOAD_URL_CACHE_ENTRIES, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_SOFT_DELETE_FIELD, DEFAULT_STRING_COLUMN_LENGTH, DOWNLOAD_METADATA_BATCH_MAX, DOWNLOAD_URL_CONCURRENCY, ETAG_KEY, IncludeSpecError, JUNCTION_TABLES_SQL, MAX_LOGICAL_NESTING_DEPTH, OrderBySpecError, PAGE_WITH_OFFSET_MESSAGE, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, RESERVED_QUERY_KEYS, RESUMABLE_UPLOAD_THRESHOLD, RebasePaginationError, TENANT_INDEX_REASON, UnknownFilterOperatorError, aggregateAlias, and, apiHiddenFieldNames, applyDefaultValuesOnCreate, assertBeforeQueryIsPostgresOnly, authSecretsMissingExclusion, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, buildTenantSecurityRule, callbackRefusal, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, canReadField, canWriteField, checkOperation, classifyTable, collectAllPages, compileRulePolicies, composeCallbacks, cond, copyCarriesRelation, createAccessorSlugResolver, createBatchLoader, createCallContext, createConcurrencyLimit, createDataSourceRegistry, createDownloadUrlCache, createPaginationHelpers, createRelationRef, createRelationRefWithData, cursorToStartAfter, decodeCursor, defaultUsersCollection, defineCollection, denormalizeInclude, deserializeFilter, deserializeInclude, deserializeLogicalCondition, deserializeOrderBy, deserializeOrderByList, detectJunctionTables, effectiveAccess, embedParentExpression, encodeCursor, enumToObjectEntries, etagOf, evaluateCondition, evaluatePolicy, fieldKeyForColumn, findAnonymousGrants, findRelation, findTableDdl, firstSqlRow, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getCopyValues, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionConfigForRelation, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, getTenantConfig, hasEntityId, hasFieldAccessRules, includableRelationNames, includePaths, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationRequired, isRelationalCollection, mergeIncludeSpecs, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeOrderBy, normalizeToEntityRelation, not, or, paginateFind, parseIdValues, parseOrderBySpecStrict, policyToPostgres, policyToSecurityRule, primaryOrderBy, queryableFieldNames, reconcileCursorOrder, redactSqlLiterals, registerConditionOperations, relationDeclaringProperty, relationalCollections, requireCallbackClient, requireCallbackCollection, resetDeprecationWarningsForTests, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, resolveTenantWrite, restoresSoftDeletedRow, restrictedFieldNames, revokeInternalTableAccess, revokeInternalTableSql, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeInclude, serializeLogicalCondition, serializeOrderBy, softDeleteFieldOf, sortCollectionsBySlug, sortProperties, sqlRows, sqlToPolicy, stripCollectionPath, tableDdlMessage, tenantBypassRoles, tenantPolicyName, tenantScopeExpression, toCallbackError, toCamelCase, toCollectionAccessor, toFilterTuples, toPascalCase, toSafeIdentifier, topLevelIncludeNames, traverseValueProperty, traverseValuesProperties, unavailableStorageSource, unknownAggregateField, unknownFilterField, unknownIncludeRelation, unknownOrderByField, unknownProjectionField, unknownRelationTargetField, unknownVectorProperty, untrustedEnvelope, updateDateAutoValues, updateUserAutoValues, uploadResumable, vectorPropertyNames, warnDeprecated, warnOnTableDdl, withETag, withRelationReferencesOnly, wrapAsEntityData, wrapAsSdkData };
60
+ export { ADMIN_ROLE, CALLBACK_REJECTED, COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, CursorError, CursorMismatchError, DEFAULT_DOWNLOAD_URL_CACHE_ENTRIES, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_SOFT_DELETE_FIELD, DEFAULT_STRING_COLUMN_LENGTH, DOWNLOAD_METADATA_BATCH_MAX, DOWNLOAD_URL_CONCURRENCY, ETAG_KEY, IncludeSpecError, JUNCTION_TABLES_SQL, MAX_LOGICAL_NESTING_DEPTH, OrderBySpecError, PAGE_WITH_OFFSET_MESSAGE, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, RESERVED_QUERY_KEYS, RESUMABLE_UPLOAD_THRESHOLD, RebasePaginationError, TENANT_INDEX_REASON, UnknownFilterOperatorError, aggregateAlias, and, apiHiddenFieldNames, applyDefaultValuesOnCreate, assertBeforeQueryIsPostgresOnly, authSecretsMissingExclusion, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, buildTenantSecurityRule, callbackRefusal, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, canReadField, canWriteField, checkOperation, classifyTable, collectAllPages, compileRulePolicies, composeCallbacks, cond, copyCarriesRelation, createAccessorSlugResolver, createBatchLoader, createCallContext, createConcurrencyLimit, createDataSourceRegistry, createDownloadUrlCache, createPaginationHelpers, createRelationRef, createRelationRefWithData, cursorToStartAfter, decodeCursor, defaultUsersCollection, defineCollection, denormalizeInclude, deserializeFilter, deserializeInclude, deserializeLogicalCondition, deserializeOrderBy, deserializeOrderByList, detectJunctionTables, effectiveAccess, embedParentExpression, encodeCursor, enumToObjectEntries, etagOf, evaluateCondition, evaluatePolicy, fieldKeyForColumn, findAnonymousGrants, findRelation, findTableDdl, firstSqlRow, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getCopyValues, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionConfigForRelation, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, getTenantConfig, hasEntityId, hasFieldAccessRules, includableRelationNames, includePaths, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationRequired, isRelationalCollection, mergeIncludeSpecs, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeOrderBy, normalizeToEntityRelation, not, or, paginateFind, parseIdValues, parseOrderBySpecStrict, policyToPostgres, policyToSecurityRule, primaryOrderBy, queryableFieldNames, reconcileCursorOrder, redactSqlLiterals, registerConditionOperations, relationDeclaringProperty, relationalCollections, requireCallbackClient, requireCallbackCollection, resetDeprecationWarningsForTests, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, resolveTenantWrite, restoresSoftDeletedRow, restrictedFieldNames, revokeInternalTableAccess, revokeInternalTableSql, rowToViewModel, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeInclude, serializeLogicalCondition, serializeOrderBy, softDeleteFieldOf, sortCollectionsBySlug, sortProperties, sqlRows, sqlToPolicy, stripCollectionPath, tableDdlMessage, tenantBypassRoles, tenantPolicyName, tenantScopeExpression, toCallbackError, toCamelCase, toCollectionAccessor, toFilterTuples, toPascalCase, toSafeIdentifier, topLevelIncludeNames, traverseValueProperty, traverseValuesProperties, unavailableStorageSource, unknownAggregateField, unknownFilterField, unknownIncludeRelation, unknownOrderByField, unknownProjectionField, unknownRelationTargetField, unknownVectorProperty, untrustedEnvelope, updateDateAutoValues, updateUserAutoValues, uploadResumable, vectorPropertyNames, warnDeprecated, warnOnTableDdl, withETag, withRelationReferencesOnly, wrapAsEntityData, wrapAsSdkData };
@@ -142,7 +142,9 @@ export declare function getColumnName(fullColumn: string): string;
142
142
  * and it is precisely the defect: a derived foreign key reached the wire under
143
143
  * its column name while every hand-authored field beside it was camelCase.
144
144
  */
145
- export declare function fieldKeyForColumn(collection: CollectionConfig | undefined, column: string): string;
145
+ export declare function fieldKeyForColumn(collection: {
146
+ properties?: Record<string, unknown>;
147
+ } | undefined, column: string): string;
146
148
  /**
147
149
  * Look up a relation by key with forgiving normalization.
148
150
  *
@@ -1 +1 @@
1
- {"version":3,"file":"relations.es.js","names":[],"sources":["../../src/util/relations.ts"],"sourcesContent":["import { CollectionConfig, isRelationalCollectionConfig, Property, ResolvedRelation, RelationProperty } from \"@rebasepro/types\";\nimport { toSnakeCase, toWireKey } from \"@rebasepro/utils\";\n\nimport { resolveRelation } from \"./resolve-relation\";\n\n/**\n * Whether the target rows are shared with other parents — a many-to-many, or a\n * multi-hop `via` chain.\n *\n * Decides what a write \"through\" the relation may touch: a shared target\n * belongs to every parent that links it, so the parent owns the *link* and not\n * the row. The backend enforces that (an unlink rather than a delete) and the\n * admin renders it (remove-from-parent rather than delete).\n *\n * Now a field on the resolved relation rather than a re-derivation, so both\n * sides read the same answer instead of each computing one.\n */\nexport function isJunctionBackedRelation(relation: ResolvedRelation): boolean {\n return relation.shared;\n}\n\n/** WeakMap cache — same collection instance always yields the same relation map. */\nconst _resolvedRelationsCache = new WeakMap<CollectionConfig, Record<string, ResolvedRelation>>();\n\n/**\n * Every relation a collection declares, keyed by the name it is addressed by.\n *\n * A relation reaches the map from either of two places — the collection's\n * `relations` array, or a `relation` property that declares one inline — and is\n * keyed by its resolved `relationName`, which is what a nested path segment,\n * an `include` key and an admin tab all match against.\n *\n * Resolution no longer swallows failures. It used to wrap each relation in a\n * `try/catch` that dropped anything it could not work out, so a\n * mis-declared relation silently vanished instead of being reported; with the\n * kind declared, the only remaining failure is a `target` that does not resolve,\n * which is worth hearing about.\n */\nexport function resolveCollectionRelations(\n collection: CollectionConfig\n): Record<string, ResolvedRelation> {\n const cached = _resolvedRelationsCache.get(collection);\n if (cached) return cached;\n\n if (!isRelationalCollectionConfig(collection)) return {};\n\n const relations: Record<string, ResolvedRelation> = {};\n\n for (const relation of collection.relations ?? []) {\n const resolved = resolveRelation(relation, collection);\n relations[resolved.relationName] = resolved;\n }\n\n // A property declaring a relation inline is registered under the property\n // key as well: the fetch layer hydrates the result back onto that key, and\n // it is the name the admin addresses the field by.\n for (const [propertyKey, property] of Object.entries(collection.properties ?? {})) {\n if ((property as Property)?.type !== \"relation\") continue;\n const declared = (property as RelationProperty).relation;\n if (!declared || relations[propertyKey]) continue;\n\n relations[propertyKey] = resolveRelation(declared, collection, propertyKey);\n }\n\n _resolvedRelationsCache.set(collection, relations);\n return relations;\n}\n\n/**\n * The `type: \"relation\"` property that declares a link, or `undefined` for one\n * that only exists in the collection's `relations` array.\n *\n * Both declaration sites end up in {@link resolveCollectionRelations}, and only\n * one of them has a property to carry field-level facts — `name`, `admin`, and\n * the one this exists for, `validation.required`.\n */\nexport function relationDeclaringProperty(\n collection: CollectionConfig,\n relation: ResolvedRelation\n): RelationProperty | undefined {\n const resolved = resolveCollectionRelations(collection);\n for (const [key, raw] of Object.entries(collection.properties ?? {})) {\n const prop = raw as Property | undefined;\n if (prop?.type !== \"relation\") continue;\n // A relation declared inline is keyed by the property; one declared in\n // `relations` is keyed by its name, which the property addresses.\n if (resolved[key] === relation) return prop as RelationProperty;\n const addressed = (prop as RelationProperty).relation?.relationName;\n if (addressed && findRelation(resolved, addressed) === relation) return prop as RelationProperty;\n }\n return undefined;\n}\n\n/**\n * Must every row of this collection point at a target through this link?\n *\n * Read from the declaring property's `validation.required` — the same key every\n * other field uses, and the only place it lives.\n *\n * `RelationBase` carried its own `validation.required` until 0.18, which made\n * this two questions rather than one. They were answered by different readers:\n * the Postgres DDL generator asked the property (so the foreign-key column was\n * `NOT NULL`) and the SDK type generator asked the relation (so the generated\n * `Insert` type made the field optional). A `create()` that left the relation\n * out therefore typechecked and then failed at the database with a not-null\n * violation, and the two `required`s had to be written twice, identically, for\n * the pair to agree.\n *\n * A relation with no declaring property — an entry in `relations` nothing\n * points at — is not required. There is no field to fill in.\n */\nexport function isRelationRequired(collection: CollectionConfig, relation: ResolvedRelation): boolean {\n return Boolean(relationDeclaringProperty(collection, relation)?.validation?.required);\n}\n\n/**\n * The path of the collection a relation property points at, derived from the\n * property alone.\n *\n * A preview holds a property and a value and no collection, so it cannot call\n * `resolveRelationProperty`. It does not need to: both forms that carry a\n * target — the stamped `resolvedRelation` and the inline `relation` — name it\n * directly. Only the third form, a relation declared by name in the\n * collection's `relations` array, is out of reach, and that one has no target\n * to read without the collection anyway.\n *\n * This is what lets a preview render a relation column that arrived as a bare\n * foreign key: the id says *which* row, the declared target says *which\n * collection*, and `RelationPreview` fetches the rest. Without it a scalar id\n * is indistinguishable from a value of the wrong type.\n */\nexport function getRelationTargetPath(property: RelationProperty): string | undefined {\n const stamped = property.resolvedRelation?.targetSlug;\n if (stamped) return stamped;\n\n const target = property.relation?.target;\n if (typeof target !== \"function\") return undefined;\n try {\n return target()?.slug;\n } catch (_e) {\n // A thunk reaching into a module that has not finished initialising:\n // there is no target to name yet, and a preview is not worth throwing over.\n return undefined;\n }\n}\n\n/**\n * The table a collection reads and writes.\n *\n * `table` when it is set, otherwise `toSnakeCase(slug)` — which is what made it\n * safe to drop `table` from the required fields on the config type: the runtime\n * had always derived it, and the type was demanding a value it did not need.\n *\n * The `||` chain is load-bearing. `toSnakeCase(undefined)` returns `\"\"`, not\n * `undefined`, so the previous `??` chain short-circuited on the empty string\n * and the name fallback could never run — a safety net that read like one and\n * caught nothing. It was unreachable while `slug` was required; it stops being\n * unreachable the moment anything constructs a config without one.\n */\nexport function getTableName(collection: CollectionConfig): string {\n const declared = isRelationalCollectionConfig(collection) ? collection.table : undefined;\n return declared || toSnakeCase(collection.slug) || toSnakeCase(collection.name);\n}\n\n/**\n * A JavaScript identifier: what a generated `export const <name> =` needs.\n *\n * Deliberately the same shape the two schema generators already define\n * privately — this is the third place that needed it, and the first two guard\n * property keys and member accesses while nothing guarded the variable name\n * itself.\n */\nconst JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;\n\n/**\n * The variable name a generated table is bound to.\n *\n * Camel-cases underscores, and then guarantees the result is a legal\n * identifier. It did only the first, so a table name that is legal in Postgres\n * and not in JavaScript produced a `schema.generated.ts` that does not parse:\n *\n * `2024_archive` → `export const 2024Archive = pgTable(…)`\n * \"An identifier or keyword cannot immediately follow\n * a numeric literal\"\n * `reporting.events` → `export const reporting.events = pgTable(…)`\n * \"',' expected\"\n *\n * That file is imported by the server, so the failure is not one broken\n * collection — `rebase build` and `db push` fail at tsc for the whole\n * directory. And it is reachable from a documented flow: `rebase init` against\n * a database holding a table called `2024_archive` writes a collection file\n * that parses and a schema file that does not.\n *\n * **A no-op for every name that already worked**, which is what makes changing\n * a derived name safe here: the only inputs whose output changes are the ones\n * that produced a syntax error, and nothing can be running against those.\n * Separators become camel case rather than disappearing, so `reporting.events`\n * and `reporting_events` do not collide into one variable.\n */\nexport function getTableVarName(tableName: string): string {\n const camel = tableName.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());\n if (JS_IDENTIFIER.test(camel)) return camel;\n\n const sanitised = camel\n // Any other separator gets the same treatment `_` did, so two tables\n // differing only by separator keep differing.\n .replace(/[^A-Za-z0-9_$]+([A-Za-z0-9])?/g, (_, char?: string) =>\n (char ? char.toUpperCase() : \"\"))\n // A leading digit is legal in Postgres and not in JavaScript. Prefixed\n // rather than stripped, so `2024_archive` and `archive` stay distinct.\n .replace(/^([0-9])/, \"t$1\");\n\n return JS_IDENTIFIER.test(sanitised) ? sanitised : `t${sanitised}`;\n}\n\nexport function getEnumVarName(tableName: string, propName: string): string {\n const tableVar = getTableVarName(tableName);\n const propVar = propName.charAt(0).toUpperCase() + propName.slice(1);\n return `${tableVar}${propVar}`;\n}\n\nexport function getColumnName(fullColumn: string): string {\n return fullColumn.includes(\".\") ? fullColumn.split(\".\").pop()! : fullColumn;\n}\n\n/**\n * The field key a database column is served and addressed under.\n *\n * A column has two names and they are not the same name. `author_id` is what\n * Postgres stores; `authorId` is the key on the JSON row, the key in the\n * generated Drizzle table, and the key a caller writes in `where` and\n * `orderBy`. Every place that starts from a column and has to reach a row, a\n * Drizzle table or a payload goes through here, so there is one answer rather\n * than one per call site — the two that disagreed put `displayName` and\n * `author_id` on the same API.\n *\n * A declared property is the authority when there is one, because its key *is*\n * the wire name and `columnName` is the only thing that ever renamed the\n * column:\n *\n * 1. an explicit `columnName` equal to this column;\n * 2. a property whose key is literally the column (an author who wrote\n * `author_id:` meant `author_id` on the wire, and gets it);\n * 3. a property whose key snake-cases to the column, which is the default\n * mapping — `authorId` → `author_id`.\n *\n * With no property in the way — a foreign key derived from a relation, which\n * usually has none — the name is derived: {@link toWireKey}.\n *\n * Note the fallback is *not* the column verbatim. That was the old behaviour\n * and it is precisely the defect: a derived foreign key reached the wire under\n * its column name while every hand-authored field beside it was camelCase.\n */\nexport function fieldKeyForColumn(collection: CollectionConfig | undefined, column: string): string {\n const properties = collection?.properties;\n if (properties) {\n for (const [key, prop] of Object.entries(properties)) {\n const columnName = (prop as { columnName?: unknown } | undefined)?.columnName;\n if (typeof columnName === \"string\" && columnName === column) return key;\n }\n for (const key of Object.keys(properties)) {\n if (key === column) return key;\n if (toSnakeCase(key) === column) return key;\n }\n }\n return toWireKey(column);\n}\n\n/**\n * Look up a relation by key with forgiving normalization.\n *\n * `resolveCollectionRelations` stores each relation under a single canonical\n * key (no aliases). This helper tries the given key as-is, then falls back to\n * slug form (underscores → hyphens) and snake_case form (hyphens → underscores)\n * so that callers that receive a key from external input (URL path segments,\n * user-provided config, etc.) can still find the right entry.\n */\nexport function findRelation(\n resolvedRelations: Record<string, ResolvedRelation>,\n key: string\n): ResolvedRelation | undefined {\n // Exact match first\n if (resolvedRelations[key]) return resolvedRelations[key];\n\n // Try slug form (e.g. \"company_id\" → \"company-id\")\n const slugKey = key.replace(/_/g, \"-\");\n if (slugKey !== key && resolvedRelations[slugKey]) return resolvedRelations[slugKey];\n\n // Try snake_case form (e.g. \"company-id\" → \"company_id\")\n const snakeKey = key.replace(/-/g, \"_\");\n if (snakeKey !== key && resolvedRelations[snakeKey]) return resolvedRelations[snakeKey];\n\n return undefined;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"relations.es.js","names":[],"sources":["../../src/util/relations.ts"],"sourcesContent":["import { CollectionConfig, isRelationalCollectionConfig, Property, ResolvedRelation, RelationProperty } from \"@rebasepro/types\";\nimport { toSnakeCase, toWireKey } from \"@rebasepro/utils\";\n\nimport { resolveRelation } from \"./resolve-relation\";\n\n/**\n * Whether the target rows are shared with other parents — a many-to-many, or a\n * multi-hop `via` chain.\n *\n * Decides what a write \"through\" the relation may touch: a shared target\n * belongs to every parent that links it, so the parent owns the *link* and not\n * the row. The backend enforces that (an unlink rather than a delete) and the\n * admin renders it (remove-from-parent rather than delete).\n *\n * Now a field on the resolved relation rather than a re-derivation, so both\n * sides read the same answer instead of each computing one.\n */\nexport function isJunctionBackedRelation(relation: ResolvedRelation): boolean {\n return relation.shared;\n}\n\n/** WeakMap cache — same collection instance always yields the same relation map. */\nconst _resolvedRelationsCache = new WeakMap<CollectionConfig, Record<string, ResolvedRelation>>();\n\n/**\n * Every relation a collection declares, keyed by the name it is addressed by.\n *\n * A relation reaches the map from either of two places — the collection's\n * `relations` array, or a `relation` property that declares one inline — and is\n * keyed by its resolved `relationName`, which is what a nested path segment,\n * an `include` key and an admin tab all match against.\n *\n * Resolution no longer swallows failures. It used to wrap each relation in a\n * `try/catch` that dropped anything it could not work out, so a\n * mis-declared relation silently vanished instead of being reported; with the\n * kind declared, the only remaining failure is a `target` that does not resolve,\n * which is worth hearing about.\n */\nexport function resolveCollectionRelations(\n collection: CollectionConfig\n): Record<string, ResolvedRelation> {\n const cached = _resolvedRelationsCache.get(collection);\n if (cached) return cached;\n\n if (!isRelationalCollectionConfig(collection)) return {};\n\n const relations: Record<string, ResolvedRelation> = {};\n\n for (const relation of collection.relations ?? []) {\n const resolved = resolveRelation(relation, collection);\n relations[resolved.relationName] = resolved;\n }\n\n // A property declaring a relation inline is registered under the property\n // key as well: the fetch layer hydrates the result back onto that key, and\n // it is the name the admin addresses the field by.\n for (const [propertyKey, property] of Object.entries(collection.properties ?? {})) {\n if ((property as Property)?.type !== \"relation\") continue;\n const declared = (property as RelationProperty).relation;\n if (!declared || relations[propertyKey]) continue;\n\n relations[propertyKey] = resolveRelation(declared, collection, propertyKey);\n }\n\n _resolvedRelationsCache.set(collection, relations);\n return relations;\n}\n\n/**\n * The `type: \"relation\"` property that declares a link, or `undefined` for one\n * that only exists in the collection's `relations` array.\n *\n * Both declaration sites end up in {@link resolveCollectionRelations}, and only\n * one of them has a property to carry field-level facts — `name`, `admin`, and\n * the one this exists for, `validation.required`.\n */\nexport function relationDeclaringProperty(\n collection: CollectionConfig,\n relation: ResolvedRelation\n): RelationProperty | undefined {\n const resolved = resolveCollectionRelations(collection);\n for (const [key, raw] of Object.entries(collection.properties ?? {})) {\n const prop = raw as Property | undefined;\n if (prop?.type !== \"relation\") continue;\n // A relation declared inline is keyed by the property; one declared in\n // `relations` is keyed by its name, which the property addresses.\n if (resolved[key] === relation) return prop as RelationProperty;\n const addressed = (prop as RelationProperty).relation?.relationName;\n if (addressed && findRelation(resolved, addressed) === relation) return prop as RelationProperty;\n }\n return undefined;\n}\n\n/**\n * Must every row of this collection point at a target through this link?\n *\n * Read from the declaring property's `validation.required` — the same key every\n * other field uses, and the only place it lives.\n *\n * `RelationBase` carried its own `validation.required` until 0.18, which made\n * this two questions rather than one. They were answered by different readers:\n * the Postgres DDL generator asked the property (so the foreign-key column was\n * `NOT NULL`) and the SDK type generator asked the relation (so the generated\n * `Insert` type made the field optional). A `create()` that left the relation\n * out therefore typechecked and then failed at the database with a not-null\n * violation, and the two `required`s had to be written twice, identically, for\n * the pair to agree.\n *\n * A relation with no declaring property — an entry in `relations` nothing\n * points at — is not required. There is no field to fill in.\n */\nexport function isRelationRequired(collection: CollectionConfig, relation: ResolvedRelation): boolean {\n return Boolean(relationDeclaringProperty(collection, relation)?.validation?.required);\n}\n\n/**\n * The path of the collection a relation property points at, derived from the\n * property alone.\n *\n * A preview holds a property and a value and no collection, so it cannot call\n * `resolveRelationProperty`. It does not need to: both forms that carry a\n * target — the stamped `resolvedRelation` and the inline `relation` — name it\n * directly. Only the third form, a relation declared by name in the\n * collection's `relations` array, is out of reach, and that one has no target\n * to read without the collection anyway.\n *\n * This is what lets a preview render a relation column that arrived as a bare\n * foreign key: the id says *which* row, the declared target says *which\n * collection*, and `RelationPreview` fetches the rest. Without it a scalar id\n * is indistinguishable from a value of the wrong type.\n */\nexport function getRelationTargetPath(property: RelationProperty): string | undefined {\n const stamped = property.resolvedRelation?.targetSlug;\n if (stamped) return stamped;\n\n const target = property.relation?.target;\n if (typeof target !== \"function\") return undefined;\n try {\n return target()?.slug;\n } catch (_e) {\n // A thunk reaching into a module that has not finished initialising:\n // there is no target to name yet, and a preview is not worth throwing over.\n return undefined;\n }\n}\n\n/**\n * The table a collection reads and writes.\n *\n * `table` when it is set, otherwise `toSnakeCase(slug)` — which is what made it\n * safe to drop `table` from the required fields on the config type: the runtime\n * had always derived it, and the type was demanding a value it did not need.\n *\n * The `||` chain is load-bearing. `toSnakeCase(undefined)` returns `\"\"`, not\n * `undefined`, so the previous `??` chain short-circuited on the empty string\n * and the name fallback could never run — a safety net that read like one and\n * caught nothing. It was unreachable while `slug` was required; it stops being\n * unreachable the moment anything constructs a config without one.\n */\nexport function getTableName(collection: CollectionConfig): string {\n const declared = isRelationalCollectionConfig(collection) ? collection.table : undefined;\n return declared || toSnakeCase(collection.slug) || toSnakeCase(collection.name);\n}\n\n/**\n * A JavaScript identifier: what a generated `export const <name> =` needs.\n *\n * Deliberately the same shape the two schema generators already define\n * privately — this is the third place that needed it, and the first two guard\n * property keys and member accesses while nothing guarded the variable name\n * itself.\n */\nconst JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;\n\n/**\n * The variable name a generated table is bound to.\n *\n * Camel-cases underscores, and then guarantees the result is a legal\n * identifier. It did only the first, so a table name that is legal in Postgres\n * and not in JavaScript produced a `schema.generated.ts` that does not parse:\n *\n * `2024_archive` → `export const 2024Archive = pgTable(…)`\n * \"An identifier or keyword cannot immediately follow\n * a numeric literal\"\n * `reporting.events` → `export const reporting.events = pgTable(…)`\n * \"',' expected\"\n *\n * That file is imported by the server, so the failure is not one broken\n * collection — `rebase build` and `db push` fail at tsc for the whole\n * directory. And it is reachable from a documented flow: `rebase init` against\n * a database holding a table called `2024_archive` writes a collection file\n * that parses and a schema file that does not.\n *\n * **A no-op for every name that already worked**, which is what makes changing\n * a derived name safe here: the only inputs whose output changes are the ones\n * that produced a syntax error, and nothing can be running against those.\n * Separators become camel case rather than disappearing, so `reporting.events`\n * and `reporting_events` do not collide into one variable.\n */\nexport function getTableVarName(tableName: string): string {\n const camel = tableName.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());\n if (JS_IDENTIFIER.test(camel)) return camel;\n\n const sanitised = camel\n // Any other separator gets the same treatment `_` did, so two tables\n // differing only by separator keep differing.\n .replace(/[^A-Za-z0-9_$]+([A-Za-z0-9])?/g, (_, char?: string) =>\n (char ? char.toUpperCase() : \"\"))\n // A leading digit is legal in Postgres and not in JavaScript. Prefixed\n // rather than stripped, so `2024_archive` and `archive` stay distinct.\n .replace(/^([0-9])/, \"t$1\");\n\n return JS_IDENTIFIER.test(sanitised) ? sanitised : `t${sanitised}`;\n}\n\nexport function getEnumVarName(tableName: string, propName: string): string {\n const tableVar = getTableVarName(tableName);\n const propVar = propName.charAt(0).toUpperCase() + propName.slice(1);\n return `${tableVar}${propVar}`;\n}\n\nexport function getColumnName(fullColumn: string): string {\n return fullColumn.includes(\".\") ? fullColumn.split(\".\").pop()! : fullColumn;\n}\n\n/**\n * The field key a database column is served and addressed under.\n *\n * A column has two names and they are not the same name. `author_id` is what\n * Postgres stores; `authorId` is the key on the JSON row, the key in the\n * generated Drizzle table, and the key a caller writes in `where` and\n * `orderBy`. Every place that starts from a column and has to reach a row, a\n * Drizzle table or a payload goes through here, so there is one answer rather\n * than one per call site — the two that disagreed put `displayName` and\n * `author_id` on the same API.\n *\n * A declared property is the authority when there is one, because its key *is*\n * the wire name and `columnName` is the only thing that ever renamed the\n * column:\n *\n * 1. an explicit `columnName` equal to this column;\n * 2. a property whose key is literally the column (an author who wrote\n * `author_id:` meant `author_id` on the wire, and gets it);\n * 3. a property whose key snake-cases to the column, which is the default\n * mapping — `authorId` → `author_id`.\n *\n * With no property in the way — a foreign key derived from a relation, which\n * usually has none — the name is derived: {@link toWireKey}.\n *\n * Note the fallback is *not* the column verbatim. That was the old behaviour\n * and it is precisely the defect: a derived foreign key reached the wire under\n * its column name while every hand-authored field beside it was camelCase.\n */\nexport function fieldKeyForColumn(collection: { properties?: Record<string, unknown> } | undefined, column: string): string {\n const properties = collection?.properties;\n if (properties) {\n for (const [key, prop] of Object.entries(properties)) {\n const columnName = (prop as { columnName?: unknown } | undefined)?.columnName;\n if (typeof columnName === \"string\" && columnName === column) return key;\n }\n for (const key of Object.keys(properties)) {\n if (key === column) return key;\n if (toSnakeCase(key) === column) return key;\n }\n }\n return toWireKey(column);\n}\n\n/**\n * Look up a relation by key with forgiving normalization.\n *\n * `resolveCollectionRelations` stores each relation under a single canonical\n * key (no aliases). This helper tries the given key as-is, then falls back to\n * slug form (underscores → hyphens) and snake_case form (hyphens → underscores)\n * so that callers that receive a key from external input (URL path segments,\n * user-provided config, etc.) can still find the right entry.\n */\nexport function findRelation(\n resolvedRelations: Record<string, ResolvedRelation>,\n key: string\n): ResolvedRelation | undefined {\n // Exact match first\n if (resolvedRelations[key]) return resolvedRelations[key];\n\n // Try slug form (e.g. \"company_id\" → \"company-id\")\n const slugKey = key.replace(/_/g, \"-\");\n if (slugKey !== key && resolvedRelations[slugKey]) return resolvedRelations[slugKey];\n\n // Try snake_case form (e.g. \"company-id\" → \"company_id\")\n const snakeKey = key.replace(/-/g, \"_\");\n if (snakeKey !== key && resolvedRelations[snakeKey]) return resolvedRelations[snakeKey];\n\n return undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,SAAgB,yBAAyB,UAAqC;CAC1E,OAAO,SAAS;AACpB;;AAGA,IAAM,0CAA0B,IAAI,QAA4D;;;;;;;;;;;;;;;AAgBhG,SAAgB,2BACZ,YACgC;CAChC,MAAM,SAAS,wBAAwB,IAAI,UAAU;CACrD,IAAI,QAAQ,OAAO;CAEnB,IAAI,CAAC,6BAA6B,UAAU,GAAG,OAAO,CAAC;CAEvD,MAAM,YAA8C,CAAC;CAErD,KAAK,MAAM,YAAY,WAAW,aAAa,CAAC,GAAG;EAC/C,MAAM,WAAW,gBAAgB,UAAU,UAAU;EACrD,UAAU,SAAS,gBAAgB;CACvC;CAKA,KAAK,MAAM,CAAC,aAAa,aAAa,OAAO,QAAQ,WAAW,cAAc,CAAC,CAAC,GAAG;EAC/E,IAAK,UAAuB,SAAS,YAAY;EACjD,MAAM,WAAY,SAA8B;EAChD,IAAI,CAAC,YAAY,UAAU,cAAc;EAEzC,UAAU,eAAe,gBAAgB,UAAU,YAAY,WAAW;CAC9E;CAEA,wBAAwB,IAAI,YAAY,SAAS;CACjD,OAAO;AACX;;;;;;;;;AAUA,SAAgB,0BACZ,YACA,UAC4B;CAC5B,MAAM,WAAW,2BAA2B,UAAU;CACtD,KAAK,MAAM,CAAC,KAAK,QAAQ,OAAO,QAAQ,WAAW,cAAc,CAAC,CAAC,GAAG;EAClE,MAAM,OAAO;EACb,IAAI,MAAM,SAAS,YAAY;EAG/B,IAAI,SAAS,SAAS,UAAU,OAAO;EACvC,MAAM,YAAa,KAA0B,UAAU;EACvD,IAAI,aAAa,aAAa,UAAU,SAAS,MAAM,UAAU,OAAO;CAC5E;AAEJ;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,mBAAmB,YAA8B,UAAqC;CAClG,OAAO,QAAQ,0BAA0B,YAAY,QAAQ,CAAC,EAAE,YAAY,QAAQ;AACxF;;;;;;;;;;;;;;;;;AAkBA,SAAgB,sBAAsB,UAAgD;CAClF,MAAM,UAAU,SAAS,kBAAkB;CAC3C,IAAI,SAAS,OAAO;CAEpB,MAAM,SAAS,SAAS,UAAU;CAClC,IAAI,OAAO,WAAW,YAAY,OAAO,KAAA;CACzC,IAAI;EACA,OAAO,OAAO,CAAC,EAAE;CACrB,SAAS,IAAI;EAGT;CACJ;AACJ;;;;;;;;;;;;;;AAeA,SAAgB,aAAa,YAAsC;CAE/D,QADiB,6BAA6B,UAAU,IAAI,WAAW,QAAQ,KAAA,MAC5D,YAAY,WAAW,IAAI,KAAK,YAAY,WAAW,IAAI;AAClF;;;;;;;;;AAUA,IAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BtB,SAAgB,gBAAgB,WAA2B;CACvD,MAAM,QAAQ,UAAU,QAAQ,cAAc,GAAG,SAAiB,KAAK,YAAY,CAAC;CACpF,IAAI,cAAc,KAAK,KAAK,GAAG,OAAO;CAEtC,MAAM,YAAY,MAGb,QAAQ,mCAAmC,GAAG,SAC1C,OAAO,KAAK,YAAY,IAAI,EAAG,CAAC,CAGpC,QAAQ,YAAY,KAAK;CAE9B,OAAO,cAAc,KAAK,SAAS,IAAI,YAAY,IAAI;AAC3D;AAEA,SAAgB,eAAe,WAAmB,UAA0B;CAGxE,OAAO,GAFU,gBAAgB,SAEvB,IADM,SAAS,OAAO,CAAC,CAAC,CAAC,YAAY,IAAI,SAAS,MAAM,CAAC;AAEvE;AAEA,SAAgB,cAAc,YAA4B;CACtD,OAAO,WAAW,SAAS,GAAG,IAAI,WAAW,MAAM,GAAG,CAAC,CAAC,IAAI,IAAK;AACrE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,kBAAkB,YAAkE,QAAwB;CACxH,MAAM,aAAa,YAAY;CAC/B,IAAI,YAAY;EACZ,KAAK,MAAM,CAAC,KAAK,SAAS,OAAO,QAAQ,UAAU,GAAG;GAClD,MAAM,aAAc,MAA+C;GACnE,IAAI,OAAO,eAAe,YAAY,eAAe,QAAQ,OAAO;EACxE;EACA,KAAK,MAAM,OAAO,OAAO,KAAK,UAAU,GAAG;GACvC,IAAI,QAAQ,QAAQ,OAAO;GAC3B,IAAI,YAAY,GAAG,MAAM,QAAQ,OAAO;EAC5C;CACJ;CACA,OAAO,UAAU,MAAM;AAC3B;;;;;;;;;;AAWA,SAAgB,aACZ,mBACA,KAC4B;CAE5B,IAAI,kBAAkB,MAAM,OAAO,kBAAkB;CAGrD,MAAM,UAAU,IAAI,QAAQ,MAAM,GAAG;CACrC,IAAI,YAAY,OAAO,kBAAkB,UAAU,OAAO,kBAAkB;CAG5E,MAAM,WAAW,IAAI,QAAQ,MAAM,GAAG;CACtC,IAAI,aAAa,OAAO,kBAAkB,WAAW,OAAO,kBAAkB;AAGlF"}