@rebasepro/client 0.17.3 → 0.18.0

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 (63) hide show
  1. package/README.md +4 -0
  2. package/dist/auth.d.ts +80 -0
  3. package/dist/functions.d.ts +6 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.es.js +471 -94
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/offline-connectivity.d.ts +12 -1
  8. package/dist/offline.d.ts +23 -1
  9. package/dist/query-contract.types.d.ts +30 -0
  10. package/dist/realtime-channel.d.ts +29 -1
  11. package/dist/sdk_query_builder.d.ts +21 -2
  12. package/dist/transport.d.ts +24 -0
  13. package/package.json +28 -15
  14. package/src/admin.ts +0 -90
  15. package/src/anonymous-client-guard.test.ts +0 -190
  16. package/src/api-keys.ts +0 -87
  17. package/src/auth-listener-errors.test.ts +0 -57
  18. package/src/auth-refresh-overflow.test.ts +0 -89
  19. package/src/auth.ts +0 -982
  20. package/src/backups.ts +0 -40
  21. package/src/client-close.test.ts +0 -80
  22. package/src/collection-listen-meta.test.ts +0 -105
  23. package/src/collection-observe.test.ts +0 -138
  24. package/src/collection.test.ts +0 -293
  25. package/src/collection.ts +0 -525
  26. package/src/cron.test.ts +0 -164
  27. package/src/cron.ts +0 -62
  28. package/src/data-proxy.test.ts +0 -183
  29. package/src/errors.ts +0 -9
  30. package/src/functions.ts +0 -82
  31. package/src/index.ts +0 -639
  32. package/src/like-pattern-redos.test.ts +0 -61
  33. package/src/offline-codec.ts +0 -79
  34. package/src/offline-connectivity.test.ts +0 -191
  35. package/src/offline-connectivity.ts +0 -255
  36. package/src/offline-idb-store.test.ts +0 -340
  37. package/src/offline-integration.test.ts +0 -180
  38. package/src/offline-query.test.ts +0 -431
  39. package/src/offline-query.ts +0 -529
  40. package/src/offline-store.ts +0 -357
  41. package/src/offline-sync-engine.test.ts +0 -857
  42. package/src/offline.test.ts +0 -897
  43. package/src/offline.ts +0 -1928
  44. package/src/query-contract.types.ts +0 -206
  45. package/src/query_builder.ts +0 -1
  46. package/src/realtime-channel.test.ts +0 -542
  47. package/src/realtime-channel.ts +0 -539
  48. package/src/realtime-concurrent-subscribe.test.ts +0 -102
  49. package/src/realtime-error-surfacing.test.ts +0 -105
  50. package/src/realtime-optout.test.ts +0 -279
  51. package/src/realtime-row-identity.test.ts +0 -254
  52. package/src/realtime-subscription-key.test.ts +0 -92
  53. package/src/reviver.ts +0 -39
  54. package/src/sdk_query_builder.ts +0 -206
  55. package/src/storage-key-encoding.test.ts +0 -65
  56. package/src/storage-registry.ts +0 -102
  57. package/src/storage.ts +0 -253
  58. package/src/transport-baseurl.test.ts +0 -101
  59. package/src/transport.ts +0 -505
  60. package/src/vector-search-listen.test.ts +0 -42
  61. package/src/vector-search-query.test.ts +0 -55
  62. package/src/websocket-url.test.ts +0 -97
  63. package/src/websocket.ts +0 -1837
