@rebasepro/client 0.13.0 → 0.13.1-canary.g18cfeb7

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/collection.ts CHANGED
@@ -11,10 +11,16 @@ import {
11
11
  WhereValue,
12
12
  WriteOptions
13
13
  } from "@rebasepro/types";
14
- import { collectAllPages, paginateFind } from "@rebasepro/common";
14
+ import { collectAllPages, paginateFind, resolveFindWindow } from "@rebasepro/common";
15
15
 
16
16
  import { SDKQueryBuilder } from "./sdk_query_builder";
17
17
 
18
+ /**
19
+ * Counts currently in flight, keyed by the exact request they issue. Entries
20
+ * live only for the duration of the request — see `count()` for why.
21
+ */
22
+ const inflightCounts = new Map<string, Promise<number>>();
23
+
18
24
  /**
19
25
  * A live query result: a normal {@link FindResult} plus what an interface
20
26
  * needs to decide whether to show a "saving…" or "offline" affordance over it.
@@ -155,7 +161,7 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
155
161
  return raw as M;
156
162
  },
157
163
 
158
- async createMany(data: Partial<M>[], options?: { upsert?: boolean }) {
164
+ async createMany(data: Partial<M>[], options?: { upsert?: boolean } & WriteOptions) {
159
165
  if (!Array.isArray(data)) {
160
166
  throw new TypeError("createMany expects an array of records.");
161
167
  }
@@ -166,11 +172,29 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
166
172
  body: JSON.stringify({
167
173
  rows: data,
168
174
  ...(options?.upsert ? { upsert: true } : {})
169
- })
175
+ }),
176
+ ...(options?.idempotencyKey
177
+ ? { headers: { "Idempotency-Key": options.idempotencyKey } }
178
+ : {})
170
179
  });
171
180
  return (raw.data || []) as M[];
172
181
  },
173
182
 
183
+ /**
184
+ * Still `PUT`, deliberately, even though the server now serves `PATCH`
185
+ * on the same handler and `PATCH` is the honest verb for a merge.
186
+ *
187
+ * The two are interchangeable server-side, so switching buys nothing at
188
+ * runtime — and it costs compatibility in the direction that fails
189
+ * quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
190
+ * a route that does not exist and get a **404**, which is
191
+ * indistinguishable from "that row is gone". Every write would look like
192
+ * a missing record.
193
+ *
194
+ * `PATCH` is what the OpenAPI spec advertises, so anyone generating a
195
+ * client gets the correct verb; this stays on `PUT` until the oldest
196
+ * supported server is one that serves both.
197
+ */
174
198
  async update(id: string | number, data: Partial<M>) {
175
199
  const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, {
176
200
  method: "PUT",
@@ -179,12 +203,54 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
179
203
  return raw as M;
180
204
  },
181
205
 
206
+ async updateMany(updates: { id: string | number; data: Partial<M> }[], options?: WriteOptions) {
207
+ if (!Array.isArray(updates)) {
208
+ throw new TypeError("updateMany expects an array of { id, data } entries.");
209
+ }
210
+ if (updates.length === 0) return [];
211
+
212
+ const raw = await transport.request<{ data: Record<string, unknown>[] }>(`${basePath}/bulk`, {
213
+ method: "PATCH",
214
+ body: JSON.stringify({ updates }),
215
+ ...(options?.idempotencyKey
216
+ ? { headers: { "Idempotency-Key": options.idempotencyKey } }
217
+ : {})
218
+ });
219
+ return (raw.data || []) as M[];
220
+ },
221
+
182
222
  async delete(id: string | number) {
183
223
  await transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
184
224
  method: "DELETE"
185
225
  });
186
226
  },
187
227
 
