@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/README.md +11 -11
- package/dist/admin.d.ts +1 -0
- package/dist/auth.d.ts +35 -38
- package/dist/backups.d.ts +13 -0
- package/dist/collection.d.ts +9 -13
- package/dist/errors.d.ts +9 -0
- package/dist/index.d.ts +38 -22
- package/dist/index.es.js +985 -271
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +89 -0
- package/dist/sdk_query_builder.d.ts +63 -0
- package/dist/transport.d.ts +36 -6
- package/dist/websocket.d.ts +92 -22
- package/package.json +8 -9
- package/src/admin.ts +1 -1
- package/src/api-keys.ts +1 -1
- package/src/auth.ts +188 -64
- package/src/backups.ts +40 -0
- package/src/collection.test.ts +92 -3
- package/src/collection.ts +88 -75
- package/src/data-proxy.test.ts +167 -0
- package/src/errors.ts +9 -0
- package/src/index.ts +211 -28
- package/src/realtime-channel.test.ts +241 -0
- package/src/realtime-channel.ts +238 -0
- package/src/realtime-optout.test.ts +119 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/reviver.ts +2 -2
- package/src/sdk_query_builder.ts +141 -0
- package/src/storage.ts +64 -32
- package/src/transport.ts +59 -25
- package/src/websocket.ts +522 -193
- package/dist/collection.test.d.ts +0 -1
- package/dist/cron.test.d.ts +0 -1
- package/dist/index.umd.js +0 -2215
- package/dist/index.umd.js.map +0 -1
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
export
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
263
|
-
|
|
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
|
-
//
|
|
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
|
+
});
|