@rebasepro/common 0.13.0 → 0.13.1-canary.g06dbe5b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/data/buildRebaseData.d.ts +10 -1
  2. package/dist/data/filter-conditions.d.ts +34 -0
  3. package/dist/data/filter-dialect.d.ts +19 -3
  4. package/dist/data/paginate.d.ts +20 -0
  5. package/dist/data/query_builder.d.ts +16 -4
  6. package/dist/data/resolveDataSource.d.ts +36 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.es.js +622 -92
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/util/builders.d.ts +2 -2
  11. package/dist/util/collections.d.ts +17 -0
  12. package/dist/util/conditions.d.ts +7 -3
  13. package/dist/util/entities.d.ts +8 -1
  14. package/dist/util/index.d.ts +1 -0
  15. package/dist/util/internal-tables.d.ts +95 -0
  16. package/dist/util/permissions.d.ts +30 -0
  17. package/dist/util/policy/sqlToPolicy.d.ts +4 -4
  18. package/dist/util/relations.d.ts +18 -1
  19. package/dist/util/resolutions.d.ts +31 -0
  20. package/package.json +4 -3
  21. package/src/data/buildRebaseData.ts +161 -27
  22. package/src/data/filter-conditions.ts +46 -0
  23. package/src/data/filter-dialect.ts +110 -37
  24. package/src/data/paginate.ts +30 -0
  25. package/src/data/query_builder.ts +26 -4
  26. package/src/data/resolveDataSource.ts +56 -0
  27. package/src/index.ts +1 -0
  28. package/src/util/auth-default-policies.ts +8 -2
  29. package/src/util/builders.ts +3 -3
  30. package/src/util/collections.ts +17 -1
  31. package/src/util/conditions.ts +8 -3
  32. package/src/util/entities.ts +15 -1
  33. package/src/util/index.ts +1 -0
  34. package/src/util/internal-tables.ts +154 -0
  35. package/src/util/permissions.test.ts +23 -2
  36. package/src/util/permissions.ts +43 -6
  37. package/src/util/policy/evaluatePolicy.ts +24 -2
  38. package/src/util/policy/policyToPostgres.ts +23 -10
  39. package/src/util/policy/sqlToPolicy.ts +127 -26
  40. package/src/util/relations.ts +31 -0
  41. package/src/util/resolutions.ts +77 -5
@@ -14,11 +14,13 @@ import {
14
14
  SDKCollectionClient,
15
15
  SDKQueryBuilderInterface,
16
16
  WhereFilterOp,
17
- WhereValue
17
+ WhereValueFor,
18
+ type ComputedSortField,
19
+ type SearchMatch
18
20
  } from "@rebasepro/types";
19
21
  import { toSnakeCase } from "@rebasepro/utils";
20
22
  import { QueryBuilder } from "./query_builder";
21
- import { collectAllPages, paginateFind } from "./paginate";
23
+ import { collectAllPages, paginateFind, resolveFindWindow } from "./paginate";
22
24
  import { deserializeFilter } from "./filter-dialect";
23
25
  import { buildCompositeId, resolvePrimaryKeys, PrimaryKeyInfo } from "../util/identity";
24
26
 
@@ -89,12 +91,19 @@ function rowToEntity<M extends Record<string, unknown>>(
89
91
  slug: string,
90
92
  primaryKeys: PrimaryKeyInfo[] = []
91
93
  ): Entity<M> {
94
+ // Query-computed metadata rides in on the row because that is how the wire
95
+ // carries it, but it is not a column: it belongs beside `values`, not in
96
+ // them. Left inside, `_matches` would show up in the record inspector as a
97
+ // field the collection never declared.
98
+ const { _matches, ...values } = row as Record<string, unknown> & { _matches?: SearchMatch[] };
99
+
92
100
  return {
93
101
  id: primaryKeys.length > 0
94
102
  ? buildCompositeId(row, primaryKeys)
95
103
  : row.id as string | number,
96
104
  path: slug,
97
- values: row as EntityValues<M>
105
+ values: values as EntityValues<M>,
106
+ ...(_matches ? { searchMatches: _matches } : {})
98
107
  };
99
108
  }
100
109
 
