@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.
- package/dist/index.es.js +93 -58
- package/dist/index.es.js.map +1 -1
- package/dist/offline-query.d.ts +18 -3
- package/package.json +4 -4
- package/src/auth-listener-errors.test.ts +57 -0
- package/src/auth.ts +10 -1
- package/src/collection-listen-meta.test.ts +105 -0
- package/src/collection-observe.test.ts +138 -0
- package/src/collection.ts +88 -46
- package/src/index.ts +48 -15
- package/src/offline-query.test.ts +28 -2
- package/src/offline-query.ts +20 -7
- package/src/offline.ts +6 -4
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/transport-baseurl.test.ts +49 -1
- package/src/transport.ts +22 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +18 -9
package/dist/offline-query.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { FilterValues, FindResult, LogicalCondition, FilterCondition, OrderByTuple, WhereFilterOp } from "@rebasepro/types";
|
|
2
2
|
import { FindParams } from "./transport";
|
|
3
|
-
/**
|
|
4
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
33
|
-
"@rebasepro/utils": "0.13.
|
|
34
|
-
"@rebasepro/types": "0.13.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|