@rebasepro/client 0.9.1-canary.fd3754b → 0.10.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/dist/auth.d.ts +5 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.es.js +799 -95
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +243 -0
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +96 -2
- package/package.json +11 -10
- package/src/auth.ts +32 -0
- package/src/collection.ts +16 -0
- package/src/index.ts +105 -2
- package/src/realtime-channel.test.ts +542 -0
- package/src/realtime-channel.ts +539 -0
- package/src/realtime-optout.test.ts +245 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/sdk_query_builder.ts +4 -1
- package/src/transport-baseurl.test.ts +53 -0
- package/src/transport.ts +59 -3
- package/src/websocket.ts +515 -88
- 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 -2513
- package/dist/index.umd.js.map +0 -1
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
* Per-channel sequence number, present only on retained channels.
|
|
37
|
+
*
|
|
38
|
+
* Monotonically increasing and dense, so a consumer that remembers the last
|
|
39
|
+
* one it applied can tell the server exactly where to resume from.
|
|
40
|
+
*/
|
|
41
|
+
seq?: number;
|
|
42
|
+
/**
|
|
43
|
+
* True when this arrived through catch-up rather than live.
|
|
44
|
+
*
|
|
45
|
+
* Handlers do not have to care — replayed messages are delivered to the
|
|
46
|
+
* same `onBroadcast` handlers, in sequence order, so an operation stream
|
|
47
|
+
* needs no second code path. It is exposed for consumers that want to,
|
|
48
|
+
* for example, skip an animation while fast-forwarding.
|
|
49
|
+
*/
|
|
50
|
+
replayed?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** One retained message, as returned by {@link RebaseRealtimeChannel.history}. */
|
|
53
|
+
export interface ChannelHistoryEntry {
|
|
54
|
+
seq: number;
|
|
55
|
+
event: string;
|
|
56
|
+
payload: unknown;
|
|
57
|
+
senderId?: string;
|
|
58
|
+
at?: string;
|
|
59
|
+
}
|
|
60
|
+
/** The answer to a catch-up request. */
|
|
61
|
+
export interface ChannelHistoryResult {
|
|
62
|
+
messages: ChannelHistoryEntry[];
|
|
63
|
+
/**
|
|
64
|
+
* Whether the server retains anything for this channel.
|
|
65
|
+
*
|
|
66
|
+
* False means there is no retention rule configured for it, so the empty
|
|
67
|
+
* list means "never keeps history" rather than "you missed nothing" — a
|
|
68
|
+
* client that needs to converge has to fall back to a full resync.
|
|
69
|
+
*/
|
|
70
|
+
retained: boolean;
|
|
71
|
+
/** Highest sequence the server holds, even if this batch was capped. */
|
|
72
|
+
latestSeq?: number;
|
|
73
|
+
}
|
|
74
|
+
/** Options for a channel handle. */
|
|
75
|
+
export interface ChannelOptions {
|
|
76
|
+
/**
|
|
77
|
+
* Ask the server to replay what this client missed, on join and on every
|
|
78
|
+
* reconnect.
|
|
79
|
+
*
|
|
80
|
+
* Only meaningful for a channel the *server* has a retention rule for —
|
|
81
|
+
* retention is configured on the backend, since a channel is created by
|
|
82
|
+
* whoever names it and a client-chosen history depth would let any visitor
|
|
83
|
+
* commit the backend to unbounded storage. On a channel with no rule the
|
|
84
|
+
* server answers `retained: false` and this is inert.
|
|
85
|
+
*/
|
|
86
|
+
history?: boolean;
|
|
87
|
+
}
|
|
88
|
+
/** The socket operations a channel needs; satisfied by RebaseWebSocketClient. */
|
|
89
|
+
export interface ChannelTransport {
|
|
90
|
+
sendMessage(message: Record<string, unknown>): Promise<unknown>;
|
|
91
|
+
onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void;
|
|
92
|
+
onReconnect(handler: () => void): () => void;
|
|
93
|
+
}
|
|
94
|
+
export declare class RebaseRealtimeChannel {
|
|
95
|
+
readonly name: string;
|
|
96
|
+
private transport;
|
|
97
|
+
private presenceHandlers;
|
|
98
|
+
private broadcastHandlers;
|
|
99
|
+
private unsubscribers;
|
|
100
|
+
/** Last known roster, kept so handlers always get a full picture. */
|
|
101
|
+
private presences;
|
|
102
|
+
/** What this client last tracked, replayed on reconnect and heartbeat. */
|
|
103
|
+
private trackedState;
|
|
104
|
+
private heartbeat;
|
|
105
|
+
private joined;
|
|
106
|
+
/** Whether this handle asks the server to replay missed messages. */
|
|
107
|
+
private wantsHistory;
|
|
108
|
+
/**
|
|
109
|
+
* Highest sequence number delivered to handlers so far.
|
|
110
|
+
*
|
|
111
|
+
* This is the resume point sent as `sinceSeq`, and the watermark that makes
|
|
112
|
+
* replay idempotent: catch-up ranges overlap with what arrived live, and
|
|
113
|
+
* anything at or below this has already been seen.
|
|
114
|
+
*/
|
|
115
|
+
private lastSeq;
|
|
116
|
+
/**
|
|
117
|
+
* Live messages that arrived while a catch-up was in flight.
|
|
118
|
+
*
|
|
119
|
+
* Without this they would be delivered ahead of the older messages being
|
|
120
|
+
* fetched, and — worse — would advance {@link lastSeq} past them, so the
|
|
121
|
+
* catch-up response would then be discarded as already-seen and those
|
|
122
|
+
* messages would be lost for good. Held here and flushed, in order, once
|
|
123
|
+
* the replay lands.
|
|
124
|
+
*/
|
|
125
|
+
private pendingLive;
|
|
126
|
+
private catchUpInFlight;
|
|
127
|
+
/**
|
|
128
|
+
* Deadline for a catch-up response.
|
|
129
|
+
*
|
|
130
|
+
* Buffering live messages is only safe because the wait is bounded. A
|
|
131
|
+
* catch-up frame that never arrives — a server that dropped it, a socket
|
|
132
|
+
* that died between request and reply — would otherwise leave the channel
|
|
133
|
+
* silently holding every subsequent edit forever, which is a worse failure
|
|
134
|
+
* than the one replay was added to fix.
|
|
135
|
+
*/
|
|
136
|
+
private catchUpTimeout;
|
|
137
|
+
/**
|
|
138
|
+
* Callers of {@link history} awaiting the next `channel_history` frame.
|
|
139
|
+
*
|
|
140
|
+
* These frames are addressed by channel rather than by request id, so they
|
|
141
|
+
* are matched in arrival order. Requests on one channel are serialized by
|
|
142
|
+
* the socket, so FIFO is the right correlation here.
|
|
143
|
+
*/
|
|
144
|
+
private historyWaiters;
|
|
145
|
+
constructor(name: string, transport: ChannelTransport, options?: ChannelOptions);
|
|
146
|
+
/**
|
|
147
|
+
* Turn on catch-up for a handle that was created without it.
|
|
148
|
+
*
|
|
149
|
+
* The client hands back the same channel object for a given name, so a
|
|
150
|
+
* later `channel(name, { history: true })` has no new object to configure —
|
|
151
|
+
* it upgrades this one instead. Idempotent, and never downgrades: one
|
|
152
|
+
* caller asking for history must not be switched off by another that did
|
|
153
|
+
* not ask.
|
|
154
|
+
*/
|
|
155
|
+
enableHistory(): void;
|
|
156
|
+
/**
|
|
157
|
+
* Join the channel and ask for the current roster.
|
|
158
|
+
*
|
|
159
|
+
* Called automatically by `track`, `broadcast`, `onPresence` and
|
|
160
|
+
* `onBroadcast`; calling it directly is only needed to start receiving
|
|
161
|
+
* before there is anything to send.
|
|
162
|
+
*/
|
|
163
|
+
/**
|
|
164
|
+
* Send a channel message.
|
|
165
|
+
*
|
|
166
|
+
* Every channel message is read by the server out of a `payload` envelope
|
|
167
|
+
* (`payload?.channel`, `payload?.state`, `payload?.event`). Sending those
|
|
168
|
+
* fields flat does not error: `payload?.channel` simply reads as
|
|
169
|
+
* `undefined`, so the client is registered into channel `undefined` with
|
|
170
|
+
* empty state, and the echo comes back with no `channel` for
|
|
171
|
+
* `onChannelMessage` to match — presence and broadcast both go quiet with
|
|
172
|
+
* nothing logged. Funnelled through one place so a new message type cannot
|
|
173
|
+
* reintroduce that.
|
|
174
|
+
*/
|
|
175
|
+
private send;
|
|
176
|
+
join(): Promise<void>;
|
|
177
|
+
private rejoin;
|
|
178
|
+
/**
|
|
179
|
+
* Ask the server for everything after {@link lastSeq}.
|
|
180
|
+
*
|
|
181
|
+
* Live messages are buffered from here until the answer arrives — see
|
|
182
|
+
* {@link pendingLive}.
|
|
183
|
+
*/
|
|
184
|
+
private requestHistory;
|
|
185
|
+
/**
|
|
186
|
+
* Give up waiting for a catch-up and release what was held back.
|
|
187
|
+
*
|
|
188
|
+
* The buffered messages are still the freshest thing this client has, so
|
|
189
|
+
* they are delivered rather than dropped. Callers of {@link history} are
|
|
190
|
+
* answered with `retained: false` — accurate in the sense that matters:
|
|
191
|
+
* this client has no history to work from and has to resync.
|
|
192
|
+
*/
|
|
193
|
+
private abandonCatchUp;
|
|
194
|
+
/**
|
|
195
|
+
* Publish this client's presence state, and keep publishing it.
|
|
196
|
+
*
|
|
197
|
+
* Calling `track` again replaces the state (and restarts the heartbeat),
|
|
198
|
+
* which is how you update e.g. a cursor position.
|
|
199
|
+
*/
|
|
200
|
+
track(state: Record<string, unknown>): Promise<void>;
|
|
201
|
+
/** Stop publishing presence, without leaving the channel. */
|
|
202
|
+
untrack(): Promise<void>;
|
|
203
|
+
/**
|
|
204
|
+
* Observe the roster. The handler fires immediately with what is already
|
|
205
|
+
* known, then on every change.
|
|
206
|
+
*/
|
|
207
|
+
onPresence(handler: (state: PresenceState, diff?: PresenceDiff) => void): () => void;
|
|
208
|
+
/** Send a broadcast. The sender does not receive its own message. */
|
|
209
|
+
broadcast(event: string, payload: unknown): Promise<void>;
|
|
210
|
+
/** Observe broadcasts. Pass an event name to filter. */
|
|
211
|
+
onBroadcast(handler: (event: BroadcastEvent) => void): () => void;
|
|
212
|
+
onBroadcast(event: string, handler: (payload: unknown) => void): () => void;
|
|
213
|
+
/**
|
|
214
|
+
* The last sequence number this channel has delivered.
|
|
215
|
+
*
|
|
216
|
+
* Zero on a channel that retains nothing. Persist it if you want catch-up
|
|
217
|
+
* to survive a page reload as well as a reconnect, and pass it back via
|
|
218
|
+
* {@link history}.
|
|
219
|
+
*/
|
|
220
|
+
get sequence(): number;
|
|
221
|
+
/**
|
|
222
|
+
* Fetch retained messages explicitly, instead of waiting for join or
|
|
223
|
+
* reconnect to do it.
|
|
224
|
+
*
|
|
225
|
+
* Defaults to resuming from {@link sequence}. Messages are delivered to
|
|
226
|
+
* `onBroadcast` handlers as usual — the returned value is for callers that
|
|
227
|
+
* want to inspect the batch, or to learn from `retained` that the channel
|
|
228
|
+
* keeps no history at all.
|
|
229
|
+
*/
|
|
230
|
+
history(options?: {
|
|
231
|
+
sinceSeq?: number;
|
|
232
|
+
limit?: number;
|
|
233
|
+
}): Promise<ChannelHistoryResult>;
|
|
234
|
+
/** Leave the channel and release every listener and timer. */
|
|
235
|
+
leave(): Promise<void>;
|
|
236
|
+
private stopHeartbeat;
|
|
237
|
+
/** Fold an incoming frame into the roster and fan it out. */
|
|
238
|
+
private handle;
|
|
239
|
+
/** Deliver everything held back during a catch-up, in sequence order. */
|
|
240
|
+
private flushPendingLive;
|
|
241
|
+
private deliver;
|
|
242
|
+
private emitPresence;
|
|
243
|
+
}
|
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
|
@@ -23,6 +23,23 @@ 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
|
+
/** Set by `close()`. Blocks any later operation from silently redialling. */
|
|
29
|
+
private closedByCaller;
|
|
30
|
+
/**
|
|
31
|
+
* Whether a socket exists at all (open or still opening).
|
|
32
|
+
*
|
|
33
|
+
* Lets callers distinguish "authenticate the live socket" from "there is
|
|
34
|
+
* nothing to authenticate yet", without that question forcing a dial.
|
|
35
|
+
*/
|
|
36
|
+
get hasSocket(): boolean;
|
|
37
|
+
/** So the "no WebSocket in this environment" warning is said once, not per call. */
|
|
38
|
+
private warnedNoWebSocket;
|
|
39
|
+
/** Subscribe to broadcast/presence frames for one channel. */
|
|
40
|
+
onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void;
|
|
41
|
+
/** Notified after the socket comes back, so channels can re-join. */
|
|
42
|
+
onReconnect(handler: () => void): () => void;
|
|
26
43
|
on(event: "connect" | "disconnect" | "reconnect" | "error", cb: (...args: unknown[]) => void): () => boolean;
|
|
27
44
|
private emit;
|
|
28
45
|
private collectionSubscriptions;
|
|
@@ -35,6 +52,7 @@ export declare class RebaseWebSocketClient {
|
|
|
35
52
|
private isConnected;
|
|
36
53
|
private messageQueue;
|
|
37
54
|
private requestTimeoutMs;
|
|
55
|
+
private subscriptionTimeoutMs;
|
|
38
56
|
private reconnectTimeout;
|
|
39
57
|
private isAuthenticated;
|
|
40
58
|
private authPromise;
|
|
@@ -42,6 +60,14 @@ export declare class RebaseWebSocketClient {
|
|
|
42
60
|
onUnauthorized?: () => Promise<boolean>;
|
|
43
61
|
private refreshInProgress;
|
|
44
62
|
constructor(config: RebaseWebSocketConfig);
|
|
63
|
+
/**
|
|
64
|
+
* Open the socket if it is not open (or opening) already.
|
|
65
|
+
*
|
|
66
|
+
* Idempotent, synchronous, and safe to call on every operation that needs a
|
|
67
|
+
* live socket — `initWebSocket` already no-ops on an open socket and is
|
|
68
|
+
* re-entrant, since the reconnect path has always called it.
|
|
69
|
+
*/
|
|
70
|
+
ensureConnected(): void;
|
|
45
71
|
/**
|
|
46
72
|
* Authenticate the WebSocket connection
|
|
47
73
|
*/
|
|
@@ -50,7 +76,15 @@ export declare class RebaseWebSocketClient {
|
|
|
50
76
|
* Set the auth token getter function
|
|
51
77
|
*/
|
|
52
78
|
setAuthTokenGetter(getAuthToken: () => Promise<string | null>): void;
|
|
53
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Drop the socket.
|
|
81
|
+
*
|
|
82
|
+
* `permanent` distinguishes the two callers. Signing out drops the socket
|
|
83
|
+
* but the client stays usable — a later subscribe should reconnect
|
|
84
|
+
* anonymously. `client.close()` is the caller saying they are done, and
|
|
85
|
+
* must not be undone by a stray queued frame.
|
|
86
|
+
*/
|
|
87
|
+
disconnect(permanent?: boolean): void;
|
|
54
88
|
private initWebSocket;
|
|
55
89
|
private processMessageQueue;
|
|
56
90
|
private attemptReconnect;
|
|
@@ -64,7 +98,11 @@ export declare class RebaseWebSocketClient {
|
|
|
64
98
|
private handleWebSocketMessage;
|
|
65
99
|
private ensureAuthenticated;
|
|
66
100
|
reauthenticate(): Promise<void>;
|
|
67
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
|
103
|
+
* Not part of the stable surface — prefer `client.realtime.channel(name)`.
|
|
104
|
+
*/
|
|
105
|
+
sendMessage(message: Record<string, unknown>): Promise<unknown>;
|
|
68
106
|
private doSendMessage;
|
|
69
107
|
fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]>;
|
|
70
108
|
fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined>;
|
|
@@ -76,6 +114,7 @@ export declare class RebaseWebSocketClient {
|
|
|
76
114
|
}): Promise<Record<string, unknown>[]>;
|
|
77
115
|
fetchAvailableDatabases(): Promise<string[]>;
|
|
78
116
|
fetchAvailableRoles(): Promise<string[]>;
|
|
117
|
+
fetchApplicationRoles(): Promise<string[]>;
|
|
79
118
|
fetchCurrentDatabase(): Promise<string | undefined>;
|
|
80
119
|
checkUniqueField(path: string, name: string, value: unknown, id?: string, collection?: CollectionConfig): Promise<boolean>;
|
|
81
120
|
count<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number>;
|
|
@@ -92,6 +131,19 @@ export declare class RebaseWebSocketClient {
|
|
|
92
131
|
*/
|
|
93
132
|
private deepEqual;
|
|
94
133
|
private normalizeForComparison;
|
|
134
|
+
/**
|
|
135
|
+
* The address of a row, for matching it against another copy of itself.
|
|
136
|
+
*
|
|
137
|
+
* A row is exactly its columns and carries no address, so it is derived
|
|
138
|
+
* from the key columns the server named — including the ordinary case where
|
|
139
|
+
* that key is `id`, which the server reports like any other.
|
|
140
|
+
*
|
|
141
|
+
* Undefined when there are no keys, which means the server could not
|
|
142
|
+
* resolve any: such rows genuinely cannot be recognised, and guessing at a
|
|
143
|
+
* column called `id` would be inventing an identity for a table that has
|
|
144
|
+
* none.
|
|
145
|
+
*/
|
|
146
|
+
private rowAddress;
|
|
95
147
|
/**
|
|
96
148
|
* Merge incoming rows with cached data, preserving cached references
|
|
97
149
|
* for rows whose values haven't changed. This avoids unnecessary
|
|
@@ -101,6 +153,48 @@ export declare class RebaseWebSocketClient {
|
|
|
101
153
|
private mergeRows;
|
|
102
154
|
listenCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>, onUpdate: (rows: Record<string, unknown>[]) => void, onError?: (error: Error) => void): () => void;
|
|
103
155
|
listenOne<M extends Record<string, unknown>>(props: FetchOneProps<M>, onUpdate: (row: Record<string, unknown> | null) => void, onError?: (error: Error) => void): () => void;
|
|
156
|
+
/**
|
|
157
|
+
* Send a `subscribe_collection` for an already-registered subscription and
|
|
158
|
+
* arm its watchdog.
|
|
159
|
+
*
|
|
160
|
+
* Every path that registers a collection subscription goes through here, so
|
|
161
|
+
* that a subscribe which never lands — a rejected send, or a server that
|
|
162
|
+
* never answers — always ends up in `failCollectionSubscription` rather than
|
|
163
|
+
* leaving the entry parked with `isInitialDataReceived === false` forever.
|
|
164
|
+
*/
|
|
165
|
+
private sendCollectionSubscribe;
|
|
166
|
+
/** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
|
|
167
|
+
private sendEntitySubscribe;
|
|
168
|
+
/**
|
|
169
|
+
* Report a subscribe failure to every listener and drop the registration.
|
|
170
|
+
*
|
|
171
|
+
* Dropping it is the point: the callbacks stay live (their components are
|
|
172
|
+
* still mounted and have been told), but the next `listenCollection` for
|
|
173
|
+
* these params finds no entry and issues a fresh subscribe instead of
|
|
174
|
+
* silently attaching to a dead one.
|
|
175
|
+
*/
|
|
176
|
+
private failCollectionSubscription;
|
|
177
|
+
/** The `listenOne` counterpart of {@link failCollectionSubscription}. */
|
|
178
|
+
private failEntitySubscription;
|
|
179
|
+
/**
|
|
180
|
+
* Stop the watchdogs without failing anything — used when the socket drops,
|
|
181
|
+
* since the reconnect path re-subscribes everything anyway and a watchdog
|
|
182
|
+
* firing mid-reconnect would tear down healthy subscriptions.
|
|
183
|
+
*/
|
|
184
|
+
private suspendSubscribeWatchdogs;
|
|
185
|
+
/**
|
|
186
|
+
* Arm watchdogs for subscribes that were requested while offline and have
|
|
187
|
+
* just been flushed to the socket. Their timers were deliberately not set at
|
|
188
|
+
* request time, so without this they would have no timeout at all.
|
|
189
|
+
*/
|
|
190
|
+
private armPendingSubscribeWatchdogs;
|
|
191
|
+
private sendCollectionSubscribeWatchdog;
|
|
192
|
+
private sendEntitySubscribeWatchdog;
|
|
193
|
+
/**
|
|
194
|
+
* Fail every subscription that never received data. Called when reconnection
|
|
195
|
+
* is given up on, so views surface an error instead of spinning forever.
|
|
196
|
+
*/
|
|
197
|
+
private failAllPendingSubscriptions;
|
|
104
198
|
/**
|
|
105
199
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
106
200
|
* 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.
|
|
4
|
+
"version": "0.10.0",
|
|
5
5
|
"description": "HTTP SDK client for the Rebase custom backend",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -13,37 +13,38 @@
|
|
|
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/types": "0.
|
|
35
|
-
"@rebasepro/
|
|
32
|
+
"@rebasepro/utils": "0.10.0",
|
|
33
|
+
"@rebasepro/types": "0.10.0",
|
|
34
|
+
"@rebasepro/common": "0.10.0"
|
|
36
35
|
},
|
|
37
36
|
"devDependencies": {
|
|
38
37
|
"@jest/globals": "^30.4.1",
|
|
39
38
|
"@types/jest": "^30.0.0",
|
|
40
39
|
"@types/node": "^25.9.3",
|
|
40
|
+
"@types/ws": "^8.18.1",
|
|
41
41
|
"cross-env": "^10.1.0",
|
|
42
42
|
"jest": "^30.4.2",
|
|
43
43
|
"ts-jest": "^29.4.11",
|
|
44
44
|
"tsd": "^0.33.0",
|
|
45
45
|
"typescript": "^6.0.3",
|
|
46
|
-
"vite": "^8.0.16"
|
|
46
|
+
"vite": "^8.0.16",
|
|
47
|
+
"ws": "^8.21.0"
|
|
47
48
|
},
|
|
48
49
|
"files": [
|
|
49
50
|
"dist",
|
|
@@ -75,7 +76,7 @@
|
|
|
75
76
|
},
|
|
76
77
|
"scripts": {
|
|
77
78
|
"watch": "vite build --watch",
|
|
78
|
-
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
|
|
79
|
+
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
|
|
79
80
|
"test:lint": "eslint \"src/**\" --quiet",
|
|
80
81
|
"test": "jest --passWithNoTests --forceExit",
|
|
81
82
|
"clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
|
package/src/auth.ts
CHANGED
|
@@ -516,6 +516,37 @@ newPassword })
|
|
|
516
516
|
});
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Link an OAuth provider to the **currently signed-in** account.
|
|
521
|
+
*
|
|
522
|
+
* Use this when `signIn*` failed with `EMAIL_NOT_VERIFIED` — an account
|
|
523
|
+
* with that email already exists under a different sign-in method — or to
|
|
524
|
+
* attach a provider whose email differs from the account's.
|
|
525
|
+
*
|
|
526
|
+
* The payload is the same one the provider's sign-in method takes, e.g.
|
|
527
|
+
* `linkProvider("google", { idToken })`.
|
|
528
|
+
*
|
|
529
|
+
* Unlike sign-in, this does not require the provider to have verified the
|
|
530
|
+
* email, and the emails need not match: the active session already proves
|
|
531
|
+
* account ownership.
|
|
532
|
+
*
|
|
533
|
+
* Throws `IDENTITY_ALREADY_LINKED` (409) if that provider identity is
|
|
534
|
+
* attached to a different user. Succeeds idempotently (`alreadyLinked:
|
|
535
|
+
* true`) if it is already attached to the current one.
|
|
536
|
+
*/
|
|
537
|
+
async function linkProvider(
|
|
538
|
+
providerId: string,
|
|
539
|
+
payload: Record<string, unknown>
|
|
540
|
+
) {
|
|
541
|
+
return transport.request<{ success: boolean; provider: string; alreadyLinked: boolean; }>(
|
|
542
|
+
authPath + "/link/" + providerId,
|
|
543
|
+
{
|
|
544
|
+
method: "POST",
|
|
545
|
+
body: JSON.stringify(payload)
|
|
546
|
+
}
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
519
550
|
async function sendVerificationEmail() {
|
|
520
551
|
return transport.request<{ success: boolean; message: string; }>(authPath + "/send-verification", {
|
|
521
552
|
method: "POST"
|
|
@@ -666,6 +697,7 @@ refreshToken: session.refreshToken };
|
|
|
666
697
|
resetPasswordForEmail,
|
|
667
698
|
resetPassword,
|
|
668
699
|
changePassword,
|
|
700
|
+
linkProvider,
|
|
669
701
|
sendVerificationEmail,
|
|
670
702
|
verifyEmail,
|
|
671
703
|
sendMagicLink,
|
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",
|