@rebasepro/client 0.9.1-canary.ff338b5 → 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.
@@ -32,6 +32,58 @@ export interface PresenceDiff {
32
32
  export interface BroadcastEvent {
33
33
  event: string;
34
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;
35
87
  }
36
88
  /** The socket operations a channel needs; satisfied by RebaseWebSocketClient. */
37
89
  export interface ChannelTransport {
@@ -51,7 +103,56 @@ export declare class RebaseRealtimeChannel {
51
103
  private trackedState;
52
104
  private heartbeat;
53
105
  private joined;
54
- constructor(name: string, transport: ChannelTransport);
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;
55
156
  /**
56
157
  * Join the channel and ask for the current roster.
57
158
  *
@@ -74,6 +175,22 @@ export declare class RebaseRealtimeChannel {
74
175
  private send;
75
176
  join(): Promise<void>;
76
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;
77
194
  /**
78
195
  * Publish this client's presence state, and keep publishing it.
79
196
  *
@@ -93,10 +210,34 @@ export declare class RebaseRealtimeChannel {
93
210
  /** Observe broadcasts. Pass an event name to filter. */
94
211
  onBroadcast(handler: (event: BroadcastEvent) => void): () => void;
95
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>;
96
234
  /** Leave the channel and release every listener and timer. */
97
235
  leave(): Promise<void>;
98
236
  private stopHeartbeat;
99
237
  /** Fold an incoming frame into the roster and fan it out. */
100
238
  private handle;
239
+ /** Deliver everything held back during a catch-up, in sequence order. */
240
+ private flushPendingLive;
241
+ private deliver;
101
242
  private emitPresence;
102
243
  }
@@ -114,6 +114,7 @@ export declare class RebaseWebSocketClient {
114
114
  }): Promise<Record<string, unknown>[]>;
115
115
  fetchAvailableDatabases(): Promise<string[]>;
116
116
  fetchAvailableRoles(): Promise<string[]>;
117
+ fetchApplicationRoles(): Promise<string[]>;
117
118
  fetchCurrentDatabase(): Promise<string | undefined>;
118
119
  checkUniqueField(path: string, name: string, value: unknown, id?: string, collection?: CollectionConfig): Promise<boolean>;
119
120
  count<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number>;
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.ff338b5",
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"
@@ -29,9 +29,9 @@
29
29
  "./package.json": "./package.json"
30
30
  },
