@rebasepro/client 0.13.0 → 0.13.1-canary.g3660bd5

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.
@@ -1,7 +1,14 @@
1
1
  import { FilterValues, FindResult, LogicalCondition, FilterCondition, OrderByTuple, WhereFilterOp } from "@rebasepro/types";
2
2
  import { FindParams } from "./transport";
3
- /** The server's page size when the caller does not ask for one. */
4
- export declare const DEFAULT_PAGE_SIZE = 20;
3
+ /**
4
+ * The server's page size when the caller does not ask for one.
5
+ *
6
+ * Re-exported rather than redeclared. This was its own `= 20` — a third
7
+ * constant of this name in the workspace, next to `@rebasepro/common`'s 200 and
8
+ * the 50 the REST layer actually applies — and a local copy of a number that
9
+ * belongs to another process is a number that goes stale silently.
10
+ */
11
+ export { DEFAULT_LIST_LIMIT as DEFAULT_PAGE_SIZE } from "@rebasepro/types";
5
12
  /**
6
13
  * Three-way compare with SQL's type coercion but not its collation. Returns
7
14
  * `undefined` when the two values are not ordered relative to each other,
@@ -33,7 +40,15 @@ export declare function matchesParams(row: Record<string, unknown>, params?: Fin
33
40
  * not shuffle rows between pages.
34
41
  */
35
42
  export declare function sortRows<M extends Record<string, unknown>>(rows: M[], orderBy?: OrderByTuple): M[];
36
- /** Resolve `page`/`offset`/`limit` the way the server does. */
43
+ /**
44
+ * Resolve `page`/`offset`/`limit` the way the server does.
45
+ *
46
+ * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
47
+ * 50, so the same `observe()` answered with 20 rows from the local database and
48
+ * 50 from the network — a list that changed length depending on which side
49
+ * answered, with `page` striding differently on each. Delegated now, so the
50
+ * sentence above is true by construction rather than by agreement.
51
+ */
37
52
  export declare function resolvePagination(params?: FindParams): {
38
53
  limit: number;
39
54
  offset: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.13.0",
4
+ "version": "0.13.1-canary.g3660bd5",
5
5
  "description": "HTTP SDK client for the Rebase custom backend",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -29,9 +29,9 @@
29
29
  "./package.json": "./package.json"
30
30
  },