@@ -153,8 +162,7 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
153
162
  async find(params?: FindParams<M>): Promise<FindResponse<M>> {
154
163
  // Ensure filters are in canonical [op, value] format even if passed as PostgREST strings
155
164
  const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
156
- const limit = params?.limit ?? 20;
157
- const offset = params?.offset ?? 0;
165
+ const { limit, offset, driverOffset } = resolveFindWindow(params);
158
166
 
159
167
  // One relation shape, whatever the call looks like.
160
168
  //
@@ -177,8 +185,12 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
177
185
  slug,
178
186
  {
179
187
  filter,
180
- limit: params?.limit,
181
- offset: params?.offset,
188
+ // Without this the group was dropped and the read ran
189
+ // unfiltered — every row the caller's policies allow,
190
+ // in place of the ones they asked for.
191
+ logical: params?.logical,
192
+ limit,
193
+ offset: driverOffset,
182
194
  orderBy: params?.orderBy?.[0],
183
195
  order: params?.orderBy?.[1],
184
196
  searchString: params?.searchString
@@ -187,9 +199,10 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
187
199
  )
188
200
  : await driver.fetchCollection<M>({
189
201
  path: slug,
190
- limit: params?.limit,
191
- offset: params?.offset,
202
+ limit,
203
+ offset: driverOffset,
192
204
  filter,
205
+ logical: params?.logical,
193
206
  orderBy: params?.orderBy?.[0],
194
207
  order: params?.orderBy?.[1],
195
208
  searchString: params?.searchString
@@ -199,7 +212,16 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
199
212
  let total = rows.length + offset;
200
213
  let hasMore = rows.length >= limit;
201
214
  if (driver.count) {
202
- total = await driver.count({ path: slug, filter });
215
+ // The same narrowing the rows were read with. Counting only by
216
+ // `filter` reported the whole collection beside a narrowed
217
+ // page, and `hasMore` is derived from it — so the list offered
218
+ // a next page that did not exist.
219
+ total = await driver.count({
220
+ path: slug,
221
+ filter,
222
+ logical: params?.logical,
223
+ searchString: params?.searchString
224
+ });
203
225
  hasMore = offset + rows.length < total;
204
226
  }
205
227
 
@@ -258,37 +280,70 @@ values: {} as Record<string, unknown> }
258
280
  });
259
281
  },
260
282
 
283
+ // Present only when the driver is: exposing these unconditionally and
284
+ // looping single writes underneath would give a caller neither the
285
+ // atomicity nor the single round trip they reached for a batch to get,
286
+ // while looking exactly like it had.
287
+ updateMany: driver.updateMany
288
+ ? async (updates: { id: string | number; data: Partial<EntityValues<M>> }[]): Promise<Entity<M>[]> => {
289
+ const rows = await driver.updateMany!<M>({
290
+ path: slug,
291
+ updates: updates.map(u => ({ id: u.id,
292
+ values: u.data })),
293
+ });
294
+ return rows.map(row => rowToEntity<M>(row, slug, getPks()));
295
+ }
296
+ : undefined,
297
+
298
+ deleteMany: driver.deleteMany
299
+ ? async (ids: (string | number)[]): Promise<void> => {
300
+ await driver.deleteMany!<M>({ path: slug,
301
+ ids });
302
+ }
303
+ : undefined,
304
+
261
305
  count: driver.count
262
306
  ? async (params?: FindParams<M>): Promise<number> => {
263
307
  const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
308
+ // Every narrowing `find()` applies has to apply here too, or
309
+ // the count describes a different query than the one it is
310
+ // reported against.
264
311
  return driver.count!({
265
312
  path: slug,
266
- filter
313
+ filter,
314
+ logical: params?.logical,
315
+ searchString: params?.searchString
267
316
  });
268
317
  }
269
318
  : undefined,
270
319
 
271
320
  listen: driver.listenCollection
