@rebasepro/client 0.8.0 → 0.9.1-canary.09aaf62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,36 +1,84 @@
1
1
  import { createTransport, RebaseClientConfig } from "./transport";
2
+ import { RebaseClientError } from "./errors";
2
3
  import { createAuth, CreateAuthOptions } from "./auth";
3
4
  import { createAdmin, CreateAdminOptions } from "./admin";
4
5
  import { createCron, CreateCronOptions } from "./cron";
6
+ import { createBackups } from "./backups";
5
7
  import { createApiKeys, CreateApiKeysOptions } from "./api-keys";
6
8
  import { CollectionClient, createCollectionClient } from "./collection";
7
9
  import { createFunctionsClient } from "./functions";
8
10
  import { createStorage } from "./storage";
9
11
  import { ClientStorageSourceRegistry } from "./storage-registry";
10
12
  import { RebaseWebSocketClient } from "./websocket";
13
+ import { RebaseRealtimeChannel } from "./realtime-channel";
11
14
  import {
12
15
  DEFAULT_STORAGE_SOURCE_KEY,
16
+ InsertOf,
13
17
  RebaseClient,
14
- RebaseData,
18
+ RebaseSdkData,
19
+ RowOf,
15
20
  StorageSource,
16
21
  StorageSourceDefinition,
17
- StorageSourceRegistry
22
+ StorageSourceRegistry,
23
+ UpdateOf
18
24
  } from "@rebasepro/types";
19
25
  import { toSnakeCase } from "@rebasepro/utils";
20
26
 
21
- export * from "./transport";
22
- export * from "./auth";
23
- export * from "./admin";
24
- export * from "./cron";
25
- export * from "./api-keys";
26
- export * from "./collection";
27
- export * from "./query_builder";
28
- export * from "./websocket";
29
- export * from "./storage";
30
- export * from "./storage-registry";
31
- export * from "./reviver";
32
- export * from "./functions";
33
- export type { Entity, FindResponse } from "@rebasepro/types";
27
+ // ─── Public API surface ──────────────────────────────────────────────────────
28
+ //
29
+ // This barrel is the public API of `@rebasepro/client`. It is an explicit,
30
+ // curated list — NOT `export *` — so that adding an export to a module below
31
+ // does not silently republish it to app developers. Internal factories
32
+ // (`createTransport`, `createAuth`, `createCollectionClient`, …), the raw
33
+ // `Transport`, the storage-source registry impl, the JSON reviver, and the
34
+ // concrete `SDKQueryBuilder` class are intentionally NOT re-exported: they are
35
+ // implementation details of `createRebaseClient()` and have no external
36
+ // consumers. App developers reach them through the client instance, never by
37
+ // importing the factory. To add something to the public surface, add it here
38
+ // deliberately.
39
+
40
+ // Errors — the single error type thrown by SDK HTTP calls, plus the
41
+ // data-proxy's unknown-collection error.
42
+ export { RebaseApiError } from "./transport";
43
+ export { RebaseClientError } from "./errors";
44
+
45
+ // Query + collection types (annotate SDK results; construct via the fluent API).
46
+ export type { RebaseClientConfig, FindParams, FindResponse } from "./transport";
47
+ export type { CollectionClient } from "./collection";
48
+ export type { FindResult, SDKCollectionClient, SDKQueryBuilderInterface, PaginationMeta } from "@rebasepro/types";
49
+
50
+ // Logical-condition helpers for `.where(or(...), and(...))`.
51
+ export { QueryBuilder, or, and, cond } from "@rebasepro/common";
52
+
53
+ // Auth: session/token types, config, and the pluggable storage strategies.
54
+ export { createCookieStorage, createMemoryStorage } from "./auth";
55
+ export type { AuthConfig, AuthStorage, CookieStorageOptions, CreateAuthOptions } from "./auth";
56
+ export type { RebaseSession, AuthTokens, AuthChangeEvent, DeviceSession } from "@rebasepro/types";
57
+ /** @deprecated Import `User` / `AuthTokens` from `@rebasepro/types` instead. */
58
+ export type { RebaseUser, RebaseTokens } from "./auth";
59
+
60
+ // Control-plane client option/DTO types (the client instance exposes the impls).
61
+ export type { CreateAdminOptions } from "./admin";
62
+ export type { AdminUser } from "./admin";
63
+ export type { CreateCronOptions } from "./cron";
64
+ export { createBackups } from "./backups";
65
+ export type { CreateBackupsOptions } from "./backups";
66
+ export type {
67
+ ApiKeyMasked,
68
+ ApiKeyPermission,
69
+ ApiKeyWithSecret,
70
+ CreateApiKeyRequest,
71
+ CreateApiKeysOptions,
72
+ UpdateApiKeyRequest
73
+ } from "./api-keys";
74
+ export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
75
+
76
+ // Realtime: the WebSocket client class is internal to `createRebaseClient()`,
77
+ // but re-exported (see @internal on the class) because the `client-postgres`
78
+ // driver constructs it directly. Not a stable app-facing API.
79
+ export { RebaseWebSocketClient } from "./websocket";
80
+ export { RebaseRealtimeChannel } from "./realtime-channel";
81
+ export type { PresenceState, PresenceDiff, BroadcastEvent, ChannelTransport } from "./realtime-channel";
34
82
 