@@ -1,529 +0,0 @@
1
- import {
2
- EntityRelation,
3
- FilterValues,
4
- FindResult,
5
- LogicalCondition,
6
- FilterCondition,
7
- OrderBySpec,
8
- WhereFilterOp,
9
- toCanonicalOp,
10
- parseRelationAggregateSort
11
- } from "@rebasepro/types";
12
- import { FindParams } from "./transport";
13
- import { normalizeOrderBy, resolveFindWindow } from "@rebasepro/common";
14
-
15
- /**
16
- * A local evaluator for `FindParams`, so cached rows can answer a query the
17
- * client has never sent to the server — and so a row written offline shows up
18
- * in every filtered list it belongs to, not just in unfiltered ones.
19
- *
20
- * This mirrors the Postgres driver's semantics rather than JavaScript's:
21
- *
22
- * - Comparing against NULL is *unknown*, not false-or-true. `status != "done"`
23
- * excludes rows where `status` is null, exactly as SQL does — a JS `!==`
24
- * would have included them.
25
- * - `ORDER BY` puts nulls last ascending and first descending, which is the
26
- * Postgres default.
27
- * - The wire format carries no types, so values arriving as strings are
28
- * compared numerically against numeric columns and as instants against
29
- * date columns. `["==", "3"]` matches the number `3`, as it does server-side.
30
- *
31
- * Two things it deliberately approximates, both flagged by
32
- * {@link isExactlyEvaluable}: `searchString` becomes a case-insensitive
33
- * substring scan over the row's string fields (the server runs real full-text
34
- * search over the collection's configured columns), and `include` cannot be
35
- * evaluated at all, because the related rows live in collections this query
36
- * knows nothing about.
37
- */
38
-
39
- const collator = typeof Intl !== "undefined" && typeof Intl.Collator === "function"
40
- ? new Intl.Collator(undefined, { numeric: false, sensitivity: "variant" })
41
- : undefined;
42
-
43
- /**
44
- * The server's page size when the caller does not ask for one.
45
- *
46
- * Re-exported rather than redeclared. This was its own `= 20` — a third
47
- * constant of this name in the workspace, next to `@rebasepro/common`'s 200 and
48
- * the 50 the REST layer actually applies — and a local copy of a number that
49
- * belongs to another process is a number that goes stale silently.
50
- */
51
- export { DEFAULT_LIST_LIMIT as DEFAULT_PAGE_SIZE } from "@rebasepro/types";
52
-
53
- function isNullish(value: unknown): boolean {
54
- return value === null || value === undefined;
55
- }
56
-
57
- /**
58
- * Reduce a value to something comparable. Relations compare by the id they
59
- * point at — the column holds a foreign key, so that is what the server
60
- * compares too.
61
- */
62
- function toComparable(value: unknown): unknown {
63
- if (value instanceof Date) return value.getTime();
64
- if (value instanceof EntityRelation) return value.id;
65
- if (value && typeof value === "object") {
66
- const record = value as Record<string, unknown>;
67
- // A reference/relation that lost its prototype somewhere still
68
- // compares by id.
69
- if (typeof record.__type === "string" && "id" in record) return record.id;
70
- }
71
- return value;
72
- }
73
-
74
- /**
75
- * Three-way compare with SQL's type coercion but not its collation. Returns
76
- * `undefined` when the two values are not ordered relative to each other,
77
- * which is how NULL propagates through a comparison.
78
- */
79
- export function compareValues(a: unknown, b: unknown): number | undefined {
80
- const left = toComparable(a);
81
- const right = toComparable(b);
82
- if (isNullish(left) || isNullish(right)) return undefined;
83
-
84
- if (typeof left === "boolean" || typeof right === "boolean") {
85
- const l = left === true || left === "true" || left === 1 ? 1 : 0;
86
- const r = right === true || right === "true" || right === 1 ? 1 : 0;
87
- return l - r;
88
- }
89
-
90
- // A numeric string on either side means the wire dropped the type; compare
91
- // as numbers so `["<", "10"]` does not order "10" before "9" as text.
92
- const leftNum = typeof left === "number" ? left : numericOrNaN(left);
93
- const rightNum = typeof right === "number" ? right : numericOrNaN(right);
94
- if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
95
- return leftNum < rightNum ? -1 : leftNum > rightNum ? 1 : 0;
96
- }
97
-
98
- // One side is a date-shaped string and the other an instant.
99
- if (typeof left === "number" || typeof right === "number") {
100
- const leftTime = toTime(left);
101
- const rightTime = toTime(right);
102
- if (leftTime !== undefined && rightTime !== undefined) {
103
- return leftTime < rightTime ? -1 : leftTime > rightTime ? 1 : 0;
104
- }
105
- }
106
-
107
- const leftStr = String(left);
108
- const rightStr = String(right);
109
- if (collator) return collator.compare(leftStr, rightStr);
110
- return leftStr < rightStr ? -1 : leftStr > rightStr ? 1 : 0;
111
- }
112
-
113
- function numericOrNaN(value: unknown): number {
114
- if (typeof value === "number") return value;
115
- if (typeof value === "string" && value.trim() !== "") {
116
- const n = Number(value);
117
- return Number.isNaN(n) ? NaN : n;
118
- }
119
- if (typeof value === "bigint") return Number(value);
120
- return NaN;
121
- }
122
-
123
- function toTime(value: unknown): number | undefined {
124
- if (typeof value === "number") return value;
125
- if (typeof value === "string") {
126
- const t = Date.parse(value);
127
- return Number.isNaN(t) ? undefined : t;
128
- }
129
- return undefined;
130
- }
131
-
132
- /** Equality with the wire's type erasure allowed for, but never across NULL. */
133
- export function looseEquals(a: unknown, b: unknown): boolean {
134
- const left = toComparable(a);
135
- const right = toComparable(b);
136
- if (isNullish(left) || isNullish(right)) return isNullish(left) && isNullish(right);
137
- if (left === right) return true;
138
- const cmp = compareValues(left, right);
139
- return cmp === 0;
140
- }
141
-
142
- /**
143
- * Translate a SQL `LIKE` pattern to an anchored regular expression.
144
- * `%` matches any run of characters, `_` exactly one, and a backslash escapes
145
- * either of them.
146
- */
147
- function likeToRegExp(pattern: string, caseInsensitive: boolean): RegExp {
148
- let source = "^";
149
- // Runs of `%` collapse to one. `%%%%X` means exactly what `%X` means, but
150
- // as a regular expression it is four adjacent unbounded quantifiers, and on
151
- // a subject that does not match the engine tries every way of splitting the
152
- // subject between them. Fourteen of them against a forty-eight character
153
- // value took eighty-seven seconds to answer `false`.
154
- //
155
- // The pattern is user input — `?title=like.%25%25%25…` over HTTP — so that
156
- // is a request that pins a CPU. Collapsing is semantics-preserving and
157
- // removes the ambiguity the backtracking feeds on.
158
- let lastWasWildcard = false;
159
- for (let i = 0; i < pattern.length; i++) {
160
- const char = pattern[i];
161
- if (char === "\\" && i + 1 < pattern.length) {
162
- source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
163
- i++;
164
- lastWasWildcard = false;
165
- } else if (char === "%") {
166
- if (!lastWasWildcard) source += "[\\s\\S]*";
167
- lastWasWildcard = true;
168
- } else if (char === "_") {
169
- source += "[\\s\\S]";
170
- lastWasWildcard = false;
171
- } else {
172
- source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
173
- lastWasWildcard = false;
174
- }
175
- }
176
- return new RegExp(source + "$", caseInsensitive ? "i" : "");
177
- }
178
-
179
- function asArray(value: unknown): unknown[] {
180
- if (Array.isArray(value)) return value;
181
- if (value === undefined) return [];
182
- return [value];
183
- }
184
-
185
- /** Evaluate one canonical operator against one row value. */
186
- export function matchesOperator(rowValue: unknown, op: WhereFilterOp, filterValue: unknown): boolean {
187
- switch (op) {
188
- case "is-null":
189
- return isNullish(rowValue);
190
- case "is-not-null":
191
- return !isNullish(rowValue);
192
- case "==":
193
- return looseEquals(rowValue, filterValue);
194
- case "!=":
195
- // SQL: `x != v` is unknown when x is NULL, so the row drops out.
196
- if (isNullish(rowValue)) return false;
197
- return !looseEquals(rowValue, filterValue);
198
- case "<":
199
- case "<=":
200
- case ">":
201
- case ">=": {
202
- const cmp = compareValues(rowValue, filterValue);
203
- if (cmp === undefined) return false;
204
- if (op === "<") return cmp < 0;
205
- if (op === "<=") return cmp <= 0;
206
- if (op === ">") return cmp > 0;
207
- return cmp >= 0;
208
- }
209
- case "in":
210
- if (isNullish(rowValue)) return false;
211
- return asArray(filterValue).some((v) => looseEquals(rowValue, v));
212
- case "not-in":
213
- if (isNullish(rowValue)) return false;
214
- return !asArray(filterValue).some((v) => looseEquals(rowValue, v));
215
- case "array-contains": {
216
- if (!Array.isArray(rowValue)) return false;
217
- return rowValue.some((v) => looseEquals(v, filterValue));
218
- }
219
- case "array-contains-any": {
220
- if (!Array.isArray(rowValue)) return false;
221
- const wanted = asArray(filterValue);
222
- return rowValue.some((v) => wanted.some((w) => looseEquals(v, w)));
223
- }
224
- case "like":
225
- case "not-like":
226
- case "ilike":
227
- case "not-ilike": {
228
- if (isNullish(rowValue)) return false;
229
- const insensitive = op === "ilike" || op === "not-ilike";
230
- const negated = op === "not-like" || op === "not-ilike";
231
- const matched = likeToRegExp(String(filterValue), insensitive).test(String(rowValue));
232
- return negated ? !matched : matched;
233
- }
234
- default:
235
- // An operator this build does not know must not silently drop rows.
236
- return true;
237
- }
238
- }
239
-
240
- function isTuple(value: unknown): value is [WhereFilterOp, unknown] {
241
- return Array.isArray(value) && value.length === 2 && typeof value[0] === "string"
242
- && toCanonicalOp(value[0]) !== undefined;
243
- }
244
-
245
- /** Evaluate a `where` clause: every field, and every tuple on a field, AND-ed. */
246
- export function matchesWhere(row: Record<string, unknown>, where: FilterValues<string> | undefined): boolean {
247
- if (!where) return true;
248
- for (const [field, condition] of Object.entries(where)) {
249
- if (condition === undefined) continue;
250
- const tuples: [WhereFilterOp, unknown][] = isTuple(condition)
251
- ? [condition]
252
- : Array.isArray(condition)
253
- ? (condition as unknown[]).filter(isTuple) as [WhereFilterOp, unknown][]
254
- : [];
255
- for (const [rawOp, value] of tuples) {
256
- const op = toCanonicalOp(rawOp) ?? rawOp;
257
- if (!matchesOperator(row[field], op, value)) return false;
258
- }
259
- }
260
- return true;
261
- }
262
-
263
- /** Evaluate a nested and/or tree. */
264
- export function matchesLogical(
265
- row: Record<string, unknown>,
266
- condition: LogicalCondition | FilterCondition | undefined
267
- ): boolean {
268
- if (!condition) return true;
269
- if ("type" in condition) {
270
- const children = condition.conditions ?? [];
271
- if (children.length === 0) return true;
272
- return condition.type === "or"
273
- ? children.some((c) => matchesLogical(row, c))
274
- : children.every((c) => matchesLogical(row, c));
275
- }
276
- const op = toCanonicalOp(condition.operator) ?? condition.operator;
277
- return matchesOperator(row[condition.column], op as WhereFilterOp, condition.value);
278
- }
279
-
280
- /**
281
- * Approximate the server's full-text search with a case-insensitive substring
282
- * scan over the row's own string fields. Narrower than the real thing (no
283
- * stemming, no configured search columns), and it never matches a field the
284
- * cached row does not carry — a local list may therefore be missing rows the
285
- * server would have returned, which is why {@link isExactlyEvaluable} refuses
286
- * to call a search query exact.
287
- */
288
- export function matchesSearch(row: Record<string, unknown>, searchString: string | undefined): boolean {
289
- if (!searchString) return true;
290
- const needle = searchString.trim().toLowerCase();
291
- if (!needle) return true;
292
- for (const value of Object.values(row)) {
293
- if (typeof value === "string" && value.toLowerCase().includes(needle)) return true;
294
- if (typeof value === "number" && String(value).includes(needle)) return true;
295
- }
296
- return false;
297
- }
298
-
299
- /** Does this row belong in the result set for `params`, ignoring pagination? */
300
- export function matchesParams(row: Record<string, unknown>, params?: FindParams): boolean {
301
- if (!params) return true;
302
- return matchesWhere(row, params.where)
303
- && matchesLogical(row, params.logical)
304
- && matchesSearch(row, params.searchString);
305
- }
306
-
307
- /**
308
- * Sort in place, Postgres-style: nulls last ascending, first descending, with
309
- * the row id as a tiebreak so paging through an unsorted-but-equal run does
310
- * not shuffle rows between pages.
311
- *
312
- * The tiebreak runs *descending*, which is not a taste: every server-side sort
313
- * ends on `id DESC` — `FetchService.buildOrderExpressions` appends it to make
314
- * the ordering total, and the keyset cursor is built to match. This ran
315
- * ascending, so two rows sharing a sort value came back from the local overlay
316
- * in the opposite order to the server's, and {@link isLocallySortable} called
317
- * that page exactly reproducible while it was not.
318
- */
319
- export function sortRows<M extends Record<string, unknown>>(rows: M[], orderBy?: OrderBySpec): M[] {
320
- const keys = normalizeOrderBy(orderBy);
321
- if (!keys) return rows;
322
- return rows.sort((a, b) => {
323
- for (const [field, direction = "asc"] of keys) {
324
- const cmp = compareOnKey(a, b, field, direction);
325
- // Equal on this key — and equal is not a decision, so the next key
326
- // gets to make one. Returning the tiebreak here instead is what a
327
- // single-key sort does at the end, and doing it per key would order
328
- // by the id the moment two rows shared a role.
329
- if (cmp !== 0) return cmp;
330
- }
331
- return tiebreak(a, b);
332
- });
333
- }
334
-
335
- /** One key's verdict: negative, positive, or 0 for "these two are equal here". */
336
- function compareOnKey(
337
- a: Record<string, unknown>,
338
- b: Record<string, unknown>,
339
- field: string,
340
- direction: "asc" | "desc"
341
- ): number {
342
- const av = a[field];
343
- const bv = b[field];
344
- const aNull = isNullish(toComparable(av));
345
- const bNull = isNullish(toComparable(bv));
346
- if (aNull || bNull) {
347
- if (aNull && bNull) return 0;
348
- // NULLS LAST ascending, NULLS FIRST descending.
349
- return (aNull ? 1 : -1) * (direction === "desc" ? -1 : 1);
350
- }
351
- const cmp = compareValues(av, bv);
352
- if (cmp === undefined || cmp === 0) return 0;
353
- return cmp * (direction === "desc" ? -1 : 1);
354
- }
355
-
356
- /** The last word, and the server's: `id DESC`. */
357
- function tiebreak(a: Record<string, unknown>, b: Record<string, unknown>): number {
358
- const cmp = compareValues(a.id, b.id);
359
- return cmp === undefined ? 0 : -cmp;
360
- }
361
-
362
- /**
363
- * Resolve `page`/`offset`/`limit` the way the server does.
364
- *
365
- * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
366
- * 50, so the same `observe()` answered with 20 rows from the local database and
367
- * 50 from the network — a list that changed length depending on which side
368
- * answered, with `page` striding differently on each. Delegated now, so the
369
- * sentence above is true by construction rather than by agreement.
370
- */
371
- export function resolvePagination(params?: FindParams): { limit: number; offset: number } {
372
- const { limit, offset } = resolveFindWindow(params);
373
- return { limit, offset };
374
- }
375
-
376
- /** `<`, `<=`, `>`, `>=` — the operators whose answer depends on a collation. */
377
- const ORDERING_OPS = new Set<WhereFilterOp>(["<", "<=", ">", ">="]);
378
-
379
- /** Does any condition in this `where` clause order its operands? */
380
- function whereOrders(where: FilterValues<string> | undefined): boolean {
381
- if (!where) return false;
382
- for (const condition of Object.values(where)) {
383
- const tuples = isTuple(condition) ? [condition] : (condition as unknown[]).filter(isTuple);
384
- if (tuples.some(([op]) => ORDERING_OPS.has(op))) return true;
385
- }
386
- return false;
387
- }
388
-
389
- /** The same question, through an `and(...)`/`or(...)` tree. */
390
- function logicalOrders(condition: LogicalCondition | FilterCondition | undefined, depth = 0): boolean {
391
- if (!condition || depth > 32) return false;
392
- if ("type" in condition) {
393
- return (condition.conditions ?? []).some((c) => logicalOrders(c, depth + 1));
394
- }
395
- return ORDERING_OPS.has(condition.operator);
396
- }
397
-
398
- /**
399
- * Can a locally evaluated answer to `params` be trusted to match the server's,
400
- * assuming the cache holds every row of the collection?
401
- *
402
- * `include` pulls in rows from other collections that this evaluator never
403
- * sees, and `searchString` is only approximated — both make the local answer a
404
- * best effort rather than an equivalent one.
405
- *
406
- * **Ordering comparisons are refused, and that is the interesting one.**
407
- * `compareValues` falls back to an `Intl.Collator` for operands it cannot read
408
- * as numbers or instants. PostgreSQL orders text by the *database's* collation,
409
- * which is a property of the server this process has never been told: under the
410
- * C collation `'apple' < 'Banana'` is false, under `en_US.UTF-8` it is true,
411
- * and the collator says true. So `["<", "Banana"]` selects a different set here
412
- * than it does there — silently, and in whichever direction the deployment
413
- * happens to have been created.
414
- *
415
- * The refusal covers *every* ordering comparison rather than only the ones with
416
- * a string operand, because the operand type does not settle it: a numeric
417
- * bound against a text column (`["<", 10]` on a `varchar`) also reaches the
418
- * collator, and nothing in `params` says what the column holds. Conservative on
419
- * purpose — the cost is that a query combining an ordering filter with
420
- * *unsynced local writes* stops placing those writes optimistically, which is a
421
- * degraded answer rather than a wrong one. Claiming exactness we do not have is
422
- * the other way round.
423
- *
424
- * This says nothing about ordering *results*; that is a separate claim with a
425
- * separate answer, because a sort changes which rows come first and not which
426
- * rows match. See {@link isLocallySortable}.
427
- */
428
- export function isExactlyEvaluable(params?: FindParams): boolean {
429
- if (!params) return true;
430
- if (params.include && params.include.length > 0) return false;
431
- if (params.searchString) return false;
432
- // Nearest-neighbour ordering is the server's to compute: the cache holds no
433
- // vectors, and even with them, answering from a subset would return the
434
- // nearest of what happens to be cached while looking like the nearest there
435
- // are — a wrong answer that is indistinguishable from a right one.
436
- if (params.vectorSearch) return false;
437
- if (whereOrders(params.where)) return false;
438
- if (logicalOrders(params.logical)) return false;
439
- // A dotted key reaches through a relation — `applications.status` asks
440
- // about rows in another table. `matchesWhere` reads `row[field]` flat, so
441
- // the key resolves to `undefined` on every cached row and the condition
442
- // excludes all of them: a 200 with an empty list, indistinguishable from
443
- // "nothing matched". The cache holds one collection; it cannot answer a
444
- // question about a second, so it must not claim to.
445
- if (whereReachesThroughRelation(params.where)) return false;
446
- if (logicalReachesThroughRelation(params.logical)) return false;
447
- return true;
448
- }
449
-
450
- /** Does any filter key reach outside this row — `applications.status`? */
451
- function whereReachesThroughRelation(where: FilterValues<string> | undefined): boolean {
452
- return where ? Object.keys(where).some((field) => field.includes(".")) : false;
453
- }
454
-
455
- /** The same question, through an `and(...)`/`or(...)` tree. */
456
- function logicalReachesThroughRelation(
457
- condition: LogicalCondition | FilterCondition | undefined,
458
- depth = 0
459
- ): boolean {
460
- if (!condition || depth > 32) return false;
461
- if ("type" in condition) {
462
- return (condition.conditions ?? []).some((c) => logicalReachesThroughRelation(c, depth + 1));
463
- }
464
- return typeof condition.column === "string" && condition.column.includes(".");
465
- }
466
-
467
- /**
468
- * Would sorting `rows` locally reproduce the order the server would have sent?
469
- *
470
- * Asked of the rows rather than of the query, because unlike a filter this one
471
- * *is* decidable from the data in hand: {@link compareValues} reaches the
472
- * collator only when it cannot read both operands as numbers, and `toComparable`
473
- * has already turned dates and relations into numbers and ids by then. If every
474
- * value on the sort column normalises to a number, the collator is unreachable
475
- * and the local order is the server's order.
476
- *
477
- * A text column is therefore refused — see {@link isExactlyEvaluable} for why
478
- * the two cannot be made to agree — and so is a column this page happens to see
479
- * only as strings, which is the same thing from here.
480
- *
481
- * Nulls are fine either way: they are ordered by an explicit rule (last
482
- * ascending, first descending) that matches Postgres and never reaches the
483
- * comparator.
484
- */
485
- export function isLocallySortable(
486
- rows: readonly Record<string, unknown>[],
487
- orderBy?: OrderBySpec
488
- ): boolean {
489
- const keys = normalizeOrderBy(orderBy);
490
- if (!keys) return true;
491
- // An aggregate over a relation is computed by the database and is not a
492
- // field on the row, so every cached row reads `undefined` for it. That
493
- // looks exactly like a column of nulls to the loop below, which would call
494
- // the order reproducible and then hand back rows in id order — a queue
495
- // sorted by nothing at all, presented as the server's answer.
496
- if (keys.some(([field]) => parseRelationAggregateSort(field))) return false;
497
- // Every key has to be decidable, not just the first: a sort the local side
498
- // can only agree with down to its second column is one it disagrees with.
499
- return keys.every(([field]) => rows.every((row) => {
500
- const value = toComparable(row[field]);
501
- if (isNullish(value)) return true;
502
- if (typeof value === "number" || typeof value === "boolean") return true;
503
- if (typeof value === "bigint") return true;
504
- // A numeric string is compared as a number, so it is safe too — this is
505
- // the wire's type erasure, which `compareValues` already undoes.
506
- if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) return true;
507
- return false;
508
- }));
509
- }
510
-
511
- /** Run a full query — filter, sort, paginate — over a set of rows. */
512
- export function runLocalQuery<M extends Record<string, unknown>>(
513
- rows: M[],
514
- params?: FindParams
515
- ): FindResult<M> {
516
- const matched = rows.filter((row) => matchesParams(row, params));
517
- sortRows(matched, params?.orderBy);
518
- const { limit, offset } = resolvePagination(params);
519
- const page = matched.slice(offset, offset + limit);
520
- return {
521
- data: page,
522
- meta: {
523
- total: matched.length,
524
- limit,
525
- offset,
526
- hasMore: offset + page.length < matched.length
527
- }
528
- };
529
- }