@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/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
- absoluteUrl = new URL(baseUrl, window.location.href).origin;
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
- const protocol = absoluteUrl.startsWith("https:") || absoluteUrl.startsWith("wss:") ? "wss:" : "ws:";
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
- // Only `realtime: false` gets here — a hard opt-out, so this
533
- // stays an error. Being merely *unconnected* does not: the
534
- // socket opens on the first channel operation, which is the
535
- // whole point of asking for a channel before you use one.
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
- "Realtime is disabled on this client (realtime: false), so channels are unavailable."
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 { ConnectivityMonitor, isNetworkError, isRetryableError } from "./offline-connectivity";
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 error.status !== undefined && RETRYABLE_STATUSES.has(error.status);
45
- return false;
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
- return error.code === "23505" || error.status === 409;
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", () => {
@@ -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
- /** The server's page size when the caller does not ask for one. */
42
- export const DEFAULT_PAGE_SIZE = 20;
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
- /** Resolve `page`/`offset`/`limit` the way the server does. */
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?.limit ?? DEFAULT_PAGE_SIZE;
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
 
@@ -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