35
83
  export interface CreateRebaseClientOptions extends RebaseClientConfig {
36
84
  auth?: CreateAuthOptions;
@@ -62,17 +110,21 @@ type KebabToCamelCase<S extends string> =
62
110
  ? `${T}${Capitalize<KebabToCamelCase<U>>}`
63
111
  : S;
64
112
 
113
+ // Resolve a generated `Database` entry from a (kebab-case) slug literal,
114
+ // or `unknown` when the slug isn't in the schema — the extractors below
115
+ // then fall back to the open row / partial shapes.
116
+ type DBEntry<DB, S extends string> =
117
+ KebabToCamelCase<S> extends keyof DB ? DB[KebabToCamelCase<S>] : unknown;
118
+
65
119
  type TypedDataLayer<DB> = {
66
120
  collection<S extends string>(slug: S): CollectionClient<
67
- KebabToCamelCase<S> extends keyof DB
68
- ? (DB[KebabToCamelCase<S>] extends { Row: infer R extends Record<string, unknown> } ? R : Record<string, unknown>)
69
- : Record<string, unknown>
121
+ RowOf<DBEntry<DB, S>>,
122
+ InsertOf<DBEntry<DB, S>>,
123
+ UpdateOf<DBEntry<DB, S>>
70
124
  >;
71
125
  } & {
72
- [K in keyof DB]: CollectionClient<
73
- DB[K] extends { Row: infer R extends Record<string, unknown> } ? R : Record<string, unknown>
74
- >;
75
- } & RebaseData;
126
+ [K in keyof DB]: CollectionClient<RowOf<DB[K]>, InsertOf<DB[K]>, UpdateOf<DB[K]>>;
127
+ } & RebaseSdkData;
76
128
 
77
129
  /**
78
130
  * The return type of `createRebaseClient<DB>()`.
@@ -81,7 +133,7 @@ type TypedDataLayer<DB> = {
81
133
  * capabilities populated and the `data` layer narrowed to provide
82
134
  * typed collection accessors when a `DB` schema generic is supplied.
83
135
  */
84
- export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<RebaseClient, "data"> & {
136
+ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<RebaseClient<DB>, "data" | "email"> & {
85
137
  setToken: (token: string | null) => void;
86
138
  setAuthTokenGetter: (getter: () => Promise<string | null>) => void;
87
139
  setOnUnauthorized: (handler: () => Promise<boolean>) => void;
@@ -89,14 +141,24 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
89
141
  auth: ReturnType<typeof createAuth>;
90
142
  admin: ReturnType<typeof createAdmin>;
91
143
  cron: ReturnType<typeof createCron>;
144
+ backups: ReturnType<typeof createBackups>;
92
145
  apiKeys: ReturnType<typeof createApiKeys>;
93
146
  functions: ReturnType<typeof createFunctionsClient>;
94
147
  ws?: RebaseWebSocketClient;
148
+ /**
149
+ * Release the realtime socket and its reconnect timer.
150
+ *
151
+ * An open socket keeps the Node event loop alive, so a script that does not
152
+ * call this will not exit on its own. Safe when realtime was never started
153
+ * (`realtime: false`), and safe to call twice.
154
+ */
155
+ close: () => void;
95
156
  storage: StorageSource;
96
157
  storageRegistry: StorageSourceRegistry;
97
158
  createStorageSource: (storageId: string) => StorageSource;
98
159
  fetchStorageSources: () => Promise<StorageSourceDefinition[]>;
99
160
  call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
161
+ collection: <M extends Record<string, unknown> = Record<string, unknown>>(slug: string) => CollectionClient<M>;
100
162
  data: TypedDataLayer<DB>;
101
163
  };
102
164
 
@@ -141,6 +203,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
141
203
  const auth = createAuth(transport, options.auth);
142
204
  const admin = createAdmin(transport, options.admin);
143
205
  const cron = createCron(transport, options.cron);
206
+ const backups = createBackups(transport);
144
207
  const apiKeys = createApiKeys(transport, options.apiKeys);
145
208
  const storage = createStorage(transport);
146
209
  const functions = createFunctionsClient(transport);
@@ -187,9 +250,17 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
187
250
  return storageSourcesPromise;
188
251
  };
