@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 +5 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.es.js +261 -10
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +142 -1
- package/dist/websocket.d.ts +1 -0
- package/package.json +4 -4
- package/src/auth.ts +32 -0
- package/src/index.ts +23 -5
- package/src/realtime-channel.test.ts +206 -0
- package/src/realtime-channel.ts +294 -4
- package/src/transport-baseurl.test.ts +53 -0
- package/src/transport.ts +25 -3
- package/src/websocket.ts +13 -2
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/websocket.d.ts
CHANGED
|
@@ -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.
|
|
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/
|
|
33
|
-
"@rebasepro/types": "0.
|
|
34
|
-
"@rebasepro/
|
|
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 {
|
|
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", () => {
|