@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.
package/dist/auth.d.ts CHANGED
@@ -147,6 +147,11 @@ export declare function createAuth(transport: Transport, options?: CreateAuthOpt
147
147
  success: boolean;
148
148
  message: string;
149
149
  }>;
150
+ linkProvider: (providerId: string, payload: Record<string, unknown>) => Promise<{
151
+ success: boolean;
152
+ provider: string;
153
+ alreadyLinked: boolean;
154
+ }>;
150
155
  sendVerificationEmail: () => Promise<{
151
156
  success: boolean;
152
157
  message: string;
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ import { createApiKeys, CreateApiKeysOptions } from "./api-keys";
7
7
  import { CollectionClient } from "./collection";
8
8
  import { createFunctionsClient } from "./functions";
9
9
  import { RebaseWebSocketClient } from "./websocket";
10
- import { RebaseRealtimeChannel } from "./realtime-channel";
10
+ import { RebaseRealtimeChannel, type ChannelOptions } from "./realtime-channel";
11
11
  import { InsertOf, RebaseClient, RebaseSdkData, RowOf, StorageSource, StorageSourceDefinition, StorageSourceRegistry, UpdateOf } from "@rebasepro/types";
12
12
  export { RebaseApiError } from "./transport";
13
13
  export { RebaseClientError } from "./errors";
@@ -29,7 +29,7 @@ export type { ApiKeyMasked, ApiKeyPermission, ApiKeyWithSecret, CreateApiKeyRequ
29
29
  export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
30
30
  export { RebaseWebSocketClient } from "./websocket";
31
31
  export { RebaseRealtimeChannel } from "./realtime-channel";
32
- export type { PresenceState, PresenceDiff, BroadcastEvent, ChannelTransport } from "./realtime-channel";
32
+ export type { PresenceState, PresenceDiff, BroadcastEvent, ChannelTransport, ChannelOptions, ChannelHistoryEntry, ChannelHistoryResult } from "./realtime-channel";
33
33
  export interface CreateRebaseClientOptions extends RebaseClientConfig {
34
34
  auth?: CreateAuthOptions;
35
35
  admin?: CreateAdminOptions;
@@ -89,8 +89,11 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
89
89
  * Join a broadcast/presence channel. Repeated calls with the same name
90
90
  * return the same channel object. Throws only when the client was
91
91
  * created with `realtime: false`.
92
+ *
93
+ * Pass `{ history: true }` to have the channel replay what it missed on
94
+ * join and on every reconnect, for channels the server retains.
92
95
  */
93
- channel: (name: string) => RebaseRealtimeChannel;
96
+ channel: (name: string, options?: ChannelOptions) => RebaseRealtimeChannel;
94
97
  };
95
98
  /**
96
99
  * Release the realtime socket and its reconnect timer.
package/dist/index.es.js CHANGED
@@ -54,6 +54,28 @@ function buildQueryString(params) {
54
54
  }
55
55
  return parts.length > 0 ? "?" + parts.join("&") : "";
56
56
  }
57
+ /**
58
+ * The base every request and every caller-built URL resolves against.
59
+ *
60
+ * `baseUrl` is optional because the common production shape is a Rebase
61
+ * backend serving its own SPA, where the API is simply the page's origin.
62
+ * Leaving it unset is therefore the *correct* configuration there — and the
63
+ * one that keeps working when a second hostname (a custom domain) points at
64
+ * the same app.
65
+ *
66
+ * When unset in a browser this resolves to the page origin rather than "".
67
+ * Requests behave identically either way, but the empty string is a trap for
68
+ * anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
69
+ * throws, so apps "fixed" it by baking an absolute host into their bundle —
70
+ * which is exactly what breaks the day a custom domain is added, and which no
71
+ * amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
72
+ * not sent cross-site either.
73
+ */
74
+ function resolveBaseUrl(configured) {
75
+ if (configured) return configured.replace(/\/$/, "");
76
+ if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
77
+ return "";
78
+ }
57
79
  function createTransport(config) {
58
80
  const fetchFn = config.fetch || globalThis.fetch;
59
81
  const apiPath = config.apiPath || "/api";
@@ -68,7 +90,7 @@ function createTransport(config) {
68
90
  };
69
91
  }
70
92
  async function request(path, init) {
71
- const url = (config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "") + apiPath + path;
93
+ const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
72
94
  let activeToken = token;
73
95
  if (tokenGetter) try {
74
96
  const fetched = await tokenGetter();
@@ -143,7 +165,7 @@ function createTransport(config) {
143
165
  onUnauthorizedHandler = handler;
144
166
  },
145
167
  get baseUrl() {
146
- return config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "";
168
+ return resolveBaseUrl(config.baseUrl);
147
169
  },
148
170
  get apiPath() {
149
171
  return apiPath;
@@ -605,6 +627,30 @@ function createAuth(transport, options) {
605
627
  })
606
628
  });
607
629
  }
630
+ /**
631
+ * Link an OAuth provider to the **currently signed-in** account.
632
+ *
633
+ * Use this when `signIn*` failed with `EMAIL_NOT_VERIFIED` — an account
634
+ * with that email already exists under a different sign-in method — or to
635
+ * attach a provider whose email differs from the account's.
636
+ *
637
+ * The payload is the same one the provider's sign-in method takes, e.g.
638
+ * `linkProvider("google", { idToken })`.
639
+ *
640
+ * Unlike sign-in, this does not require the provider to have verified the
641
+ * email, and the emails need not match: the active session already proves
642
+ * account ownership.
643
+ *
644
+ * Throws `IDENTITY_ALREADY_LINKED` (409) if that provider identity is
645
+ * attached to a different user. Succeeds idempotently (`alreadyLinked:
646
+ * true`) if it is already attached to the current one.
647
+ */
648
+ async function linkProvider(providerId, payload) {
649
+ return transport.request(authPath + "/link/" + providerId, {
650
+ method: "POST",
651
+ body: JSON.stringify(payload)
652
+ });
653
+ }
608
654
  async function sendVerificationEmail() {
609
655
  return transport.request(authPath + "/send-verification", { method: "POST" });
610
656
  }
@@ -726,6 +772,7 @@ function createAuth(transport, options) {
726
772
  resetPasswordForEmail,
727
773
  resetPassword,
728
774
  changePassword,
775
+ linkProvider,
729
776
  sendVerificationEmail,
730
777
  verifyEmail,
731
778
  sendMagicLink,
@@ -1416,7 +1463,8 @@ var CHANNEL_MESSAGE_TYPES = new Set([
1416
1463
  "broadcast",
1417
1464
  "presence_track",
1418
1465
  "presence_untrack",
1419
- "presence_state"
1466
+ "presence_state",
1467
+ "channel_history"
1420
1468
  ]);
1421
1469
  /**
1422
1470
  * Low-level realtime WebSocket client.
@@ -1755,7 +1803,7 @@ var RebaseWebSocketClient = class {
1755
1803
  }
1756
1804
  return;
1757
1805
  }
1758
- if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff")) {
1806
+ if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
1759
1807
  const handlers = this.channelHandlers.get(message.channel);
1760
1808
  if (handlers) for (const handler of [...handlers]) try {
1761
1809
  handler(message);
@@ -2051,6 +2099,9 @@ var RebaseWebSocketClient = class {
2051
2099
  async fetchAvailableRoles() {
2052
2100
  return (await this.sendMessage({ type: "FETCH_ROLES" })).roles || [];
2053
2101
  }
2102
+ async fetchApplicationRoles() {
2103
+ return (await this.sendMessage({ type: "FETCH_APPLICATION_ROLES" })).roles || [];
2104
+ }
2054
2105
  async fetchCurrentDatabase() {
2055
2106
  return (await this.sendMessage({ type: "FETCH_CURRENT_DATABASE" })).database;
2056
2107
  }
@@ -2539,6 +2590,14 @@ var RebaseWebSocketClient = class {
2539
2590
  * before the entry is reaped, so a single dropped frame is not a disappearance.
2540
2591
  */
2541
2592
  var PRESENCE_HEARTBEAT_MS = 2e4;
2593
+ /**
2594
+ * How long live messages are held back waiting for a catch-up response.
2595
+ *
2596
+ * Short, because the cost of waiting is visible — on a collaborative document
2597
+ * this is a stall in everyone else's edits appearing. Long enough that a slow
2598
+ * replay of a busy channel is not abandoned needlessly.
2599
+ */
2600
+ var CATCH_UP_TIMEOUT_MS = 1e4;
2542
2601
  var RebaseRealtimeChannel = class {
2543
2602
  name;
2544
2603
  transport;
@@ -2551,9 +2610,63 @@ var RebaseRealtimeChannel = class {
2551
2610
  trackedState = null;
2552
2611
  heartbeat = null;
2553
2612
  joined = false;
2554
- constructor(name, transport) {
2613
+ /** Whether this handle asks the server to replay missed messages. */
2614
+ wantsHistory;
2615
+ /**
2616
+ * Highest sequence number delivered to handlers so far.
2617
+ *
2618
+ * This is the resume point sent as `sinceSeq`, and the watermark that makes
2619
+ * replay idempotent: catch-up ranges overlap with what arrived live, and
2620
+ * anything at or below this has already been seen.
2621
+ */
2622
+ lastSeq = 0;
2623
+ /**
2624
+ * Live messages that arrived while a catch-up was in flight.
2625
+ *
2626
+ * Without this they would be delivered ahead of the older messages being
2627
+ * fetched, and — worse — would advance {@link lastSeq} past them, so the
2628
+ * catch-up response would then be discarded as already-seen and those
2629
+ * messages would be lost for good. Held here and flushed, in order, once
2630
+ * the replay lands.
2631
+ */
2632
+ pendingLive = [];
2633
+ catchUpInFlight = false;
2634
+ /**
2635
+ * Deadline for a catch-up response.
2636
+ *
2637
+ * Buffering live messages is only safe because the wait is bounded. A
2638
+ * catch-up frame that never arrives — a server that dropped it, a socket
2639
+ * that died between request and reply — would otherwise leave the channel
2640
+ * silently holding every subsequent edit forever, which is a worse failure
2641
+ * than the one replay was added to fix.
2642
+ */
2643
+ catchUpTimeout = null;
2644
+ /**
2645
+ * Callers of {@link history} awaiting the next `channel_history` frame.
2646
+ *
2647
+ * These frames are addressed by channel rather than by request id, so they
2648
+ * are matched in arrival order. Requests on one channel are serialized by
2649
+ * the socket, so FIFO is the right correlation here.
2650
+ */
2651
+ historyWaiters = [];
2652
+ constructor(name, transport, options = {}) {
2555
2653
  this.name = name;
2556
2654
  this.transport = transport;
2655
+ this.wantsHistory = options.history ?? false;
2656
+ }
2657
+ /**
2658
+ * Turn on catch-up for a handle that was created without it.
2659
+ *
2660
+ * The client hands back the same channel object for a given name, so a
2661
+ * later `channel(name, { history: true })` has no new object to configure —
2662
+ * it upgrades this one instead. Idempotent, and never downgrades: one
2663
+ * caller asking for history must not be switched off by another that did
2664
+ * not ask.
2665
+ */
2666
+ enableHistory() {
2667
+ if (this.wantsHistory) return;
2668
+ this.wantsHistory = true;
2669
+ if (this.joined) this.requestHistory();
2557
2670
  }
2558
2671
  /**
2559
2672
  * Join the channel and ask for the current roster.
@@ -2592,15 +2705,58 @@ var RebaseRealtimeChannel = class {
2592
2705
  }));
2593
2706
  await this.send("join_channel");
2594
2707
  await this.send("presence_state");
2708
+ if (this.wantsHistory) await this.requestHistory();
2595
2709
  }
2596
2710
  async rejoin() {
2597
2711
  try {
2598
2712
  await this.send("join_channel");
2599
2713
  await this.send("presence_state");
2600
2714
  if (this.trackedState) await this.send("presence_track", { state: this.trackedState });
2715
+ if (this.wantsHistory) await this.requestHistory();
2601
2716
  } catch {}
2602
2717
  }
2603
2718
  /**
2719
+ * Ask the server for everything after {@link lastSeq}.
2720
+ *
2721
+ * Live messages are buffered from here until the answer arrives — see
2722
+ * {@link pendingLive}.
2723
+ */
2724
+ async requestHistory(limit) {
2725
+ this.catchUpInFlight = true;
2726
+ if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
2727
+ this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
2728
+ this.catchUpTimeout.unref?.();
2729
+ try {
2730
+ await this.send("channel_history", {
2731
+ sinceSeq: this.lastSeq,
2732
+ ...limit !== void 0 ? { limit } : {}
2733
+ });
2734
+ } catch {
2735
+ this.abandonCatchUp();
2736
+ }
2737
+ }
2738
+ /**
2739
+ * Give up waiting for a catch-up and release what was held back.
2740
+ *
2741
+ * The buffered messages are still the freshest thing this client has, so
2742
+ * they are delivered rather than dropped. Callers of {@link history} are
2743
+ * answered with `retained: false` — accurate in the sense that matters:
2744
+ * this client has no history to work from and has to resync.
2745
+ */
2746
+ abandonCatchUp() {
2747
+ if (this.catchUpTimeout) {
2748
+ clearTimeout(this.catchUpTimeout);
2749
+ this.catchUpTimeout = null;
2750
+ }
2751
+ if (!this.catchUpInFlight) return;
2752
+ this.catchUpInFlight = false;
2753
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
2754
+ messages: [],
2755
+ retained: false
2756
+ });
2757
+ this.flushPendingLive();
2758
+ }
2759
+ /**
2604
2760
  * Publish this client's presence state, and keep publishing it.
2605
2761
  *
2606
2762
  * Calling `track` again replaces the state (and restarts the heartbeat),
@@ -2650,6 +2806,34 @@ var RebaseRealtimeChannel = class {
2650
2806
  this.join();
2651
2807
  return () => this.broadcastHandlers.delete(wrapped);
2652
2808
  }
2809
+ /**
2810
+ * The last sequence number this channel has delivered.
2811
+ *
2812
+ * Zero on a channel that retains nothing. Persist it if you want catch-up
2813
+ * to survive a page reload as well as a reconnect, and pass it back via
2814
+ * {@link history}.
2815
+ */
2816
+ get sequence() {
2817
+ return this.lastSeq;
2818
+ }
2819
+ /**
2820
+ * Fetch retained messages explicitly, instead of waiting for join or
2821
+ * reconnect to do it.
2822
+ *
2823
+ * Defaults to resuming from {@link sequence}. Messages are delivered to
2824
+ * `onBroadcast` handlers as usual — the returned value is for callers that
2825
+ * want to inspect the batch, or to learn from `retained` that the channel
2826
+ * keeps no history at all.
2827
+ */
2828
+ async history(options = {}) {
2829
+ await this.join();
2830
+ if (options.sinceSeq !== void 0) this.lastSeq = options.sinceSeq;
2831
+ const result = new Promise((resolve) => {
2832
+ this.historyWaiters.push(resolve);
2833
+ });
2834
+ await this.requestHistory(options.limit);
2835
+ return result;
2836
+ }
2653
2837
  /** Leave the channel and release every listener and timer. */
2654
2838
  async leave() {
2655
2839
  this.stopHeartbeat();
@@ -2657,6 +2841,17 @@ var RebaseRealtimeChannel = class {
2657
2841
  this.presences = {};
2658
2842
  this.presenceHandlers.clear();
2659
2843
  this.broadcastHandlers.clear();
2844
+ this.lastSeq = 0;
2845
+ this.pendingLive = [];
2846
+ this.catchUpInFlight = false;
2847
+ if (this.catchUpTimeout) {
2848
+ clearTimeout(this.catchUpTimeout);
2849
+ this.catchUpTimeout = null;
2850
+ }
2851
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
2852
+ messages: [],
2853
+ retained: false
2854
+ });
2660
2855
  for (const off of this.unsubscribers) off();
2661
2856
  this.unsubscribers = [];
2662
2857
  if (this.joined) {
@@ -2689,15 +2884,71 @@ var RebaseRealtimeChannel = class {
2689
2884
  break;
2690
2885
  }
2691
2886
  case "broadcast": {
2887
+ const seq = typeof message.seq === "number" ? message.seq : void 0;
2692
2888
  const event = {
2693
2889
  event: message.event,
2694
- payload: message.payload
2890
+ payload: message.payload,
2891
+ ...seq !== void 0 ? { seq } : {}
2695
2892
  };
2696
- for (const handler of this.broadcastHandlers) handler(event);
2893
+ if (seq === void 0) {
2894
+ this.deliver(event);
2895
+ break;
2896
+ }
2897
+ if (this.catchUpInFlight) {
2898
+ this.pendingLive.push(event);
2899
+ break;
2900
+ }
2901
+ if (seq <= this.lastSeq) break;
2902
+ this.lastSeq = seq;
2903
+ this.deliver(event);
2904
+ break;
2905
+ }
2906
+ case "channel_history": {
2907
+ this.catchUpInFlight = false;
2908
+ if (this.catchUpTimeout) {
2909
+ clearTimeout(this.catchUpTimeout);
2910
+ this.catchUpTimeout = null;
2911
+ }
2912
+ const entries = message.messages ?? [];
2913
+ const retained = message.retained === true;
2914
+ const latestSeq = typeof message.latestSeq === "number" ? message.latestSeq : void 0;
2915
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
2916
+ messages: entries,
2917
+ retained,
2918
+ latestSeq
2919
+ });
2920
+ for (const entry of entries) {
2921
+ if (entry.seq <= this.lastSeq) continue;
2922
+ this.lastSeq = entry.seq;
2923
+ this.deliver({
2924
+ event: entry.event,
2925
+ payload: entry.payload,
2926
+ seq: entry.seq,
2927
+ replayed: true
2928
+ });
2929
+ }
2930
+ this.flushPendingLive();
2697
2931
  break;
2698
2932
  }
2699
2933
  }
2700
2934
  }