31
31
  "dependencies": {
32
- "@rebasepro/common": "0.9.1-canary.ff338b5",
33
- "@rebasepro/types": "0.9.1-canary.ff338b5",
34
- "@rebasepro/utils": "0.9.1-canary.ff338b5"
32
+ "@rebasepro/utils": "0.10.0",
33
+ "@rebasepro/types": "0.10.0",
34
+ "@rebasepro/common": "0.10.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@jest/globals": "^30.4.1",
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/index.ts CHANGED
@@ -10,7 +10,7 @@ import { createFunctionsClient } from "./functions";
10
10
  import { createStorage } from "./storage";
11
11
  import { ClientStorageSourceRegistry } from "./storage-registry";
12
12
  import { RebaseWebSocketClient } from "./websocket";
13
- import { RebaseRealtimeChannel } from "./realtime-channel";
13
+ import { RebaseRealtimeChannel, type ChannelOptions } from "./realtime-channel";
14
14
  import {
15
15
  DEFAULT_STORAGE_SOURCE_KEY,
16
16
  InsertOf,
@@ -78,7 +78,15 @@ export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
78
78
  // driver constructs it directly. Not a stable app-facing API.
79
79
  export { RebaseWebSocketClient } from "./websocket";
80
80
  export { RebaseRealtimeChannel } from "./realtime-channel";
81
- export type { PresenceState, PresenceDiff, BroadcastEvent, ChannelTransport } from "./realtime-channel";
81
+ export type {
82
+ PresenceState,
83
+ PresenceDiff,
84
+ BroadcastEvent,
85
+ ChannelTransport,
86
+ ChannelOptions,
87
+ ChannelHistoryEntry,
88
+ ChannelHistoryResult
89
+ } from "./realtime-channel";
82
90
 
83
91
  export interface CreateRebaseClientOptions extends RebaseClientConfig {
84
92
  auth?: CreateAuthOptions;
@@ -157,8 +165,11 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
157
165
  * Join a broadcast/presence channel. Repeated calls with the same name
158
166
  * return the same channel object. Throws only when the client was
159
167
  * created with `realtime: false`.
168
+ *
169
+ * Pass `{ history: true }` to have the channel replay what it missed on
170
+ * join and on every reconnect, for channels the server retains.
160
171
  */
161
- channel: (name: string) => RebaseRealtimeChannel;
172
+ channel: (name: string, options?: ChannelOptions) => RebaseRealtimeChannel;
162
173
  };
163
174
  /**
164
175
  * Release the realtime socket and its reconnect timer.
@@ -458,7 +469,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
458
469
  * own membership — and `leave()` from one would otherwise silently
459
470
  * cut off the others.
460
471
  */
461
- channel: (name: string): RebaseRealtimeChannel => {
472
+ channel: (name: string, options?: ChannelOptions): RebaseRealtimeChannel => {
462
473
  // Only `realtime: false` gets here — a hard opt-out, so this
463
474
  // stays an error. Being merely *unconnected* does not: the
464
475
  // socket opens on the first channel operation, which is the
@@ -470,8 +481,15 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
470
481
  }
471
482
  let existing = realtimeChannels.get(name);
472
483
  if (!existing) {
473
- existing = new RebaseRealtimeChannel(name, ws);
484
+ existing = new RebaseRealtimeChannel(name, ws, options);
474
485
  realtimeChannels.set(name, existing);
486
+ } else if (options?.history) {
487
+ // Same object by name, so options on a later call have no
488
+ // new channel to apply to. Asking for history upgrades the
489
+ // one that exists rather than being quietly ignored — but
490
+ // never the reverse, so a caller that omits the option
491
+ // cannot switch it off under one that asked for it.
492
+ existing.enableHistory();
475
493
  }
476
494
  return existing;
477
495
  }
@@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals
5
5
  */
6
6
  import {
7
7
  RebaseRealtimeChannel,
8
+ type BroadcastEvent,
8
9
  type ChannelTransport,
9
10
  type PresenceState
10
11
  } from "./realtime-channel";
@@ -100,6 +101,211 @@ describe("RebaseRealtimeChannel", () => {
100
101
  payload: { channel: "doc:42", event: "saved", payload: { version: 3 } }
101
102
  });
102
103
  });
