@rebasepro/client 0.17.3 → 0.18.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/README.md +4 -0
- package/dist/auth.d.ts +80 -0
- package/dist/functions.d.ts +6 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.es.js +471 -94
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -1
- package/dist/offline.d.ts +23 -1
- package/dist/query-contract.types.d.ts +30 -0
- package/dist/realtime-channel.d.ts +29 -1
- package/dist/sdk_query_builder.d.ts +21 -2
- package/dist/transport.d.ts +24 -0
- package/package.json +28 -15
- package/src/admin.ts +0 -90
- package/src/anonymous-client-guard.test.ts +0 -190
- package/src/api-keys.ts +0 -87
- package/src/auth-listener-errors.test.ts +0 -57
- package/src/auth-refresh-overflow.test.ts +0 -89
- package/src/auth.ts +0 -982
- package/src/backups.ts +0 -40
- package/src/client-close.test.ts +0 -80
- package/src/collection-listen-meta.test.ts +0 -105
- package/src/collection-observe.test.ts +0 -138
- package/src/collection.test.ts +0 -293
- package/src/collection.ts +0 -525
- package/src/cron.test.ts +0 -164
- package/src/cron.ts +0 -62
- package/src/data-proxy.test.ts +0 -183
- package/src/errors.ts +0 -9
- package/src/functions.ts +0 -82
- package/src/index.ts +0 -639
- package/src/like-pattern-redos.test.ts +0 -61
- package/src/offline-codec.ts +0 -79
- package/src/offline-connectivity.test.ts +0 -191
- package/src/offline-connectivity.ts +0 -255
- package/src/offline-idb-store.test.ts +0 -340
- package/src/offline-integration.test.ts +0 -180
- package/src/offline-query.test.ts +0 -431
- package/src/offline-query.ts +0 -529
- package/src/offline-store.ts +0 -357
- package/src/offline-sync-engine.test.ts +0 -857
- package/src/offline.test.ts +0 -897
- package/src/offline.ts +0 -1928
- package/src/query-contract.types.ts +0 -206
- package/src/query_builder.ts +0 -1
- package/src/realtime-channel.test.ts +0 -542
- package/src/realtime-channel.ts +0 -539
- package/src/realtime-concurrent-subscribe.test.ts +0 -102
- package/src/realtime-error-surfacing.test.ts +0 -105
- package/src/realtime-optout.test.ts +0 -279
- package/src/realtime-row-identity.test.ts +0 -254
- package/src/realtime-subscription-key.test.ts +0 -92
- package/src/reviver.ts +0 -39
- package/src/sdk_query_builder.ts +0 -206
- package/src/storage-key-encoding.test.ts +0 -65
- package/src/storage-registry.ts +0 -102
- package/src/storage.ts +0 -253
- package/src/transport-baseurl.test.ts +0 -101
- package/src/transport.ts +0 -505
- package/src/vector-search-listen.test.ts +0 -42
- package/src/vector-search-query.test.ts +0 -55
- package/src/websocket-url.test.ts +0 -97
- package/src/websocket.ts +0 -1837
package/src/offline-query.ts
DELETED
|
@@ -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
|
-
}
|