@rebasepro/client 0.9.0 → 0.9.1-canary.0fce67c
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 +1 -1
- package/dist/admin.d.ts +1 -0
- package/dist/backups.d.ts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.es.js +280 -72
- package/dist/index.es.js.map +1 -1
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +57 -1
- package/package.json +8 -9
- package/src/admin.ts +1 -1
- package/src/api-keys.ts +1 -1
- package/src/backups.ts +40 -0
- package/src/collection.ts +16 -0
- package/src/index.ts +33 -2
- package/src/realtime-optout.test.ts +119 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/sdk_query_builder.ts +4 -1
- package/src/transport.ts +34 -0
- package/src/websocket.ts +359 -71
- package/dist/collection.test.d.ts +0 -1
- package/dist/cron.test.d.ts +0 -1
- package/dist/data-proxy.test.d.ts +0 -1
- package/dist/index.umd.js +0 -2484
- package/dist/index.umd.js.map +0 -1
package/dist/transport.d.ts
CHANGED
|
@@ -2,12 +2,46 @@ import { FindParams as TypesFindParams, FindResponse as TypesFindResponse } from
|
|
|
2
2
|
export { RebaseApiError } from "@rebasepro/types";
|
|
3
3
|
export type { RebaseErrorInit } from "@rebasepro/types";
|
|
4
4
|
export interface RebaseClientConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Origin of the Rebase server — scheme, host and port **only**.
|
|
7
|
+
*
|
|
8
|
+
* {@link apiPath} is appended to this, so do not include it here:
|
|
9
|
+
* `"http://localhost:3001"` is correct, while `"http://localhost:3001/api"`
|
|
10
|
+
* silently builds `/api/api/…` and every request 404s. Omit entirely for
|
|
11
|
+
* same-origin requests from the browser.
|
|
12
|
+
*/
|
|
5
13
|
baseUrl?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Bearer token sent as `Authorization` on every request.
|
|
16
|
+
*
|
|
17
|
+
* In the browser this is the signed-in user's access token, so row-level
|
|
18
|
+
* security applies. Server-side callers — scripts, cron jobs, ETL — pass the
|
|
19
|
+
* service key instead, which resolves to `{ uid: "service", roles: ["admin"] }`
|
|
20
|
+
* and **bypasses RLS**: there is no user to constrain those queries, so scope
|
|
21
|
+
* them explicitly.
|
|
22
|
+
*/
|
|
6
23
|
token?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Path the API is mounted under, appended to {@link baseUrl}.
|
|
26
|
+
* Defaults to `"/api"`; override only if the server mounts it elsewhere.
|
|
27
|
+
*/
|
|
7
28
|
apiPath?: string;
|
|
8
29
|
fetch?: typeof globalThis.fetch;
|
|
9
30
|
onUnauthorized?: () => Promise<boolean>;
|
|
10
31
|
websocketUrl?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Open the realtime WebSocket. **Defaults to `true`.**
|
|
34
|
+
*
|
|
35
|
+
* The socket connects as soon as the client is constructed and keeps the
|
|
36
|
+
* Node event loop alive, so a one-shot script (CLI, cron job, ETL) will not
|
|
37
|
+
* exit on its own. Set this to `false` for any process that reads or writes
|
|
38
|
+
* and then terminates — `.listen()` and `.listenById()` then throw instead
|
|
39
|
+
* of silently doing nothing.
|
|
40
|
+
*
|
|
41
|
+
* Long-lived processes that do want realtime can instead call
|
|
42
|
+
* `client.close()` when shutting down.
|
|
43
|
+
*/
|
|
44
|
+
realtime?: boolean;
|
|
11
45
|
}
|
|
12
46
|
/**
|
|
13
47
|
* Re-export from `@rebasepro/types` for backward compatibility.
|
package/dist/websocket.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface RebaseWebSocketConfig {
|
|
|
14
14
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
15
15
|
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
16
16
|
* `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
|
|
17
|
-
* package root only because the `@rebasepro/client-
|
|
17
|
+
* package root only because the `@rebasepro/client-postgres` driver
|
|
18
18
|
* instantiates it directly; its surface may change without a major bump.
|
|
19
19
|
*/
|
|
20
20
|
export declare class RebaseWebSocketClient {
|
|
@@ -35,6 +35,7 @@ export declare class RebaseWebSocketClient {
|
|
|
35
35
|
private isConnected;
|
|
36
36
|
private messageQueue;
|
|
37
37
|
private requestTimeoutMs;
|
|
38
|
+
private subscriptionTimeoutMs;
|
|
38
39
|
private reconnectTimeout;
|
|
39
40
|
private isAuthenticated;
|
|
40
41
|
private authPromise;
|
|
@@ -92,6 +93,19 @@ export declare class RebaseWebSocketClient {
|
|
|
92
93
|
*/
|
|
93
94
|
private deepEqual;
|
|
94
95
|
private normalizeForComparison;
|
|
96
|
+
/**
|
|
97
|
+
* The address of a row, for matching it against another copy of itself.
|
|
98
|
+
*
|
|
99
|
+
* A row is exactly its columns and carries no address, so it is derived
|
|
100
|
+
* from the key columns the server named — including the ordinary case where
|
|
101
|
+
* that key is `id`, which the server reports like any other.
|
|
102
|
+
*
|
|
103
|
+
* Undefined when there are no keys, which means the server could not
|
|
104
|
+
* resolve any: such rows genuinely cannot be recognised, and guessing at a
|
|
105
|
+
* column called `id` would be inventing an identity for a table that has
|
|
106
|
+
* none.
|
|
107
|
+
*/
|
|
108
|
+
private rowAddress;
|
|
95
109
|
/**
|
|
96
110
|
* Merge incoming rows with cached data, preserving cached references
|
|
97
111
|
* for rows whose values haven't changed. This avoids unnecessary
|
|
@@ -101,6 +115,48 @@ export declare class RebaseWebSocketClient {
|
|
|
101
115
|
private mergeRows;
|
|
102
116
|
listenCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>, onUpdate: (rows: Record<string, unknown>[]) => void, onError?: (error: Error) => void): () => void;
|
|
103
117
|
listenOne<M extends Record<string, unknown>>(props: FetchOneProps<M>, onUpdate: (row: Record<string, unknown> | null) => void, onError?: (error: Error) => void): () => void;
|
|
118
|
+
/**
|
|
119
|
+
* Send a `subscribe_collection` for an already-registered subscription and
|
|
120
|
+
* arm its watchdog.
|
|
121
|
+
*
|
|
122
|
+
* Every path that registers a collection subscription goes through here, so
|
|
123
|
+
* that a subscribe which never lands — a rejected send, or a server that
|
|
124
|
+
* never answers — always ends up in `failCollectionSubscription` rather than
|
|
125
|
+
* leaving the entry parked with `isInitialDataReceived === false` forever.
|
|
126
|
+
*/
|
|
127
|
+
private sendCollectionSubscribe;
|
|
128
|
+
/** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
|
|
129
|
+
private sendEntitySubscribe;
|
|
130
|
+
/**
|
|
131
|
+
* Report a subscribe failure to every listener and drop the registration.
|
|
132
|
+
*
|
|
133
|
+
* Dropping it is the point: the callbacks stay live (their components are
|
|
134
|
+
* still mounted and have been told), but the next `listenCollection` for
|
|
135
|
+
* these params finds no entry and issues a fresh subscribe instead of
|
|
136
|
+
* silently attaching to a dead one.
|
|
137
|
+
*/
|
|
138
|
+
private failCollectionSubscription;
|
|
139
|
+
/** The `listenOne` counterpart of {@link failCollectionSubscription}. */
|
|
140
|
+
private failEntitySubscription;
|
|
141
|
+
/**
|
|
142
|
+
* Stop the watchdogs without failing anything — used when the socket drops,
|
|
143
|
+
* since the reconnect path re-subscribes everything anyway and a watchdog
|
|
144
|
+
* firing mid-reconnect would tear down healthy subscriptions.
|
|
145
|
+
*/
|
|
146
|
+
private suspendSubscribeWatchdogs;
|
|
147
|
+
/**
|
|
148
|
+
* Arm watchdogs for subscribes that were requested while offline and have
|
|
149
|
+
* just been flushed to the socket. Their timers were deliberately not set at
|
|
150
|
+
* request time, so without this they would have no timeout at all.
|
|
151
|
+
*/
|
|
152
|
+
private armPendingSubscribeWatchdogs;
|
|
153
|
+
private sendCollectionSubscribeWatchdog;
|
|
154
|
+
private sendEntitySubscribeWatchdog;
|
|
155
|
+
/**
|
|
156
|
+
* Fail every subscription that never received data. Called when reconnection
|
|
157
|
+
* is given up on, so views surface an error instead of spinning forever.
|
|
158
|
+
*/
|
|
159
|
+
private failAllPendingSubscriptions;
|
|
104
160
|
/**
|
|
105
161
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
106
162
|
* The server wipes subscription state when a client disconnects, so
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/client",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.1-canary.0fce67c",
|
|
5
5
|
"description": "HTTP SDK client for the Rebase custom backend",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -13,26 +13,25 @@
|
|
|
13
13
|
"url": "https://github.com/rebasepro/rebase.git",
|
|
14
14
|
"directory": "packages/client"
|
|
15
15
|
},
|
|
16
|
-
"main": "./dist/index.
|
|
16
|
+
"main": "./dist/index.es.js",
|
|
17
17
|
"module": "./dist/index.es.js",
|
|
18
18
|
"types": "./dist/index.d.ts",
|
|
19
19
|
"source": "src/index.ts",
|
|
20
20
|
"engines": {
|
|
21
|
-
"node": ">=
|
|
21
|
+
"node": ">=20"
|
|
22
22
|
},
|
|
23
23
|
"exports": {
|
|
24
24
|
".": {
|
|
25
25
|
"types": "./dist/index.d.ts",
|
|
26
26
|
"development": "./dist/index.es.js",
|
|
27
|
-
"import": "./dist/index.es.js"
|
|
28
|
-
"require": "./dist/index.umd.cjs"
|
|
27
|
+
"import": "./dist/index.es.js"
|
|
29
28
|
},
|
|
30
29
|
"./package.json": "./package.json"
|
|
31
30
|
},
|
|
32
31
|
"dependencies": {
|
|
33
|
-
"@rebasepro/
|
|
34
|
-
"@rebasepro/
|
|
35
|
-
"@rebasepro/
|
|
32
|
+
"@rebasepro/utils": "0.9.1-canary.0fce67c",
|
|
33
|
+
"@rebasepro/common": "0.9.1-canary.0fce67c",
|
|
34
|
+
"@rebasepro/types": "0.9.1-canary.0fce67c"
|
|
36
35
|
},
|
|
37
36
|
"devDependencies": {
|
|
38
37
|
"@jest/globals": "^30.4.1",
|
|
@@ -75,7 +74,7 @@
|
|
|
75
74
|
},
|
|
76
75
|
"scripts": {
|
|
77
76
|
"watch": "vite build --watch",
|
|
78
|
-
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
|
|
77
|
+
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
|
|
79
78
|
"test:lint": "eslint \"src/**\" --quiet",
|
|
80
79
|
"test": "jest --passWithNoTests --forceExit",
|
|
81
80
|
"clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
|
package/src/admin.ts
CHANGED
|
@@ -54,7 +54,7 @@ export function createAdmin(transport: Transport, options?: CreateAdminOptions)
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
async function resetPassword(userId: string, options?: { password?: string }) {
|
|
57
|
-
return transport.request<{ user: AdminUser; temporaryPassword?: string; invitationSent?: boolean }>(
|
|
57
|
+
return transport.request<{ user: AdminUser; temporaryPassword?: string; invitationSent?: boolean; emailDeliveryFailed?: boolean }>(
|
|
58
58
|
adminPath + "/users/" + encodeURIComponent(userId) + "/reset-password",
|
|
59
59
|
{
|
|
60
60
|
method: "POST",
|
package/src/api-keys.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Transport } from "./transport";
|
|
2
2
|
|
|
3
|
-
// Re-define the types locally since they live in server
|
|
3
|
+
// Re-define the types locally since they live in server, not in @rebasepro/types.
|
|
4
4
|
// These match the server-side types exactly.
|
|
5
5
|
|
|
6
6
|
/** A single permission entry scoping an API key to a collection and its allowed operations. */
|
package/src/backups.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
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/collection.ts
CHANGED
|
@@ -70,6 +70,22 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
70
70
|
return raw as M;
|
|
71
71
|
},
|
|
72
72
|
|
|
73
|
+
async createMany(data: Partial<M>[], options?: { upsert?: boolean }) {
|
|
74
|
+
if (!Array.isArray(data)) {
|
|
75
|
+
throw new TypeError("createMany expects an array of records.");
|
|
76
|
+
}
|
|
77
|
+
if (data.length === 0) return [];
|
|
78
|
+
|
|
79
|
+
const raw = await transport.request<{ data: Record<string, unknown>[] }>(`${basePath}/bulk`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
rows: data,
|
|
83
|
+
...(options?.upsert ? { upsert: true } : {})
|
|
84
|
+
})
|
|
85
|
+
});
|
|
86
|
+
return (raw.data || []) as M[];
|
|
87
|
+
},
|
|
88
|
+
|
|
73
89
|
async update(id: string | number, data: Partial<M>) {
|
|
74
90
|
const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
75
91
|
method: "PUT",
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { RebaseClientError } from "./errors";
|
|
|
3
3
|
import { createAuth, CreateAuthOptions } from "./auth";
|
|
4
4
|
import { createAdmin, CreateAdminOptions } from "./admin";
|
|
5
5
|
import { createCron, CreateCronOptions } from "./cron";
|
|
6
|
+
import { createBackups } from "./backups";
|
|
6
7
|
import { createApiKeys, CreateApiKeysOptions } from "./api-keys";
|
|
7
8
|
import { CollectionClient, createCollectionClient } from "./collection";
|
|
8
9
|
import { createFunctionsClient } from "./functions";
|
|
@@ -59,6 +60,8 @@ export type { RebaseUser, RebaseTokens } from "./auth";
|
|
|
59
60
|
export type { CreateAdminOptions } from "./admin";
|
|
60
61
|
export type { AdminUser } from "./admin";
|
|
61
62
|
export type { CreateCronOptions } from "./cron";
|
|
63
|
+
export { createBackups } from "./backups";
|
|
64
|
+
export type { CreateBackupsOptions } from "./backups";
|
|
62
65
|
export type {
|
|
63
66
|
ApiKeyMasked,
|
|
64
67
|
ApiKeyPermission,
|
|
@@ -70,7 +73,7 @@ export type {
|
|
|
70
73
|
export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
|
|
71
74
|
|
|
72
75
|
// Realtime: the WebSocket client class is internal to `createRebaseClient()`,
|
|
73
|
-
// but re-exported (see @internal on the class) because the `client-
|
|
76
|
+
// but re-exported (see @internal on the class) because the `client-postgres`
|
|
74
77
|
// driver constructs it directly. Not a stable app-facing API.
|
|
75
78
|
export { RebaseWebSocketClient } from "./websocket";
|
|
76
79
|
|
|
@@ -135,14 +138,24 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
|
|
|
135
138
|
auth: ReturnType<typeof createAuth>;
|
|
136
139
|
admin: ReturnType<typeof createAdmin>;
|
|
137
140
|
cron: ReturnType<typeof createCron>;
|
|
141
|
+
backups: ReturnType<typeof createBackups>;
|
|
138
142
|
apiKeys: ReturnType<typeof createApiKeys>;
|
|
139
143
|
functions: ReturnType<typeof createFunctionsClient>;
|
|
140
144
|
ws?: RebaseWebSocketClient;
|
|
145
|
+
/**
|
|
146
|
+
* Release the realtime socket and its reconnect timer.
|
|
147
|
+
*
|
|
148
|
+
* An open socket keeps the Node event loop alive, so a script that does not
|
|
149
|
+
* call this will not exit on its own. Safe when realtime was never started
|
|
150
|
+
* (`realtime: false`), and safe to call twice.
|
|
151
|
+
*/
|
|
152
|
+
close: () => void;
|
|
141
153
|
storage: StorageSource;
|
|
142
154
|
storageRegistry: StorageSourceRegistry;
|
|
143
155
|
createStorageSource: (storageId: string) => StorageSource;
|
|
144
156
|
fetchStorageSources: () => Promise<StorageSourceDefinition[]>;
|
|
145
157
|
call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
|
|
158
|
+
collection: <M extends Record<string, unknown> = Record<string, unknown>>(slug: string) => CollectionClient<M>;
|
|
146
159
|
data: TypedDataLayer<DB>;
|
|
147
160
|
};
|
|
148
161
|
|
|
@@ -187,6 +200,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
187
200
|
const auth = createAuth(transport, options.auth);
|
|
188
201
|
const admin = createAdmin(transport, options.admin);
|
|
189
202
|
const cron = createCron(transport, options.cron);
|
|
203
|
+
const backups = createBackups(transport);
|
|
190
204
|
const apiKeys = createApiKeys(transport, options.apiKeys);
|
|
191
205
|
const storage = createStorage(transport);
|
|
192
206
|
const functions = createFunctionsClient(transport);
|
|
@@ -233,7 +247,13 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
233
247
|
return storageSourcesPromise;
|
|
234
248
|
};
|
|
235
249
|
|
|
236
|
-
|
|
250
|
+
// Opting out has to happen before the URL is derived: `deriveWebSocketUrl`
|
|
251
|
+
// always produces one, so a truthy check alone can never leave the socket
|
|
252
|
+
// closed.
|
|
253
|
+
const realtimeEnabled = options.realtime !== false;
|
|
254
|
+
const resolvedWsUrl = realtimeEnabled
|
|
255
|
+
? (options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl))
|
|
256
|
+
: undefined;
|
|
237
257
|
|
|
238
258
|
let ws: RebaseWebSocketClient | undefined;
|
|
239
259
|
if (resolvedWsUrl) {
|
|
@@ -395,6 +415,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
395
415
|
auth,
|
|
396
416
|
admin,
|
|
397
417
|
cron,
|
|
418
|
+
backups,
|
|
398
419
|
apiKeys,
|
|
399
420
|
functions,
|
|
400
421
|
storage,
|
|
@@ -402,6 +423,16 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
402
423
|
createStorageSource,
|
|
403
424
|
fetchStorageSources,
|
|
404
425
|
ws,
|
|
426
|
+
/**
|
|
427
|
+
* Release the realtime socket and its reconnect timer.
|
|
428
|
+
*
|
|
429
|
+
* Until this returns, the open socket keeps the Node event loop alive
|
|
430
|
+
* and the process will not exit on its own. Safe to call when realtime
|
|
431
|
+
* was never started, and safe to call twice.
|
|
432
|
+
*/
|
|
433
|
+
close: () => {
|
|
434
|
+
ws?.disconnect();
|
|
435
|
+
},
|
|
405
436
|
setToken: transport.setToken,
|
|
406
437
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
407
438
|
setOnUnauthorized: transport.setOnUnauthorized,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { createRebaseClient } from "./index";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A WebSocket stand-in that records construction. The real socket keeps the Node
|
|
6
|
+
* event loop alive, which is what makes a one-shot script hang; here we only
|
|
7
|
+
* need to know whether one would have been opened at all.
|
|
8
|
+
*/
|
|
9
|
+
function trackingWebSocket() {
|
|
10
|
+
const opened: string[] = [];
|
|
11
|
+
const closed: string[] = [];
|
|
12
|
+
|
|
13
|
+
class FakeWebSocket {
|
|
14
|
+
static readonly OPEN = 1;
|
|
15
|
+
readyState = 0;
|
|
16
|
+
onopen: (() => void) | null = null;
|
|
17
|
+
onclose: (() => void) | null = null;
|
|
18
|
+
onerror: (() => void) | null = null;
|
|
19
|
+
onmessage: (() => void) | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(public url: string) {
|
|
22
|
+
opened.push(url);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
close() {
|
|
26
|
+
closed.push(this.url);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
send() { /* no-op */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { FakeWebSocket: FakeWebSocket as unknown as typeof WebSocket, opened, closed };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("realtime opt-out", () => {
|
|
36
|
+
const original = globalThis.WebSocket;
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
globalThis.WebSocket = original;
|
|
40
|
+
jest.restoreAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("opens the socket by default", () => {
|
|
44
|
+
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
45
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
46
|
+
|
|
47
|
+
createRebaseClient({ baseUrl: "http://localhost:3000/api" });
|
|
48
|
+
|
|
49
|
+
expect(opened).toHaveLength(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("opens no socket when realtime is disabled", () => {
|
|
53
|
+
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
54
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
55
|
+
|
|
56
|
+
const client = createRebaseClient({
|
|
57
|
+
baseUrl: "http://localhost:3000/api",
|
|
58
|
+
realtime: false
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// The socket is what keeps a CLI / cron / ETL process alive past its work.
|
|
62
|
+
expect(opened).toHaveLength(0);
|
|
63
|
+
expect(client.ws).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("opens no socket when realtime is disabled even if a websocketUrl is given", () => {
|
|
67
|
+
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
68
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
69
|
+
|
|
70
|
+
createRebaseClient({
|
|
71
|
+
baseUrl: "http://localhost:3000/api",
|
|
72
|
+
websocketUrl: "ws://localhost:3000",
|
|
73
|
+
realtime: false
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(opened).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("close() releases the socket", () => {
|
|
80
|
+
const { FakeWebSocket, opened, closed } = trackingWebSocket();
|
|
81
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
82
|
+
|
|
83
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
|
|
84
|
+
expect(opened).toHaveLength(1);
|
|
85
|
+
|
|
86
|
+
client.close();
|
|
87
|
+
|
|
88
|
+
expect(closed).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("close() is safe when realtime was never started, and when called twice", () => {
|
|
92
|
+
const { FakeWebSocket } = trackingWebSocket();
|
|
93
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
94
|
+
|
|
95
|
+
const offline = createRebaseClient({ baseUrl: "http://localhost:3000/api", realtime: false });
|
|
96
|
+
expect(() => offline.close()).not.toThrow();
|
|
97
|
+
|
|
98
|
+
const live = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
|
|
99
|
+
live.close();
|
|
100
|
+
expect(() => live.close()).not.toThrow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("leaves listen() absent so callers can feature-detect, and says why via the query builder", () => {
|
|
104
|
+
const { FakeWebSocket } = trackingWebSocket();
|
|
105
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
106
|
+
|
|
107
|
+
const client = createRebaseClient({
|
|
108
|
+
baseUrl: "http://localhost:3000/api",
|
|
109
|
+
realtime: false
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// `listen` stays undefined rather than becoming a throwing stub: the
|
|
113
|
+
// optional type is what makes `if (client.listen)` work and what makes
|
|
114
|
+
// TypeScript reject a bare call.
|
|
115
|
+
expect(client.collection("posts").listen).toBeUndefined();
|
|
116
|
+
expect(() => client.data.posts.include("author").listen(() => { /* noop */ }))
|
|
117
|
+
.toThrow(/realtime: false/);
|
|
118
|
+
});
|
|
119
|
+
});
|