@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.
- package/dist/data/buildRebaseData.d.ts +10 -1
- package/dist/data/filter-conditions.d.ts +34 -0
- package/dist/data/filter-dialect.d.ts +19 -3
- package/dist/data/paginate.d.ts +20 -0
- package/dist/data/query_builder.d.ts +16 -4
- package/dist/data/resolveDataSource.d.ts +36 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +622 -92
- package/dist/index.es.js.map +1 -1
- package/dist/util/builders.d.ts +2 -2
- package/dist/util/collections.d.ts +17 -0
- package/dist/util/conditions.d.ts +7 -3
- package/dist/util/entities.d.ts +8 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/internal-tables.d.ts +95 -0
- package/dist/util/permissions.d.ts +30 -0
- package/dist/util/policy/sqlToPolicy.d.ts +4 -4
- package/dist/util/relations.d.ts +18 -1
- package/dist/util/resolutions.d.ts +31 -0
- package/package.json +4 -3
- package/src/data/buildRebaseData.ts +161 -27
- package/src/data/filter-conditions.ts +46 -0
- package/src/data/filter-dialect.ts +110 -37
- package/src/data/paginate.ts +30 -0
- package/src/data/query_builder.ts +26 -4
- package/src/data/resolveDataSource.ts +56 -0
- package/src/index.ts +1 -0
- package/src/util/auth-default-policies.ts +8 -2
- package/src/util/builders.ts +3 -3
- package/src/util/collections.ts +17 -1
- package/src/util/conditions.ts +8 -3
- package/src/util/entities.ts +15 -1
- package/src/util/index.ts +1 -0
- package/src/util/internal-tables.ts +154 -0
- package/src/util/permissions.test.ts +23 -2
- package/src/util/permissions.ts +43 -6
- package/src/util/policy/evaluatePolicy.ts +24 -2
- package/src/util/policy/policyToPostgres.ts +23 -10
- package/src/util/policy/sqlToPolicy.ts +127 -26
- package/src/util/relations.ts +31 -0
- package/src/util/resolutions.ts +77 -5
|
@@ -14,11 +14,13 @@ import {
|
|
|
14
14
|
SDKCollectionClient,
|
|
15
15
|
SDKQueryBuilderInterface,
|
|
16
16
|
WhereFilterOp,
|
|
17
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
191
|
-
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
|
-
|
|
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
|
|
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
|
|
282
|
-
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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
|
|
58
|
-
return value.replace(
|
|
70
|
+
function escapeWireValue(value: string): string {
|
|
71
|
+
return value.replace(WIRE_SPECIALS, ch => `\\${ch}`);
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
/**
|
|
62
|
-
* Unescape a
|
|
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
|
|
89
|
+
function unescapeWireValue(value: string): string {
|
|
66
90
|
let result = "";
|
|
67
91
|
for (let i = 0; i < value.length; i++) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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(
|
|
121
|
+
items.push(unescapeWireValue(current));
|
|
96
122
|
current = "";
|
|
97
123
|
} else {
|
|
98
124
|
current += inner[i];
|
|
99
125
|
}
|
|
100
126
|
}
|
|
101
|
-
items.push(
|
|
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 =>
|
|
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 =>
|
|
394
|
+
const items = cond.value.map(v => escapeWireValue(stringifyValue(v))).join(",");
|
|
341
395
|
return `${cond.column}.${restOp}.(${items})`;
|
|
342
396
|
}
|
|
343
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
}
|