@rebasepro/client 0.14.0 → 0.14.1
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/index.es.js +47 -27
- package/dist/index.es.js.map +1 -1
- package/dist/offline-query.d.ts +10 -3
- package/dist/sdk_query_builder.d.ts +5 -0
- package/package.json +4 -4
- package/src/collection.ts +6 -3
- package/src/offline-query.test.ts +20 -3
- package/src/offline-query.ts +54 -28
- package/src/sdk_query_builder.ts +9 -1
package/dist/offline-query.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FilterValues, FindResult, LogicalCondition, FilterCondition,
|
|
1
|
+
import { FilterValues, FindResult, LogicalCondition, FilterCondition, OrderBySpec, WhereFilterOp } from "@rebasepro/types";
|
|
2
2
|
import { FindParams } from "./transport";
|
|
3
3
|
/**
|
|
4
4
|
* The server's page size when the caller does not ask for one.
|
|
@@ -38,8 +38,15 @@ export declare function matchesParams(row: Record<string, unknown>, params?: Fin
|
|
|
38
38
|
* Sort in place, Postgres-style: nulls last ascending, first descending, with
|
|
39
39
|
* the row id as a tiebreak so paging through an unsorted-but-equal run does
|
|
40
40
|
* not shuffle rows between pages.
|
|
41
|
+
*
|
|
42
|
+
* The tiebreak runs *descending*, which is not a taste: every server-side sort
|
|
43
|
+
* ends on `id DESC` — `FetchService.buildOrderExpressions` appends it to make
|
|
44
|
+
* the ordering total, and the keyset cursor is built to match. This ran
|
|
45
|
+
* ascending, so two rows sharing a sort value came back from the local overlay
|
|
46
|
+
* in the opposite order to the server's, and {@link isLocallySortable} called
|
|
47
|
+
* that page exactly reproducible while it was not.
|
|
41
48
|
*/
|
|
42
|
-
export declare function sortRows<M extends Record<string, unknown>>(rows: M[], orderBy?:
|
|
49
|
+
export declare function sortRows<M extends Record<string, unknown>>(rows: M[], orderBy?: OrderBySpec): M[];
|
|
43
50
|
/**
|
|
44
51
|
* Resolve `page`/`offset`/`limit` the way the server does.
|
|
45
52
|
*
|
|
@@ -102,6 +109,6 @@ export declare function isExactlyEvaluable(params?: FindParams): boolean;
|
|
|
102
109
|
* ascending, first descending) that matches Postgres and never reaches the
|
|
103
110
|
* comparator.
|
|
104
111
|
*/
|
|
105
|
-
export declare function isLocallySortable(rows: readonly Record<string, unknown>[], orderBy?:
|
|
112
|
+
export declare function isLocallySortable(rows: readonly Record<string, unknown>[], orderBy?: OrderBySpec): boolean;
|
|
106
113
|
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
|
107
114
|
export declare function runLocalQuery<M extends Record<string, unknown>>(rows: M[], params?: FindParams): FindResult<M>;
|
|
@@ -25,6 +25,11 @@ export declare class SDKQueryBuilder<M extends Record<string, unknown> = Record<
|
|
|
25
25
|
where(logicalCondition: LogicalCondition): this;
|
|
26
26
|
/**
|
|
27
27
|
* Order the results by a specific column.
|
|
28
|
+
*
|
|
29
|
+
* Call it again to add a tie-breaker rather than replace the sort: keys
|
|
30
|
+
* apply in the order they were added, so
|
|
31
|
+
* `.orderBy("roles").orderBy("created_at", "desc")` sorts by role and
|
|
32
|
+
* shows the newest first within each one.
|
|
28
33
|
*/
|
|
29
34
|
orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): this;
|
|
30
35
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/client",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.14.
|
|
4
|
+
"version": "0.14.1",
|
|
5
5
|
"description": "HTTP SDK client for the Rebase custom backend",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"./package.json": "./package.json"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@rebasepro/
|
|
33
|
-
"@rebasepro/
|
|
34
|
-
"@rebasepro/
|
|
32
|
+
"@rebasepro/common": "0.14.1",
|
|
33
|
+
"@rebasepro/utils": "0.14.1",
|
|
34
|
+
"@rebasepro/types": "0.14.1"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@jest/globals": "^30.4.1",
|
package/src/collection.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
WriteOptions,
|
|
13
13
|
type ComputedSortField
|
|
14
14
|
} from "@rebasepro/types";
|
|
15
|
-
import { collectAllPages, paginateFind, resolveFindWindow } from "@rebasepro/common";
|
|
15
|
+
import { collectAllPages, normalizeOrderBy, paginateFind, resolveFindWindow } from "@rebasepro/common";
|
|
16
16
|
|
|
17
17
|
import { SDKQueryBuilder } from "./sdk_query_builder";
|
|
18
18
|
|
|
@@ -429,8 +429,11 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
429
429
|
// handed "20" where it expected a keyset value, and the
|
|
430
430
|
// offset it does understand never arrived at all.
|
|
431
431
|
offset: window.driverOffset,
|
|
432
|
-
|
|
433
|
-
|
|
432
|
+
// The list form, so a multi-key sort reaches the socket
|
|
433
|
+
// whole. Indexing `[0]`/`[1]` here read a tuple-of-tuples as
|
|
434
|
+
// a field name and a direction, and a live subscription came
|
|
435
|
+
// back in a different order from the same query fetched.
|
|
436
|
+
orderBy: normalizeOrderBy(params?.orderBy),
|
|
434
437
|
searchString: params?.searchString,
|
|
435
438
|
searchExplain: params?.searchExplain
|
|
436
439
|
},
|
|
@@ -158,9 +158,26 @@ describe("local query engine", () => {
|
|
|
158
158
|
expect(sortRows([...rows], ["n", "desc"]).map((r) => r.id)).toEqual([2, 1, 3]);
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
it("breaks ties on id
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
it("breaks ties on id descending, which is what the server orders by", () => {
|
|
162
|
+
// Not an arbitrary direction: `FetchService.buildOrderExpressions`
|
|
163
|
+
// appends `id DESC` to every sort it builds, and the keyset cursor
|
|
164
|
+
// is written to match it. Ascending here meant the overlay handed
|
|
165
|
+
// back tied rows in the opposite order to the network answer for
|
|
166
|
+
// the same query — while `isLocallySortable` reported the page as
|
|
167
|
+
// exactly reproducible.
|
|
168
|
+
const rows: Row[] = [{ id: "a", n: 1 }, { id: "c", n: 1 }, { id: "b", n: 1 }];
|
|
169
|
+
expect(sortRows(rows, ["n", "asc"]).map((r) => r.id)).toEqual(["c", "b", "a"]);
|
|
170
|
+
expect(sortRows(rows, ["n", "desc"]).map((r) => r.id)).toEqual(["c", "b", "a"]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("lets a later key decide before the id tiebreak does", () => {
|
|
174
|
+
const rows: Row[] = [
|
|
175
|
+
{ id: 1, role: "admin", at: 2 },
|
|
176
|
+
{ id: 2, role: "admin", at: 1 },
|
|
177
|
+
{ id: 3, role: "user", at: 9 }
|
|
178
|
+
];
|
|
179
|
+
expect(sortRows(rows, [["role", "asc"], ["at", "desc"]]).map((r) => r.id))
|
|
180
|
+
.toEqual([1, 2, 3]);
|
|
164
181
|
});
|
|
165
182
|
|
|
166
183
|
it("orders dates chronologically, not lexicographically", () => {
|
package/src/offline-query.ts
CHANGED
|
@@ -4,12 +4,12 @@ import {
|
|
|
4
4
|
FindResult,
|
|
5
5
|
LogicalCondition,
|
|
6
6
|
FilterCondition,
|
|
7
|
-
|
|
7
|
+
OrderBySpec,
|
|
8
8
|
WhereFilterOp,
|
|
9
9
|
toCanonicalOp
|
|
10
10
|
} from "@rebasepro/types";
|
|
11
11
|
import { FindParams } from "./transport";
|
|
12
|
-
import { resolveFindWindow } from "@rebasepro/common";
|
|
12
|
+
import { normalizeOrderBy, resolveFindWindow } from "@rebasepro/common";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* A local evaluator for `FindParams`, so cached rows can answer a query the
|
|
@@ -307,30 +307,55 @@ export function matchesParams(row: Record<string, unknown>, params?: FindParams)
|
|
|
307
307
|
* Sort in place, Postgres-style: nulls last ascending, first descending, with
|
|
308
308
|
* the row id as a tiebreak so paging through an unsorted-but-equal run does
|
|
309
309
|
* not shuffle rows between pages.
|
|
310
|
+
*
|
|
311
|
+
* The tiebreak runs *descending*, which is not a taste: every server-side sort
|
|
312
|
+
* ends on `id DESC` — `FetchService.buildOrderExpressions` appends it to make
|
|
313
|
+
* the ordering total, and the keyset cursor is built to match. This ran
|
|
314
|
+
* ascending, so two rows sharing a sort value came back from the local overlay
|
|
315
|
+
* in the opposite order to the server's, and {@link isLocallySortable} called
|
|
316
|
+
* that page exactly reproducible while it was not.
|
|
310
317
|
*/
|
|
311
|
-
export function sortRows<M extends Record<string, unknown>>(rows: M[], orderBy?:
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const sign = direction === "desc" ? -1 : 1;
|
|
318
|
+
export function sortRows<M extends Record<string, unknown>>(rows: M[], orderBy?: OrderBySpec): M[] {
|
|
319
|
+
const keys = normalizeOrderBy(orderBy);
|
|
320
|
+
if (!keys) return rows;
|
|
315
321
|
return rows.sort((a, b) => {
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
return (aNull ? 1 : -1) * (direction === "desc" ? -1 : 1);
|
|
322
|
+
for (const [field, direction = "asc"] of keys) {
|
|
323
|
+
const cmp = compareOnKey(a, b, field, direction);
|
|
324
|
+
// Equal on this key — and equal is not a decision, so the next key
|
|
325
|
+
// gets to make one. Returning the tiebreak here instead is what a
|
|
326
|
+
// single-key sort does at the end, and doing it per key would order
|
|
327
|
+
// by the id the moment two rows shared a role.
|
|
328
|
+
if (cmp !== 0) return cmp;
|
|
324
329
|
}
|
|
325
|
-
|
|
326
|
-
if (cmp === undefined || cmp === 0) return tiebreak(a, b);
|
|
327
|
-
return cmp * sign;
|
|
330
|
+
return tiebreak(a, b);
|
|
328
331
|
});
|
|
329
332
|
}
|
|
330
333
|
|
|
334
|
+
/** One key's verdict: negative, positive, or 0 for "these two are equal here". */
|
|
335
|
+
function compareOnKey(
|
|
336
|
+
a: Record<string, unknown>,
|
|
337
|
+
b: Record<string, unknown>,
|
|
338
|
+
field: string,
|
|
339
|
+
direction: "asc" | "desc"
|
|
340
|
+
): number {
|
|
341
|
+
const av = a[field];
|
|
342
|
+
const bv = b[field];
|
|
343
|
+
const aNull = isNullish(toComparable(av));
|
|
344
|
+
const bNull = isNullish(toComparable(bv));
|
|
345
|
+
if (aNull || bNull) {
|
|
346
|
+
if (aNull && bNull) return 0;
|
|
347
|
+
// NULLS LAST ascending, NULLS FIRST descending.
|
|
348
|
+
return (aNull ? 1 : -1) * (direction === "desc" ? -1 : 1);
|
|
349
|
+
}
|
|
350
|
+
const cmp = compareValues(av, bv);
|
|
351
|
+
if (cmp === undefined || cmp === 0) return 0;
|
|
352
|
+
return cmp * (direction === "desc" ? -1 : 1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The last word, and the server's: `id DESC`. */
|
|
331
356
|
function tiebreak(a: Record<string, unknown>, b: Record<string, unknown>): number {
|
|
332
357
|
const cmp = compareValues(a.id, b.id);
|
|
333
|
-
return cmp
|
|
358
|
+
return cmp === undefined ? 0 : -cmp;
|
|
334
359
|
}
|
|
335
360
|
|
|
336
361
|
/**
|
|
@@ -433,21 +458,22 @@ export function isExactlyEvaluable(params?: FindParams): boolean {
|
|
|
433
458
|
*/
|
|
434
459
|
export function isLocallySortable(
|
|
435
460
|
rows: readonly Record<string, unknown>[],
|
|
436
|
-
orderBy?:
|
|
461
|
+
orderBy?: OrderBySpec
|
|
437
462
|
): boolean {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
463
|
+
const keys = normalizeOrderBy(orderBy);
|
|
464
|
+
if (!keys) return true;
|
|
465
|
+
// Every key has to be decidable, not just the first: a sort the local side
|
|
466
|
+
// can only agree with down to its second column is one it disagrees with.
|
|
467
|
+
return keys.every(([field]) => rows.every((row) => {
|
|
441
468
|
const value = toComparable(row[field]);
|
|
442
|
-
if (isNullish(value))
|
|
443
|
-
if (typeof value === "number" || typeof value === "boolean")
|
|
444
|
-
if (typeof value === "bigint")
|
|
469
|
+
if (isNullish(value)) return true;
|
|
470
|
+
if (typeof value === "number" || typeof value === "boolean") return true;
|
|
471
|
+
if (typeof value === "bigint") return true;
|
|
445
472
|
// A numeric string is compared as a number, so it is safe too — this is
|
|
446
473
|
// the wire's type erasure, which `compareValues` already undoes.
|
|
447
|
-
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value)))
|
|
474
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) return true;
|
|
448
475
|
return false;
|
|
449
|
-
}
|
|
450
|
-
return true;
|
|
476
|
+
}));
|
|
451
477
|
}
|
|
452
478
|
|
|
453
479
|
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
package/src/sdk_query_builder.ts
CHANGED
|
@@ -2,12 +2,14 @@ import {
|
|
|
2
2
|
FindParams,
|
|
3
3
|
FindResult,
|
|
4
4
|
LogicalCondition,
|
|
5
|
+
OrderByTuple,
|
|
5
6
|
SDKCollectionClient,
|
|
6
7
|
SDKQueryBuilderInterface,
|
|
7
8
|
WhereFilterOp,
|
|
8
9
|
WhereValueFor,
|
|
9
10
|
type ComputedSortField
|
|
10
11
|
} from "@rebasepro/types";
|
|
12
|
+
import { normalizeOrderBy } from "@rebasepro/common";
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* SDK Query Builder — returns flat rows (`FindResult<M>`) instead of
|
|
@@ -72,9 +74,15 @@ export class SDKQueryBuilder<M extends Record<string, unknown> = Record<string,
|
|
|
72
74
|
|
|
73
75
|
/**
|
|
74
76
|
* Order the results by a specific column.
|
|
77
|
+
*
|
|
78
|
+
* Call it again to add a tie-breaker rather than replace the sort: keys
|
|
79
|
+
* apply in the order they were added, so
|
|
80
|
+
* `.orderBy("roles").orderBy("created_at", "desc")` sorts by role and
|
|
81
|
+
* shows the newest first within each one.
|
|
75
82
|
*/
|
|
76
83
|
orderBy(column: (keyof M & string) | ComputedSortField, direction: "asc" | "desc" = "asc"): this {
|
|
77
|
-
this.params.orderBy
|
|
84
|
+
const existing = normalizeOrderBy(this.params.orderBy) ?? [];
|
|
85
|
+
this.params.orderBy = [...existing, [column, direction] as OrderByTuple];
|
|
78
86
|
return this;
|
|
79
87
|
}
|
|
80
88
|
|