31
31
  "dependencies": {
32
- "@rebasepro/common": "0.13.0",
33
- "@rebasepro/utils": "0.13.0",
34
- "@rebasepro/types": "0.13.0"
32
+ "@rebasepro/common": "0.13.1-canary.g3660bd5",
33
+ "@rebasepro/utils": "0.13.1-canary.g3660bd5",
34
+ "@rebasepro/types": "0.13.1-canary.g3660bd5"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@jest/globals": "^30.4.1",
@@ -0,0 +1,57 @@
1
+ import { jest } from "@jest/globals";
2
+ import { createAuth } from "./auth";
3
+ import type { Transport } from "./transport";
4
+
5
+ /**
6
+ * What happens to an error thrown by the app's own auth listener.
7
+ *
8
+ * `emit` isolates listeners from each other, which is right — one bad handler
9
+ * must not stop the rest from being told about a sign-in. It also discarded the
10
+ * error entirely, which is not: the throw came from the *caller's* code, and it
11
+ * vanished with nothing in the console, so a broken `onAuthStateChange` handler
12
+ * looked like an event that never fired.
13
+ *
14
+ * The socket in this same package already reports these
15
+ * ("Error in channel handler:"). This was the one listener loop that did not.
16
+ */
17
+ function transport(): Transport {
18
+ return {
19
+ request: jest.fn<any>().mockResolvedValue({}),
20
+ setToken: jest.fn(),
21
+ setAuthTokenGetter: jest.fn(),
22
+ setOnUnauthorized: jest.fn(),
23
+ baseUrl: "http://localhost:3000",
24
+ apiPath: "/api",
25
+ fetchFn: globalThis.fetch,
26
+ getHeaders: () => ({}),
27
+ resolveToken: jest.fn<any>().mockResolvedValue(null)
28
+ } as unknown as Transport;
29
+ }
30
+
31
+ describe("auth state listeners", () => {
32
+ let error: ReturnType<typeof jest.spyOn>;
33
+
34
+ beforeEach(() => { error = jest.spyOn(console, "error").mockImplementation(() => { /* quiet */ }); });
35
+ afterEach(() => error.mockRestore());
36
+
37
+ it("keeps telling the other listeners when one throws", () => {
38
+ const auth = createAuth(transport());
39
+ const second = jest.fn();
40
+
41
+ auth.onAuthStateChange(() => { throw new Error("handler blew up"); });
42
+ auth.onAuthStateChange(second);
43
+ auth.signOut();
44
+
45
+ expect(second).toHaveBeenCalled();
46
+ });
47
+
48
+ it("reports the throw instead of discarding it", () => {
49
+ const auth = createAuth(transport());
50
+ auth.onAuthStateChange(() => { throw new Error("handler blew up"); });
51
+
52
+ auth.signOut();
53
+
54
+ const said = error.mock.calls.map((c: unknown[]) => c.map(String).join(" ")).join("\n");
55
+ expect(said).toMatch(/handler blew up/);
56
+ });
57
+ });
package/src/auth.ts CHANGED
@@ -129,7 +129,16 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
129
129
 
130
130
  function emit(event: AuthChangeEvent, session: RebaseSession | null) {
131
131
  for (const fn of listeners) {
132
- try { fn(event, session); } catch (e) { /* ignore */ }
132
+ try {
133
+ fn(event, session);
134
+ } catch (e) {
135
+ // Isolated so one bad handler cannot stop the rest being told —
136
+ // but reported, because the throw came from the caller's own
137
+ // code and discarding it made a broken `onAuthStateChange`
138
+ // handler look like an event that never fired. The socket in
139
+ // this package already reports handler errors this way.
140
+ console.error("Error in auth state change listener:", e);
141
+ }
133
142
  }
134
143
  }
135
144
 
@@ -0,0 +1,105 @@
1
+ import { jest } from "@jest/globals";
2
+ import { DEFAULT_LIST_LIMIT } from "@rebasepro/types";
3
+ import { createCollectionClient } from "./collection";
4
+ import type { FindResult } from "@rebasepro/types";
5
+ import type { Transport } from "./transport";
6
+ import type { RebaseWebSocketClient } from "./websocket";
7
+
8
+ type Row = { id: string };
9
+
10
+ /**
11
+ * `listen()` gets rows off the socket and has to describe the window they came
12
+ * from itself. Everything it reports is therefore something it decided, and
13
+ * these tests are about which of those decisions are claims it can support.
14
+ */
15
+
16
+ const flush = () => new Promise<void>((r) => setTimeout(r, 0));
17
+
18
+ function createWs() {
19
+ let push: ((rows: Record<string, unknown>[]) => void) | undefined;
20
+ const ws = {
21
+ listenCollection: (_p: unknown, onUpdate: (rows: Record<string, unknown>[]) => void) => {
22
+ push = onUpdate;
23
+ return () => { push = undefined; };
24
+ }
25
+ } as unknown as RebaseWebSocketClient;
26
+ return { ws, push: (rows: Record<string, unknown>[]) => push?.(rows) };
27
+ }
28
+
29
+ function createTransport(count: () => Promise<unknown>): Transport {
30
+ return {
31
+ request: jest.fn<any>((url: string) => {
32
+ if (url.includes("/count")) return count();
33
+ return Promise.resolve({ data: [], meta: {} });
34
+ }),
35
+ setToken: jest.fn(),
36
+ setAuthTokenGetter: jest.fn(),
37
+ setOnUnauthorized: jest.fn(),
38
+ baseUrl: "http://localhost:3000",
39
+ apiPath: "/api",
40
+ fetchFn: globalThis.fetch,
41
+ getHeaders: () => ({}),
42
+ resolveToken: jest.fn<any>().mockResolvedValue(null)
43
+ } as unknown as Transport;
44
+ }
45
+
46
+ describe("listen() pagination metadata", () => {
47
+ it("reports the page size the server actually applies when none was requested", async () => {
48
+ const transport = createTransport(() => Promise.resolve({ count: 3 }));
49
+ const { ws, push } = createWs();
50
+ const client = createCollectionClient<Row>(transport, "products", ws);
51
+
52
+ const updates: FindResult<Row>[] = [];
53
+ const stop = client.listen!(undefined, (r) => updates.push(r));
54
+ push([{ id: "1" }, { id: "2" }, { id: "3" }]);
55
+ await flush();
56
+
57
+ // Not 20. A caller who names no limit gets `DEFAULT_LIST_LIMIT` rows
58
+ // from the REST layer, so any other number here describes a window the
59
+ // rows did not come from.
60
+ expect(updates.at(-1)?.meta.limit).toBe(DEFAULT_LIST_LIMIT);
61
+ stop!();
62
+ });
63
+
64
+ it("does not shrink the total to one page when the count request fails", async () => {
65
+ let counts = 0;
66
+ const transport = createTransport(() => {
67
+ counts += 1;
68
+ return counts === 1
69
+ ? Promise.resolve({ count: 500 })
70
+ : Promise.reject(new Error("count unavailable"));
71
+ });
72
+ const { ws, push } = createWs();
73
+ const client = createCollectionClient<Row>(transport, "products", ws);
74
+
75
+ const updates: FindResult<Row>[] = [];
76
+ const stop = client.listen!({ limit: 2 }, (r) => updates.push(r));
77
+
78
+ push([{ id: "1" }, { id: "2" }]);
79
+ await flush();
80
+ expect(updates.at(-1)?.meta.total).toBe(500);
81
+
82
+ // The collection did not lose 498 rows because a count request failed.
83
+ push([{ id: "1" }, { id: "2" }]);
84
+ await flush();
85
+ expect(updates.at(-1)?.meta.total).toBe(500);
86
+ expect(updates.at(-1)?.meta.hasMore).toBe(true);
87
+ stop!();
88
+ });
89
+
90
+ it("never claims a total below the rows already skipped", async () => {
91
+ const transport = createTransport(() => Promise.reject(new Error("nope")));
92
+ const { ws, push } = createWs();
93
+ const client = createCollectionClient<Row>(transport, "products", ws);
94
+
95
+ const updates: FindResult<Row>[] = [];
96
+ const stop = client.listen!({ limit: 2, offset: 10 }, (r) => updates.push(r));
97
+ push([{ id: "11" }, { id: "12" }]);
98
+ await flush();
99
+
100
+ // With no count ever obtained the total is unknown, but it cannot be 2:
101
+ // ten rows were paged past to reach these.
102
+ expect(updates.at(-1)?.meta.total).toBe(12);
103
+ stop!();
104
+ });
105
+ });
@@ -0,0 +1,138 @@
1
+ import { jest } from "@jest/globals";
2
+ import { createCollectionClient, type LiveResult } from "./collection";
3
+ import type { Transport } from "./transport";
4
+ import type { RebaseWebSocketClient } from "./websocket";
5
+
6
+ type Row = { id: string; name: string };
7
+
8
+ /**
9
+ * `observe()` on a client without the offline layer is two sources feeding one
10
+ * callback: a one-shot `find()` and a realtime subscription. Neither of these
11
+ * tests is about the sources — they are about what the callback is allowed to
12
+ * be told, which is the part an app actually depends on.
13
+ */
14
+
15
+ function deferred<T>() {
16
+ let resolve!: (value: T) => void;
17
+ let reject!: (error: unknown) => void;
18
+ const promise = new Promise<T>((res, rej) => {
19
+ resolve = res;
20
+ reject = rej;
21
+ });
22
+ return { promise, resolve, reject };
23
+ }
24
+
25
+ /** Let every already-queued microtask run. */
26
+ const flush = () => new Promise<void>((r) => setTimeout(r, 0));
27
+
28
+ function createTransport(handlers: {
29
+ find?: () => Promise<unknown>;
30
+ count?: () => Promise<unknown>;
31
+ }): Transport {
32
+ return {
33
+ request: jest.fn<any>((url: string) => {
34
+ if (url.includes("/count")) return handlers.count?.() ?? Promise.resolve({ count: 0 });
35
+ return handlers.find?.() ?? Promise.resolve({ data: [], meta: {} });
36
+ }),
37
+ setToken: jest.fn(),
38
+ setAuthTokenGetter: jest.fn(),
39
+ setOnUnauthorized: jest.fn(),
40
+ baseUrl: "http://localhost:3000",
41
+ apiPath: "/api",
42
+ fetchFn: globalThis.fetch,
43
+ getHeaders: () => ({}),
44
+ resolveToken: jest.fn<any>().mockResolvedValue(null)
45
+ } as unknown as Transport;
46
+ }
47
+
48
+ /** A socket whose collection updates this test drives by hand. */
49
+ function createWs() {
50
+ let push: ((rows: Record<string, unknown>[]) => void) | undefined;
51
+ const ws = {
52
+ listenCollection: (
53
+ _props: unknown,
54
+ onUpdate: (rows: Record<string, unknown>[]) => void
55
+ ) => {
56
+ push = onUpdate;
57
+ return () => { push = undefined; };
58
+ }
59
+ } as unknown as RebaseWebSocketClient;
60
+ return {
61
+ ws,
62
+ push: (rows: Record<string, unknown>[]) => push?.(rows)
63
+ };
64
+ }
65
+
66
+ describe("observe() without the offline layer", () => {
67
+ it("does not replace a live update with the initial fetch that resolved after it", async () => {
68
+ // The initial `find()` is in flight for the whole test; the socket
69
+ // answers first. This is not exotic — `listenCollection` replays cached
70
+ // rows synchronously when another observer already holds the same
71
+ // subscription, so a second component observing the same query hits it
72
+ // every time.
73
+ const find = deferred<unknown>();
74
+ const transport = createTransport({
75
+ find: () => find.promise,
76
+ count: () => Promise.resolve({ count: 1 })
77
+ });
78
+ const { ws, push } = createWs();
79
+ const client = createCollectionClient<Row>(transport, "products", ws);
80
+
81
+ const results: LiveResult<Row>[] = [];
82
+ const stop = client.observe(undefined, (r) => results.push(r));
83
+
84
+ push([{ id: "1", name: "live" }]);
85
+ await flush();
86
+
87
+ find.resolve({ data: [{ id: "1", name: "stale" }], meta: { total: 1 } });
88
+ await flush();
89
+
90
+ expect(results.at(-1)?.data).toEqual([{ id: "1", name: "live" }]);
91
+ stop();
92
+ });
93
+
94
+ it("de-duplicates emissions, as the contract on observe() states", async () => {
95
+ const transport = createTransport({
96
+ find: () => Promise.resolve({ data: [{ id: "1", name: "a" }], meta: { total: 1 } }),
97
+ count: () => Promise.resolve({ count: 1 })
98
+ });
99
+ const { ws, push } = createWs();
100
+ const client = createCollectionClient<Row>(transport, "products", ws);
101
+
102
+ const results: LiveResult<Row>[] = [];
103
+ const stop = client.observe(undefined, (r) => results.push(r));
104
+ await flush();
105
+
106
+ const afterInitial = results.length;
107
+
108
+ // Two socket updates carrying exactly the rows the app already has.
109
+ push([{ id: "1", name: "a" }]);
110
+ await flush();
111
+ push([{ id: "1", name: "a" }]);
112
+ await flush();
113
+
114
+ expect(results.length).toBe(afterInitial);
115
+
116
+ // A real change still gets through.
117
+ push([{ id: "1", name: "b" }]);
118
+ await flush();
119
+ expect(results.length).toBe(afterInitial + 1);
120
+ expect(results.at(-1)?.data).toEqual([{ id: "1", name: "b" }]);
121
+ stop();
122
+ });
123
+
124
+ it("stops emitting once unsubscribed, including for a fetch still in flight", async () => {
125
+ const find = deferred<unknown>();
126
+ const transport = createTransport({ find: () => find.promise });
127
+ const client = createCollectionClient<Row>(transport, "products");
128
+
129
+ const results: LiveResult<Row>[] = [];
130
+ const stop = client.observe(undefined, (r) => results.push(r));
131
+ stop();
132
+
133
+ find.resolve({ data: [{ id: "1", name: "a" }], meta: { total: 1 } });
134
+ await flush();
135
+
136
+ expect(results).toHaveLength(0);
137
+ });
138
+ });
package/src/collection.ts CHANGED
@@ -11,7 +11,7 @@ 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
 
@@ -212,15 +212,36 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
212
212
  options?: ObserveOptions
213
213
  ) {
214
214
  let closed = false;
215
- const emit = (result: FindResult<M>) => {
215
+ // Two sources race into one callback: the one-shot fetch below and
216
+ // the subscription beside it. Whichever resolves last used to win,
217
+ // so a socket update that landed first was overwritten by the
218
+ // fetch's older snapshot and stayed wrong until the next change.
219
+ // `listenCollection` replays cached rows synchronously to a second
220
+ // subscriber, so a second component observing the same query hit
221
+ // that ordering every time.
222
+ let liveDelivered = false;
223
+ let signature: string | undefined;
224
+
225
+ const deliver = (result: FindResult<M>, fromLive: boolean) => {
216
226
  if (closed) return;
227
+ // Once the socket has spoken, the fetch issued alongside it is
228
+ // older news — delivering it would move the app backwards.
229
+ if (fromLive) liveDelivered = true;
230
+ else if (liveDelivered) return;
231
+ // The de-duplication `observe()` documents. Keyed on the rows
232
+ // and the total, the same two things the offline layer keys on,
233
+ // so both implementations mean the same thing by "changed".
234
+ const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
235
+ if (signature !== undefined && next === signature) return;
236
+ signature = next;
217
237
  onResult({ ...result, fromCache: false, hasPendingWrites: false, partial: false });
218
238
  };
219
- client.find(params).then(emit).catch((error) => {
239
+
240
+ client.find(params).then((result) => deliver(result, false)).catch((error) => {
220
241
  if (!closed) onError?.(error as Error);
221
242
  });
222
243
  const live = options?.realtime !== false && client.listen
223
- ? client.listen(params, emit, onError)
244
+ ? client.listen(params, (result) => deliver(result, true), onError)
224
245
  : undefined;
225
246
  return () => {
226
247
  closed = true;
@@ -235,15 +256,25 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
235
256
  options?: ObserveOptions
236
257
  ) {
237
258
  let closed = false;
238
- const emit = (row: M | undefined) => {
259
+ let liveDelivered = false;
260
+ let signature: string | undefined;
261
+
262
+ // Same ordering and de-duplication rules as `observe`, for one row.
263
+ const deliver = (row: M | undefined, fromLive: boolean) => {
239
264
  if (closed) return;
265
+ if (fromLive) liveDelivered = true;
266
+ else if (liveDelivered) return;
267
+ const next = row === undefined ? "missing" : JSON.stringify(row);
268
+ if (signature !== undefined && next === signature) return;
269
+ signature = next;
240
270
  onResult(row, { fromCache: false, hasPendingWrites: false });
241
271
  };
242
- client.findById(id).then(emit).catch((error) => {
272
+
273
+ client.findById(id).then((row) => deliver(row, false)).catch((error) => {
243
274
  if (!closed) onError?.(error as Error);
244
275
  });
245
276
  const live = options?.realtime !== false && client.listenById
246
- ? client.listenById(id, emit, onError)
277
+ ? client.listenById(id, (row) => deliver(row, true), onError)
247
278
  : undefined;
248
279
  return () => {
249
280
  closed = true;
@@ -280,69 +311,80 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
280
311
  client.listen = (params: FindParams<M> | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void) => {
281
312
  let active = true;
282
313
  let lastUpdateId = 0;
314
+ // The last total a `count()` actually returned. A later count that
315
+ // fails says nothing about how big the collection is, so it must
316
+ // not be allowed to replace this with the length of one page.
317
+ let lastKnownTotal: number | undefined;
318
+ const window = resolveFindWindow(params);
283
319
  const unsub = ws.listenCollection(
284
320
  {
285
321
  path: slug,
286
322
  filter: params?.where,
323
+ // The group used to be dropped here, so a subscription
324
+ // filtered with `or(...)` was pushed every row instead.
325
+ logical: params?.logical,
287
326
  limit: params?.limit,
288
- startAfter: params?.offset ? String(params.offset) : undefined,
327
+ // `offset` used to be stringified into `startAfter`, which
328
+ // is a cursor *row*, not a row count — so the server was
329
+ // handed "20" where it expected a keyset value, and the
330
+ // offset it does understand never arrived at all.
331
+ offset: window.driverOffset,
289
332
  orderBy: params?.orderBy?.[0],
290
333
  order: params?.orderBy?.[1],
291
334
  searchString: params?.searchString
292
335
  },
293
336
  (incomingRows: Record<string, unknown>[]) => {
294
337
  const currentUpdateId = ++lastUpdateId;
295
- const requestedLimit = params?.limit || 20;
296
- const offset = params?.offset || 0;
338
+ // What the server pages by when the caller names no limit.
339
+ // A hardcoded 20 here described a window the rows had not
340
+ // come from, and any app sizing its next request off
341
+ // `meta.limit` was told the wrong number.
342
+ const requestedLimit = window.limit;
343
+ const offset = window.offset;
297
344
 
298
345
  // WS client already delivers flat rows — just cast
299
346
  const rows = incomingRows as M[];
300
347
 
301
- // Heuristic metadata (used as fallback if count call fails)
302
- const heuristicTotal = rows.length;
303
- const heuristicHasMore = rows.length >= requestedLimit;
348
+ const emit = (total: number, hasMore: boolean) => {
349
+ if (!active || currentUpdateId !== lastUpdateId) return;
350
+ onUpdate({
351
+ data: rows,
352
+ meta: {
353
+ total,
354
+ limit: requestedLimit,
355
+ offset,
356
+ hasMore
357
+ }
358
+ });
359
+ };
360
+
361
+ // With no count to go on, the only defensible total is a
362
+ // lower bound: the rows on this page plus the ones paged
363
+ // past to reach them. Reporting `rows.length` claimed a
364
+ // collection read at offset 10 held two rows.
365
+ const emitWithoutCount = () => emit(
366
+ offset + rows.length,
367
+ rows.length >= requestedLimit
368
+ );
304
369
 
305
- // Try to get authoritative count; fall back to heuristic
306
370
  if (client.count) {
307
371
  client.count(params)
308
372
  .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
- }
373
+ lastKnownTotal = total;
374
+ emit(total, offset + rows.length < total);
320
375
  })
321
376
  .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
- });
377
+ // A count that failed is not evidence about the
378
+ // size of the collection. Keep the last real
379
+ // answer; only guess if there has never been one.
380
+ if (lastKnownTotal !== undefined) {
381
+ emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
382
+ } else {
383
+ emitWithoutCount();
333
384
  }
334
385
  });
335
386
  } 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
- });
387
+ emitWithoutCount();
346
388
  }
347
389
  },
348
390
  onError