@rebasepro/client 0.17.3-canary.gdd23447 → 0.18.0

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.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/dist/auth.d.ts +80 -0
  3. package/dist/functions.d.ts +6 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.es.js +471 -94
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/offline-connectivity.d.ts +12 -1
  8. package/dist/offline.d.ts +23 -1
  9. package/dist/query-contract.types.d.ts +30 -0
  10. package/dist/realtime-channel.d.ts +29 -1
  11. package/dist/sdk_query_builder.d.ts +21 -2
  12. package/dist/transport.d.ts +24 -0
  13. package/package.json +28 -15
  14. package/src/admin.ts +0 -90
  15. package/src/anonymous-client-guard.test.ts +0 -190
  16. package/src/api-keys.ts +0 -87
  17. package/src/auth-listener-errors.test.ts +0 -57
  18. package/src/auth-refresh-overflow.test.ts +0 -89
  19. package/src/auth.ts +0 -982
  20. package/src/backups.ts +0 -40
  21. package/src/client-close.test.ts +0 -80
  22. package/src/collection-listen-meta.test.ts +0 -105
  23. package/src/collection-observe.test.ts +0 -138
  24. package/src/collection.test.ts +0 -293
  25. package/src/collection.ts +0 -525
  26. package/src/cron.test.ts +0 -164
  27. package/src/cron.ts +0 -62
  28. package/src/data-proxy.test.ts +0 -183
  29. package/src/errors.ts +0 -9
  30. package/src/functions.ts +0 -82
  31. package/src/index.ts +0 -639
  32. package/src/like-pattern-redos.test.ts +0 -61
  33. package/src/offline-codec.ts +0 -79
  34. package/src/offline-connectivity.test.ts +0 -191
  35. package/src/offline-connectivity.ts +0 -255
  36. package/src/offline-idb-store.test.ts +0 -340
  37. package/src/offline-integration.test.ts +0 -180
  38. package/src/offline-query.test.ts +0 -431
  39. package/src/offline-query.ts +0 -529
  40. package/src/offline-store.ts +0 -357
  41. package/src/offline-sync-engine.test.ts +0 -857
  42. package/src/offline.test.ts +0 -897
  43. package/src/offline.ts +0 -1928
  44. package/src/query-contract.types.ts +0 -206
  45. package/src/query_builder.ts +0 -1
  46. package/src/realtime-channel.test.ts +0 -542
  47. package/src/realtime-channel.ts +0 -539
  48. package/src/realtime-concurrent-subscribe.test.ts +0 -102
  49. package/src/realtime-error-surfacing.test.ts +0 -105
  50. package/src/realtime-optout.test.ts +0 -279
  51. package/src/realtime-row-identity.test.ts +0 -254
  52. package/src/realtime-subscription-key.test.ts +0 -92
  53. package/src/reviver.ts +0 -39
  54. package/src/sdk_query_builder.ts +0 -206
  55. package/src/storage-key-encoding.test.ts +0 -65
  56. package/src/storage-registry.ts +0 -102
  57. package/src/storage.ts +0 -253
  58. package/src/transport-baseurl.test.ts +0 -101
  59. package/src/transport.ts +0 -505
  60. package/src/vector-search-listen.test.ts +0 -42
  61. package/src/vector-search-query.test.ts +0 -55
  62. package/src/websocket-url.test.ts +0 -97
  63. package/src/websocket.ts +0 -1837
