@rebasepro/client 0.9.0 → 0.9.1-canary.09aaf62
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 +15 -0
- package/dist/index.es.js +516 -72
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +89 -0
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +68 -2
- 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 +66 -2
- package/src/realtime-channel.test.ts +241 -0
- package/src/realtime-channel.ts +238 -0
- 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 +403 -72
- 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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broadcast channels and presence, as an SDK surface.
|
|
3
|
+
*
|
|
4
|
+
* The realtime engine has supported `join_channel`, `broadcast`,
|
|
5
|
+
* `presence_track`, `presence_untrack` and `presence_state` for a while, but
|
|
6
|
+
* the client only recognised those types well enough to send them
|
|
7
|
+
* fire-and-forget: there were no methods to call and no way to receive channel
|
|
8
|
+
* or broadcast events, since `on()` handles only connect / disconnect /
|
|
9
|
+
* reconnect / error. Anything wanting presence therefore opened a *second*
|
|
10
|
+
* socket and reimplemented the AUTHENTICATE → AUTH_SUCCESS handshake, the
|
|
11
|
+
* reconnect backoff, and the presence heartbeat — a couple of hundred lines
|
|
12
|
+
* per app, all of it duplicating this package.
|
|
13
|
+
*
|
|
14
|
+
* Two protocol details this hides, because both are easy to get wrong and
|
|
15
|
+
* neither is discoverable from the message list:
|
|
16
|
+
*
|
|
17
|
+
* - **A joining client is told only about its own join.** The `presence_diff`
|
|
18
|
+
* it receives after `presence_track` contains just itself. The existing
|
|
19
|
+
* roster arrives only in response to an explicit `presence_state` request,
|
|
20
|
+
* so `join()` sends one.
|
|
21
|
+
* - **Presence expires after 30s** (`PRESENCE_TIMEOUT_MS` server-side). A
|
|
22
|
+
* client that tracks once and goes quiet silently vanishes from everyone
|
|
23
|
+
* else's roster while still sitting in the document, so `track()` starts a
|
|
24
|
+
* heartbeat and `leave()` stops it.
|
|
25
|
+
*/
|
|
26
|
+
/** Presence state keyed by the server's client id. */
|
|
27
|
+
export type PresenceState = Record<string, Record<string, unknown>>;
|
|
28
|
+
export interface PresenceDiff {
|
|
29
|
+
joins: PresenceState;
|
|
30
|
+
leaves: PresenceState;
|
|
31
|
+
}
|
|
32
|
+
export interface BroadcastEvent {
|
|
33
|
+
event: string;
|
|
34
|
+
payload: unknown;
|
|
35
|
+
}
|
|
36
|
+
/** The socket operations a channel needs; satisfied by RebaseWebSocketClient. */
|
|
37
|
+
export interface ChannelTransport {
|
|
38
|
+
sendMessage(message: Record<string, unknown>): Promise<unknown>;
|
|
39
|
+
onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void;
|
|
40
|
+
onReconnect(handler: () => void): () => void;
|
|
41
|
+
}
|
|
42
|
+
export declare class RebaseRealtimeChannel {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
private transport;
|
|
45
|
+
private presenceHandlers;
|
|
46
|
+
private broadcastHandlers;
|
|
47
|
+
private unsubscribers;
|
|
48
|
+
/** Last known roster, kept so handlers always get a full picture. */
|
|
49
|
+
private presences;
|
|
50
|
+
/** What this client last tracked, replayed on reconnect and heartbeat. */
|
|
51
|
+
private trackedState;
|
|
52
|
+
private heartbeat;
|
|
53
|
+
private joined;
|
|
54
|
+
constructor(name: string, transport: ChannelTransport);
|
|
55
|
+
/**
|
|
56
|
+
* Join the channel and ask for the current roster.
|
|
57
|
+
*
|
|
58
|
+
* Called automatically by `track`, `broadcast`, `onPresence` and
|
|
59
|
+
* `onBroadcast`; calling it directly is only needed to start receiving
|
|
60
|
+
* before there is anything to send.
|
|
61
|
+
*/
|
|
62
|
+
join(): Promise<void>;
|
|
63
|
+
private rejoin;
|
|
64
|
+
/**
|
|
65
|
+
* Publish this client's presence state, and keep publishing it.
|
|
66
|
+
*
|
|
67
|
+
* Calling `track` again replaces the state (and restarts the heartbeat),
|
|
68
|
+
* which is how you update e.g. a cursor position.
|
|
69
|
+
*/
|
|
70
|
+
track(state: Record<string, unknown>): Promise<void>;
|
|
71
|
+
/** Stop publishing presence, without leaving the channel. */
|
|
72
|
+
untrack(): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Observe the roster. The handler fires immediately with what is already
|
|
75
|
+
* known, then on every change.
|
|
76
|
+
*/
|
|
77
|
+
onPresence(handler: (state: PresenceState, diff?: PresenceDiff) => void): () => void;
|
|
78
|
+
/** Send a broadcast. The sender does not receive its own message. */
|
|
79
|
+
broadcast(event: string, payload: unknown): Promise<void>;
|
|
80
|
+
/** Observe broadcasts. Pass an event name to filter. */
|
|
81
|
+
onBroadcast(handler: (event: BroadcastEvent) => void): () => void;
|
|
82
|
+
onBroadcast(event: string, handler: (payload: unknown) => void): () => void;
|
|
83
|
+
/** Leave the channel and release every listener and timer. */
|
|
84
|
+
leave(): Promise<void>;
|
|
85
|
+
private stopHeartbeat;
|
|
86
|
+
/** Fold an incoming frame into the roster and fan it out. */
|
|
87
|
+
private handle;
|
|
88
|
+
private emitPresence;
|
|
89
|
+
}
|
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 {
|
|
@@ -23,6 +23,12 @@ export declare class RebaseWebSocketClient {
|
|
|
23
23
|
getAuthToken?: () => Promise<string | null>;
|
|
24
24
|
private subscriptions;
|
|
25
25
|
private listeners;
|
|
26
|
+
/** Channel-name → handlers, for broadcast and presence frames. */
|
|
27
|
+
private channelHandlers;
|
|
28
|
+
/** Subscribe to broadcast/presence frames for one channel. */
|
|
29
|
+
onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void;
|
|
30
|
+
/** Notified after the socket comes back, so channels can re-join. */
|
|
31
|
+
onReconnect(handler: () => void): () => void;
|
|
26
32
|
on(event: "connect" | "disconnect" | "reconnect" | "error", cb: (...args: unknown[]) => void): () => boolean;
|
|
27
33
|
private emit;
|
|
28
34
|
private collectionSubscriptions;
|
|
@@ -35,6 +41,7 @@ export declare class RebaseWebSocketClient {
|
|
|
35
41
|
private isConnected;
|
|
36
42
|
private messageQueue;
|
|
37
43
|
private requestTimeoutMs;
|
|
44
|
+
private subscriptionTimeoutMs;
|
|
38
45
|
private reconnectTimeout;
|
|
39
46
|
private isAuthenticated;
|
|
40
47
|
private authPromise;
|
|
@@ -64,7 +71,11 @@ export declare class RebaseWebSocketClient {
|
|
|
64
71
|
private handleWebSocketMessage;
|
|
65
72
|
private ensureAuthenticated;
|
|
66
73
|
reauthenticate(): Promise<void>;
|
|
67
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
|
76
|
+
* Not part of the stable surface — prefer `client.realtime.channel(name)`.
|
|
77
|
+
*/
|
|
78
|
+
sendMessage(message: Record<string, unknown>): Promise<unknown>;
|
|
68
79
|
private doSendMessage;
|
|
69
80
|
fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]>;
|
|
70
81
|
fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined>;
|
|
@@ -92,6 +103,19 @@ export declare class RebaseWebSocketClient {
|
|
|
92
103
|
*/
|
|
93
104
|
private deepEqual;
|
|
94
105
|
private normalizeForComparison;
|
|
106
|
+
/**
|
|
107
|
+
* The address of a row, for matching it against another copy of itself.
|
|
108
|
+
*
|
|
109
|
+
* A row is exactly its columns and carries no address, so it is derived
|
|
110
|
+
* from the key columns the server named — including the ordinary case where
|
|
111
|
+
* that key is `id`, which the server reports like any other.
|
|
112
|
+
*
|
|
113
|
+
* Undefined when there are no keys, which means the server could not
|
|
114
|
+
* resolve any: such rows genuinely cannot be recognised, and guessing at a
|
|
115
|
+
* column called `id` would be inventing an identity for a table that has
|
|
116
|
+
* none.
|
|
117
|
+
*/
|
|
118
|
+
private rowAddress;
|
|
95
119
|
/**
|
|
96
120
|
* Merge incoming rows with cached data, preserving cached references
|
|
97
121
|
* for rows whose values haven't changed. This avoids unnecessary
|
|
@@ -101,6 +125,48 @@ export declare class RebaseWebSocketClient {
|
|
|
101
125
|
private mergeRows;
|
|
102
126
|
listenCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>, onUpdate: (rows: Record<string, unknown>[]) => void, onError?: (error: Error) => void): () => void;
|
|
103
127
|
listenOne<M extends Record<string, unknown>>(props: FetchOneProps<M>, onUpdate: (row: Record<string, unknown> | null) => void, onError?: (error: Error) => void): () => void;
|
|
128
|
+
/**
|
|
129
|
+
* Send a `subscribe_collection` for an already-registered subscription and
|
|
130
|
+
* arm its watchdog.
|
|
131
|
+
*
|
|
132
|
+
* Every path that registers a collection subscription goes through here, so
|
|
133
|
+
* that a subscribe which never lands — a rejected send, or a server that
|
|
134
|
+
* never answers — always ends up in `failCollectionSubscription` rather than
|
|
135
|
+
* leaving the entry parked with `isInitialDataReceived === false` forever.
|
|
136
|
+
*/
|
|
137
|
+
private sendCollectionSubscribe;
|
|
138
|
+
/** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
|
|
139
|
+
private sendEntitySubscribe;
|
|
140
|
+
/**
|
|
141
|
+
* Report a subscribe failure to every listener and drop the registration.
|
|
142
|
+
*
|
|
143
|
+
* Dropping it is the point: the callbacks stay live (their components are
|
|
144
|
+
* still mounted and have been told), but the next `listenCollection` for
|
|
145
|
+
* these params finds no entry and issues a fresh subscribe instead of
|
|
146
|
+
* silently attaching to a dead one.
|
|
147
|
+
*/
|
|
148
|
+
private failCollectionSubscription;
|
|
149
|
+
/** The `listenOne` counterpart of {@link failCollectionSubscription}. */
|
|
150
|
+
private failEntitySubscription;
|
|
151
|
+
/**
|
|
152
|
+
* Stop the watchdogs without failing anything — used when the socket drops,
|
|
153
|
+
* since the reconnect path re-subscribes everything anyway and a watchdog
|
|
154
|
+
* firing mid-reconnect would tear down healthy subscriptions.
|
|
155
|
+
*/
|
|
156
|
+
private suspendSubscribeWatchdogs;
|
|
157
|
+
/**
|
|
158
|
+
* Arm watchdogs for subscribes that were requested while offline and have
|
|
159
|
+
* just been flushed to the socket. Their timers were deliberately not set at
|
|
160
|
+
* request time, so without this they would have no timeout at all.
|
|
161
|
+
*/
|
|
162
|
+
private armPendingSubscribeWatchdogs;
|
|
163
|
+
private sendCollectionSubscribeWatchdog;
|
|
164
|
+
private sendEntitySubscribeWatchdog;
|
|
165
|
+
/**
|
|
166
|
+
* Fail every subscription that never received data. Called when reconnection
|
|
167
|
+
* is given up on, so views surface an error instead of spinning forever.
|
|
168
|
+
*/
|
|
169
|
+
private failAllPendingSubscriptions;
|
|
104
170
|
/**
|
|
105
171
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
106
172
|
* 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.09aaf62",
|
|
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/utils": "0.9.
|
|
35
|
-
"@rebasepro/
|
|
32
|
+
"@rebasepro/common": "0.9.1-canary.09aaf62",
|
|
33
|
+
"@rebasepro/utils": "0.9.1-canary.09aaf62",
|
|
34
|
+
"@rebasepro/types": "0.9.1-canary.09aaf62"
|
|
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,12 +3,14 @@ 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";
|
|
9
10
|
import { createStorage } from "./storage";
|
|
10
11
|
import { ClientStorageSourceRegistry } from "./storage-registry";
|
|
11
12
|
import { RebaseWebSocketClient } from "./websocket";
|
|
13
|
+
import { RebaseRealtimeChannel } from "./realtime-channel";
|
|
12
14
|
import {
|
|
13
15
|
DEFAULT_STORAGE_SOURCE_KEY,
|
|
14
16
|
InsertOf,
|
|
@@ -59,6 +61,8 @@ export type { RebaseUser, RebaseTokens } from "./auth";
|
|
|
59
61
|
export type { CreateAdminOptions } from "./admin";
|
|
60
62
|
export type { AdminUser } from "./admin";
|
|
61
63
|
export type { CreateCronOptions } from "./cron";
|
|
64
|
+
export { createBackups } from "./backups";
|
|
65
|
+
export type { CreateBackupsOptions } from "./backups";
|
|
62
66
|
export type {
|
|
63
67
|
ApiKeyMasked,
|
|
64
68
|
ApiKeyPermission,
|
|
@@ -70,9 +74,11 @@ export type {
|
|
|
70
74
|
export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
|
|
71
75
|
|
|
72
76
|
// Realtime: the WebSocket client class is internal to `createRebaseClient()`,
|
|
73
|
-
// but re-exported (see @internal on the class) because the `client-
|
|
77
|
+
// but re-exported (see @internal on the class) because the `client-postgres`
|
|
74
78
|
// driver constructs it directly. Not a stable app-facing API.
|
|
75
79
|
export { RebaseWebSocketClient } from "./websocket";
|
|
80
|
+
export { RebaseRealtimeChannel } from "./realtime-channel";
|
|
81
|
+
export type { PresenceState, PresenceDiff, BroadcastEvent, ChannelTransport } from "./realtime-channel";
|
|
76
82
|
|
|
77
83
|
export interface CreateRebaseClientOptions extends RebaseClientConfig {
|
|
78
84
|
auth?: CreateAuthOptions;
|
|
@@ -135,14 +141,24 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
|
|
|
135
141
|
auth: ReturnType<typeof createAuth>;
|
|
136
142
|
admin: ReturnType<typeof createAdmin>;
|
|
137
143
|
cron: ReturnType<typeof createCron>;
|
|
144
|
+
backups: ReturnType<typeof createBackups>;
|
|
138
145
|
apiKeys: ReturnType<typeof createApiKeys>;
|
|
139
146
|
functions: ReturnType<typeof createFunctionsClient>;
|
|
140
147
|
ws?: RebaseWebSocketClient;
|
|
148
|
+
/**
|
|
149
|
+
* Release the realtime socket and its reconnect timer.
|
|
150
|
+
*
|
|
151
|
+
* An open socket keeps the Node event loop alive, so a script that does not
|
|
152
|
+
* call this will not exit on its own. Safe when realtime was never started
|
|
153
|
+
* (`realtime: false`), and safe to call twice.
|
|
154
|
+
*/
|
|
155
|
+
close: () => void;
|
|
141
156
|
storage: StorageSource;
|
|
142
157
|
storageRegistry: StorageSourceRegistry;
|
|
143
158
|
createStorageSource: (storageId: string) => StorageSource;
|
|
144
159
|
fetchStorageSources: () => Promise<StorageSourceDefinition[]>;
|
|
145
160
|
call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
|
|
161
|
+
collection: <M extends Record<string, unknown> = Record<string, unknown>>(slug: string) => CollectionClient<M>;
|
|
146
162
|
data: TypedDataLayer<DB>;
|
|
147
163
|
};
|
|
148
164
|
|
|
@@ -187,6 +203,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
187
203
|
const auth = createAuth(transport, options.auth);
|
|
188
204
|
const admin = createAdmin(transport, options.admin);
|
|
189
205
|
const cron = createCron(transport, options.cron);
|
|
206
|
+
const backups = createBackups(transport);
|
|
190
207
|
const apiKeys = createApiKeys(transport, options.apiKeys);
|
|
191
208
|
const storage = createStorage(transport);
|
|
192
209
|
const functions = createFunctionsClient(transport);
|
|
@@ -233,9 +250,17 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
233
250
|
return storageSourcesPromise;
|
|
234
251
|
};
|
|
235
252
|
|
|
236
|
-
|
|
253
|
+
// Opting out has to happen before the URL is derived: `deriveWebSocketUrl`
|
|
254
|
+
// always produces one, so a truthy check alone can never leave the socket
|
|
255
|
+
// closed.
|
|
256
|
+
const realtimeEnabled = options.realtime !== false;
|
|
257
|
+
const resolvedWsUrl = realtimeEnabled
|
|
258
|
+
? (options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl))
|
|
259
|
+
: undefined;
|
|
237
260
|
|
|
238
261
|
let ws: RebaseWebSocketClient | undefined;
|
|
262
|
+
/** One channel object per name — see `realtime.channel`. */
|
|
263
|
+
const realtimeChannels = new Map<string, RebaseRealtimeChannel>();
|
|
239
264
|
if (resolvedWsUrl) {
|
|
240
265
|
const wsOnUnauthorized = options.onUnauthorized || (async () => {
|
|
241
266
|
try {
|
|
@@ -395,6 +420,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
395
420
|
auth,
|
|
396
421
|
admin,
|
|
397
422
|
cron,
|
|
423
|
+
backups,
|
|
398
424
|
apiKeys,
|
|
399
425
|
functions,
|
|
400
426
|
storage,
|
|
@@ -402,6 +428,44 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
402
428
|
createStorageSource,
|
|
403
429
|
fetchStorageSources,
|
|
404
430
|
ws,
|
|
431
|
+
realtime: {
|
|
432
|
+
/**
|
|
433
|
+
* Join a broadcast/presence channel.
|
|
434
|
+
*
|
|
435
|
+
* Repeated calls with the same name return the same channel, so
|
|
436
|
+
* separate components can attach handlers without each opening its
|
|
437
|
+
* own membership — and `leave()` from one would otherwise silently
|
|
438
|
+
* cut off the others.
|
|
439
|
+
*/
|
|
440
|
+
channel: (name: string): RebaseRealtimeChannel => {
|
|
441
|
+
if (!ws) {
|
|
442
|
+
throw new RebaseClientError(
|
|
443
|
+
"Realtime is disabled on this client (realtime: false), so channels are unavailable."
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
let existing = realtimeChannels.get(name);
|
|
447
|
+
if (!existing) {
|
|
448
|
+
existing = new RebaseRealtimeChannel(name, ws);
|
|
449
|
+
realtimeChannels.set(name, existing);
|
|
450
|
+
}
|
|
451
|
+
return existing;
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
/**
|
|
455
|
+
* Release the realtime socket and its reconnect timer.
|
|
456
|
+
*
|
|
457
|
+
* Until this returns, the open socket keeps the Node event loop alive
|
|
458
|
+
* and the process will not exit on its own. Safe to call when realtime
|
|
459
|
+
* was never started, and safe to call twice.
|
|
460
|
+
*/
|
|
461
|
+
close: () => {
|
|
462
|
+
// Channels hold presence heartbeat timers, which would otherwise
|
|
463
|
+
// keep firing (and keep a Node process alive) after the socket
|
|
464
|
+
// they publish over is gone.
|
|
465
|
+
for (const channel of realtimeChannels.values()) void channel.leave();
|
|
466
|
+
realtimeChannels.clear();
|
|
467
|
+
ws?.disconnect();
|
|
468
|
+
},
|
|
405
469
|
setToken: transport.setToken,
|
|
406
470
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
407
471
|
setOnUnauthorized: transport.setOnUnauthorized,
|