@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/dist/index.d.ts +1 -0
- package/dist/index.es.js +263 -73
- package/dist/index.es.js.map +1 -1
- package/dist/offline-query.d.ts +18 -3
- package/dist/offline-store.d.ts +8 -1
- package/package.json +4 -4
- package/src/auth-listener-errors.test.ts +57 -0
- package/src/auth-refresh-overflow.test.ts +89 -0
- package/src/auth.ts +30 -1
- package/src/collection-listen-meta.test.ts +105 -0
- package/src/collection-observe.test.ts +138 -0
- package/src/collection.ts +184 -50
- package/src/index.ts +51 -15
- package/src/like-pattern-redos.test.ts +61 -0
- package/src/offline-query.test.ts +28 -2
- package/src/offline-query.ts +35 -8
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +114 -1
- package/src/offline.ts +121 -7
- 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/dist/offline-store.d.ts
CHANGED
|
@@ -48,9 +48,16 @@ export interface PendingMutation {
|
|
|
48
48
|
/** Unique, lexicographically sortable identity — also the queue key suffix. */
|
|
49
49
|
mutationId: string;
|
|
50
50
|
collection: string;
|
|
51
|
-
type: "create" | "createMany" | "update" | "delete";
|
|
51
|
+
type: "create" | "createMany" | "update" | "updateMany" | "delete" | "deleteMany";
|
|
52
52
|
/** Target row id for update/delete, and the (client-generated) id of an offline create. */
|
|
53
53
|
id?: string | number;
|
|
54
|
+
/** Target row ids for `deleteMany`. */
|
|
55
|
+
ids?: (string | number)[];
|
|
56
|
+
/** `{ id, data }` entries for `updateMany`. */
|
|
57
|
+
updates?: {
|
|
58
|
+
id: string | number;
|
|
59
|
+
data: Record<string, unknown>;
|
|
60
|
+
}[];
|
|
54
61
|
/**
|
|
55
62
|
* True when the SDK minted this create's id itself. Only such creates may
|
|
56
63
|
* cancel out against a later offline delete: a freshly generated UUID
|
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.g18cfeb7",
|
|
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.g18cfeb7",
|
|
33
|
+
"@rebasepro/utils": "0.13.1-canary.g18cfeb7",
|
|
34
|
+
"@rebasepro/types": "0.13.1-canary.g18cfeb7"
|
|
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
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { createAuth, createMemoryStorage } from "./auth";
|
|
3
|
+
import type { Transport } from "./transport";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How far ahead the token refresh is allowed to be scheduled.
|
|
7
|
+
*
|
|
8
|
+
* `scheduleRefresh` computes `(expiresAt - REFRESH_BUFFER_MS) - Date.now()` and
|
|
9
|
+
* hands it to `setTimeout`, with a floor for the past and no ceiling. Node and
|
|
10
|
+
* every browser store that delay in a **32-bit signed integer**: hand them more
|
|
11
|
+
* than 2,147,483,647 ms — about 24.8 days — and the timer does not wait, it
|
|
12
|
+
* clamps to 1 ms and fires immediately.
|
|
13
|
+
*
|
|
14
|
+
* `auth.accessExpiresIn` is configurable and defaults to `"1h"`, so this is
|
|
15
|
+
* dormant on a default deployment. Set it to `"30d"` — an ordinary choice for an
|
|
16
|
+
* internal tool or a kiosk — and every signed-in browser refreshes at once,
|
|
17
|
+
* receives a token expiring another 30 days out, schedules again, overflows
|
|
18
|
+
* again: a hot loop against `/auth/refresh`, one per open tab.
|
|
19
|
+
*
|
|
20
|
+
* A floor without a ceiling is the tell. Someone already knew the input was
|
|
21
|
+
* untrusted and bounded one side of it. (Class 23.)
|
|
22
|
+
*/
|
|
23
|
+
const MAX_DELAY = 2_147_483_647;
|
|
24
|
+
|
|
25
|
+
function transport(): Transport {
|
|
26
|
+
return {
|
|
27
|
+
request: jest.fn<any>().mockResolvedValue({}),
|
|
28
|
+
setToken: jest.fn(),
|
|
29
|
+
setAuthTokenGetter: jest.fn(),
|
|
30
|
+
setOnUnauthorized: jest.fn(),
|
|
31
|
+
baseUrl: "http://localhost:3000",
|
|
32
|
+
apiPath: "/api",
|
|
33
|
+
fetchFn: jest.fn<any>().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }),
|
|
34
|
+
getHeaders: () => ({}),
|
|
35
|
+
resolveToken: jest.fn<any>().mockResolvedValue(null)
|
|
36
|
+
} as unknown as Transport;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Storage already holding a session expiring `days` from now. */
|
|
40
|
+
function storageWithSession(days: number) {
|
|
41
|
+
const storage = createMemoryStorage();
|
|
42
|
+
storage.setItem("rebase_auth", JSON.stringify({
|
|
43
|
+
accessToken: "at",
|
|
44
|
+
refreshToken: "rt",
|
|
45
|
+
expiresAt: Date.now() + days * 24 * 60 * 60 * 1000,
|
|
46
|
+
user: { uid: "u1" }
|
|
47
|
+
}));
|
|
48
|
+
return storage;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("scheduled token refresh", () => {
|
|
52
|
+
let delays: number[];
|
|
53
|
+
let realSetTimeout: typeof setTimeout;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
delays = [];
|
|
57
|
+
realSetTimeout = globalThis.setTimeout;
|
|
58
|
+
globalThis.setTimeout = ((fn: () => void, ms?: number) => {
|
|
59
|
+
if (typeof ms === "number") delays.push(ms);
|
|
60
|
+
return realSetTimeout(() => { /* never run in this test */ }, 0);
|
|
61
|
+
}) as unknown as typeof setTimeout;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => { globalThis.setTimeout = realSetTimeout; });
|
|
65
|
+
|
|
66
|
+
it("schedules a normal one-hour token in the ordinary way", () => {
|
|
67
|
+
createAuth(transport(), { storage: storageWithSession(1 / 24) });
|
|
68
|
+
|
|
69
|
+
const scheduled = delays.filter(d => d > 1000);
|
|
70
|
+
expect(scheduled.length).toBeGreaterThan(0);
|
|
71
|
+
expect(Math.max(...scheduled)).toBeLessThanOrEqual(MAX_DELAY);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("never asks setTimeout for a delay it cannot hold", () => {
|
|
75
|
+
// 30 days: 2,592,000,000 ms, past the 32-bit ceiling.
|
|
76
|
+
createAuth(transport(), { storage: storageWithSession(30) });
|
|
77
|
+
|
|
78
|
+
for (const d of delays) expect(d).toBeLessThanOrEqual(MAX_DELAY);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not schedule a refresh for one millisecond from now", () => {
|
|
82
|
+
// The symptom of the overflow: the clamp makes it immediate, and the
|
|
83
|
+
// refresh that follows schedules another one just as far out.
|
|
84
|
+
createAuth(transport(), { storage: storageWithSession(90) });
|
|
85
|
+
|
|
86
|
+
const immediate = delays.filter(d => d > 0 && d < 1000);
|
|
87
|
+
expect(immediate).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -89,6 +89,11 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
|
|
|
89
89
|
|
|
90
90
|
const STORAGE_KEY = "rebase_auth";
|
|
91
91
|
const REFRESH_BUFFER_MS = 120000;
|
|
92
|
+
/**
|
|
93
|
+
* The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
|
|
94
|
+
* Anything larger is silently clamped to 1ms by Node and every browser.
|
|
95
|
+
*/
|
|
96
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
92
97
|
// Auto-refresh resilience: retry transient failures with exponential backoff
|
|
93
98
|
// (1s, 2s, 4s, … capped) before giving up and signing out.
|
|
94
99
|
const MAX_REFRESH_RETRIES = 5;
|
|
@@ -129,7 +134,16 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
|
|
|
129
134
|
|
|
130
135
|
function emit(event: AuthChangeEvent, session: RebaseSession | null) {
|
|
131
136
|
for (const fn of listeners) {
|
|
132
|
-
try {
|
|
137
|
+
try {
|
|
138
|
+
fn(event, session);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// Isolated so one bad handler cannot stop the rest being told —
|
|
141
|
+
// but reported, because the throw came from the caller's own
|
|
142
|
+
// code and discarding it made a broken `onAuthStateChange`
|
|
143
|
+
// handler look like an event that never fired. The socket in
|
|
144
|
+
// this package already reports handler errors this way.
|
|
145
|
+
console.error("Error in auth state change listener:", e);
|
|
146
|
+
}
|
|
133
147
|
}
|
|
134
148
|
}
|
|
135
149
|
|
|
@@ -260,6 +274,21 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
|
|
|
260
274
|
return;
|
|
261
275
|
}
|
|
262
276
|
|
|
277
|
+
// `setTimeout` holds its delay in a 32-bit signed integer. Past
|
|
278
|
+
// ~24.8 days it does not wait — it clamps to 1ms and fires at once. The
|
|
279
|
+
// refresh would then land, receive a token expiring just as far out,
|
|
280
|
+
// schedule again and overflow again: a hot loop against
|
|
281
|
+
// `/auth/refresh`, one per open tab.
|
|
282
|
+
//
|
|
283
|
+
// `auth.accessExpiresIn` is configurable and defaults to "1h", so this
|
|
284
|
+
// is dormant on a default deployment and immediate on `"30d"` — an
|
|
285
|
+
// ordinary setting for an internal tool. Re-arm instead of refreshing:
|
|
286
|
+
// sleep the maximum, then work out again how long is left.
|
|
287
|
+
if (delay > MAX_TIMER_DELAY_MS) {
|
|
288
|
+
refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
263
292
|
refreshTimeout = setTimeout(() => { void attemptScheduledRefresh(0); }, delay);
|
|
264
293
|
}
|
|
265
294
|
|
|
@@ -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
|
+
});
|