@rebasepro/client 0.13.0 → 0.13.1-canary.g249daa1

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