@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.
- package/README.md +4 -0
- package/dist/auth.d.ts +80 -0
- package/dist/functions.d.ts +6 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.es.js +471 -94
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -1
- package/dist/offline.d.ts +23 -1
- package/dist/query-contract.types.d.ts +30 -0
- package/dist/realtime-channel.d.ts +29 -1
- package/dist/sdk_query_builder.d.ts +21 -2
- package/dist/transport.d.ts +24 -0
- package/package.json +28 -15
- package/src/admin.ts +0 -90
- package/src/anonymous-client-guard.test.ts +0 -190
- package/src/api-keys.ts +0 -87
- package/src/auth-listener-errors.test.ts +0 -57
- package/src/auth-refresh-overflow.test.ts +0 -89
- package/src/auth.ts +0 -982
- package/src/backups.ts +0 -40
- package/src/client-close.test.ts +0 -80
- package/src/collection-listen-meta.test.ts +0 -105
- package/src/collection-observe.test.ts +0 -138
- package/src/collection.test.ts +0 -293
- package/src/collection.ts +0 -525
- package/src/cron.test.ts +0 -164
- package/src/cron.ts +0 -62
- package/src/data-proxy.test.ts +0 -183
- package/src/errors.ts +0 -9
- package/src/functions.ts +0 -82
- package/src/index.ts +0 -639
- package/src/like-pattern-redos.test.ts +0 -61
- package/src/offline-codec.ts +0 -79
- package/src/offline-connectivity.test.ts +0 -191
- package/src/offline-connectivity.ts +0 -255
- package/src/offline-idb-store.test.ts +0 -340
- package/src/offline-integration.test.ts +0 -180
- package/src/offline-query.test.ts +0 -431
- package/src/offline-query.ts +0 -529
- package/src/offline-store.ts +0 -357
- package/src/offline-sync-engine.test.ts +0 -857
- package/src/offline.test.ts +0 -897
- package/src/offline.ts +0 -1928
- package/src/query-contract.types.ts +0 -206
- package/src/query_builder.ts +0 -1
- package/src/realtime-channel.test.ts +0 -542
- package/src/realtime-channel.ts +0 -539
- package/src/realtime-concurrent-subscribe.test.ts +0 -102
- package/src/realtime-error-surfacing.test.ts +0 -105
- package/src/realtime-optout.test.ts +0 -279
- package/src/realtime-row-identity.test.ts +0 -254
- package/src/realtime-subscription-key.test.ts +0 -92
- package/src/reviver.ts +0 -39
- package/src/sdk_query_builder.ts +0 -206
- package/src/storage-key-encoding.test.ts +0 -65
- package/src/storage-registry.ts +0 -102
- package/src/storage.ts +0 -253
- package/src/transport-baseurl.test.ts +0 -101
- package/src/transport.ts +0 -505
- package/src/vector-search-listen.test.ts +0 -42
- package/src/vector-search-query.test.ts +0 -55
- package/src/websocket-url.test.ts +0 -97
- 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
|
-
}
|
package/src/client-close.test.ts
DELETED
|
@@ -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
|
-
});
|
package/src/collection.test.ts
DELETED
|
@@ -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
|
-
});
|