2935
+ /** Deliver everything held back during a catch-up, in sequence order. */
2936
+ flushPendingLive() {
2937
+ if (this.pendingLive.length === 0) return;
2938
+ const buffered = this.pendingLive.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
2939
+ this.pendingLive = [];
2940
+ for (const event of buffered) {
2941
+ const seq = event.seq;
2942
+ if (seq !== void 0) {
2943
+ if (seq <= this.lastSeq) continue;
2944
+ this.lastSeq = seq;
2945
+ }
2946
+ this.deliver(event);
2947
+ }
2948
+ }
2949
+ deliver(event) {
2950
+ for (const handler of [...this.broadcastHandlers]) handler(event);
2951
+ }
2701
2952
  emitPresence(diff) {
2702
2953
  const snapshot = { ...this.presences };
2703
2954
  for (const handler of this.presenceHandlers) handler(snapshot, diff);
@@ -2875,13 +3126,13 @@ function createRebaseClient(options) {
2875
3126
  * own membership — and `leave()` from one would otherwise silently
2876
3127
  * cut off the others.
2877
3128
  */
2878
- channel: (name) => {
3129
+ channel: (name, options) => {
2879
3130
  if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
2880
3131
  let existing = realtimeChannels.get(name);
2881
3132
  if (!existing) {
2882
- existing = new RebaseRealtimeChannel(name, ws);
3133
+ existing = new RebaseRealtimeChannel(name, ws, options);
2883
3134
  realtimeChannels.set(name, existing);
2884
- }
3135
+ } else if (options?.history) existing.enableHistory();
2885
3136
  return existing;
2886
3137
  } },
2887
3138
  /**