@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.
@@ -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
+ }
@@ -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.
@@ -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
- disconnect(): void;
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
- private sendMessage;
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.9.1-canary.fd3754b",
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.umd.cjs",
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": ">=14"
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/common": "0.9.1-canary.fd3754b",
34
- "@rebasepro/types": "0.9.1-canary.fd3754b",
35
- "@rebasepro/utils": "0.9.1-canary.fd3754b"
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",