228
+ /**
229
+ * `POST .../bulk/delete`, not `DELETE .../bulk`.
230
+ *
231
+ * The honest verb would take the ids in a DELETE body, and that is the
232
+ * one request shape the HTTP ecosystem handles unreliably: bodies on
233
+ * DELETE are permitted but widely dropped by proxies and CDNs, and
234
+ * several OpenAPI generators ignore `requestBody` on a DELETE
235
+ * operation, so a generated client would send the request without its
236
+ * ids. A backend deployed behind arbitrary ingress cannot take that
237
+ * bet. Same reason `:batchDelete` exists in Google's API guidelines.
238
+ */
239
+ async deleteMany(ids: (string | number)[], options?: WriteOptions) {
240
+ if (!Array.isArray(ids)) {
241
+ throw new TypeError("deleteMany expects an array of ids.");
242
+ }
243
+ if (ids.length === 0) return;
244
+
245
+ await transport.request<void>(`${basePath}/bulk/delete`, {
246
+ method: "POST",
247
+ body: JSON.stringify({ ids }),
248
+ ...(options?.idempotencyKey
249
+ ? { headers: { "Idempotency-Key": options.idempotencyKey } }
250
+ : {})
251
+ });
252
+ },
253
+
188
254
  async count(params?: FindParams<M>): Promise<number> {
189
255
  const countParams: FindParams<M> = {
190
256
  ...params,
@@ -198,8 +264,34 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
198
264
  include: undefined
199
265
  };
200
266
  const qs = buildQueryString(countParams);
201
- const raw = await transport.request<{ count: number }>(basePath + "/count" + qs, { method: "GET" });
202
- return raw.count ?? 0;
267
+
268
+ // One count per query in flight, not one per caller.
269
+ //
270
+ // A count is a property of the query, and every concurrent caller
271
+ // asking the same question wants the same answer — so they can
272
+ // share the one request. This is not a micro-optimisation: a live
273
+ // subscription re-counts on every push (see `listen` below), and
274
+ // `listenCollection` deliberately collapses identical queries onto
275
+ // a single socket subscription while keeping one callback per
276
+ // subscriber. Each push therefore woke N subscribers, and each of
277
+ // them issued its own identical count. A table showing one relation
278
+ // column fired one count per visible cell, on every update.
279
+ //
280
+ // The entry is dropped as soon as it settles, so this merges
281
+ // concurrent calls only and never serves a cached total.
282
+ const key = basePath + "/count" + qs;
283
+ const inflight = inflightCounts.get(key);
284
+ if (inflight) return inflight;
285
+
286
+ const request = transport
287
+ .request<{ count: number }>(key, { method: "GET" })
288
+ .then((raw) => raw.count ?? 0);
289
+ inflightCounts.set(key, request);
290
+ try {
291
+ return await request;
292
+ } finally {
293
+ inflightCounts.delete(key);
294
+ }
203
295
  },
204
296
 
205
297
  // Reactive reads. Without the offline layer there is no local database
@@ -212,15 +304,36 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
212
304
  options?: ObserveOptions