104
+
105
+ it("carries the catch-up cursor inside the envelope", async () => {
106
+ // Same failure mode as the rest: `payload?.sinceSeq` read flat is
107
+ // undefined, which the server would treat as "replay from zero" —
108
+ // a client asking to resume would silently get the whole history
109
+ // back and re-apply every operation it had already applied.
110
+ const retained = new RebaseRealtimeChannel("doc:42", fake.transport, { history: true });
111
+ await retained.join();
112
+
113
+ const request = fake.sent.find((m) => m.type === "channel_history");
114
+ expect(request).toMatchObject({
115
+ type: "channel_history",
116
+ payload: { channel: "doc:42", sinceSeq: 0 }
117
+ });
118
+ expect(request).not.toHaveProperty("sinceSeq");
119
+ });
120
+ });
121
+
122
+ describe("history", () => {
123
+ /**
124
+ * A joined channel that asks for catch-up.
125
+ *
126
+ * `settle` answers the join-time history request the way a server does.
127
+ * Without it the channel is legitimately still catching up, and holds
128
+ * live messages back — which is the behaviour one test below asserts on
129
+ * purpose.
130
+ */
131
+ async function retainedChannel({ settle = true } = {}) {
132
+ const c = new RebaseRealtimeChannel("doc:42", fake.transport, { history: true });
133
+ const received: BroadcastEvent[] = [];
134
+ c.onBroadcast((e) => received.push(e));
135
+ await c.join();
136
+ // `onBroadcast` already started a join, so the `await` above returns
137
+ // on the second (idempotent) call while the first is still working
138
+ // through its sends. Let it finish, or the history request has not
139
+ // been made yet and there is nothing for `settle` to answer.
140
+ await jest.advanceTimersByTimeAsync(0);
141
+ if (settle) {
142
+ fake.push({ type: "channel_history", channel: "doc:42", retained: true, messages: [] });
143
+ }
144
+ return { channel: c, received };
145
+ }
146
+
147
+ it("asks for history on join, and only when asked to", async () => {
148
+ await channel.join();
149
+ expect(fake.types()).not.toContain("channel_history");
150
+
151
+ fake.sent.length = 0;
152
+ const { channel: retained } = await retainedChannel();
153
+ expect(fake.types()).toContain("channel_history");
154
+ expect(retained.sequence).toBe(0);
155
+ });
156
+
157
+ it("tracks the sequence of live messages", async () => {
158
+ const { channel: retained, received } = await retainedChannel();
159
+
160
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 1 }, seq: 1 });
161
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 2 }, seq: 2 });
162
+
163
+ expect(received.map((e) => e.seq)).toEqual([1, 2]);
164
+ expect(retained.sequence).toBe(2);
165
+ });
166
+
167
+ it("delivers replayed messages through the same handlers, marked as replay", async () => {
168
+ const { channel: retained, received } = await retainedChannel();
169
+
170
+ fake.push({
171
+ type: "channel_history",
172
+ channel: "doc:42",
173
+ retained: true,
174
+ latestSeq: 2,
175
+ messages: [
176
+ { seq: 1, event: "op", payload: { n: 1 } },
177
+ { seq: 2, event: "op", payload: { n: 2 } }
178
+ ]
179
+ });
180
+
181
+ expect(received.map((e) => e.payload)).toEqual([{ n: 1 }, { n: 2 }]);
182
+ expect(received.every((e) => e.replayed)).toBe(true);
183
+ expect(retained.sequence).toBe(2);
184
+ });
185
+
186
+ it("never re-delivers a message it already saw", async () => {
187
+ // Catch-up ranges overlap with what arrived live — the server
188
+ // cannot know exactly what landed before the socket dropped. The
189
+ // watermark is what makes replaying an overlap harmless.
190
+ const { received } = await retainedChannel();
191
+
192
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 1 }, seq: 1 });
193
+ fake.push({
194
+ type: "channel_history",
195
+ channel: "doc:42",
196
+ retained: true,
197
+ messages: [
198
+ { seq: 1, event: "op", payload: { n: 1 } },
199
+ { seq: 2, event: "op", payload: { n: 2 } }
200
+ ]
201
+ });
202
+
203
+ expect(received.map((e) => e.payload)).toEqual([{ n: 1 }, { n: 2 }]);
204
+ });
205
+
206
+ it("holds live messages back until the catch-up lands, then orders them", async () => {
207
+ // The subtle one. A live message arriving mid-catch-up would
208
+ // otherwise be delivered first AND advance the watermark past the
209
+ // older messages still in flight — which the catch-up response
210
+ // would then discard as already-seen. Those messages would be lost
211
+ // silently, which is precisely the failure history exists to fix.
212
+ const { channel: retained, received } = await retainedChannel({ settle: false });
213
+
214
+ // seq 5 arrives while the replay of 1..4 is still on the wire.
215
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 5 }, seq: 5 });
216
+ expect(received).toHaveLength(0);
217
+
218
+ fake.push({
219
+ type: "channel_history",
220
+ channel: "doc:42",
221
+ retained: true,
222
+ messages: [
223
+ { seq: 3, event: "op", payload: { n: 3 } },
224
+ { seq: 4, event: "op", payload: { n: 4 } }
225
+ ]
226
+ });
227
+
228
+ expect(received.map((e) => e.seq)).toEqual([3, 4, 5]);
229
+ expect(retained.sequence).toBe(5);
230
+ });
231
+
232
+ it("leaves unsequenced channels completely untouched", async () => {
233
+ // An ephemeral channel retains nothing, so its broadcasts carry no
234
+ // seq. They must not be buffered, deduped or reordered.
235
+ const received: BroadcastEvent[] = [];
236
+ channel.onBroadcast((e) => received.push(e));
237
+ await channel.join();
238
+
239
+ fake.push({ type: "broadcast", channel: "doc:42", event: "cursor", payload: { x: 1 } });
240
+ fake.push({ type: "broadcast", channel: "doc:42", event: "cursor", payload: { x: 2 } });
241
+
242
+ expect(received.map((e) => e.payload)).toEqual([{ x: 1 }, { x: 2 }]);
243
+ expect(received.every((e) => e.seq === undefined)).toBe(true);
244
+ expect(channel.sequence).toBe(0);
245
+ });
246
+
247
+ it("resumes from the last sequence on reconnect", async () => {
248
+ const { channel: retained } = await retainedChannel();
249
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 1 }, seq: 7 });
250
+ fake.sent.length = 0;
251
+
252
+ fake.reconnect();
253
+ await jest.advanceTimersByTimeAsync(0);
254
+
255
+ expect(fake.types()).toEqual(["join_channel", "presence_state", "channel_history"]);
256
+ expect(fake.sent.at(-1)).toMatchObject({ payload: { channel: "doc:42", sinceSeq: 7 } });
257
+ expect(retained.sequence).toBe(7);
258
+ });
259
+
260
+ it("stops holding messages back if the catch-up never arrives", async () => {
261
+ // Buffering is only safe because the wait is bounded. A reply that
262
+ // never comes would otherwise leave the channel silently swallowing
263
+ // every edit from then on — worse than the problem replay solves.
264
+ const { received } = await retainedChannel({ settle: false });
265
+
266
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 1 }, seq: 1 });
267
+ expect(received).toHaveLength(0);
268
+
269
+ await jest.advanceTimersByTimeAsync(11_000);
270
+
271
+ expect(received.map((e) => e.payload)).toEqual([{ n: 1 }]);
272
+ });
273
+
274
+ it("reports a channel that keeps no history, rather than an empty one", async () => {
275
+ const { channel: retained } = await retainedChannel();
276
+
277
+ const pending = retained.history();
278
+ await jest.advanceTimersByTimeAsync(0);
279
+ fake.push({ type: "channel_history", channel: "doc:42", retained: false, messages: [] });
280
+
281
+ // The distinction matters: `retained: false` tells a client its
282
+ // reconnect strategy has to be a full resync.
283
+ await expect(pending).resolves.toEqual({
284
+ messages: [], retained: false, latestSeq: undefined
285
+ });
286
+ });
287
+
288
+ it("upgrades an existing channel when history is asked for later", async () => {
289
+ // The client hands back the same object per name, so a later
290
+ // `channel(name, { history: true })` has nothing new to configure.
291
+ await channel.join();
292
+ fake.sent.length = 0;
293
+
294
+ channel.enableHistory();
295
+ await jest.advanceTimersByTimeAsync(0);
296
+
297
+ expect(fake.types()).toContain("channel_history");
298
+ });
299
+
300
+ it("forgets its position on leave, so a rejoin does not skip the past", async () => {
301
+ const { channel: retained } = await retainedChannel();
302
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: {}, seq: 9 });
303
+ expect(retained.sequence).toBe(9);
304
+
305
+ await retained.leave();
306
+
307
+ expect(retained.sequence).toBe(0);
308
+ });
103
309
  });
104
310
 
105
311
  describe("joining", () => {