@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.
@@ -1,4 +1,4 @@
1
- import { FilterValues, FindResult, LogicalCondition, FilterCondition, OrderByTuple, WhereFilterOp } from "@rebasepro/types";
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?: OrderByTuple): M[];
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?: OrderByTuple): boolean;
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.0",
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/types": "0.14.0",
33
- "@rebasepro/common": "0.14.0",
34
- "@rebasepro/utils": "0.14.0"
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
- orderBy: params?.orderBy?.[0],
433
- order: params?.orderBy?.[1],
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 so paging cannot shuffle equal rows between pages", () => {
162
- const rows: Row[] = [{ id: "c", n: 1 }, { id: "a", n: 1 }, { id: "b", n: 1 }];
163
- expect(sortRows(rows, ["n", "asc"]).map((r) => r.id)).toEqual(["a", "b", "c"]);
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", () => {
@@ -4,12 +4,12 @@ import {
4
4
  FindResult,
5
5
  LogicalCondition,
6
6
  FilterCondition,
7
- OrderByTuple,
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?: OrderByTuple): M[] {
312
- if (!orderBy) return rows;
313
- const [field, direction = "asc"] = orderBy;
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 av = a[field];
317
- const bv = b[field];
318
- const aNull = isNullish(toComparable(av));
319
- const bNull = isNullish(toComparable(bv));
320
- if (aNull || bNull) {
321
- if (aNull && bNull) return tiebreak(a, b);
322
- // NULLS LAST ascending, NULLS FIRST descending.
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
- const cmp = compareValues(av, bv);
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 ?? 0;
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?: OrderByTuple
461
+ orderBy?: OrderBySpec
437
462
  ): boolean {
438
- if (!orderBy) return true;
439
- const [field] = orderBy;
440
- for (const row of rows) {
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)) continue;
443
- if (typeof value === "number" || typeof value === "boolean") continue;
444
- if (typeof value === "bigint") continue;
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))) continue;
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. */
@@ -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 = [column, direction];
84
+ const existing = normalizeOrderBy(this.params.orderBy) ?? [];
85
+ this.params.orderBy = [...existing, [column, direction] as OrderByTuple];
78
86
  return this;
79
87
  }
80
88