213
305
  ) {
214
306
  let closed = false;
215
- const emit = (result: FindResult<M>) => {
307
+ // Two sources race into one callback: the one-shot fetch below and
308
+ // the subscription beside it. Whichever resolves last used to win,
309
+ // so a socket update that landed first was overwritten by the
310
+ // fetch's older snapshot and stayed wrong until the next change.
311
+ // `listenCollection` replays cached rows synchronously to a second
312
+ // subscriber, so a second component observing the same query hit
313
+ // that ordering every time.
314
+ let liveDelivered = false;
315
+ let signature: string | undefined;
316
+
317
+ const deliver = (result: FindResult<M>, fromLive: boolean) => {
216
318
  if (closed) return;
319
+ // Once the socket has spoken, the fetch issued alongside it is
320
+ // older news — delivering it would move the app backwards.
321
+ if (fromLive) liveDelivered = true;
322
+ else if (liveDelivered) return;
323
+ // The de-duplication `observe()` documents. Keyed on the rows
324
+ // and the total, the same two things the offline layer keys on,
325
+ // so both implementations mean the same thing by "changed".
326
+ const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
327
+ if (signature !== undefined && next === signature) return;
328
+ signature = next;
217
329
  onResult({ ...result, fromCache: false, hasPendingWrites: false, partial: false });
218
330
  };
219
- client.find(params).then(emit).catch((error) => {
331
+
332
+ client.find(params).then((result) => deliver(result, false)).catch((error) => {
220
333
  if (!closed) onError?.(error as Error);
221
334
  });
222
335
  const live = options?.realtime !== false && client.listen
223
- ? client.listen(params, emit, onError)
336
+ ? client.listen(params, (result) => deliver(result, true), onError)
224
337
  : undefined;
225
338
  return () => {
226
339
  closed = true;
@@ -235,15 +348,25 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
235
348
  options?: ObserveOptions
236
349
  ) {
237
350
  let closed = false;
238
- const emit = (row: M | undefined) => {
351
+ let liveDelivered = false;
352
+ let signature: string | undefined;
353
+
354
+ // Same ordering and de-duplication rules as `observe`, for one row.
355
+ const deliver = (row: M | undefined, fromLive: boolean) => {
239
356
  if (closed) return;
357
+ if (fromLive) liveDelivered = true;
358
+ else if (liveDelivered) return;
359
+ const next = row === undefined ? "\u0000missing" : JSON.stringify(row);
360
+ if (signature !== undefined && next === signature) return;
361
+ signature = next;
240
362
  onResult(row, { fromCache: false, hasPendingWrites: false });
241
363
  };
242
- client.findById(id).then(emit).catch((error) => {
364
+
365
+ client.findById(id).then((row) => deliver(row, false)).catch((error) => {
243
366
  if (!closed) onError?.(error as Error);
244
367
  });
245
368
  const live = options?.realtime !== false && client.listenById
246
- ? client.listenById(id, emit, onError)
369
+ ? client.listenById(id, (row) => deliver(row, true), onError)
247
370
  : undefined;
248
371
  return () => {
249
372
  closed = true;
@@ -280,69 +403,80 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
280
403
  client.listen = (params: FindParams<M> | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void) => {
281
404
  let active = true;
282
405
  let lastUpdateId = 0;
406
+ // The last total a `count()` actually returned. A later count that
407
+ // fails says nothing about how big the collection is, so it must
408
+ // not be allowed to replace this with the length of one page.
409
+ let lastKnownTotal: number | undefined;
410
+ const window = resolveFindWindow(params);
283
411
  const unsub = ws.listenCollection(
284
412
  {
285
413
  path: slug,
286
414
  filter: params?.where,
415
+ // The group used to be dropped here, so a subscription
416
+ // filtered with `or(...)` was pushed every row instead.
417
+ logical: params?.logical,
287
418
  limit: params?.limit,
288
- startAfter: params?.offset ? String(params.offset) : undefined,
419
+ // `offset` used to be stringified into `startAfter`, which
420
+ // is a cursor *row*, not a row count — so the server was
421
+ // handed "20" where it expected a keyset value, and the
422
+ // offset it does understand never arrived at all.
423
+ offset: window.driverOffset,
289
424
  orderBy: params?.orderBy?.[0],
290
425
  order: params?.orderBy?.[1],
291
426
  searchString: params?.searchString
292
427
  },
293
428
  (incomingRows: Record<string, unknown>[]) => {
294
429
  const currentUpdateId = ++lastUpdateId;
295
- const requestedLimit = params?.limit || 20;
296
- const offset = params?.offset || 0;
430
+ // What the server pages by when the caller names no limit.
431
+ // A hardcoded 20 here described a window the rows had not
432
+ // come from, and any app sizing its next request off
433
+ // `meta.limit` was told the wrong number.
434
+ const requestedLimit = window.limit;
435
+ const offset = window.offset;
297
436
 
298
437
  // WS client already delivers flat rows — just cast
299
438
  const rows = incomingRows as M[];
300
439
 
301
- // Heuristic metadata (used as fallback if count call fails)
302
- const heuristicTotal = rows.length;
303
- const heuristicHasMore = rows.length >= requestedLimit;
440
+ const emit = (total: number, hasMore: boolean) => {
441
+ if (!active || currentUpdateId !== lastUpdateId) return;
442
+ onUpdate({
443
+ data: rows,
444
+ meta: {
445
+ total,
446
+ limit: requestedLimit,
447
+ offset,
448
+ hasMore
449
+ }
450
+ });
451
+ };
452
+
453
+ // With no count to go on, the only defensible total is a
454
+ // lower bound: the rows on this page plus the ones paged
455
+ // past to reach them. Reporting `rows.length` claimed a
456
+ // collection read at offset 10 held two rows.
457
+ const emitWithoutCount = () => emit(
458
+ offset + rows.length,
459
+ rows.length >= requestedLimit
460
+ );
304
461
 
305
- // Try to get authoritative count; fall back to heuristic
306
462
  if (client.count) {
307
463
  client.count(params)
308
464
  .then((total) => {
309
- if (active && currentUpdateId === lastUpdateId) {
310
- onUpdate({
311
- data: rows,
312
- meta: {
313
- total,
314
- limit: requestedLimit,
315
- offset,
316
- hasMore: offset + rows.length < total
317
- }
318
- });
319
- }
465
+ lastKnownTotal = total;
466
+ emit(total, offset + rows.length < total);
320
467
  })
321
468
  .catch(() => {
322
- // Count failed — use heuristic meta
323
- if (active && currentUpdateId === lastUpdateId) {
324
- onUpdate({
325
- data: rows,
326
- meta: {
327
- total: heuristicTotal,
328
- limit: requestedLimit,
329
- offset,
330
- hasMore: heuristicHasMore
331
- }
332
- });
469
+ // A count that failed is not evidence about the
470
+ // size of the collection. Keep the last real
471
+ // answer; only guess if there has never been one.
472
+ if (lastKnownTotal !== undefined) {
473
+ emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
474
+ } else {
475
+ emitWithoutCount();
333
476
  }
334
477
  });
335
478
  } else {
336
- // No count method — fire immediately with heuristic meta
337
- onUpdate({
338
- data: rows,
339
- meta: {
340
- total: heuristicTotal,
341
- limit: requestedLimit,
342
- offset,
343
- hasMore: heuristicHasMore
344
- }
345
- });
479
+ emitWithoutCount();
346
480
  }
347
481
  },
348
482
  onError
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
+ });
@@ -10,7 +10,8 @@ import {
10
10
  runLocalQuery,
11
11
  sortRows
12
12
  } from "./offline-query";
13
- import { EntityRelation } from "@rebasepro/types";
13
+ import { DEFAULT_LIST_LIMIT, EntityRelation } from "@rebasepro/types";
14
+ import { resolveFindWindow } from "@rebasepro/common";
14
15
  import type { FindParams } from "./transport";
15
16
 
16
17
  type Row = Record<string, unknown>;
@@ -172,12 +173,37 @@ describe("local query engine", () => {
172
173
 
173
174
  describe("pagination", () => {
174
175
  it("resolves page over offset the way the server does", () => {
175
- expect(resolvePagination()).toEqual({ limit: 20, offset: 0 });
176
176
  expect(resolvePagination({ limit: 10, offset: 30 })).toEqual({ limit: 10, offset: 30 });
177
177
  expect(resolvePagination({ limit: 10, page: 3 })).toEqual({ limit: 10, offset: 20 });
178
178
  // `page` wins over `offset`, and the first page is not negative.
179
179
  expect(resolvePagination({ limit: 10, page: 1, offset: 99 })).toEqual({ limit: 10, offset: 0 });
180
180
  });
181
+
182
+ it("defaults to the same page size the server would apply", () => {
183
+ // Asserted against the constant, not against a number written here.
184
+ // This test used to say `{ limit: 20 }` — restating a default this
185
+ // module had invented, while `/api/data` paged by 50. The local
186
+ // answer and the network answer to one `observe()` were therefore
187
+ // different lengths, and the test agreed with the wrong one.
188
+ expect(resolvePagination()).toEqual({ limit: DEFAULT_LIST_LIMIT, offset: 0 });
189
+ });
190
+
191
+ it("agrees with the shared resolver on every shape of window", () => {
192
+ // The one that matters: the local evaluator and every other
193
+ // transport must resolve identical windows, or a cached list and a
194
+ // fetched list disagree about which rows page two holds.
195
+ for (const params of [
196
+ undefined,
197
+ { limit: 10 },
198
+ { offset: 30 },
199
+ { page: 4 },
200
+ { limit: 25, page: 2 },
201
+ { limit: 10, page: 1, offset: 99 }
202
+ ]) {
203
+ const { limit, offset } = resolveFindWindow(params);
204
+ expect(resolvePagination(params)).toEqual({ limit, offset });
205
+ }
206
+ });
181
207
  });
182
208
 
183
209
  describe("runLocalQuery", () => {
@@ -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,12 +333,17 @@ 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
 
@@ -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