package/src/backups.ts DELETED
@@ -1,40 +0,0 @@
1
- import { Transport } from "./transport";
2
- import type { BackupInfo, BackupDestinationKind } from "@rebasepro/types";
3
-
4
- export interface CreateBackupsOptions {
5
- backupsPath?: string;
6
- }
7
-
8
- export function createBackups(transport: Transport, options?: CreateBackupsOptions) {
9
- const backupsPath = options?.backupsPath || "/admin/backups";
10
-
11
- async function list(): Promise<{
12
- backups: BackupInfo[];
13
- destinationKind: BackupDestinationKind;
14
- configured: boolean;
15
- }> {
16
- return transport.request(backupsPath, { method: "GET" });
17
- }
18
-
19
- /**
20
- * Download a backup's bytes. Uses an authenticated fetch (not the JSON
21
- * transport) so the octet-stream response comes back as a Blob.
22
- */
23
- async function download(key: string): Promise<Blob> {
24
- const token = await transport.resolveToken();
25
- // Mirror transport.request's URL construction (baseUrl + apiPath + path)
26
- // — this endpoint returns an octet-stream, so we fetch it directly
27
- // instead of going through the JSON transport.
28
- const url = `${transport.baseUrl}${transport.apiPath}${backupsPath}/download?key=${encodeURIComponent(key)}`;
29
- const res = await fetch(url, {
30
- method: "GET",
31
- headers: token ? { Authorization: `Bearer ${token}` } : {}
32
- });
33
- if (!res.ok) {
34
- throw new Error(`Failed to download backup (${res.status})`);
35
- }
36
- return res.blob();
37
- }
38
-
39
- return { list, download };
40
- }
@@ -1,80 +0,0 @@
1
- import { jest } from "@jest/globals";
2
- import { createRebaseClient } from "./index";
3
-
4
- /**
5
- * `close()` has one job: leave nothing behind that can keep a process alive.
6
- *
7
- * The realtime socket is the obvious such handle and was the only one this
8
- * method released. The scheduled token refresh is the other, and it is easy to
9
- * miss because it only exists once somebody signs in: `scheduleRefresh` arms a
10
- * plain `setTimeout` for roughly the access token's lifetime, and it is not
11
- * `unref`'d. So a signed-in Node client — a seed script, a cron handler, a job,
12
- * an SSR request — released its socket, called `close()` exactly as documented,
13
- * and still hung until the token would have expired.
14
- *
15
- * Found by `tests/e2e/tests/client-sdk-e2e.ts`, which reported every check green and
16
- * then never exited. Pinned here rather than only there because that suite needs
17
- * Docker and a real Postgres, and this costs nothing.
18
- *
19
- * The assertion is the pending timer count, not a spy on `stopAutoRefresh`:
20
- * what matters is that no handle survives, not which internal call removed it.
21
- */
22
- describe("client.close()", () => {
23
- const realFetch = globalThis.fetch;
24
-
25
- afterEach(() => {
26
- globalThis.fetch = realFetch;
27
- jest.useRealTimers();
28
- jest.restoreAllMocks();
29
- });
30
-
31
- /** A login response shaped exactly as `handleAuthResponse` reads it. */
32
- function loginResponse(accessTokenExpiresAt: number) {
33
- return new Response(
34
- JSON.stringify({
35
- tokens: { accessToken: "access-token",
36
- refreshToken: "refresh-token",
37
- accessTokenExpiresAt },
38
- user: { uid: "user-1",
39
- email: "user@example.test" }
40
- }),
41
- { status: 200,
42
- headers: { "content-type": "application/json" } }
43
- );
44
- }
45
-
46
- it("clears the scheduled token refresh, so a signed-in script can exit", async () => {
47
- jest.useFakeTimers();
48
-
49
- // An hour out, so the refresh is scheduled rather than fired
50
- // immediately — `scheduleRefresh` runs the refresh inline when the
51
- // delay has already elapsed, which would arm no timer and let a broken
52
- // `close()` pass this test.
53
- const expiresAt = Date.now() + 60 * 60 * 1000;
54
- globalThis.fetch = jest.fn(async () => loginResponse(expiresAt)) as unknown as typeof fetch;
55
-
56
- // `realtime: false` so the socket cannot account for a pending handle:
57
- // the only thing this test may observe is the refresh timer.
58
- const client = createRebaseClient({ baseUrl: "http://localhost:9999",
59
- realtime: false });
60
-
61
- await client.auth.signInWithEmail("user@example.test", "password");
62
- expect(jest.getTimerCount()).toBeGreaterThan(0);
63
-
64
- client.close();
65
- expect(jest.getTimerCount()).toBe(0);
66
- });
67
-
68
- it("is safe on a client that never signed in, and safe to call twice", () => {
69
- jest.useFakeTimers();
70
-
71
- const client = createRebaseClient({ baseUrl: "http://localhost:9999",
72
- realtime: false });
73
-
74
- expect(() => {
75
- client.close();
76
- client.close();
77
- }).not.toThrow();
78
- expect(jest.getTimerCount()).toBe(0);
79
- });
80
- });
@@ -1,105 +0,0 @@
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
- });
@@ -1,138 +0,0 @@
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
- });
@@ -1,293 +0,0 @@
1
- import { jest } from "@jest/globals";
2
- import { createCollectionClient } from "./collection";
3
- import type { Transport } from "./transport";
4
-
5
- function createMockTransport(): Transport {
6
- return {
7
- request: jest.fn<any>().mockResolvedValue({}),
8
- setToken: jest.fn(),
9
- setAuthTokenGetter: jest.fn(),
10
- setOnUnauthorized: jest.fn(),
11
- baseUrl: "http://localhost:3000",
12
- apiPath: "/api",
13
- fetchFn: globalThis.fetch,
14
- getHeaders: () => ({}),
15
- resolveToken: jest.fn<any>().mockResolvedValue(null)
16
- };
17
- }
18
-
19
- describe("createCollectionClient", () => {
20
- let transport: Transport;
21
-
22
- beforeEach(() => {
23
- transport = createMockTransport();
24
- });
25
-
26
- describe("count()", () => {
27
- it("should call the /count endpoint and return the count", async () => {
28
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 42 });
29
-
30
- const client = createCollectionClient(transport, "products");
31
- const result = await client.count();
32
-
33
- expect(transport.request).toHaveBeenCalledWith(
34
- "/data/products/count",
35
- { method: "GET" }
36
- );
37
- expect(result).toBe(42);
38
- });
39
-
40
- it("should forward where filters to the count endpoint", async () => {
41
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 7 });
42
-
43
- const client = createCollectionClient(transport, "products");
44
- const result = await client.count({
45
- where: { status: ["==", "published"] }
46
- });
47
-
48
- expect(transport.request).toHaveBeenCalledWith(
49
- expect.stringContaining("/data/products/count?"),
50
- { method: "GET" }
51
- );
52
- // Verify the query string includes the filter
53
- const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
54
- expect(calledUrl).toContain("status=");
55
- expect(result).toBe(7);
56
- });
57
-
58
- it("should forward orderBy to the count endpoint", async () => {
59
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 15 });
60
-
61
- const client = createCollectionClient(transport, "items");
62
- const result = await client.count({
63
- orderBy: ["created_at", "desc"]
64
- });
65
-
66
- const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
67
- expect(calledUrl).toContain("/data/items/count?");
68
- expect(calledUrl).toContain("orderBy=");
69
- expect(result).toBe(15);
70
- });
71
-
72
- it("should NOT include limit or offset in the count request", async () => {
73
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 100 });
74
-
75
- const client = createCollectionClient(transport, "users");
76
- // Even if the caller passes limit/offset in params, they should be stripped
77
- await client.count({ limit: 50,
78
- offset: 10 });
79
-
80
- const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
81
- expect(calledUrl).not.toContain("limit=");
82
- expect(calledUrl).not.toContain("offset=");
83
- });
84
-
85
- it("should return 0 when meta.count is missing", async () => {
86
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({});
87
-
88
- const client = createCollectionClient(transport, "empty");
89
- const result = await client.count();
90
-
91
- expect(result).toBe(0);
92
- });
93
-
94
- it("should forward searchString to the count endpoint", async () => {
95
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 3 });
96
-
97
- const client = createCollectionClient(transport, "articles");
98
- const result = await client.count({
99
- searchString: "hello world"
100
- });
101
-
102
- const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
103
- expect(calledUrl).toContain("searchString=");
104
- expect(calledUrl).toContain("hello");
105
- expect(result).toBe(3);
106
- });
107
-
108
- it("should forward multiple where filters correctly", async () => {
109
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 5 });
110
-
111
- const client = createCollectionClient(transport, "orders");
112
- await client.count({
113
- where: {
114
- status: ["==", "active"],
115
- total: [">=", 100]
116
- }
117
- });
118
-
119
- const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
120
- expect(calledUrl).toContain("status=");
121
- expect(calledUrl).toContain("total=");
122
- });
123
- });
124
-
125
- describe("find()", () => {
126
- it("should call the list endpoint and return flat rows", async () => {
127
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({
128
- data: [{ id: "1",
129
- name: "Product A" }],
130
- meta: { total: 1,
131
- limit: 20,
132
- offset: 0,
133
- hasMore: false }
134
- });
135
-
136
- const client = createCollectionClient(transport, "products");
137
- const result = await client.find();
138
-
139
- expect(transport.request).toHaveBeenCalledWith(
140
- "/data/products",
141
- { method: "GET" }
142
- );
143
- expect(result.data).toHaveLength(1);
144
- // Flat row access — no .values wrapper
145
- expect(result.data[0].id).toBe("1");
146
- expect((result.data[0] as Record<string, unknown>).name).toBe("Product A");
147
- // No path field — that's admin leakage
148
- expect((result.data[0] as Record<string, unknown>).path).toBeUndefined();
149
- expect(result.meta.total).toBe(1);
150
- });
151
- });
152
-
153
- describe("findById()", () => {
154
- it("should return a flat row directly", async () => {
155
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "42", title: "Hello" });
156
-
157
- const client = createCollectionClient(transport, "posts");
158
- const result = await client.findById("42");
159
-
160
- expect(transport.request).toHaveBeenCalledWith(
161
- "/data/posts/42",
162
- { method: "GET" }
163
- );
164
- expect(result).toBeDefined();
165
- expect(result!.id).toBe("42");
166
- expect((result as Record<string, unknown>).title).toBe("Hello");
167
- });
168
-
169
- it("should return undefined for 404", async () => {
170
- const { RebaseApiError } = await import("./transport");
171
- (transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
172
- new RebaseApiError("Not Found", { status: 404 })
173
- );
174
-
175
- const client = createCollectionClient(transport, "posts");
176
- const result = await client.findById("999");
177
- expect(result).toBeUndefined();
178
- });
179
- });
180
-
181
- describe("create()", () => {
182
- it("should return a flat row", async () => {
183
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "new-1", title: "Created" });
184
-
185
- const client = createCollectionClient(transport, "posts");
186
- const result = await client.create({ title: "Created" } as Record<string, unknown>);
187
-
188
- expect(result.id).toBe("new-1");
189
- expect((result as Record<string, unknown>).title).toBe("Created");
190
- });
191
- });
192
-
193
- describe("update()", () => {
194
- it("should return a flat row", async () => {
195
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "1", title: "Updated" });
196
-
197
- const client = createCollectionClient(transport, "posts");
198
- const result = await client.update("1", { title: "Updated" } as Record<string, unknown>);
199
-
200
- expect(result.id).toBe("1");
201
- expect((result as Record<string, unknown>).title).toBe("Updated");
202
- });
203
-
204
- it("should throw RebaseApiError for 404", async () => {
205
- const { RebaseApiError } = await import("./transport");
206
- (transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
207
- new RebaseApiError("Not Found", { status: 404 })
208
- );
209
-
210
- const client = createCollectionClient(transport, "posts");
211
- await expect(
212
- client.update("999", { title: "Nope" } as Record<string, unknown>)
213
- ).rejects.toMatchObject({ status: 404 });
214
- });
215
- });
216
-
217
- describe("delete()", () => {
218
- it("should throw RebaseApiError for 404", async () => {
219
- const { RebaseApiError } = await import("./transport");
220
- (transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
221
- new RebaseApiError("Not Found", { status: 404 })
222
- );
223
-
224
- const client = createCollectionClient(transport, "posts");
225
- await expect(client.delete("999")).rejects.toMatchObject({ status: 404 });
226
- });
227
-
228
- it("should propagate non-404 errors", async () => {
229
- const { RebaseApiError } = await import("./transport");
230
- (transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
231
- new RebaseApiError("Internal Server Error", { status: 500 })
232
- );
233
-
234
- const client = createCollectionClient(transport, "posts");
235
- await expect(client.delete("1")).rejects.toThrow("Internal Server Error");
236
- });
237
- });
238
-
239
- /**
240
- * `listen()` branches on `client.count` being present, and consumers such
241
- * as EntitiesCount do the same. Asserting `typeof client.count ===
242
- * "function"` on an object literal the factory just built cannot fail;
243
- * what can regress is the branch downstream of it — whether the
244
- * authoritative total actually reaches the subscriber, or whether the
245
- * heuristic row count silently stands in for it.
246
- */
247
- describe("listen() total comes from count()", () => {
248
- /** A WS double that hands the collection callback one page of rows. */
249
- function wsDeliveringRows(rows: Record<string, unknown>[]) {
250
- let emit: ((rows: Record<string, unknown>[]) => void) | undefined;
251
- const ws = {
252
- listenCollection: jest.fn((_params: unknown, onUpdate: any) => {
253
- emit = onUpdate;
254
- return () => undefined;
255
- })
256
- };
257
- return { ws: ws as any,
258
- deliver: () => emit!(rows) };
259
- }
260
-
261
- it("reports the authoritative count, not the number of rows delivered", async () => {
262
- (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 137 });
263
- const { ws, deliver } = wsDeliveringRows([{ id: "1" }, { id: "2" }]);
264
-
265
- const client = createCollectionClient(transport, "products", ws);
266
- const updates: any[] = [];
267
- client.listen!({ limit: 2 }, (res) => { updates.push(res); });
268
- deliver();
269
- await new Promise(r => setImmediate(r));
270
-
271
- expect(updates).toHaveLength(1);
272
- expect(updates[0].meta.total).toBe(137);
273
- // 0 + 2 < 137, so the page is not the end of the collection.
274
- expect(updates[0].meta.hasMore).toBe(true);
275
- });
276
-
277
- it("falls back to the row count when the count request fails", async () => {
278
- (transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(new Error("boom"));
279
- const { ws, deliver } = wsDeliveringRows([{ id: "1" }, { id: "2" }]);
280
-
281
- const client = createCollectionClient(transport, "products", ws);
282
- const updates: any[] = [];
283
- client.listen!({ limit: 2 }, (res) => { updates.push(res); });
284
- deliver();
285
- await new Promise(r => setImmediate(r));
286
-
287
- expect(updates).toHaveLength(1);
288
- expect(updates[0].meta.total).toBe(2);
289
- // A full page with no authoritative total: assume there is more.
290
- expect(updates[0].meta.hasMore).toBe(true);
291
- });
292
- });
293
- });