@rebasepro/client 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/index.d.ts +1 -0
- package/dist/index.es.js +452 -84
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -0
- package/dist/offline-query.d.ts +59 -3
- package/dist/offline-store.d.ts +8 -1
- package/dist/query-contract.types.d.ts +144 -0
- package/dist/sdk_query_builder.d.ts +46 -4
- package/package.json +4 -4
- package/src/auth-listener-errors.test.ts +57 -0
- package/src/auth-refresh-overflow.test.ts +89 -0
- package/src/auth.ts +30 -1
- package/src/collection-listen-meta.test.ts +105 -0
- package/src/collection-observe.test.ts +138 -0
- package/src/collection.ts +200 -57
- package/src/index.ts +51 -15
- package/src/like-pattern-redos.test.ts +61 -0
- package/src/offline-connectivity.test.ts +35 -1
- package/src/offline-connectivity.ts +35 -3
- package/src/offline-query.test.ts +116 -2
- package/src/offline-query.ts +123 -8
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +180 -2
- package/src/offline.ts +169 -12
- package/src/query-contract.types.ts +206 -0
- package/src/realtime-error-surfacing.test.ts +105 -0
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/sdk_query_builder.ts +56 -4
- package/src/transport-baseurl.test.ts +49 -1
- package/src/transport.ts +34 -0
- package/src/vector-search-query.test.ts +55 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +34 -9
package/src/index.ts
CHANGED
|
@@ -42,6 +42,9 @@ import { toSnakeCase } from "@rebasepro/utils";
|
|
|
42
42
|
// data-proxy's unknown-collection error.
|
|
43
43
|
export { RebaseApiError } from "./transport";
|
|
44
44
|
export { RebaseClientError } from "./errors";
|
|
45
|
+
// The codes `RebaseApiError.code` carries. An open union — routes add their own
|
|
46
|
+
// — so it gives completion on the common ones without pretending to be closed.
|
|
47
|
+
export type { RebaseErrorCode } from "@rebasepro/types";
|
|
45
48
|
|
|
46
49
|
// Query + collection types (annotate SDK results; construct via the fluent API).
|
|
47
50
|
export type { RebaseClientConfig, FindParams, FindResponse } from "./transport";
|
|
@@ -238,35 +241,48 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
|
|
|
238
241
|
/**
|
|
239
242
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
240
243
|
* `http://` → `ws://`, `https://` → `wss://`.
|
|
244
|
+
*
|
|
245
|
+
* A backend mounted under a path is the reason `baseUrl` accepts one, so the
|
|
246
|
+
* path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
|
|
247
|
+
* relative one — resolved through `.origin` — so one deployment dialled two
|
|
248
|
+
* different sockets depending on whether its config said `"/backend"` or
|
|
249
|
+
* `"https://app.example.com/backend"`.
|
|
250
|
+
*
|
|
251
|
+
* Returns `""` when there is nothing to resolve against: a relative `baseUrl`
|
|
252
|
+
* outside a browser has no origin, and inventing one would dial somewhere
|
|
253
|
+
* arbitrary. The caller warns rather than leaving that silent.
|
|
241
254
|
*/
|
|
242
255
|
function deriveWebSocketUrl(baseUrl?: string): string {
|
|
256
|
+
const toWsProtocol = (url: string): string => {
|
|
257
|
+
const secure = /^(https|wss):/i.test(url);
|
|
258
|
+
return url
|
|
259
|
+
.replace(/^https?:\/\//i, secure ? "wss://" : "ws://")
|
|
260
|
+
.replace(/^wss?:\/\//i, secure ? "wss://" : "ws://")
|
|
261
|
+
.replace(/\/$/, "");
|
|
262
|
+
};
|
|
263
|
+
|
|
243
264
|
if (typeof window !== "undefined") {
|
|
244
|
-
let absoluteUrl
|
|
265
|
+
let absoluteUrl: string;
|
|
245
266
|
if (!baseUrl) {
|
|
246
267
|
absoluteUrl = window.location.origin;
|
|
247
268
|
} else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) {
|
|
248
269
|
absoluteUrl = baseUrl;
|
|
249
270
|
} else {
|
|
250
271
|
try {
|
|
251
|
-
|
|
272
|
+
const resolved = new URL(baseUrl, window.location.href);
|
|
273
|
+
absoluteUrl = resolved.origin + resolved.pathname;
|
|
252
274
|
} catch {
|
|
253
275
|
absoluteUrl = window.location.origin;
|
|
254
276
|
}
|
|
255
277
|
}
|
|
256
|
-
|
|
257
|
-
return absoluteUrl
|
|
258
|
-
.replace(/^https?:\/\//i, `${protocol}//`)
|
|
259
|
-
.replace(/^wss?:\/\//i, `${protocol}//`)
|
|
260
|
-
.replace(/\/$/, "");
|
|
278
|
+
return toWsProtocol(absoluteUrl);
|
|
261
279
|
}
|
|
262
280
|
|
|
263
281
|
if (!baseUrl) return "";
|
|
264
282
|
if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) {
|
|
265
283
|
return "";
|
|
266
284
|
}
|
|
267
|
-
return baseUrl
|
|
268
|
-
.replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://")
|
|
269
|
-
.replace(/\/$/, "");
|
|
285
|
+
return toWsProtocol(baseUrl);
|
|
270
286
|
}
|
|
271
287
|
|
|
272
288
|
export function createRebaseClient<DB = Record<string, unknown>>(options: CreateRebaseClientOptions): CreateRebaseClientResult<DB> {
|
|
@@ -332,6 +348,24 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
332
348
|
? (options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl))
|
|
333
349
|
: undefined;
|
|
334
350
|
|
|
351
|
+
// Realtime is on unless it was switched off, so "on, but no URL could be
|
|
352
|
+
// derived" is a misconfiguration and not a choice. It used to be silent:
|
|
353
|
+
// the client simply had no socket, `observe()` quietly degraded to a
|
|
354
|
+
// one-shot fetch, and `realtime.channel()` blamed `realtime: false` — an
|
|
355
|
+
// option the caller had not passed.
|
|
356
|
+
const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
|
|
357
|
+
const unreachableReason =
|
|
358
|
+
"no WebSocket URL could be derived from baseUrl " +
|
|
359
|
+
`${JSON.stringify(options.baseUrl ?? null)} — outside a browser there is no page origin ` +
|
|
360
|
+
"to resolve a relative URL against. Pass an absolute `baseUrl`, set `websocketUrl` " +
|
|
361
|
+
"explicitly, or pass `realtime: false` to say this was intended.";
|
|
362
|
+
if (realtimeUnreachable) {
|
|
363
|
+
console.warn(
|
|
364
|
+
`[Rebase] Realtime is enabled but ${unreachableReason} ` +
|
|
365
|
+
"Live queries will fall back to a single fetch and channels will throw."
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
335
369
|
let ws: RebaseWebSocketClient | undefined;
|
|
336
370
|
/** One channel object per name — see `realtime.channel`. */
|
|
337
371
|
const realtimeChannels = new Map<string, RebaseRealtimeChannel>();
|
|
@@ -529,13 +563,15 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
529
563
|
* cut off the others.
|
|
530
564
|
*/
|
|
531
565
|
channel: (name: string, options?: ChannelOptions): RebaseRealtimeChannel => {
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
566
|
+
// Being merely *unconnected* is not an error: the socket opens
|
|
567
|
+
// on the first channel operation, which is the whole point of
|
|
568
|
+
// asking for a channel before you use one. Having no socket at
|
|
569
|
+
// all is, and there are two reasons for it — say which.
|
|
536
570
|
if (!ws) {
|
|
537
571
|
throw new RebaseClientError(
|
|
538
|
-
|
|
572
|
+
realtimeUnreachable
|
|
573
|
+
? `Realtime is enabled but ${unreachableReason}`
|
|
574
|
+
: "Realtime is disabled on this client (realtime: false), so channels are unavailable."
|
|
539
575
|
);
|
|
540
576
|
}
|
|
541
577
|
let existing = realtimeChannels.get(name);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { matchesOperator } from "./offline-query";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A `like` pattern is user input, and it becomes a regular expression.
|
|
5
|
+
*
|
|
6
|
+
* `%` translates to `[\s\S]*`, so `%%%%X` becomes four adjacent unbounded
|
|
7
|
+
* quantifiers followed by a literal. On a subject that does not match, the
|
|
8
|
+
* engine has to try every way of splitting the subject between them — which is
|
|
9
|
+
* exponential. Twelve `%` against a forty-character value took **100 seconds**
|
|
10
|
+
* on this machine before answering `false`.
|
|
11
|
+
*
|
|
12
|
+
* The pattern arrives over HTTP: `?title=like.%25%25%25…` is a public filter
|
|
13
|
+
* operator, listed in the operator table. On the offline evaluator that freezes
|
|
14
|
+
* the tab; the same translation in the Mongo driver hands the expression to the
|
|
15
|
+
* database, where it occupies a server thread instead.
|
|
16
|
+
*
|
|
17
|
+
* Consecutive `%` mean exactly what one `%` means, so collapsing a run is
|
|
18
|
+
* semantics-preserving and removes the ambiguity the backtracking feeds on.
|
|
19
|
+
*/
|
|
20
|
+
const HOSTILE = "%".repeat(14) + "X";
|
|
21
|
+
const SUBJECT = "a".repeat(48);
|
|
22
|
+
|
|
23
|
+
describe("like patterns cannot be made to backtrack", () => {
|
|
24
|
+
it("answers a hostile pattern promptly", () => {
|
|
25
|
+
const started = Date.now();
|
|
26
|
+
const result = matchesOperator(SUBJECT, "like", HOSTILE);
|
|
27
|
+
const elapsed = Date.now() - started;
|
|
28
|
+
|
|
29
|
+
expect(result).toBe(false);
|
|
30
|
+
expect(elapsed).toBeLessThan(1000);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("answers the case-insensitive form promptly too", () => {
|
|
34
|
+
const started = Date.now();
|
|
35
|
+
matchesOperator(SUBJECT, "ilike", HOSTILE);
|
|
36
|
+
|
|
37
|
+
expect(Date.now() - started).toBeLessThan(1000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("still means what LIKE means", () => {
|
|
41
|
+
expect(matchesOperator("post-1", "like", "post-%")).toBe(true);
|
|
42
|
+
expect(matchesOperator("post-1", "like", "%1")).toBe(true);
|
|
43
|
+
expect(matchesOperator("post-1", "like", "%st-%")).toBe(true);
|
|
44
|
+
expect(matchesOperator("post-1", "like", "other-%")).toBe(false);
|
|
45
|
+
// A run of wildcards is the same query as one wildcard.
|
|
46
|
+
expect(matchesOperator("post-1", "like", "post%%%%1")).toBe(true);
|
|
47
|
+
expect(matchesOperator("abc", "like", "a_c")).toBe(true);
|
|
48
|
+
expect(matchesOperator("abbc", "like", "a_c")).toBe(false);
|
|
49
|
+
// `_` is fixed-width, so a run of them still counts.
|
|
50
|
+
expect(matchesOperator("abc", "like", "a__")).toBe(true);
|
|
51
|
+
expect(matchesOperator("ab", "like", "a__")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps an escaped percent literal", () => {
|
|
55
|
+
expect(matchesOperator("50%", "like", "50\\%")).toBe(true);
|
|
56
|
+
expect(matchesOperator("500", "like", "50\\%")).toBe(false);
|
|
57
|
+
// An escaped percent next to a wildcard is still a literal.
|
|
58
|
+
expect(matchesOperator("50%off", "like", "50\\%%")).toBe(true);
|
|
59
|
+
expect(matchesOperator("50off", "like", "50\\%%")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ConnectivityMonitor,
|
|
3
|
+
isDuplicateKeyError,
|
|
4
|
+
isNetworkError,
|
|
5
|
+
isRetryableError
|
|
6
|
+
} from "./offline-connectivity";
|
|
2
7
|
import { RebaseApiError } from "./transport";
|
|
3
8
|
|
|
4
9
|
/**
|
|
@@ -35,6 +40,35 @@ describe("network error classification", () => {
|
|
|
35
40
|
// blip, and retrying it forever jams every write queued behind it.
|
|
36
41
|
expect(isRetryableError(new RebaseApiError("boom", { status: 500 }))).toBe(false);
|
|
37
42
|
});
|
|
43
|
+
|
|
44
|
+
it("retries a write the server is still answering, and only that 409", () => {
|
|
45
|
+
// The server's own message says to retry — "its result will be
|
|
46
|
+
// replayed" — and a key whose claim outlived the process that took it
|
|
47
|
+
// is refused until the lease expires. Giving up instead rolls back a
|
|
48
|
+
// write that retrying would have completed.
|
|
49
|
+
expect(isRetryableError(new RebaseApiError("in progress", {
|
|
50
|
+
status: 409, code: "IDEMPOTENCY_KEY_IN_PROGRESS"
|
|
51
|
+
}))).toBe(true);
|
|
52
|
+
// Every other 409 is a real conflict and stays fatal.
|
|
53
|
+
expect(isRetryableError(new RebaseApiError("row exists", { status: 409, code: "23505" }))).toBe(false);
|
|
54
|
+
expect(isRetryableError(new RebaseApiError("conflict", { status: 409 }))).toBe(false);
|
|
55
|
+
expect(isRetryableError(new RebaseApiError("reused", {
|
|
56
|
+
status: 422, code: "IDEMPOTENCY_KEY_REUSED"
|
|
57
|
+
}))).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not read an unanswered write as a row that is already there", () => {
|
|
61
|
+
// The status alone cannot decide it. Read as a duplicate, the queue
|
|
62
|
+
// went looking for a row that was never written, found nothing,
|
|
63
|
+
// concluded there was nothing left to do and deleted the write.
|
|
64
|
+
expect(isDuplicateKeyError(new RebaseApiError("in progress", {
|
|
65
|
+
status: 409, code: "IDEMPOTENCY_KEY_IN_PROGRESS"
|
|
66
|
+
}))).toBe(false);
|
|
67
|
+
|
|
68
|
+
expect(isDuplicateKeyError(new RebaseApiError("dup", { status: 400, code: "23505" }))).toBe(true);
|
|
69
|
+
expect(isDuplicateKeyError(new RebaseApiError("conflict", { status: 409 }))).toBe(true);
|
|
70
|
+
expect(isDuplicateKeyError(new RebaseApiError("gone", { status: 404 }))).toBe(false);
|
|
71
|
+
});
|
|
38
72
|
});
|
|
39
73
|
|
|
40
74
|
describe("ConnectivityMonitor", () => {
|
|
@@ -38,11 +38,37 @@ export function isNetworkError(error: unknown): boolean {
|
|
|
38
38
|
*/
|
|
39
39
|
const RETRYABLE_STATUSES = new Set([408, 425, 429, 502, 503, 504]);
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The server holds this key for a request it has not answered yet.
|
|
43
|
+
*
|
|
44
|
+
* It is a 409 like a duplicate row is a 409, and nothing but the code separates
|
|
45
|
+
* them — one means "your write is already there", the other means "your write
|
|
46
|
+
* may not have happened at all, ask again".
|
|
47
|
+
*/
|
|
48
|
+
const IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Is the server still answering an earlier attempt of this same write?
|
|
52
|
+
*
|
|
53
|
+
* The only correct response is to ask again — which is exactly what the
|
|
54
|
+
* server's own message says, and exactly what this SDK used not to do.
|
|
55
|
+
*/
|
|
56
|
+
export function isIdempotencyInProgressError(error: unknown): boolean {
|
|
57
|
+
return error instanceof RebaseApiError
|
|
58
|
+
&& error.status === 409
|
|
59
|
+
&& error.code === IDEMPOTENCY_IN_PROGRESS;
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
/** Is this failure worth another attempt later? */
|
|
42
63
|
export function isRetryableError(error: unknown): boolean {
|
|
43
64
|
if (isNetworkError(error)) return true;
|
|
44
|
-
if (error instanceof RebaseApiError) return
|
|
45
|
-
|
|
65
|
+
if (!(error instanceof RebaseApiError)) return false;
|
|
66
|
+
// The one 409 that resolves on its own. A key whose claim outlived the
|
|
67
|
+
// request that took it — the process was killed between the write and the
|
|
68
|
+
// answer — is refused until the claim's lease expires, and giving up on it
|
|
69
|
+
// means dropping a write that retrying would have completed.
|
|
70
|
+
if (isIdempotencyInProgressError(error)) return true;
|
|
71
|
+
return error.status !== undefined && RETRYABLE_STATUSES.has(error.status);
|
|
46
72
|
}
|
|
47
73
|
|
|
48
74
|
/**
|
|
@@ -55,10 +81,16 @@ export function isRetryableError(error: unknown): boolean {
|
|
|
55
81
|
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
56
82
|
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
57
83
|
* the server can only be rejecting it because the first attempt actually landed.
|
|
84
|
+
*
|
|
85
|
+
* Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
|
|
86
|
+
* is a 409 that means the opposite — the row may not exist at all. Read as a
|
|
87
|
+
* duplicate, the queue looked for a row that was never written, found nothing,
|
|
88
|
+
* concluded there was nothing left to do and deleted the write from the queue.
|
|
58
89
|
*/
|
|
59
90
|
export function isDuplicateKeyError(error: unknown): boolean {
|
|
60
91
|
if (!(error instanceof RebaseApiError)) return false;
|
|
61
|
-
|
|
92
|
+
if (error.code === "23505") return true;
|
|
93
|
+
return error.status === 409 && !isIdempotencyInProgressError(error);
|
|
62
94
|
}
|
|
63
95
|
|
|
64
96
|
export interface ConnectivityOptions {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
compareValues,
|
|
3
3
|
isExactlyEvaluable,
|
|
4
|
+
isLocallySortable,
|
|
4
5
|
looseEquals,
|
|
5
6
|
matchesLogical,
|
|
6
7
|
matchesOperator,
|
|
@@ -10,7 +11,8 @@ import {
|
|
|
10
11
|
runLocalQuery,
|
|
11
12
|
sortRows
|
|
12
13
|
} from "./offline-query";
|
|
13
|
-
import { EntityRelation } from "@rebasepro/types";
|
|
14
|
+
import { DEFAULT_LIST_LIMIT, EntityRelation } from "@rebasepro/types";
|
|
15
|
+
import { resolveFindWindow } from "@rebasepro/common";
|
|
14
16
|
import type { FindParams } from "./transport";
|
|
15
17
|
|
|
16
18
|
type Row = Record<string, unknown>;
|
|
@@ -172,12 +174,37 @@ describe("local query engine", () => {
|
|
|
172
174
|
|
|
173
175
|
describe("pagination", () => {
|
|
174
176
|
it("resolves page over offset the way the server does", () => {
|
|
175
|
-
expect(resolvePagination()).toEqual({ limit: 20, offset: 0 });
|
|
176
177
|
expect(resolvePagination({ limit: 10, offset: 30 })).toEqual({ limit: 10, offset: 30 });
|
|
177
178
|
expect(resolvePagination({ limit: 10, page: 3 })).toEqual({ limit: 10, offset: 20 });
|
|
178
179
|
// `page` wins over `offset`, and the first page is not negative.
|
|
179
180
|
expect(resolvePagination({ limit: 10, page: 1, offset: 99 })).toEqual({ limit: 10, offset: 0 });
|
|
180
181
|
});
|
|
182
|
+
|
|
183
|
+
it("defaults to the same page size the server would apply", () => {
|
|
184
|
+
// Asserted against the constant, not against a number written here.
|
|
185
|
+
// This test used to say `{ limit: 20 }` — restating a default this
|
|
186
|
+
// module had invented, while `/api/data` paged by 50. The local
|
|
187
|
+
// answer and the network answer to one `observe()` were therefore
|
|
188
|
+
// different lengths, and the test agreed with the wrong one.
|
|
189
|
+
expect(resolvePagination()).toEqual({ limit: DEFAULT_LIST_LIMIT, offset: 0 });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("agrees with the shared resolver on every shape of window", () => {
|
|
193
|
+
// The one that matters: the local evaluator and every other
|
|
194
|
+
// transport must resolve identical windows, or a cached list and a
|
|
195
|
+
// fetched list disagree about which rows page two holds.
|
|
196
|
+
for (const params of [
|
|
197
|
+
undefined,
|
|
198
|
+
{ limit: 10 },
|
|
199
|
+
{ offset: 30 },
|
|
200
|
+
{ page: 4 },
|
|
201
|
+
{ limit: 25, page: 2 },
|
|
202
|
+
{ limit: 10, page: 1, offset: 99 }
|
|
203
|
+
]) {
|
|
204
|
+
const { limit, offset } = resolveFindWindow(params);
|
|
205
|
+
expect(resolvePagination(params)).toEqual({ limit, offset });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
181
208
|
});
|
|
182
209
|
|
|
183
210
|
describe("runLocalQuery", () => {
|
|
@@ -229,6 +256,93 @@ describe("local query engine", () => {
|
|
|
229
256
|
expect(isExactlyEvaluable({ include: ["author"] })).toBe(false);
|
|
230
257
|
expect(isExactlyEvaluable({ include: [] })).toBe(true);
|
|
231
258
|
});
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* An ordering comparison is answered here by `compareValues`, which
|
|
262
|
+
* falls back to an `Intl.Collator`, and by Postgres using the
|
|
263
|
+
* *database's* collation — a property of the server this process has
|
|
264
|
+
* never been told. `'apple' < 'Banana'` is false under the C collation
|
|
265
|
+
* and true under `en_US.UTF-8`; the collator says true. So the two
|
|
266
|
+
* select different sets, in whichever direction the deployment happened
|
|
267
|
+
* to be created.
|
|
268
|
+
*/
|
|
269
|
+
it("refuses an ordering comparison, whose answer depends on the server's collation", () => {
|
|
270
|
+
for (const op of ["<", "<=", ">", ">="] as const) {
|
|
271
|
+
expect({ op, exact: isExactlyEvaluable({ where: { name: [op, "Banana"] } }) })
|
|
272
|
+
.toEqual({ op, exact: false });
|
|
273
|
+
}
|
|
274
|
+
// Equality, membership and pattern matching are unaffected — all
|
|
275
|
+
// verified against a real database in
|
|
276
|
+
// `server-postgres/test/e2e/offline-query-agreement.test.ts`.
|
|
277
|
+
for (const op of ["==", "!=", "in", "not-in", "like", "ilike"] as const) {
|
|
278
|
+
expect({ op, exact: isExactlyEvaluable({ where: { name: [op, "x"] } }) })
|
|
279
|
+
.toEqual({ op, exact: true });
|
|
280
|
+
}
|
|
281
|
+
expect(isExactlyEvaluable({ where: { a: ["is-null", null] } })).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("refuses a numeric range too, because the operand type does not settle it", () => {
|
|
285
|
+
// `compareValues` deliberately reads numeric strings as numbers, so
|
|
286
|
+
// it cannot tell an integer column from a text column of digits —
|
|
287
|
+
// and on the latter Postgres orders "10" before "9" while this
|
|
288
|
+
// orders 9 before 10. Conservative on purpose; the schema would be
|
|
289
|
+
// needed to do better.
|
|
290
|
+
expect(isExactlyEvaluable({ where: { qty: [">", 10] } })).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("looks inside and/or groups, not just the top-level where", () => {
|
|
294
|
+
expect(isExactlyEvaluable({
|
|
295
|
+
logical: { type: "or", conditions: [
|
|
296
|
+
{ column: "a", operator: "==", value: 1 },
|
|
297
|
+
{ column: "b", operator: "==", value: 2 }
|
|
298
|
+
] }
|
|
299
|
+
} as never)).toBe(true);
|
|
300
|
+
expect(isExactlyEvaluable({
|
|
301
|
+
logical: { type: "or", conditions: [
|
|
302
|
+
{ column: "a", operator: "==", value: 1 },
|
|
303
|
+
{ type: "and", conditions: [{ column: "b", operator: "<", value: "m" }] }
|
|
304
|
+
] }
|
|
305
|
+
} as never)).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("still refuses several conditions on one field when any of them orders", () => {
|
|
309
|
+
expect(isExactlyEvaluable({ where: { a: [["==", 1], ["<", "m"]] } } as never)).toBe(false);
|
|
310
|
+
expect(isExactlyEvaluable({ where: { a: [["==", 1], ["!=", 2]] } } as never)).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("isLocallySortable", () => {
|
|
315
|
+
/**
|
|
316
|
+
* Asked of the rows rather than the query, because unlike a filter this
|
|
317
|
+
* one is decidable from the data in hand: the collator is reachable
|
|
318
|
+
* only when a value cannot be read as a number.
|
|
319
|
+
*/
|
|
320
|
+
it("accepts a column this page holds only as numbers", () => {
|
|
321
|
+
expect(isLocallySortable([{ id: 1, n: 3 }, { id: 2, n: 10 }], ["n", "asc"])).toBe(true);
|
|
322
|
+
// The wire's type erasure is already undone by `compareValues`.
|
|
323
|
+
expect(isLocallySortable([{ id: 1, n: "3" }, { id: 2, n: "10" }], ["n", "asc"])).toBe(true);
|
|
324
|
+
// Dates normalise to instants before any comparison happens.
|
|
325
|
+
expect(isLocallySortable(
|
|
326
|
+
[{ id: 1, at: new Date(1) }, { id: 2, at: new Date(2) }], ["at", "desc"]
|
|
327
|
+
)).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("refuses a column holding text, whose order is the database's to decide", () => {
|
|
331
|
+
expect(isLocallySortable([{ id: 1, name: "apple" }, { id: 2, name: "Banana" }], ["name", "asc"]))
|
|
332
|
+
.toBe(false);
|
|
333
|
+
// One text value is enough — a column is one type, and the page that
|
|
334
|
+
// happens to be cached does not get to vote.
|
|
335
|
+
expect(isLocallySortable([{ id: 1, n: 3 }, { id: 2, n: "x" }], ["n", "asc"])).toBe(false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("ignores nulls, which are ordered by an explicit rule", () => {
|
|
339
|
+
expect(isLocallySortable([{ id: 1, n: null }, { id: 2, n: 5 }], ["n", "asc"])).toBe(true);
|
|
340
|
+
expect(isLocallySortable([{ id: 1 }, { id: 2 }], ["missing", "asc"])).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("is vacuously true with no sort", () => {
|
|
344
|
+
expect(isLocallySortable([{ id: 1, name: "a" }], undefined)).toBe(true);
|
|
345
|
+
});
|
|
232
346
|
});
|
|
233
347
|
|
|
234
348
|
describe("primitives", () => {
|
package/src/offline-query.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
toCanonicalOp
|
|
10
10
|
} from "@rebasepro/types";
|
|
11
11
|
import { FindParams } from "./transport";
|
|
12
|
+
import { resolveFindWindow } from "@rebasepro/common";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* A local evaluator for `FindParams`, so cached rows can answer a query the
|
|
@@ -38,8 +39,15 @@ const collator = typeof Intl !== "undefined" && typeof Intl.Collator === "functi
|
|
|
38
39
|
? new Intl.Collator(undefined, { numeric: false, sensitivity: "variant" })
|
|
39
40
|
: undefined;
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The server's page size when the caller does not ask for one.
|
|
44
|
+
*
|
|
45
|
+
* Re-exported rather than redeclared. This was its own `= 20` — a third
|
|
46
|
+
* constant of this name in the workspace, next to `@rebasepro/common`'s 200 and
|
|
47
|
+
* the 50 the REST layer actually applies — and a local copy of a number that
|
|
48
|
+
* belongs to another process is a number that goes stale silently.
|
|
49
|
+
*/
|
|
50
|
+
export { DEFAULT_LIST_LIMIT as DEFAULT_PAGE_SIZE } from "@rebasepro/types";
|
|
43
51
|
|
|
44
52
|
function isNullish(value: unknown): boolean {
|
|
45
53
|
return value === null || value === undefined;
|
|
@@ -137,17 +145,31 @@ export function looseEquals(a: unknown, b: unknown): boolean {
|
|
|
137
145
|
*/
|
|
138
146
|
function likeToRegExp(pattern: string, caseInsensitive: boolean): RegExp {
|
|
139
147
|
let source = "^";
|
|
148
|
+
// Runs of `%` collapse to one. `%%%%X` means exactly what `%X` means, but
|
|
149
|
+
// as a regular expression it is four adjacent unbounded quantifiers, and on
|
|
150
|
+
// a subject that does not match the engine tries every way of splitting the
|
|
151
|
+
// subject between them. Fourteen of them against a forty-eight character
|
|
152
|
+
// value took eighty-seven seconds to answer `false`.
|
|
153
|
+
//
|
|
154
|
+
// The pattern is user input — `?title=like.%25%25%25…` over HTTP — so that
|
|
155
|
+
// is a request that pins a CPU. Collapsing is semantics-preserving and
|
|
156
|
+
// removes the ambiguity the backtracking feeds on.
|
|
157
|
+
let lastWasWildcard = false;
|
|
140
158
|
for (let i = 0; i < pattern.length; i++) {
|
|
141
159
|
const char = pattern[i];
|
|
142
160
|
if (char === "\\" && i + 1 < pattern.length) {
|
|
143
161
|
source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
144
162
|
i++;
|
|
163
|
+
lastWasWildcard = false;
|
|
145
164
|
} else if (char === "%") {
|
|
146
|
-
source += "[\\s\\S]*";
|
|
165
|
+
if (!lastWasWildcard) source += "[\\s\\S]*";
|
|
166
|
+
lastWasWildcard = true;
|
|
147
167
|
} else if (char === "_") {
|
|
148
168
|
source += "[\\s\\S]";
|
|
169
|
+
lastWasWildcard = false;
|
|
149
170
|
} else {
|
|
150
171
|
source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
172
|
+
lastWasWildcard = false;
|
|
151
173
|
}
|
|
152
174
|
}
|
|
153
175
|
return new RegExp(source + "$", caseInsensitive ? "i" : "");
|
|
@@ -311,15 +333,42 @@ function tiebreak(a: Record<string, unknown>, b: Record<string, unknown>): numbe
|
|
|
311
333
|
return cmp ?? 0;
|
|
312
334
|
}
|
|
313
335
|
|
|
314
|
-
/**
|
|
336
|
+
/**
|
|
337
|
+
* Resolve `page`/`offset`/`limit` the way the server does.
|
|
338
|
+
*
|
|
339
|
+
* It did not: this defaulted an absent limit to 20 while `/api/data` pages by
|
|
340
|
+
* 50, so the same `observe()` answered with 20 rows from the local database and
|
|
341
|
+
* 50 from the network — a list that changed length depending on which side
|
|
342
|
+
* answered, with `page` striding differently on each. Delegated now, so the
|
|
343
|
+
* sentence above is true by construction rather than by agreement.
|
|
344
|
+
*/
|
|
315
345
|
export function resolvePagination(params?: FindParams): { limit: number; offset: number } {
|
|
316
|
-
const limit = params
|
|
317
|
-
const offset = params?.page != null
|
|
318
|
-
? Math.max(0, (params.page - 1) * limit)
|
|
319
|
-
: (params?.offset ?? 0);
|
|
346
|
+
const { limit, offset } = resolveFindWindow(params);
|
|
320
347
|
return { limit, offset };
|
|
321
348
|
}
|
|
322
349
|
|
|
350
|
+
/** `<`, `<=`, `>`, `>=` — the operators whose answer depends on a collation. */
|
|
351
|
+
const ORDERING_OPS = new Set<WhereFilterOp>(["<", "<=", ">", ">="]);
|
|
352
|
+
|
|
353
|
+
/** Does any condition in this `where` clause order its operands? */
|
|
354
|
+
function whereOrders(where: FilterValues<string> | undefined): boolean {
|
|
355
|
+
if (!where) return false;
|
|
356
|
+
for (const condition of Object.values(where)) {
|
|
357
|
+
const tuples = isTuple(condition) ? [condition] : (condition as unknown[]).filter(isTuple);
|
|
358
|
+
if (tuples.some(([op]) => ORDERING_OPS.has(op))) return true;
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The same question, through an `and(...)`/`or(...)` tree. */
|
|
364
|
+
function logicalOrders(condition: LogicalCondition | FilterCondition | undefined, depth = 0): boolean {
|
|
365
|
+
if (!condition || depth > 32) return false;
|
|
366
|
+
if ("type" in condition) {
|
|
367
|
+
return (condition.conditions ?? []).some((c) => logicalOrders(c, depth + 1));
|
|
368
|
+
}
|
|
369
|
+
return ORDERING_OPS.has(condition.operator);
|
|
370
|
+
}
|
|
371
|
+
|
|
323
372
|
/**
|
|
324
373
|
* Can a locally evaluated answer to `params` be trusted to match the server's,
|
|
325
374
|
* assuming the cache holds every row of the collection?
|
|
@@ -327,11 +376,77 @@ export function resolvePagination(params?: FindParams): { limit: number; offset:
|
|
|
327
376
|
* `include` pulls in rows from other collections that this evaluator never
|
|
328
377
|
* sees, and `searchString` is only approximated — both make the local answer a
|
|
329
378
|
* best effort rather than an equivalent one.
|
|
379
|
+
*
|
|
380
|
+
* **Ordering comparisons are refused, and that is the interesting one.**
|
|
381
|
+
* `compareValues` falls back to an `Intl.Collator` for operands it cannot read
|
|
382
|
+
* as numbers or instants. PostgreSQL orders text by the *database's* collation,
|
|
383
|
+
* which is a property of the server this process has never been told: under the
|
|
384
|
+
* C collation `'apple' < 'Banana'` is false, under `en_US.UTF-8` it is true,
|
|
385
|
+
* and the collator says true. So `["<", "Banana"]` selects a different set here
|
|
386
|
+
* than it does there — silently, and in whichever direction the deployment
|
|
387
|
+
* happens to have been created.
|
|
388
|
+
*
|
|
389
|
+
* The refusal covers *every* ordering comparison rather than only the ones with
|
|
390
|
+
* a string operand, because the operand type does not settle it: a numeric
|
|
391
|
+
* bound against a text column (`["<", 10]` on a `varchar`) also reaches the
|
|
392
|
+
* collator, and nothing in `params` says what the column holds. Conservative on
|
|
393
|
+
* purpose — the cost is that a query combining an ordering filter with
|
|
394
|
+
* *unsynced local writes* stops placing those writes optimistically, which is a
|
|
395
|
+
* degraded answer rather than a wrong one. Claiming exactness we do not have is
|
|
396
|
+
* the other way round.
|
|
397
|
+
*
|
|
398
|
+
* This says nothing about ordering *results*; that is a separate claim with a
|
|
399
|
+
* separate answer, because a sort changes which rows come first and not which
|
|
400
|
+
* rows match. See {@link isLocallySortable}.
|
|
330
401
|
*/
|
|
331
402
|
export function isExactlyEvaluable(params?: FindParams): boolean {
|
|
332
403
|
if (!params) return true;
|
|
333
404
|
if (params.include && params.include.length > 0) return false;
|
|
334
405
|
if (params.searchString) return false;
|
|
406
|
+
// Nearest-neighbour ordering is the server's to compute: the cache holds no
|
|
407
|
+
// vectors, and even with them, answering from a subset would return the
|
|
408
|
+
// nearest of what happens to be cached while looking like the nearest there
|
|
409
|
+
// are — a wrong answer that is indistinguishable from a right one.
|
|
410
|
+
if (params.vectorSearch) return false;
|
|
411
|
+
if (whereOrders(params.where)) return false;
|
|
412
|
+
if (logicalOrders(params.logical)) return false;
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Would sorting `rows` locally reproduce the order the server would have sent?
|
|
418
|
+
*
|
|
419
|
+
* Asked of the rows rather than of the query, because unlike a filter this one
|
|
420
|
+
* *is* decidable from the data in hand: {@link compareValues} reaches the
|
|
421
|
+
* collator only when it cannot read both operands as numbers, and `toComparable`
|
|
422
|
+
* has already turned dates and relations into numbers and ids by then. If every
|
|
423
|
+
* value on the sort column normalises to a number, the collator is unreachable
|
|
424
|
+
* and the local order is the server's order.
|
|
425
|
+
*
|
|
426
|
+
* A text column is therefore refused — see {@link isExactlyEvaluable} for why
|
|
427
|
+
* the two cannot be made to agree — and so is a column this page happens to see
|
|
428
|
+
* only as strings, which is the same thing from here.
|
|
429
|
+
*
|
|
430
|
+
* Nulls are fine either way: they are ordered by an explicit rule (last
|
|
431
|
+
* ascending, first descending) that matches Postgres and never reaches the
|
|
432
|
+
* comparator.
|
|
433
|
+
*/
|
|
434
|
+
export function isLocallySortable(
|
|
435
|
+
rows: readonly Record<string, unknown>[],
|
|
436
|
+
orderBy?: OrderByTuple
|
|
437
|
+
): boolean {
|
|
438
|
+
if (!orderBy) return true;
|
|
439
|
+
const [field] = orderBy;
|
|
440
|
+
for (const row of rows) {
|
|
441
|
+
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;
|
|
445
|
+
// A numeric string is compared as a number, so it is safe too — this is
|
|
446
|
+
// the wire's type erasure, which `compareValues` already undoes.
|
|
447
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) continue;
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
335
450
|
return true;
|
|
336
451
|
}
|
|
337
452
|
|
package/src/offline-store.ts
CHANGED
|
@@ -52,9 +52,13 @@ export interface PendingMutation {
|
|
|
52
52
|
/** Unique, lexicographically sortable identity — also the queue key suffix. */
|
|
53
53
|
mutationId: string;
|
|
54
54
|
collection: string;
|
|
55
|
-
type: "create" | "createMany" | "update" | "delete";
|
|
55
|
+
type: "create" | "createMany" | "update" | "updateMany" | "delete" | "deleteMany";
|
|
56
56
|
/** Target row id for update/delete, and the (client-generated) id of an offline create. */
|
|
57
57
|
id?: string | number;
|
|
58
|
+
/** Target row ids for `deleteMany`. */
|
|
59
|
+
ids?: (string | number)[];
|
|
60
|
+
/** `{ id, data }` entries for `updateMany`. */
|
|
61
|
+
updates?: { id: string | number; data: Record<string, unknown> }[];
|
|
58
62
|
/**
|
|
59
63
|
* True when the SDK minted this create's id itself. Only such creates may
|
|
60
64
|
* cancel out against a later offline delete: a freshly generated UUID
|