272
321
  ? (params: FindParams<M> | undefined, onUpdate: (response: FindResponse<M>) => void, onError?: (error: Error) => void) => {
273
- const limit = params?.limit ?? 20;
274
- const offset = params?.offset ?? 0;
322
+ const { limit, offset, driverOffset } = resolveFindWindow(params);
275
323
  // Realtime has no REST-pipeline equivalent, so the rows arrive
276
324
  // admin-shaped. Flatten them to the one shape the rest of this
277
325
  // accessor serves.
278
326
  const normalize = driver.restFetchService ? inlineRelationRefs : (row: Record<string, unknown>) => row;
279
327
  return driver.listenCollection!<M>({
280
328
  path: slug,
281
- limit: params?.limit,
282
- offset: params?.offset,
329
+ limit,
330
+ offset: driverOffset,
283
331
  filter: params?.where,
332
+ logical: params?.logical,
284
333
  orderBy: params?.orderBy?.[0],
285
334
  order: params?.orderBy?.[1],
286
335
  searchString: params?.searchString,
336
+ searchExplain: params?.searchExplain,
287
337
  onUpdate: (entities) => {
288
338
  onUpdate({
289
339
  data: entities.map((row: Record<string, unknown>) => rowToEntity<M>(normalize(row), slug, getPks())),
290
340
  meta: {
291
- total: entities.length,
341
+ // No count is issued on this path, so the total
342
+ // is unknown; the lower bound is the rows in
343
+ // hand plus the ones paged past to reach them.
344
+ // Reporting `entities.length` claimed a read at
345
+ // offset 100 had found a collection of two.
346
+ total: offset + entities.length,
292
347
  limit,
293
348
  offset,
294
349
  hasMore: entities.length >= limit
@@ -316,9 +371,9 @@ values: {} as Record<string, unknown> }
316
371
  if (typeof columnOrCondition === "object") {
317
372
  return builder.where(columnOrCondition);
318
373
  }
319
- return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
374
+ return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValueFor<WhereFilterOp, M[keyof M & string]>);
320
375
  },
321
- orderBy(column: keyof M & string, ascending?: "asc" | "desc") {
376
+ orderBy(column: (keyof M & string) | ComputedSortField, ascending?: "asc" | "desc") {
322
377
  return new QueryBuilder<M>(accessor).orderBy(column, ascending);
323
378
  },
324
379
  limit(count: number) {
@@ -327,8 +382,15 @@ values: {} as Record<string, unknown> }
327
382
  offset(count: number) {
328
383
  return new QueryBuilder<M>(accessor).offset(count);
329
384
  },
330
- search(searchString: string) {
331
- return new QueryBuilder<M>(accessor).search(searchString);
385
+ search(searchString: string, options?: { explain?: boolean }) {
386
+ return new QueryBuilder<M>(accessor).search(searchString, options);
387
+ },
388
+ vectorSearch(
389
+ property: string,
390
+ vector: number[],
391
+ options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
392
+ ) {
393
+ return new QueryBuilder<M>(accessor).vectorSearch(property, vector, options);
332
394
  },
333
395
  include(...relations: string[]) {
334
396
  return new QueryBuilder<M>(accessor).include(...relations);
@@ -405,7 +467,7 @@ class SdkQueryBuilder<M extends Record<string, unknown> = Record<string, unknown
405
467
 
406
468
  constructor(private client: SDKCollectionClient<M>) {}
407
469
 
408
- where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
470
+ where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): this;
409
471
  where(logicalCondition: LogicalCondition): this;
410
472
  where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
411
473
  if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
@@ -432,14 +494,27 @@ class SdkQueryBuilder<M extends Record<string, unknown> = Record<string, unknown
432
494
  return this;
433
495
  }
434
496
 
435
- orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
497
+ orderBy(column: (keyof M & string) | ComputedSortField, direction: "asc" | "desc" = "asc"): this {
436
498
  this.params.orderBy = [column, direction];
437
499
  return this;
438
500
  }
439
501
 
440
502
  limit(count: number): this { this.params.limit = count; return this; }
441
503
  offset(count: number): this { this.params.offset = count; return this; }
442
- search(searchString: string): this { this.params.searchString = searchString; return this; }
504
+ search(searchString: string, options?: { explain?: boolean }): this { this.params.searchString = searchString; if (options?.explain !== undefined) this.params.searchExplain = options.explain; return this; }
505
+ vectorSearch(
506
+ property: string,
507
+ vector: number[],
508
+ options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
509
+ ): this {
510
+ this.params.vectorSearch = {
511
+ property,
512
+ vector,
513
+ ...(options?.distance !== undefined && { distance: options.distance }),
514
+ ...(options?.threshold !== undefined && { threshold: options.threshold })
515
+ };
516
+ return this;
517
+ }
443
518
  include(...relations: string[]): this { this.params.include = relations; return this; }
444
519
 
445
520
  async find(): Promise<FindResult<M>> {
@@ -506,9 +581,39 @@ function toSdkCollectionClient<M extends Record<string, unknown>>(
506
581
  async update(id: string | number, data: Partial<M>): Promise<M> {
507
582
  return entityToRow(await snap.update(id, data as Partial<EntityValues<M>>));
508
583
  },
584
+ async updateMany(updates: { id: string | number; data: Partial<M> }[]): Promise<M[]> {
585
+ if (!Array.isArray(updates)) {
586
+ throw new TypeError("updateMany expects an array of { id, data } entries.");
587
+ }
588
+ if (updates.length === 0) return [];
589
+ if (!snap.updateMany) {
590
+ throw new Error(
591
+ "Bulk updates are not supported by this collection's data source. " +
592
+ "Fall back to update() per record."
593
+ );
594
+ }
595
+ const rows = await snap.updateMany(
596
+ updates.map(u => ({ id: u.id,
597
+ data: u.data as Partial<EntityValues<M>> }))
598
+ );
599
+ return rows.map(entityToRow);
600
+ },
509
601
  delete(id: string | number): Promise<void> {
510
602
  return snap.delete(id);
511
603
  },
604
+ async deleteMany(ids: (string | number)[]): Promise<void> {
605
+ if (!Array.isArray(ids)) {
606
+ throw new TypeError("deleteMany expects an array of ids.");
607
+ }
608
+ if (ids.length === 0) return;
609
+ if (!snap.deleteMany) {
610
+ throw new Error(
611
+ "Bulk deletes are not supported by this collection's data source. " +
612
+ "Fall back to delete() per record."
613
+ );
614
+ }
615
+ await snap.deleteMany(ids);
616
+ },
512
617
  count: snap.count ? (params?: FindParams<M>) => snap.count!(params) : undefined,
513
618
  listen: snap.listen
514
619
  ? (params: FindParams<M> | undefined, onUpdate: (r: FindResult<M>) => void, onError?: (e: Error) => void) =>
@@ -523,12 +628,17 @@ function toSdkCollectionClient<M extends Record<string, unknown>>(
523
628
  if (typeof columnOrCondition === "object") {
524
629
  return builder.where(columnOrCondition);
525
630
  }
526
- return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
631
+ return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValueFor<WhereFilterOp, M[keyof M & string]>);
527
632
  },
528
633
  orderBy: (column: keyof M & string, direction?: "asc" | "desc") => new SdkQueryBuilder<M>(client).orderBy(column, direction),
529
634
  limit: (count: number) => new SdkQueryBuilder<M>(client).limit(count),
530
635
  offset: (count: number) => new SdkQueryBuilder<M>(client).offset(count),
531
636
  search: (searchString: string) => new SdkQueryBuilder<M>(client).search(searchString),
637
+ vectorSearch: (
638
+ property: string,
639
+ vector: number[],
640
+ options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
641
+ ) => new SdkQueryBuilder<M>(client).vectorSearch(property, vector, options),
532
642
  include: (...relations: string[]) => new SdkQueryBuilder<M>(client).include(...relations)
533
643
  };
534
644
  return client;
@@ -537,7 +647,7 @@ function toSdkCollectionClient<M extends Record<string, unknown>>(
537
647
  /**
538
648
  * Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
539
649
  * {@link CollectionAccessor}. Every returned row is re-wrapped into the
540
- * `{ id, path, values }` view-model the admin admin renders.
650
+ * `{ id, path, values }` view-model the admin panel renders.
541
651
  */
542
652
  function toEntityAccessor<M extends Record<string, unknown>>(
543
653
  sdk: SDKCollectionClient<M>,
@@ -556,6 +666,16 @@ function toEntityAccessor<M extends Record<string, unknown>>(
556
666
  async create(data: Partial<EntityValues<M>>, id?: string | number): Promise<Entity<M>> {
557
667
  return rowToEntity<M>(await sdk.create(data as Partial<M>, id), slug, getPks());
558
668
  },
669
+ // Declared on `CollectionAccessor` and, until now, never implemented on
670
+ // this side of the boundary — so the admin's own import wrote one HTTP
671
+ // request per row and could neither be atomic nor upsert. It forwards to
672
+ // the same `/bulk` route the SDK client uses.
673
+ createMany: sdk.createMany
674
+ ? async (data: Partial<EntityValues<M>>[], options?: { upsert?: boolean }): Promise<Entity<M>[]> => {
675
+ const rows = await sdk.createMany!(data as Partial<M>[], options);
676
+ return rows.map((row) => rowToEntity<M>(row, slug, getPks()));
677
+ }
678
+ : undefined,
559
679
  async update(id: string | number, data: Partial<EntityValues<M>>): Promise<Entity<M>> {
560
680
  const row = await sdk.update(id, data as Partial<M>);
561
681
  if (!row) throw new Error(`Update returned no data for id ${id}`);
@@ -578,12 +698,17 @@ function toEntityAccessor<M extends Record<string, unknown>>(
578
698
  if (typeof columnOrCondition === "object") {
579
699
  return builder.where(columnOrCondition);
580
700
  }
581
- return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
701
+ return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValueFor<WhereFilterOp, M[keyof M & string]>);
582
702
  },
583
703
  orderBy: (column: keyof M & string, direction?: "asc" | "desc") => new QueryBuilder<M>(accessor).orderBy(column, direction),
584
704
  limit: (count: number) => new QueryBuilder<M>(accessor).limit(count),
585
705
  offset: (count: number) => new QueryBuilder<M>(accessor).offset(count),
586
706
  search: (searchString: string) => new QueryBuilder<M>(accessor).search(searchString),
707
+ vectorSearch: (
708
+ property: string,
709
+ vector: number[],
710
+ options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
711
+ ) => new QueryBuilder<M>(accessor).vectorSearch(property, vector, options),
587
712
  include: (...relations: string[]) => new QueryBuilder<M>(accessor).include(...relations)
588
713
  };
589
714
  return accessor;
@@ -598,7 +723,16 @@ function toEntityAccessor<M extends Record<string, unknown>>(
598
723
  * admin `RebaseDataContext` — without it the admin renders rows with only their
599
724
  * `id`.
600
725
  */
601
- export function wrapAsEntityData(sdkData: RebaseSdkData, options?: EntityDataOptions): RebaseData {
726
+ /**
727
+ * Only the by-slug accessor is asked for, so only that is required.
728
+ *
729
+ * Taking a whole `RebaseSdkData` meant taking `RebaseSdkData<unknown>`, whose
730
+ * dynamic branch is an index signature — and no `RebaseSdkData<DB>` satisfies
731
+ * it, because its own `collection` method is not a `SDKCollectionClient`. So a
732
+ * caller holding a *typed* client could not pass it to a function that reads
733
+ * one method off it, and that method is identical on every instantiation.
734
+ */
735
+ export function wrapAsEntityData(sdkData: Pick<RebaseSdkData, "collection">, options?: EntityDataOptions): RebaseData {
602
736
  const cache = new Map<string, CollectionAccessor>();
603
737
  const primaryKeysFor = createPrimaryKeyResolver(options);
604
738
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The `FilterValues` grammar, one level below the wire codec.
3
+ *
4
+ * A field's filter is either one `[op, value]` tuple or an **array** of them —
5
+ * `{ age: [[">=", 18], ["<", 65]] }` — which is what the fluent builder produces
6
+ * from two `.where()` calls on the same column. Reading that shape is grammar,
7
+ * not a driver detail, so every compiler reads it through here.
8
+ *
9
+ * It lived only inside the Postgres compiler, and the Mongo one destructured
10
+ * `const [op, value] = filterParam` regardless: given the array-of-tuples form
11
+ * `op` bound to `[">=", 18]`, no operator matched, and the condition was
12
+ * dropped. Both of them. A read asking for adults under 65 returned every row
13
+ * of the collection with a 200.
14
+ *
15
+ * @module
16
+ */
17
+
18
+ import type { WhereFilterOp } from "@rebasepro/types";
19
+
20
+ /** One `[operator, value]` condition. */
21
+ export type FilterTuple = [WhereFilterOp, unknown];
22
+
23
+ /**
24
+ * Read one field's filter as the list of conditions it stands for.
25
+ *
26
+ * Accepts both declared shapes and normalises them to a list:
27
+ *
28
+ * ```ts
29
+ * toFilterTuples(["==", "active"]) // [["==", "active"]]
30
+ * toFilterTuples([[">=", 18], ["<", 65]]) // [[">=", 18], ["<", 65]]
31
+ * ```
32
+ *
33
+ * A falsy, non-array or empty param has no conditions in it — the empty list,
34
+ * so a caller iterating adds nothing rather than compiling a tuple of
35
+ * `undefined`s and logging about an operator nobody sent.
36
+ */
37
+ export function toFilterTuples(filterParam: unknown): FilterTuple[] {
38
+ if (!filterParam || !Array.isArray(filterParam) || filterParam.length === 0) return [];
39
+ // The first element discriminates: a condition starts with an operator
40
+ // string, a list of conditions starts with a condition. `["in", ["a","b"]]`
41
+ // is one condition whose value happens to be a list.
42
+ if (Array.isArray(filterParam[0])) {
43
+ return filterParam as FilterTuple[];
44
+ }
45
+ return [filterParam as FilterTuple];
46
+ }
@@ -9,8 +9,10 @@
9
9
  * metadata, so type coercion is the responsibility of the server-side data
10
10
  * driver which has access to the collection schema.
11
11
  *
12
- * Commas inside list values are backslash-escaped (`\,`), and literal
13
- * backslashes are escaped as `\\`.
12
+ * Structural characters inside a value are backslash-escaped: `,` → `\,`,
13
+ * `(` → `\(`, `)` → `\)`, and a literal backslash as `\\`. Decoding is
14
+ * deliberately conservative — only those four sequences are decoded, so a
15
+ * backslash that arrives unescaped from an older client survives intact.
14
16
  *
15
17
  * @module
16
18
  */
@@ -51,26 +53,49 @@ function stringifyValue(value: unknown): string {
51
53
  // ---------------------------------------------------------------------------
52
54
 
53
55
  /**
54
- * Escape a single list item for the wire format.
55
- * `\` → `\\`, `,` → `\,`
56
+ * Characters that carry structure in the wire format and must therefore be
57
+ * escaped inside a value: the separator, the group delimiters, and the escape
58
+ * character itself.
59
+ *
60
+ * Parentheses are here because `and(...)`/`or(...)` groups are parsed by
61
+ * tracking paren depth. A value containing one is not merely ambiguous, it
62
+ * moves where the parser thinks the group ends.
63
+ */
64
+ const WIRE_SPECIALS = /[\\,()]/g;
65
+
66
+ /**
67
+ * Escape a value for the wire format: `\` → `\\`, `,` → `\,`, `(` → `\(`,
68
+ * `)` → `\)`.
56
69
  */
57
- function escapeListItem(value: string): string {
58
- return value.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
70
+ function escapeWireValue(value: string): string {
71
+ return value.replace(WIRE_SPECIALS, ch => `\\${ch}`);
59
72
  }
60
73
 
61
74
  /**
62
- * Unescape a single list item from the wire format.
63
- * `\\` → `\`, `\,` → `,`
75
+ * Unescape a wire-format value.
76
+ *
77
+ * **Conservative**, and deliberately so: only the four sequences
78
+ * {@link escapeWireValue} actually produces are decoded. A backslash followed
79
+ * by anything else is left exactly as it is.
80
+ *
81
+ * This used to consume the backslash before *any* character, which is
82
+ * indistinguishable for anything this codec emitted — it only ever emits those
83
+ * four — but not for input arriving from elsewhere. A client on an older
84
+ * release sends a Windows path or a LIKE pattern with a literal `C:\x`
85
+ * unescaped, and greedy unescaping silently turned it into `C:x`, changing
86
+ * which rows matched. Decoding only what the encoder can produce makes the two
87
+ * directions agree across versions.
64
88
  */
65
- function unescapeListItem(value: string): string {
89
+ function unescapeWireValue(value: string): string {
66
90
  let result = "";
67
91
  for (let i = 0; i < value.length; i++) {
68
- if (value[i] === "\\" && i + 1 < value.length) {
69
- result += value[i + 1];
70
- i++; // skip next char
71
- } else {
72
- result += value[i];
92
+ const next = value[i + 1];
93
+ if (value[i] === "\\" && (next === "\\" || next === "," || next === "(" || next === ")")) {
94
+ result += next;
95
+ i++;
96
+ continue;
73
97
  }
98
+ result += value[i];
74
99
  }
75
100
  return result;
76
101
  }
@@ -88,20 +113,49 @@ function splitListItems(inner: string): string[] {
88
113
  let current = "";
89
114
  for (let i = 0; i < inner.length; i++) {
90
115
  if (inner[i] === "\\" && i + 1 < inner.length) {
91
- // Escaped character — consume both chars
116
+ // Escaped pair — consume both chars so the comma in `\,` is not
117
+ // read as a separator. Kept verbatim; decoding happens once, below.
92
118
  current += inner[i] + inner[i + 1];
93
119
  i++;
94
120
  } else if (inner[i] === ",") {
95
- items.push(unescapeListItem(current));
121
+ items.push(unescapeWireValue(current));
96
122
  current = "";
97
123
  } else {
98
124
  current += inner[i];
99
125
  }
100
126
  }
101
- items.push(unescapeListItem(current));
127
+ items.push(unescapeWireValue(current));
102
128
  return items;
103
129
  }
104
130
 
131
+ /**
132
+ * Split a group body on commas at paren depth 0, honouring escapes.
133
+ *
134
+ * The escape-awareness is the point. The splitter used to track only paren
135
+ * depth, so a comma inside a scalar value ended a condition:
136
+ * `or(name.eq.Doe, John,age.gte.18)` parsed as *three* conditions, the middle
137
+ * one a fabricated `" John" == true`. On an `or` that widens the result set,
138
+ * and nothing anywhere reports an error — the query simply stops meaning what
139
+ * the caller wrote.
140
+ */
141
+ function splitGroupItems(inner: string): string[] {
142
+ const parts: string[] = [];
143
+ let depth = 0;
144
+ let start = 0;
145
+ for (let i = 0; i < inner.length; i++) {
146
+ const ch = inner[i];
147
+ if (ch === "\\" && i + 1 < inner.length) { i++; continue; }
148
+ if (ch === "(") depth++;
149
+ else if (ch === ")") depth--;
150
+ else if (ch === "," && depth === 0) {
151
+ parts.push(inner.slice(start, i));
152
+ start = i + 1;
153
+ }
154
+ }
155
+ parts.push(inner.slice(start));
156
+ return parts;
157
+ }
158
+
105
159
  // ---------------------------------------------------------------------------
106
160
  // Typed operator map lookups (no `as any`)
107
161
  // ---------------------------------------------------------------------------
@@ -146,7 +200,7 @@ function serializeTuple(tuple: [WhereFilterOp, unknown]): string {
146
200
  }
147
201
 
148
202
  if (Array.isArray(value)) {
149
- const items = value.map(v => escapeListItem(stringifyValue(v))).join(",");
203
+ const items = value.map(v => escapeWireValue(stringifyValue(v))).join(",");
150
204
  return `${restOp}.(${items})`;
151
205
  }
152
206
 
@@ -337,10 +391,13 @@ export function serializeLogicalCondition(
337
391
  // FilterCondition
338
392
  const restOp = CANONICAL_OP_LOOKUP[cond.operator] ?? "eq";
339
393
  if (Array.isArray(cond.value)) {
340
- const items = cond.value.map(v => escapeListItem(stringifyValue(v))).join(",");
394
+ const items = cond.value.map(v => escapeWireValue(stringifyValue(v))).join(",");
341
395
  return `${cond.column}.${restOp}.(${items})`;
342
396
  }
343
- return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
397
+ // Escaped, like a list item. A scalar inside a group sits between the same
398
+ // delimiters a list item does, so leaving it raw let a comma in the value
399
+ // end the condition early — see `splitGroupItems`.
400
+ return `${cond.column}.${restOp}.${escapeWireValue(stringifyValue(cond.value))}`;
344
401
  }
345
402
 
346
403
  /**
@@ -354,28 +411,42 @@ export function serializeLogicalCondition(
354
411
  * deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
355
412
  * // → { type: "or", conditions: [...] }
356
413
  */
414
+ /**
415
+ * How deeply `or(...)`/`and(...)` groups may nest.
416
+ *
417
+ * This parser recurses once per level, on a value that arrives in a query
418
+ * string. Unbounded, twenty thousand levels reached `RangeError: Maximum call
419
+ * stack size exceeded`, which a caller sees as a 500 about the call stack
420
+ * rather than a 400 about their filter. Node's 16 KB header cap keeps a GET
421
+ * below that in practice, but "the HTTP layer happens to stop it" is not a
422
+ * bound this parser should rely on.
423
+ *
424
+ * Thirty-two is far past anything a real filter expresses; the deepest in this
425
+ * repository's own tests is three.
426
+ */
427
+ export const MAX_LOGICAL_NESTING_DEPTH = 32;
428
+
357
429
  export function deserializeLogicalCondition(
358
- str: string
430
+ str: string,
431
+ // Not `depth`: the body already uses that name for paren tracking, inside a
432
+ // block that shadows a parameter of the same name — so the recursion
433
+ // counter silently became the paren counter and never grew.
434
+ nesting = 0
359
435
  ): LogicalCondition | FilterCondition {
436
+ if (nesting > MAX_LOGICAL_NESTING_DEPTH) {
437
+ throw new Error(
438
+ `Filter groups nest more than ${MAX_LOGICAL_NESTING_DEPTH} levels deep. ` +
439
+ "Flatten the condition — `or(a,or(b,c))` is `or(a,b,c)`."
440
+ );
441
+ }
360
442
  // Check for logical group: "and(...)" or "or(...)"
361
443
  const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
362
444
  if (logicalMatch) {
363
445
  const type = logicalMatch[1] as "and" | "or";
364
446
  const innerStr = logicalMatch[2];
365
447
 
366
- // Split on commas that are not inside parentheses
367
- const conditions: (LogicalCondition | FilterCondition)[] = [];
368
- let depth = 0;
369
- let start = 0;
370
- for (let i = 0; i < innerStr.length; i++) {
371
- if (innerStr[i] === "(") depth++;
372
- else if (innerStr[i] === ")") depth--;
373
- else if (innerStr[i] === "," && depth === 0) {
374
- conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
375
- start = i + 1;
376
- }
377
- }
378
- conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
448
+ const conditions = splitGroupItems(innerStr)
449
+ .map(part => deserializeLogicalCondition(part, nesting + 1));
379
450
 
380
451
  return { type, conditions };
381
452
  }
@@ -392,18 +463,20 @@ export function deserializeLogicalCondition(
392
463
  const secondDot = rest.indexOf(".");
393
464
  if (secondDot === -1) {
394
465
  // "column.value" — treat as equality (value kept as string)
395
- return { column, operator: "==", value: rest };
466
+ return { column, operator: "==", value: unescapeWireValue(rest) };
396
467
  }
397
468
 
398
469
  const opStr = rest.substring(0, secondDot);
399
470
  const valueStr = rest.substring(secondDot + 1);
400
471
  const operator = toCanonicalOp(opStr) ?? "==";
401
472
 
402
- // Parse list values with escape-aware splitting
473
+ // Parse list values with escape-aware splitting. The wrapping parens are
474
+ // written by the serializer *after* the items are escaped, so an escaped
475
+ // paren inside an item can never be mistaken for them.
403
476
  if (valueStr.startsWith("(") && valueStr.endsWith(")")) {
404
477
  const items = splitListItems(valueStr.slice(1, -1));
405
478
  return { column, operator, value: items };
406
479
  }
407
480
 
408
- return { column, operator, value: valueStr };
481
+ return { column, operator, value: unescapeWireValue(valueStr) };
409
482
  }