189
252
 
190
- const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
253
+ // Opting out has to happen before the URL is derived: `deriveWebSocketUrl`
254
+ // always produces one, so a truthy check alone can never leave the socket
255
+ // closed.
256
+ const realtimeEnabled = options.realtime !== false;
257
+ const resolvedWsUrl = realtimeEnabled
258
+ ? (options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl))
259
+ : undefined;
191
260
 
192
261
  let ws: RebaseWebSocketClient | undefined;
262
+ /** One channel object per name — see `realtime.channel`. */
263
+ const realtimeChannels = new Map<string, RebaseRealtimeChannel>();
193
264
  if (resolvedWsUrl) {
194
265
  const wsOnUnauthorized = options.onUnauthorized || (async () => {
195
266
  try {
@@ -241,7 +312,62 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
241
312
  });
242
313
  }
243
314
 
315
+ /**
316
+ * Suggest the closest known collection key for a mistyped accessor.
317
+ * Uses edit-distance-1 and prefix matching — no external dependency.
318
+ */
319
+ function suggestCollection(prop: string, knownKeys: string[]): string | undefined {
320
+ // Prefix match (e.g. "prod" → "products")
321
+ const prefixMatch = knownKeys.find(k => k.startsWith(prop) || prop.startsWith(k));
322
+ if (prefixMatch) return prefixMatch;
323
+
324
+ // Edit-distance-1: deletions, insertions, substitutions, transpositions
325
+ for (const key of knownKeys) {
326
+ if (Math.abs(key.length - prop.length) > 1) continue;
327
+ let diffs = 0;
328
+ const longer = key.length >= prop.length ? key : prop;
329
+ const shorter = key.length >= prop.length ? prop : key;
330
+ if (longer.length === shorter.length) {
331
+ // Same length: allow 1 substitution or 1 transposition
332
+ for (let i = 0; i < longer.length; i++) {
333
+ if (longer[i] !== shorter[i]) {
334
+ // Check for transposition
335
+ if (
336
+ i + 1 < longer.length &&
337
+ longer[i] === shorter[i + 1] &&
338
+ longer[i + 1] === shorter[i]
339
+ ) {
340
+ diffs++;
341
+ i++; // skip next char (already accounted for)
342
+ if (diffs > 1) break;
343
+ continue;
344
+ }
345
+ diffs++;
346
+ }
347
+ if (diffs > 1) break;
348
+ }
349
+ } else {
350
+ // Length differs by 1: allow 1 insertion/deletion
351
+ let li = 0;
352
+ let si = 0;
353
+ while (li < longer.length) {
354
+ if (si < shorter.length && longer[li] === shorter[si]) {
355
+ si++;
356
+ } else {
357
+ diffs++;
358
+ }
359
+ li++;
360
+ if (diffs > 1) break;
361
+ }
362
+ }
363
+ if (diffs <= 1) return key;
364
+ }
365
+
366
+ return undefined;
367
+ }
368
+
244
369
  const collectionClients = new Map<string, CollectionClient<Record<string, unknown>>>();
370
+ let untypedWarned = false;
245
371
 
246
372
  function collection(slug: string): CollectionClient<Record<string, unknown>> {
247
373
  if (!collectionClients.has(slug)) {
@@ -259,11 +385,30 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
259
385
  }
260
386
  if (typeof prop === "symbol") return undefined;
261
387
  if (typeof prop === "string" && prop !== "then" && prop !== "toJSON" && prop !== "$$typeof") {
262
- if (options.collections && prop in options.collections) {
263
- return collection(options.collections[prop]);
388
+ if (options.collections) {
389
+ if (prop in options.collections) {
390
+ return collection(options.collections[prop]);
391
+ }
392
+ // Strict mode: the developer supplied a typed dictionary,
393
+ // so we know the full set of valid accessors.
394
+ const knownKeys = Object.keys(options.collections);
395
+ const suggestion = suggestCollection(prop, knownKeys);
396
+ const knownList = knownKeys.join(", ");
397
+ let msg = `Unknown collection accessor "${prop}". Known collections: ${knownList}.`;
398
+ if (suggestion) msg += ` Did you mean "${suggestion}"?`;
399
+ msg += ` Use data.collection("<slug>") for dynamic slugs.`;
400
+ throw new RebaseClientError(msg);
264
401
  }
265
- // Convert camelCase property names to snake_case slugs.
402
+ // Untyped fallback: convert camelCase property names to snake_case slugs.
266
403
  // e.g. `companyMembers` → `company_members`
404
+ if (!untypedWarned) {
405
+ untypedWarned = true;
406
+ console.warn(
407
+ `[Rebase] Untyped data access detected (client.data.${prop}). ` +
408
+ `Collection names are resolved via snake_case conversion, which may cause silent 404s at request time. ` +
409
+ `Pass a \`collections\` dictionary to createRebaseClient() or use the generated SDK for type-safe access.`
410
+ );
411
+ }
267
412
  const slug = toSnakeCase(prop);
268
413
  return collection(slug);
269
414
  }
@@ -275,6 +420,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
275
420
  auth,
276
421
  admin,
277
422
  cron,
423
+ backups,
278
424
  apiKeys,
279
425
  functions,
280
426
  storage,
@@ -282,6 +428,44 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
282
428
  createStorageSource,
283
429
  fetchStorageSources,
284
430
  ws,
431
+ realtime: {
432
+ /**
433
+ * Join a broadcast/presence channel.
434
+ *
435
+ * Repeated calls with the same name return the same channel, so
436
+ * separate components can attach handlers without each opening its
437
+ * own membership — and `leave()` from one would otherwise silently
438
+ * cut off the others.
439
+ */
440
+ channel: (name: string): RebaseRealtimeChannel => {
441
+ if (!ws) {
442
+ throw new RebaseClientError(
443
+ "Realtime is disabled on this client (realtime: false), so channels are unavailable."
444
+ );
445
+ }
446
+ let existing = realtimeChannels.get(name);
447
+ if (!existing) {
448
+ existing = new RebaseRealtimeChannel(name, ws);
449
+ realtimeChannels.set(name, existing);
450
+ }
451
+ return existing;
452
+ }
453
+ },
454
+ /**
455
+ * Release the realtime socket and its reconnect timer.
456
+ *
457
+ * Until this returns, the open socket keeps the Node event loop alive
458
+ * and the process will not exit on its own. Safe to call when realtime
459
+ * was never started, and safe to call twice.
460
+ */
461
+ close: () => {
462
+ // Channels hold presence heartbeat timers, which would otherwise
463
+ // keep firing (and keep a Node process alive) after the socket
464
+ // they publish over is gone.
465
+ for (const channel of realtimeChannels.values()) void channel.leave();
466
+ realtimeChannels.clear();
467
+ ws?.disconnect();
468
+ },
285
469
  setToken: transport.setToken,
286
470
  setAuthTokenGetter: transport.setAuthTokenGetter,
287
471
  setOnUnauthorized: transport.setOnUnauthorized,
@@ -297,7 +481,6 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
297
481
  return res.data ?? (res as T);
298
482
  },
299
483
  data: dataProxy,
300
- email: undefined
301
484
  } as unknown as CreateRebaseClientResult<DB>;
302
485
 
303
486
  return target;
@@ -0,0 +1,241 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals";
2
+ /**
3
+ * The channel surface, and specifically the two protocol details it exists to
4
+ * hide: the roster is not pushed on join, and presence expires after 30s.
5
+ */
6
+ import {
7
+ RebaseRealtimeChannel,
8
+ type ChannelTransport,
9
+ type PresenceState
10
+ } from "./realtime-channel";
11
+
12
+ /** Stand-in socket that records what was sent and can push frames back. */
13
+ function fakeTransport() {
14
+ const sent: Record<string, unknown>[] = [];
15
+ let channelHandler: ((m: Record<string, unknown>) => void) | undefined;
16
+ let reconnectHandler: (() => void) | undefined;
17
+
18
+ const transport: ChannelTransport = {
19
+ sendMessage: async (message) => { sent.push(message); return undefined; },
20
+ onChannelMessage: (_channel, handler) => {
21
+ channelHandler = handler;
22
+ return () => { channelHandler = undefined; };
23
+ },
24
+ onReconnect: (handler) => {
25
+ reconnectHandler = handler;
26
+ return () => { reconnectHandler = undefined; };
27
+ }
28
+ };
29
+
30
+ return {
31
+ transport,
32
+ sent,
33
+ types: () => sent.map((m) => m.type),
34
+ push: (message: Record<string, unknown>) => channelHandler?.(message),
35
+ reconnect: () => reconnectHandler?.(),
36
+ hasChannelHandler: () => channelHandler !== undefined
37
+ };
38
+ }
39
+
40
+ describe("RebaseRealtimeChannel", () => {
41
+ let fake: ReturnType<typeof fakeTransport>;
42
+ let channel: RebaseRealtimeChannel;
43
+
44
+ beforeEach(() => {
45
+ jest.useFakeTimers();
46
+ fake = fakeTransport();
47
+ channel = new RebaseRealtimeChannel("doc:42", fake.transport);
48
+ });
49
+
50
+ afterEach(() => {
51
+ jest.useRealTimers();
52
+ });
53
+
54
+ describe("joining", () => {
55
+ it("asks for the roster, because joining does not push it", async () => {
56
+ // A joining client's presence_diff contains only itself, so
57
+ // without this request the channel believes it is alone until
58
+ // somebody else happens to move.
59
+ await channel.join();
60
+
61
+ expect(fake.types()).toEqual(["join_channel", "presence_state"]);
62
+ });
63
+
64
+ it("joins only once across repeated calls", async () => {
65
+ await channel.join();
66
+ await channel.join();
67
+ await channel.broadcast("ping", {});
68
+
69
+ expect(fake.types().filter((t) => t === "join_channel")).toHaveLength(1);
70
+ });
71
+ });
72
+
73
+ describe("presence", () => {
74
+ it("reports the roster from a presence_state frame", async () => {
75
+ const seen: PresenceState[] = [];
76
+ channel.onPresence((state) => seen.push(state));
77
+ await channel.join();
78
+
79
+ fake.push({ type: "presence_state", channel: "doc:42", presences: { a: { name: "Ana" } } });
80
+
81
+ expect(seen.at(-1)).toEqual({ a: { name: "Ana" } });
82
+ });
83
+
84
+ it("maintains the roster across diffs so callers never reassemble it", async () => {
85
+ const seen: PresenceState[] = [];
86
+ channel.onPresence((state) => seen.push(state));
87
+ await channel.join();
88
+
89
+ fake.push({ type: "presence_state", channel: "doc:42", presences: { a: { name: "Ana" } } });
90
+ fake.push({ type: "presence_diff", channel: "doc:42", joins: { b: { name: "Bo" } }, leaves: {} });
91
+
92
+ expect(seen.at(-1)).toEqual({ a: { name: "Ana" }, b: { name: "Bo" } });
93
+
94
+ fake.push({ type: "presence_diff", channel: "doc:42", joins: {}, leaves: { a: { name: "Ana" } } });
95
+
96
+ expect(seen.at(-1)).toEqual({ b: { name: "Bo" } });
97
+ });
98
+
99
+ it("passes the diff alongside the full state", async () => {
100
+ let lastDiff: unknown;
101
+ channel.onPresence((_state, diff) => { lastDiff = diff; });
102
+ await channel.join();
103
+
104
+ fake.push({ type: "presence_diff", channel: "doc:42", joins: { b: { x: 1 } }, leaves: {} });
105
+
106
+ expect(lastDiff).toEqual({ joins: { b: { x: 1 } }, leaves: {} });
107
+ });
108
+
109
+ it("re-sends presence on a timer, because it expires after 30s", async () => {
110
+ // Server-side PRESENCE_TIMEOUT_MS is 30s. A client that tracks once
111
+ // and goes quiet vanishes from everyone else's roster while still
112
+ // sitting in the document.
113
+ await channel.track({ cursor: 1 });
114
+ expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(1);
115
+
116
+ await jest.advanceTimersByTimeAsync(21_000);
117
+ expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(2);
118
+
119
+ await jest.advanceTimersByTimeAsync(21_000);
120
+ expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(3);
121
+ });
122
+
123
+ it("heartbeats within the expiry window", async () => {
124
+ await channel.track({ cursor: 1 });
125
+ const before = fake.types().filter((t) => t === "presence_track").length;
126
+
127
+ // One beat must land comfortably before 30s, and with enough margin
128
+ // that a single dropped frame is not a disappearance.
129
+ await jest.advanceTimersByTimeAsync(25_000);
130
+
131
+ expect(fake.types().filter((t) => t === "presence_track").length).toBeGreaterThan(before);
132
+ });
133
+
134
+ it("heartbeats the latest state after a re-track", async () => {
135
+ await channel.track({ cursor: 1 });
136
+ await channel.track({ cursor: 99 });
137
+
138
+ await jest.advanceTimersByTimeAsync(21_000);
139
+
140
+ const beats = fake.sent.filter((m) => m.type === "presence_track");
141
+ expect(beats.at(-1)).toMatchObject({ state: { cursor: 99 } });
142
+ });
143
+
144
+ it("stops the heartbeat on untrack", async () => {
145
+ await channel.track({ cursor: 1 });
146
+ await channel.untrack();
147
+ const after = fake.types().filter((t) => t === "presence_track").length;
148
+
149
+ await jest.advanceTimersByTimeAsync(60_000);
150
+
151
+ expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(after);
152
+ });
153
+ });
154
+
155
+ describe("broadcast", () => {
156
+ it("delivers events to a handler", async () => {
157
+ const received: unknown[] = [];
158
+ channel.onBroadcast((e) => received.push(e));
159
+ await channel.join();
160
+
161
+ fake.push({ type: "broadcast", channel: "doc:42", event: "edit", payload: { at: 3 } });
162
+
163
+ expect(received).toEqual([{ event: "edit", payload: { at: 3 } }]);
164
+ });
165
+
166
+ it("filters by event name when one is given", async () => {
167
+ const received: unknown[] = [];
168
+ channel.onBroadcast("edit", (payload) => received.push(payload));
169
+ await channel.join();
170
+
171
+ fake.push({ type: "broadcast", channel: "doc:42", event: "other", payload: { no: true } });
172
+ fake.push({ type: "broadcast", channel: "doc:42", event: "edit", payload: { yes: true } });
173
+
174
+ expect(received).toEqual([{ yes: true }]);
175
+ });
176
+
177
+ it("stops delivering after the returned unsubscribe", async () => {
178
+ const received: unknown[] = [];
179
+ const off = channel.onBroadcast((e) => received.push(e));
180
+ await channel.join();
181
+ off();
182
+
183
+ fake.push({ type: "broadcast", channel: "doc:42", event: "edit", payload: {} });
184
+
185
+ expect(received).toEqual([]);
186
+ });
187
+ });
188
+
189
+ describe("reconnect", () => {
190
+ it("re-joins, re-requests the roster and re-tracks", async () => {
191
+ // A reconnect drops server-side membership and presence. Nothing
192
+ // else notices: the socket returns and the client just stops
193
+ // receiving.
194
+ await channel.track({ cursor: 7 });
195
+ fake.sent.length = 0;
196
+
197
+ fake.reconnect();
198
+ await jest.advanceTimersByTimeAsync(0);
199
+
200
+ expect(fake.types()).toEqual(["join_channel", "presence_state", "presence_track"]);
201
+ expect(fake.sent.at(-1)).toMatchObject({ state: { cursor: 7 } });
202
+ });
203
+
204
+ it("does not re-track when the client never tracked", async () => {
205
+ await channel.join();
206
+ fake.sent.length = 0;
207
+
208
+ fake.reconnect();
209
+ await jest.advanceTimersByTimeAsync(0);
210
+
211
+ expect(fake.types()).toEqual(["join_channel", "presence_state"]);
212
+ });
213
+ });
214
+
215
+ describe("leave", () => {
216
+ it("releases the socket handler, the timer and the listeners", async () => {
217
+ const received: unknown[] = [];
218
+ channel.onBroadcast((e) => received.push(e));
219
+ await channel.track({ cursor: 1 });
220
+
221
+ await channel.leave();
222
+
223
+ expect(fake.types().at(-1)).toBe("leave_channel");
224
+ expect(fake.hasChannelHandler()).toBe(false);
225
+
226
+ const beats = fake.types().filter((t) => t === "presence_track").length;
227
+ await jest.advanceTimersByTimeAsync(60_000);
228
+ expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(beats);
229
+ });
230
+
231
+ it("can rejoin after leaving", async () => {
232
+ await channel.join();
233
+ await channel.leave();
234
+ fake.sent.length = 0;
235
+
236
+ await channel.join();
237
+
238
+ expect(fake.types()).toEqual(["join_channel", "presence_state"]);
239
+ });
240
+ });
241
+ });