@voidhash/mimic 0.0.1-alpha.1
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 +17 -0
- package/package.json +33 -0
- package/src/Document.ts +256 -0
- package/src/FractionalIndex.ts +1249 -0
- package/src/Operation.ts +59 -0
- package/src/OperationDefinition.ts +23 -0
- package/src/OperationPath.ts +197 -0
- package/src/Presence.ts +142 -0
- package/src/Primitive.ts +32 -0
- package/src/Proxy.ts +8 -0
- package/src/ProxyEnvironment.ts +52 -0
- package/src/Transaction.ts +72 -0
- package/src/Transform.ts +13 -0
- package/src/client/ClientDocument.ts +1163 -0
- package/src/client/Rebase.ts +309 -0
- package/src/client/StateMonitor.ts +307 -0
- package/src/client/Transport.ts +318 -0
- package/src/client/WebSocketTransport.ts +572 -0
- package/src/client/errors.ts +145 -0
- package/src/client/index.ts +61 -0
- package/src/index.ts +12 -0
- package/src/primitives/Array.ts +457 -0
- package/src/primitives/Boolean.ts +128 -0
- package/src/primitives/Lazy.ts +89 -0
- package/src/primitives/Literal.ts +128 -0
- package/src/primitives/Number.ts +169 -0
- package/src/primitives/String.ts +189 -0
- package/src/primitives/Struct.ts +348 -0
- package/src/primitives/Tree.ts +1120 -0
- package/src/primitives/TreeNode.ts +113 -0
- package/src/primitives/Union.ts +329 -0
- package/src/primitives/shared.ts +122 -0
- package/src/server/ServerDocument.ts +267 -0
- package/src/server/errors.ts +90 -0
- package/src/server/index.ts +40 -0
- package/tests/Document.test.ts +556 -0
- package/tests/FractionalIndex.test.ts +377 -0
- package/tests/OperationPath.test.ts +151 -0
- package/tests/Presence.test.ts +321 -0
- package/tests/Primitive.test.ts +381 -0
- package/tests/client/ClientDocument.test.ts +1398 -0
- package/tests/client/WebSocketTransport.test.ts +992 -0
- package/tests/primitives/Array.test.ts +418 -0
- package/tests/primitives/Boolean.test.ts +126 -0
- package/tests/primitives/Lazy.test.ts +143 -0
- package/tests/primitives/Literal.test.ts +122 -0
- package/tests/primitives/Number.test.ts +133 -0
- package/tests/primitives/String.test.ts +128 -0
- package/tests/primitives/Struct.test.ts +311 -0
- package/tests/primitives/Tree.test.ts +467 -0
- package/tests/primitives/TreeNode.test.ts +50 -0
- package/tests/primitives/Union.test.ts +210 -0
- package/tests/server/ServerDocument.test.ts +528 -0
- package/tsconfig.build.json +24 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +18 -0
- package/vitest.mts +11 -0
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import * as Primitive from "../../src/Primitive";
|
|
4
|
+
import * as Transaction from "../../src/Transaction";
|
|
5
|
+
import * as OperationPath from "../../src/OperationPath";
|
|
6
|
+
import * as ClientDocument from "../../src/client/ClientDocument";
|
|
7
|
+
import type * as Transport from "../../src/client/Transport";
|
|
8
|
+
import * as Rebase from "../../src/client/Rebase";
|
|
9
|
+
import * as StateMonitor from "../../src/client/StateMonitor";
|
|
10
|
+
import * as Presence from "../../src/Presence";
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Mock Transport
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
interface MockTransport extends Transport.Transport {
|
|
17
|
+
sentTransactions: Transaction.Transaction[];
|
|
18
|
+
handlers: Set<(message: Transport.ServerMessage) => void>;
|
|
19
|
+
simulateServerMessage: (message: Transport.ServerMessage) => void;
|
|
20
|
+
snapshotRequested: boolean;
|
|
21
|
+
autoSendSnapshot?: { state: unknown; version: number };
|
|
22
|
+
// Presence tracking
|
|
23
|
+
presenceSetCalls: unknown[];
|
|
24
|
+
presenceClearCalls: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const createMockTransport = (options?: {
|
|
28
|
+
autoSendSnapshot?: { state: unknown; version: number };
|
|
29
|
+
}): MockTransport => {
|
|
30
|
+
const handlers = new Set<(message: Transport.ServerMessage) => void>();
|
|
31
|
+
const sentTransactions: Transaction.Transaction[] = [];
|
|
32
|
+
let _connected = false;
|
|
33
|
+
let snapshotRequested = false;
|
|
34
|
+
const presenceSetCalls: unknown[] = [];
|
|
35
|
+
let presenceClearCalls = 0;
|
|
36
|
+
|
|
37
|
+
const transport: MockTransport = {
|
|
38
|
+
sentTransactions,
|
|
39
|
+
handlers,
|
|
40
|
+
snapshotRequested,
|
|
41
|
+
autoSendSnapshot: options?.autoSendSnapshot,
|
|
42
|
+
get presenceSetCalls() { return presenceSetCalls; },
|
|
43
|
+
get presenceClearCalls() { return presenceClearCalls; },
|
|
44
|
+
|
|
45
|
+
send: (transaction) => {
|
|
46
|
+
sentTransactions.push(transaction);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
requestSnapshot: () => {
|
|
50
|
+
snapshotRequested = true;
|
|
51
|
+
// If autoSendSnapshot is configured, send it immediately
|
|
52
|
+
if (transport.autoSendSnapshot) {
|
|
53
|
+
// Use setTimeout to simulate async behavior
|
|
54
|
+
setTimeout(() => {
|
|
55
|
+
transport.simulateServerMessage({
|
|
56
|
+
type: "snapshot",
|
|
57
|
+
state: transport.autoSendSnapshot!.state,
|
|
58
|
+
version: transport.autoSendSnapshot!.version,
|
|
59
|
+
});
|
|
60
|
+
}, 0);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
subscribe: (handler) => {
|
|
65
|
+
handlers.add(handler);
|
|
66
|
+
return () => handlers.delete(handler);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
connect: async () => {
|
|
70
|
+
_connected = true;
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
disconnect: () => {
|
|
74
|
+
_connected = false;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
isConnected: () => _connected,
|
|
78
|
+
|
|
79
|
+
sendPresenceSet: (data: unknown) => {
|
|
80
|
+
presenceSetCalls.push(data);
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
sendPresenceClear: () => {
|
|
84
|
+
presenceClearCalls++;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
simulateServerMessage: (message) => {
|
|
88
|
+
for (const handler of handlers) {
|
|
89
|
+
handler(message);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return transport;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// =============================================================================
|
|
98
|
+
// Test Schema
|
|
99
|
+
// =============================================================================
|
|
100
|
+
|
|
101
|
+
const TestSchema = Primitive.Struct({
|
|
102
|
+
title: Primitive.String().default(""),
|
|
103
|
+
count: Primitive.Number().default(0),
|
|
104
|
+
items: Primitive.Array(
|
|
105
|
+
Primitive.Struct({
|
|
106
|
+
name: Primitive.String(),
|
|
107
|
+
done: Primitive.Boolean().default(false),
|
|
108
|
+
})
|
|
109
|
+
),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
type TestState = Primitive.InferState<typeof TestSchema>;
|
|
113
|
+
|
|
114
|
+
// =============================================================================
|
|
115
|
+
// ClientDocument Tests
|
|
116
|
+
// =============================================================================
|
|
117
|
+
|
|
118
|
+
describe("ClientDocument", () => {
|
|
119
|
+
let transport: ReturnType<typeof createMockTransport>;
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
transport = createMockTransport();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("make", () => {
|
|
126
|
+
it("should create a client document with initial state", async () => {
|
|
127
|
+
const initialState: TestState = {
|
|
128
|
+
title: "Test",
|
|
129
|
+
count: 5,
|
|
130
|
+
items: [],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const client = ClientDocument.make({
|
|
134
|
+
schema: TestSchema,
|
|
135
|
+
transport,
|
|
136
|
+
initialState,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await client.connect();
|
|
140
|
+
|
|
141
|
+
expect(client.get()).toEqual(initialState);
|
|
142
|
+
expect(client.getServerState()).toEqual(initialState);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should create a client document without initial state and wait for snapshot", async () => {
|
|
146
|
+
// Create transport that auto-sends snapshot
|
|
147
|
+
const transportWithSnapshot = createMockTransport({
|
|
148
|
+
autoSendSnapshot: { state: { title: "From Server", count: 42, items: [] }, version: 1 },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const client = ClientDocument.make({
|
|
152
|
+
schema: TestSchema,
|
|
153
|
+
transport: transportWithSnapshot,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(client.isReady()).toBe(false);
|
|
157
|
+
|
|
158
|
+
await client.connect();
|
|
159
|
+
|
|
160
|
+
// Should have state from server snapshot
|
|
161
|
+
expect(client.isReady()).toBe(true);
|
|
162
|
+
expect(client.get()?.title).toBe("From Server");
|
|
163
|
+
expect(client.get()?.count).toBe(42);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("transaction", () => {
|
|
168
|
+
it("should apply changes optimistically", async () => {
|
|
169
|
+
const client = ClientDocument.make({
|
|
170
|
+
schema: TestSchema,
|
|
171
|
+
transport,
|
|
172
|
+
initialState: { title: "", count: 0, items: [] },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await client.connect();
|
|
176
|
+
|
|
177
|
+
client.transaction((root) => {
|
|
178
|
+
root.title.set("New Title");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(client.get()?.title).toBe("New Title");
|
|
182
|
+
expect(client.hasPendingChanges()).toBe(true);
|
|
183
|
+
expect(client.getPendingCount()).toBe(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("should send transaction to server", async () => {
|
|
187
|
+
const client = ClientDocument.make({
|
|
188
|
+
schema: TestSchema,
|
|
189
|
+
transport,
|
|
190
|
+
initialState: { title: "", count: 0, items: [] },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await client.connect();
|
|
194
|
+
|
|
195
|
+
client.transaction((root) => {
|
|
196
|
+
root.count.set(42);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(transport.sentTransactions.length).toBe(1);
|
|
200
|
+
expect(transport.sentTransactions[0]!.ops.length).toBe(1);
|
|
201
|
+
expect(transport.sentTransactions[0]!.ops[0]!.kind).toBe("number.set");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should throw when not connected", () => {
|
|
205
|
+
const client = ClientDocument.make({
|
|
206
|
+
schema: TestSchema,
|
|
207
|
+
transport,
|
|
208
|
+
initialState: { title: "", count: 0, items: [] },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(() => {
|
|
212
|
+
client.transaction((root) => {
|
|
213
|
+
root.title.set("Test");
|
|
214
|
+
});
|
|
215
|
+
}).toThrow("Transport is not connected");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("server transaction handling", () => {
|
|
220
|
+
it("should confirm our pending transaction when server broadcasts it", async () => {
|
|
221
|
+
const client = ClientDocument.make({
|
|
222
|
+
schema: TestSchema,
|
|
223
|
+
transport,
|
|
224
|
+
initialState: { title: "", count: 0, items: [] },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await client.connect();
|
|
228
|
+
|
|
229
|
+
client.transaction((root) => {
|
|
230
|
+
root.title.set("My Change");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const sentTx = transport.sentTransactions[0]!;
|
|
234
|
+
expect(client.hasPendingChanges()).toBe(true);
|
|
235
|
+
|
|
236
|
+
// Server broadcasts our transaction
|
|
237
|
+
transport.simulateServerMessage({
|
|
238
|
+
type: "transaction",
|
|
239
|
+
transaction: sentTx,
|
|
240
|
+
version: 1,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(client.hasPendingChanges()).toBe(false);
|
|
244
|
+
expect(client.get()?.title).toBe("My Change");
|
|
245
|
+
expect(client.getServerState()?.title).toBe("My Change");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("should rebase pending changes when server transaction arrives", async () => {
|
|
249
|
+
const client = ClientDocument.make({
|
|
250
|
+
schema: TestSchema,
|
|
251
|
+
transport,
|
|
252
|
+
initialState: { title: "Original", count: 0, items: [] },
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await client.connect();
|
|
256
|
+
|
|
257
|
+
// Make a local change to title
|
|
258
|
+
client.transaction((root) => {
|
|
259
|
+
root.title.set("Client Title");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(client.get()?.title).toBe("Client Title");
|
|
263
|
+
|
|
264
|
+
// Server sends a different transaction (e.g., count change)
|
|
265
|
+
const serverTx = Transaction.make([
|
|
266
|
+
{
|
|
267
|
+
kind: "number.set",
|
|
268
|
+
path: { _tag: "OperationPath" as const, toTokens: () => ["count"], concat: () => ({} as any), append: () => ({} as any), pop: () => ({} as any), shift: () => ({} as any) },
|
|
269
|
+
payload: 100,
|
|
270
|
+
},
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
transport.simulateServerMessage({
|
|
274
|
+
type: "transaction",
|
|
275
|
+
transaction: serverTx,
|
|
276
|
+
version: 1,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Our pending change should still be there
|
|
280
|
+
expect(client.hasPendingChanges()).toBe(true);
|
|
281
|
+
expect(client.get()?.title).toBe("Client Title");
|
|
282
|
+
expect(client.getServerState()?.count).toBe(100);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("rejection handling", () => {
|
|
287
|
+
it("should handle transaction rejection and notify callback", async () => {
|
|
288
|
+
let rejectedTx: Transaction.Transaction | null = null;
|
|
289
|
+
let rejectionReason: string | null = null;
|
|
290
|
+
|
|
291
|
+
const client = ClientDocument.make({
|
|
292
|
+
schema: TestSchema,
|
|
293
|
+
transport,
|
|
294
|
+
initialState: { title: "Original", count: 0, items: [] },
|
|
295
|
+
onRejection: (tx, reason) => {
|
|
296
|
+
rejectedTx = tx;
|
|
297
|
+
rejectionReason = reason;
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await client.connect();
|
|
302
|
+
|
|
303
|
+
client.transaction((root) => {
|
|
304
|
+
root.title.set("Rejected Change");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const sentTx = transport.sentTransactions[0]!;
|
|
308
|
+
|
|
309
|
+
// Server rejects the transaction
|
|
310
|
+
transport.simulateServerMessage({
|
|
311
|
+
type: "error",
|
|
312
|
+
transactionId: sentTx.id,
|
|
313
|
+
reason: "Invalid operation",
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(client.hasPendingChanges()).toBe(false);
|
|
317
|
+
expect(client.get()?.title).toBe("Original"); // Rolled back
|
|
318
|
+
expect((rejectedTx as unknown as Transaction.Transaction | null)?.id).toBe(sentTx.id);
|
|
319
|
+
expect(rejectionReason).toBe("Invalid operation");
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("snapshot handling", () => {
|
|
324
|
+
it("should reset state when receiving snapshot", async () => {
|
|
325
|
+
let rejectionCount = 0;
|
|
326
|
+
|
|
327
|
+
const client = ClientDocument.make({
|
|
328
|
+
schema: TestSchema,
|
|
329
|
+
transport,
|
|
330
|
+
initialState: { title: "Old", count: 0, items: [] },
|
|
331
|
+
onRejection: () => {
|
|
332
|
+
rejectionCount++;
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await client.connect();
|
|
337
|
+
|
|
338
|
+
// Make some pending changes
|
|
339
|
+
client.transaction((root) => {
|
|
340
|
+
root.title.set("Pending 1");
|
|
341
|
+
});
|
|
342
|
+
client.transaction((root) => {
|
|
343
|
+
root.count.set(50);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(client.getPendingCount()).toBe(2);
|
|
347
|
+
|
|
348
|
+
// Server sends snapshot
|
|
349
|
+
transport.simulateServerMessage({
|
|
350
|
+
type: "snapshot",
|
|
351
|
+
state: { title: "Server Title", count: 100, items: [] },
|
|
352
|
+
version: 10,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
expect(client.hasPendingChanges()).toBe(false);
|
|
356
|
+
expect(client.get()?.title).toBe("Server Title");
|
|
357
|
+
expect(client.get()?.count).toBe(100);
|
|
358
|
+
expect(client.getServerVersion()).toBe(10);
|
|
359
|
+
expect(rejectionCount).toBe(2); // Both pending were rejected
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("connection management", () => {
|
|
364
|
+
it("should track connection status", async () => {
|
|
365
|
+
const client = ClientDocument.make({
|
|
366
|
+
schema: TestSchema,
|
|
367
|
+
transport,
|
|
368
|
+
initialState: { title: "", count: 0, items: [] },
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
expect(client.isConnected()).toBe(false);
|
|
372
|
+
|
|
373
|
+
await client.connect();
|
|
374
|
+
expect(client.isConnected()).toBe(true);
|
|
375
|
+
|
|
376
|
+
client.disconnect();
|
|
377
|
+
expect(client.isConnected()).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("initialization", () => {
|
|
382
|
+
it("should be ready immediately with initial state", async () => {
|
|
383
|
+
const client = ClientDocument.make({
|
|
384
|
+
schema: TestSchema,
|
|
385
|
+
transport,
|
|
386
|
+
initialState: { title: "Initial", count: 0, items: [] },
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
expect(client.isReady()).toBe(true);
|
|
390
|
+
await client.connect();
|
|
391
|
+
expect(client.isReady()).toBe(true);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("should buffer transactions during initialization", async () => {
|
|
395
|
+
let readyCalled = false;
|
|
396
|
+
|
|
397
|
+
// Create a transport that doesn't auto-send snapshot
|
|
398
|
+
const manualTransport = createMockTransport();
|
|
399
|
+
|
|
400
|
+
const client = ClientDocument.make({
|
|
401
|
+
schema: TestSchema,
|
|
402
|
+
transport: manualTransport,
|
|
403
|
+
onReady: () => {
|
|
404
|
+
readyCalled = true;
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// Start connecting (this will enter initializing state and request snapshot)
|
|
409
|
+
const connectPromise = client.connect();
|
|
410
|
+
|
|
411
|
+
// Wait a tick for the connection to start
|
|
412
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
413
|
+
|
|
414
|
+
// Simulate transactions arriving before snapshot
|
|
415
|
+
manualTransport.simulateServerMessage({
|
|
416
|
+
type: "transaction",
|
|
417
|
+
transaction: Transaction.make([
|
|
418
|
+
{
|
|
419
|
+
kind: "string.set" as const,
|
|
420
|
+
path: OperationPath.make("title"),
|
|
421
|
+
payload: "From TX v2",
|
|
422
|
+
},
|
|
423
|
+
]),
|
|
424
|
+
version: 2,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
manualTransport.simulateServerMessage({
|
|
428
|
+
type: "transaction",
|
|
429
|
+
transaction: Transaction.make([
|
|
430
|
+
{
|
|
431
|
+
kind: "number.set" as const,
|
|
432
|
+
path: OperationPath.make("count"),
|
|
433
|
+
payload: 100,
|
|
434
|
+
},
|
|
435
|
+
]),
|
|
436
|
+
version: 3,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Now send snapshot at version 1 (older than buffered transactions)
|
|
440
|
+
manualTransport.simulateServerMessage({
|
|
441
|
+
type: "snapshot",
|
|
442
|
+
state: { title: "Snapshot Title", count: 0, items: [] },
|
|
443
|
+
version: 1,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Wait for connect to complete
|
|
447
|
+
await connectPromise;
|
|
448
|
+
|
|
449
|
+
// Should be ready now
|
|
450
|
+
expect(client.isReady()).toBe(true);
|
|
451
|
+
expect(readyCalled).toBe(true);
|
|
452
|
+
|
|
453
|
+
// State should include buffered transactions applied on top of snapshot
|
|
454
|
+
expect(client.get()?.title).toBe("From TX v2");
|
|
455
|
+
expect(client.get()?.count).toBe(100);
|
|
456
|
+
expect(client.getServerVersion()).toBe(3);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("should ignore buffered transactions older than snapshot", async () => {
|
|
460
|
+
const manualTransport = createMockTransport();
|
|
461
|
+
|
|
462
|
+
const client = ClientDocument.make({
|
|
463
|
+
schema: TestSchema,
|
|
464
|
+
transport: manualTransport,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const connectPromise = client.connect();
|
|
468
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
469
|
+
|
|
470
|
+
// Simulate old transaction arriving before snapshot
|
|
471
|
+
manualTransport.simulateServerMessage({
|
|
472
|
+
type: "transaction",
|
|
473
|
+
transaction: Transaction.make([
|
|
474
|
+
{
|
|
475
|
+
kind: "string.set" as const,
|
|
476
|
+
path: OperationPath.make("title"),
|
|
477
|
+
payload: "Old Title",
|
|
478
|
+
},
|
|
479
|
+
]),
|
|
480
|
+
version: 1,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Send snapshot at version 5 (newer than buffered transaction)
|
|
484
|
+
manualTransport.simulateServerMessage({
|
|
485
|
+
type: "snapshot",
|
|
486
|
+
state: { title: "Snapshot Title", count: 50, items: [] },
|
|
487
|
+
version: 5,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
await connectPromise;
|
|
491
|
+
|
|
492
|
+
// State should be from snapshot, old transaction should be ignored
|
|
493
|
+
expect(client.get()?.title).toBe("Snapshot Title");
|
|
494
|
+
expect(client.get()?.count).toBe(50);
|
|
495
|
+
expect(client.getServerVersion()).toBe(5);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("should throw when creating transaction before ready", async () => {
|
|
499
|
+
const manualTransport = createMockTransport();
|
|
500
|
+
|
|
501
|
+
const client = ClientDocument.make({
|
|
502
|
+
schema: TestSchema,
|
|
503
|
+
transport: manualTransport,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Start connecting but don't complete
|
|
507
|
+
const connectPromise = client.connect();
|
|
508
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
509
|
+
|
|
510
|
+
// Try to create transaction - should fail
|
|
511
|
+
expect(() => {
|
|
512
|
+
client.transaction((root) => {
|
|
513
|
+
root.title.set("Test");
|
|
514
|
+
});
|
|
515
|
+
}).toThrow("Client is not ready");
|
|
516
|
+
|
|
517
|
+
// Complete initialization
|
|
518
|
+
manualTransport.simulateServerMessage({
|
|
519
|
+
type: "snapshot",
|
|
520
|
+
state: { title: "", count: 0, items: [] },
|
|
521
|
+
version: 1,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
await connectPromise;
|
|
525
|
+
|
|
526
|
+
// Now transaction should work
|
|
527
|
+
expect(() => {
|
|
528
|
+
client.transaction((root) => {
|
|
529
|
+
root.title.set("Test");
|
|
530
|
+
});
|
|
531
|
+
}).not.toThrow();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("should timeout initialization if snapshot never arrives", async () => {
|
|
535
|
+
const manualTransport = createMockTransport();
|
|
536
|
+
|
|
537
|
+
const client = ClientDocument.make({
|
|
538
|
+
schema: TestSchema,
|
|
539
|
+
transport: manualTransport,
|
|
540
|
+
initTimeout: 50, // Very short timeout for testing
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// Start connecting - should timeout
|
|
544
|
+
await expect(client.connect()).rejects.toThrow("Initialization timed out");
|
|
545
|
+
|
|
546
|
+
// Should not be ready
|
|
547
|
+
expect(client.isReady()).toBe(false);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("should handle disconnect during initialization", async () => {
|
|
551
|
+
const manualTransport = createMockTransport();
|
|
552
|
+
|
|
553
|
+
const client = ClientDocument.make({
|
|
554
|
+
schema: TestSchema,
|
|
555
|
+
transport: manualTransport,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const connectPromise = client.connect();
|
|
559
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
560
|
+
|
|
561
|
+
// Disconnect while waiting for snapshot
|
|
562
|
+
client.disconnect();
|
|
563
|
+
|
|
564
|
+
// Connect should reject
|
|
565
|
+
await expect(connectPromise).rejects.toThrow("Disconnected during initialization");
|
|
566
|
+
|
|
567
|
+
expect(client.isReady()).toBe(false);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// =============================================================================
|
|
573
|
+
// Rebase Tests
|
|
574
|
+
// =============================================================================
|
|
575
|
+
|
|
576
|
+
describe("Rebase", () => {
|
|
577
|
+
describe("transformOperation", () => {
|
|
578
|
+
it("should not transform operations on different paths", () => {
|
|
579
|
+
const clientOp = {
|
|
580
|
+
kind: "string.set" as const,
|
|
581
|
+
path: OperationPath.make("title"),
|
|
582
|
+
payload: "client",
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const serverOp = {
|
|
586
|
+
kind: "number.set" as const,
|
|
587
|
+
path: OperationPath.make("count"),
|
|
588
|
+
payload: 100,
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const result = Rebase.transformOperation(clientOp, serverOp);
|
|
592
|
+
|
|
593
|
+
expect(result.type).toBe("transformed");
|
|
594
|
+
if (result.type === "transformed") {
|
|
595
|
+
expect(result.operation).toBe(clientOp);
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("should handle same-path operations (client wins)", () => {
|
|
600
|
+
const clientOp = {
|
|
601
|
+
kind: "string.set" as const,
|
|
602
|
+
path: OperationPath.make("title"),
|
|
603
|
+
payload: "client",
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const serverOp = {
|
|
607
|
+
kind: "string.set" as const,
|
|
608
|
+
path: OperationPath.make("title"),
|
|
609
|
+
payload: "server",
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const result = Rebase.transformOperation(clientOp, serverOp);
|
|
613
|
+
|
|
614
|
+
expect(result.type).toBe("transformed");
|
|
615
|
+
if (result.type === "transformed") {
|
|
616
|
+
expect(result.operation.payload).toBe("client");
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("should make client op noop when server removes target element", () => {
|
|
621
|
+
const clientOp = {
|
|
622
|
+
kind: "string.set" as const,
|
|
623
|
+
path: OperationPath.make("items/item-1/name"),
|
|
624
|
+
payload: "new name",
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const serverOp = {
|
|
628
|
+
kind: "array.remove" as const,
|
|
629
|
+
path: OperationPath.make("items"),
|
|
630
|
+
payload: { id: "item-1" },
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const result = Rebase.transformOperation(clientOp, serverOp);
|
|
634
|
+
|
|
635
|
+
expect(result.type).toBe("noop");
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
describe("rebasePendingTransactions", () => {
|
|
640
|
+
it("should transform all pending transactions against server transaction", () => {
|
|
641
|
+
const pending1 = Transaction.make([
|
|
642
|
+
{
|
|
643
|
+
kind: "string.set" as const,
|
|
644
|
+
path: OperationPath.make("title"),
|
|
645
|
+
payload: "pending1",
|
|
646
|
+
},
|
|
647
|
+
]);
|
|
648
|
+
|
|
649
|
+
const pending2 = Transaction.make([
|
|
650
|
+
{
|
|
651
|
+
kind: "number.set" as const,
|
|
652
|
+
path: OperationPath.make("count"),
|
|
653
|
+
payload: 10,
|
|
654
|
+
},
|
|
655
|
+
]);
|
|
656
|
+
|
|
657
|
+
const serverTx = Transaction.make([
|
|
658
|
+
{
|
|
659
|
+
kind: "string.set" as const,
|
|
660
|
+
path: OperationPath.make("description"),
|
|
661
|
+
payload: "server desc",
|
|
662
|
+
},
|
|
663
|
+
]);
|
|
664
|
+
|
|
665
|
+
const rebased = Rebase.rebasePendingTransactions([pending1, pending2], serverTx);
|
|
666
|
+
|
|
667
|
+
expect(rebased.length).toBe(2);
|
|
668
|
+
expect(rebased[0]!.id).toBe(pending1.id);
|
|
669
|
+
expect(rebased[1]!.id).toBe(pending2.id);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// =============================================================================
|
|
675
|
+
// StateMonitor Tests
|
|
676
|
+
// =============================================================================
|
|
677
|
+
|
|
678
|
+
describe("StateMonitor", () => {
|
|
679
|
+
describe("version tracking", () => {
|
|
680
|
+
it("should accept sequential versions", () => {
|
|
681
|
+
const monitor = StateMonitor.make();
|
|
682
|
+
|
|
683
|
+
expect(monitor.onServerVersion(1)).toBe(true);
|
|
684
|
+
expect(monitor.onServerVersion(2)).toBe(true);
|
|
685
|
+
expect(monitor.onServerVersion(3)).toBe(true);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("should detect large version gaps", () => {
|
|
689
|
+
let driftDetected = false;
|
|
690
|
+
|
|
691
|
+
const monitor = StateMonitor.make({
|
|
692
|
+
maxVersionGap: 5,
|
|
693
|
+
onEvent: (event) => {
|
|
694
|
+
if (event.type === "drift_detected") {
|
|
695
|
+
driftDetected = true;
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
monitor.onServerVersion(1);
|
|
701
|
+
const result = monitor.onServerVersion(20); // Gap of 19
|
|
702
|
+
|
|
703
|
+
expect(result).toBe(false);
|
|
704
|
+
expect(driftDetected).toBe(true);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
describe("pending tracking", () => {
|
|
709
|
+
it("should track and untrack pending transactions", () => {
|
|
710
|
+
const monitor = StateMonitor.make();
|
|
711
|
+
|
|
712
|
+
monitor.trackPending({ id: "tx-1", sentAt: Date.now() });
|
|
713
|
+
monitor.trackPending({ id: "tx-2", sentAt: Date.now() });
|
|
714
|
+
|
|
715
|
+
expect(monitor.getStatus().pendingCount).toBe(2);
|
|
716
|
+
|
|
717
|
+
monitor.untrackPending("tx-1");
|
|
718
|
+
|
|
719
|
+
expect(monitor.getStatus().pendingCount).toBe(1);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("should identify stale pending transactions", () => {
|
|
723
|
+
const monitor = StateMonitor.make({
|
|
724
|
+
stalePendingThreshold: 100, // 100ms for testing
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const oldTime = Date.now() - 200; // 200ms ago
|
|
728
|
+
monitor.trackPending({ id: "tx-old", sentAt: oldTime });
|
|
729
|
+
monitor.trackPending({ id: "tx-new", sentAt: Date.now() });
|
|
730
|
+
|
|
731
|
+
const stale = monitor.getStalePending();
|
|
732
|
+
|
|
733
|
+
expect(stale.length).toBe(1);
|
|
734
|
+
expect(stale[0]!.id).toBe("tx-old");
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
describe("reset", () => {
|
|
739
|
+
it("should clear state on reset", () => {
|
|
740
|
+
let recoveryCompleted = false;
|
|
741
|
+
|
|
742
|
+
const monitor = StateMonitor.make({
|
|
743
|
+
onEvent: (event) => {
|
|
744
|
+
if (event.type === "recovery_completed") {
|
|
745
|
+
recoveryCompleted = true;
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
monitor.trackPending({ id: "tx-1", sentAt: Date.now() });
|
|
751
|
+
monitor.onServerVersion(5);
|
|
752
|
+
|
|
753
|
+
monitor.reset(10);
|
|
754
|
+
|
|
755
|
+
expect(monitor.getStatus().pendingCount).toBe(0);
|
|
756
|
+
expect(monitor.getStatus().expectedVersion).toBe(10);
|
|
757
|
+
expect(recoveryCompleted).toBe(true);
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// =============================================================================
|
|
763
|
+
// ClientDocument Presence Tests
|
|
764
|
+
// =============================================================================
|
|
765
|
+
|
|
766
|
+
const CursorPresenceSchema = Presence.make({
|
|
767
|
+
schema: Schema.Struct({
|
|
768
|
+
x: Schema.Number,
|
|
769
|
+
y: Schema.Number,
|
|
770
|
+
name: Schema.optional(Schema.String),
|
|
771
|
+
}),
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
describe("ClientDocument Presence", () => {
|
|
775
|
+
let transport: ReturnType<typeof createMockTransport>;
|
|
776
|
+
|
|
777
|
+
beforeEach(() => {
|
|
778
|
+
transport = createMockTransport();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
describe("presence API availability", () => {
|
|
782
|
+
it("should have undefined presence when no presence schema provided", async () => {
|
|
783
|
+
const client = ClientDocument.make({
|
|
784
|
+
schema: TestSchema,
|
|
785
|
+
transport,
|
|
786
|
+
initialState: { title: "", count: 0, items: [] },
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
await client.connect();
|
|
790
|
+
|
|
791
|
+
expect(client.presence).toBeUndefined();
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it("should have defined presence when presence schema provided", async () => {
|
|
795
|
+
const client = ClientDocument.make({
|
|
796
|
+
schema: TestSchema,
|
|
797
|
+
transport,
|
|
798
|
+
initialState: { title: "", count: 0, items: [] },
|
|
799
|
+
presence: CursorPresenceSchema,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
await client.connect();
|
|
803
|
+
|
|
804
|
+
expect(client.presence).toBeDefined();
|
|
805
|
+
expect(typeof client.presence!.selfId).toBe("function");
|
|
806
|
+
expect(typeof client.presence!.self).toBe("function");
|
|
807
|
+
expect(typeof client.presence!.others).toBe("function");
|
|
808
|
+
expect(typeof client.presence!.all).toBe("function");
|
|
809
|
+
expect(typeof client.presence!.set).toBe("function");
|
|
810
|
+
expect(typeof client.presence!.clear).toBe("function");
|
|
811
|
+
expect(typeof client.presence!.subscribe).toBe("function");
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
describe("selfId", () => {
|
|
816
|
+
it("should return undefined before presence_snapshot received", async () => {
|
|
817
|
+
const client = ClientDocument.make({
|
|
818
|
+
schema: TestSchema,
|
|
819
|
+
transport,
|
|
820
|
+
initialState: { title: "", count: 0, items: [] },
|
|
821
|
+
presence: CursorPresenceSchema,
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
await client.connect();
|
|
825
|
+
|
|
826
|
+
expect(client.presence!.selfId()).toBeUndefined();
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it("should return correct id after presence_snapshot received", async () => {
|
|
830
|
+
const client = ClientDocument.make({
|
|
831
|
+
schema: TestSchema,
|
|
832
|
+
transport,
|
|
833
|
+
initialState: { title: "", count: 0, items: [] },
|
|
834
|
+
presence: CursorPresenceSchema,
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
await client.connect();
|
|
838
|
+
|
|
839
|
+
transport.simulateServerMessage({
|
|
840
|
+
type: "presence_snapshot",
|
|
841
|
+
selfId: "conn-my-id",
|
|
842
|
+
presences: {},
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
expect(client.presence!.selfId()).toBe("conn-my-id");
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
describe("self", () => {
|
|
850
|
+
it("should return undefined before set is called", async () => {
|
|
851
|
+
const client = ClientDocument.make({
|
|
852
|
+
schema: TestSchema,
|
|
853
|
+
transport,
|
|
854
|
+
initialState: { title: "", count: 0, items: [] },
|
|
855
|
+
presence: CursorPresenceSchema,
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
await client.connect();
|
|
859
|
+
|
|
860
|
+
expect(client.presence!.self()).toBeUndefined();
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it("should return data after set is called", async () => {
|
|
864
|
+
const client = ClientDocument.make({
|
|
865
|
+
schema: TestSchema,
|
|
866
|
+
transport,
|
|
867
|
+
initialState: { title: "", count: 0, items: [] },
|
|
868
|
+
presence: CursorPresenceSchema,
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
await client.connect();
|
|
872
|
+
|
|
873
|
+
client.presence!.set({ x: 100, y: 200 });
|
|
874
|
+
|
|
875
|
+
expect(client.presence!.self()).toEqual({ x: 100, y: 200 });
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
describe("others", () => {
|
|
880
|
+
it("should return empty map initially", async () => {
|
|
881
|
+
const client = ClientDocument.make({
|
|
882
|
+
schema: TestSchema,
|
|
883
|
+
transport,
|
|
884
|
+
initialState: { title: "", count: 0, items: [] },
|
|
885
|
+
presence: CursorPresenceSchema,
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
await client.connect();
|
|
889
|
+
|
|
890
|
+
expect(client.presence!.others().size).toBe(0);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it("should return other presences from snapshot", async () => {
|
|
894
|
+
const client = ClientDocument.make({
|
|
895
|
+
schema: TestSchema,
|
|
896
|
+
transport,
|
|
897
|
+
initialState: { title: "", count: 0, items: [] },
|
|
898
|
+
presence: CursorPresenceSchema,
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
await client.connect();
|
|
902
|
+
|
|
903
|
+
transport.simulateServerMessage({
|
|
904
|
+
type: "presence_snapshot",
|
|
905
|
+
selfId: "conn-me",
|
|
906
|
+
presences: {
|
|
907
|
+
"conn-other-1": { data: { x: 10, y: 20 }, userId: "user-1" },
|
|
908
|
+
"conn-other-2": { data: { x: 30, y: 40 } },
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
const others = client.presence!.others();
|
|
913
|
+
expect(others.size).toBe(2);
|
|
914
|
+
expect(others.get("conn-other-1")).toEqual({ data: { x: 10, y: 20 }, userId: "user-1" });
|
|
915
|
+
expect(others.get("conn-other-2")).toEqual({ data: { x: 30, y: 40 } });
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("should update on presence_update", async () => {
|
|
919
|
+
const client = ClientDocument.make({
|
|
920
|
+
schema: TestSchema,
|
|
921
|
+
transport,
|
|
922
|
+
initialState: { title: "", count: 0, items: [] },
|
|
923
|
+
presence: CursorPresenceSchema,
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
await client.connect();
|
|
927
|
+
|
|
928
|
+
transport.simulateServerMessage({
|
|
929
|
+
type: "presence_snapshot",
|
|
930
|
+
selfId: "conn-me",
|
|
931
|
+
presences: {},
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
transport.simulateServerMessage({
|
|
935
|
+
type: "presence_update",
|
|
936
|
+
id: "conn-new-user",
|
|
937
|
+
data: { x: 50, y: 60 },
|
|
938
|
+
userId: "user-new",
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
const others = client.presence!.others();
|
|
942
|
+
expect(others.size).toBe(1);
|
|
943
|
+
expect(others.get("conn-new-user")).toEqual({ data: { x: 50, y: 60 }, userId: "user-new" });
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it("should remove on presence_remove", async () => {
|
|
947
|
+
const client = ClientDocument.make({
|
|
948
|
+
schema: TestSchema,
|
|
949
|
+
transport,
|
|
950
|
+
initialState: { title: "", count: 0, items: [] },
|
|
951
|
+
presence: CursorPresenceSchema,
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
await client.connect();
|
|
955
|
+
|
|
956
|
+
transport.simulateServerMessage({
|
|
957
|
+
type: "presence_snapshot",
|
|
958
|
+
selfId: "conn-me",
|
|
959
|
+
presences: {
|
|
960
|
+
"conn-leaving": { data: { x: 10, y: 20 } },
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
expect(client.presence!.others().size).toBe(1);
|
|
965
|
+
|
|
966
|
+
transport.simulateServerMessage({
|
|
967
|
+
type: "presence_remove",
|
|
968
|
+
id: "conn-leaving",
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
expect(client.presence!.others().size).toBe(0);
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
describe("all", () => {
|
|
976
|
+
it("should combine self and others", async () => {
|
|
977
|
+
const client = ClientDocument.make({
|
|
978
|
+
schema: TestSchema,
|
|
979
|
+
transport,
|
|
980
|
+
initialState: { title: "", count: 0, items: [] },
|
|
981
|
+
presence: CursorPresenceSchema,
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
await client.connect();
|
|
985
|
+
|
|
986
|
+
transport.simulateServerMessage({
|
|
987
|
+
type: "presence_snapshot",
|
|
988
|
+
selfId: "conn-me",
|
|
989
|
+
presences: {
|
|
990
|
+
"conn-other": { data: { x: 10, y: 20 } },
|
|
991
|
+
},
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
client.presence!.set({ x: 100, y: 200 });
|
|
995
|
+
|
|
996
|
+
const all = client.presence!.all();
|
|
997
|
+
expect(all.size).toBe(2);
|
|
998
|
+
expect(all.get("conn-me")).toEqual({ data: { x: 100, y: 200 } });
|
|
999
|
+
expect(all.get("conn-other")).toEqual({ data: { x: 10, y: 20 } });
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("should not include self if self data not set", async () => {
|
|
1003
|
+
const client = ClientDocument.make({
|
|
1004
|
+
schema: TestSchema,
|
|
1005
|
+
transport,
|
|
1006
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1007
|
+
presence: CursorPresenceSchema,
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
await client.connect();
|
|
1011
|
+
|
|
1012
|
+
transport.simulateServerMessage({
|
|
1013
|
+
type: "presence_snapshot",
|
|
1014
|
+
selfId: "conn-me",
|
|
1015
|
+
presences: {
|
|
1016
|
+
"conn-other": { data: { x: 10, y: 20 } },
|
|
1017
|
+
},
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
const all = client.presence!.all();
|
|
1021
|
+
expect(all.size).toBe(1);
|
|
1022
|
+
expect(all.has("conn-me")).toBe(false);
|
|
1023
|
+
expect(all.get("conn-other")).toEqual({ data: { x: 10, y: 20 } });
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
describe("initialPresence", () => {
|
|
1028
|
+
it("should set presence to initialPresence value on connect", async () => {
|
|
1029
|
+
const client = ClientDocument.make({
|
|
1030
|
+
schema: TestSchema,
|
|
1031
|
+
transport,
|
|
1032
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1033
|
+
presence: CursorPresenceSchema,
|
|
1034
|
+
initialPresence: { x: 50, y: 100, name: "Initial User" },
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
await client.connect();
|
|
1038
|
+
|
|
1039
|
+
expect(client.presence!.self()).toEqual({ x: 50, y: 100, name: "Initial User" });
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it("should send initialPresence to transport on connect", async () => {
|
|
1043
|
+
const client = ClientDocument.make({
|
|
1044
|
+
schema: TestSchema,
|
|
1045
|
+
transport,
|
|
1046
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1047
|
+
presence: CursorPresenceSchema,
|
|
1048
|
+
initialPresence: { x: 25, y: 75 },
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
await client.connect();
|
|
1052
|
+
|
|
1053
|
+
expect(transport.presenceSetCalls.length).toBe(1);
|
|
1054
|
+
expect(transport.presenceSetCalls[0]).toEqual({ x: 25, y: 75 });
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it("should notify subscribers when initialPresence is set", async () => {
|
|
1058
|
+
const client = ClientDocument.make({
|
|
1059
|
+
schema: TestSchema,
|
|
1060
|
+
transport,
|
|
1061
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1062
|
+
presence: CursorPresenceSchema,
|
|
1063
|
+
initialPresence: { x: 10, y: 20 },
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
let changeCount = 0;
|
|
1067
|
+
client.presence!.subscribe({
|
|
1068
|
+
onPresenceChange: () => {
|
|
1069
|
+
changeCount++;
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
await client.connect();
|
|
1074
|
+
|
|
1075
|
+
expect(changeCount).toBe(1);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it("should not set presence when initialPresence is not provided", async () => {
|
|
1079
|
+
const client = ClientDocument.make({
|
|
1080
|
+
schema: TestSchema,
|
|
1081
|
+
transport,
|
|
1082
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1083
|
+
presence: CursorPresenceSchema,
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
await client.connect();
|
|
1087
|
+
|
|
1088
|
+
expect(client.presence!.self()).toBeUndefined();
|
|
1089
|
+
expect(transport.presenceSetCalls.length).toBe(0);
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
describe("set", () => {
|
|
1094
|
+
it("should validate data against schema", async () => {
|
|
1095
|
+
const client = ClientDocument.make({
|
|
1096
|
+
schema: TestSchema,
|
|
1097
|
+
transport,
|
|
1098
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1099
|
+
presence: CursorPresenceSchema,
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
await client.connect();
|
|
1103
|
+
|
|
1104
|
+
// Valid data should not throw
|
|
1105
|
+
expect(() => {
|
|
1106
|
+
client.presence!.set({ x: 100, y: 200 });
|
|
1107
|
+
}).not.toThrow();
|
|
1108
|
+
|
|
1109
|
+
// Invalid data should throw
|
|
1110
|
+
expect(() => {
|
|
1111
|
+
client.presence!.set({ x: "invalid", y: 200 } as any);
|
|
1112
|
+
}).toThrow();
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it("should send presence data to transport", async () => {
|
|
1116
|
+
const client = ClientDocument.make({
|
|
1117
|
+
schema: TestSchema,
|
|
1118
|
+
transport,
|
|
1119
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1120
|
+
presence: CursorPresenceSchema,
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
await client.connect();
|
|
1124
|
+
|
|
1125
|
+
client.presence!.set({ x: 100, y: 200, name: "Alice" });
|
|
1126
|
+
|
|
1127
|
+
expect(transport.presenceSetCalls.length).toBe(1);
|
|
1128
|
+
expect(transport.presenceSetCalls[0]).toEqual({ x: 100, y: 200, name: "Alice" });
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it("should update local self state", async () => {
|
|
1132
|
+
const client = ClientDocument.make({
|
|
1133
|
+
schema: TestSchema,
|
|
1134
|
+
transport,
|
|
1135
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1136
|
+
presence: CursorPresenceSchema,
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
await client.connect();
|
|
1140
|
+
|
|
1141
|
+
expect(client.presence!.self()).toBeUndefined();
|
|
1142
|
+
|
|
1143
|
+
client.presence!.set({ x: 50, y: 75 });
|
|
1144
|
+
|
|
1145
|
+
expect(client.presence!.self()).toEqual({ x: 50, y: 75 });
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
describe("clear", () => {
|
|
1150
|
+
it("should send presence_clear to transport", async () => {
|
|
1151
|
+
const client = ClientDocument.make({
|
|
1152
|
+
schema: TestSchema,
|
|
1153
|
+
transport,
|
|
1154
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1155
|
+
presence: CursorPresenceSchema,
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
await client.connect();
|
|
1159
|
+
|
|
1160
|
+
client.presence!.clear();
|
|
1161
|
+
|
|
1162
|
+
expect(transport.presenceClearCalls).toBe(1);
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it("should clear local self state", async () => {
|
|
1166
|
+
const client = ClientDocument.make({
|
|
1167
|
+
schema: TestSchema,
|
|
1168
|
+
transport,
|
|
1169
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1170
|
+
presence: CursorPresenceSchema,
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
await client.connect();
|
|
1174
|
+
|
|
1175
|
+
client.presence!.set({ x: 100, y: 200 });
|
|
1176
|
+
expect(client.presence!.self()).toEqual({ x: 100, y: 200 });
|
|
1177
|
+
|
|
1178
|
+
client.presence!.clear();
|
|
1179
|
+
expect(client.presence!.self()).toBeUndefined();
|
|
1180
|
+
});
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
describe("subscribe", () => {
|
|
1184
|
+
it("should notify on presence_snapshot", async () => {
|
|
1185
|
+
const client = ClientDocument.make({
|
|
1186
|
+
schema: TestSchema,
|
|
1187
|
+
transport,
|
|
1188
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1189
|
+
presence: CursorPresenceSchema,
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
await client.connect();
|
|
1193
|
+
|
|
1194
|
+
let changeCount = 0;
|
|
1195
|
+
client.presence!.subscribe({
|
|
1196
|
+
onPresenceChange: () => {
|
|
1197
|
+
changeCount++;
|
|
1198
|
+
},
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
transport.simulateServerMessage({
|
|
1202
|
+
type: "presence_snapshot",
|
|
1203
|
+
selfId: "conn-me",
|
|
1204
|
+
presences: {},
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
expect(changeCount).toBe(1);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it("should notify on presence_update", async () => {
|
|
1211
|
+
const client = ClientDocument.make({
|
|
1212
|
+
schema: TestSchema,
|
|
1213
|
+
transport,
|
|
1214
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1215
|
+
presence: CursorPresenceSchema,
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
await client.connect();
|
|
1219
|
+
|
|
1220
|
+
let changeCount = 0;
|
|
1221
|
+
client.presence!.subscribe({
|
|
1222
|
+
onPresenceChange: () => {
|
|
1223
|
+
changeCount++;
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
transport.simulateServerMessage({
|
|
1228
|
+
type: "presence_update",
|
|
1229
|
+
id: "conn-other",
|
|
1230
|
+
data: { x: 10, y: 20 },
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
expect(changeCount).toBe(1);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
it("should notify on presence_remove", async () => {
|
|
1237
|
+
const client = ClientDocument.make({
|
|
1238
|
+
schema: TestSchema,
|
|
1239
|
+
transport,
|
|
1240
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1241
|
+
presence: CursorPresenceSchema,
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
await client.connect();
|
|
1245
|
+
|
|
1246
|
+
let changeCount = 0;
|
|
1247
|
+
client.presence!.subscribe({
|
|
1248
|
+
onPresenceChange: () => {
|
|
1249
|
+
changeCount++;
|
|
1250
|
+
},
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
transport.simulateServerMessage({
|
|
1254
|
+
type: "presence_remove",
|
|
1255
|
+
id: "conn-other",
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
expect(changeCount).toBe(1);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("should notify on local set", async () => {
|
|
1262
|
+
const client = ClientDocument.make({
|
|
1263
|
+
schema: TestSchema,
|
|
1264
|
+
transport,
|
|
1265
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1266
|
+
presence: CursorPresenceSchema,
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
await client.connect();
|
|
1270
|
+
|
|
1271
|
+
let changeCount = 0;
|
|
1272
|
+
client.presence!.subscribe({
|
|
1273
|
+
onPresenceChange: () => {
|
|
1274
|
+
changeCount++;
|
|
1275
|
+
},
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
client.presence!.set({ x: 100, y: 200 });
|
|
1279
|
+
|
|
1280
|
+
expect(changeCount).toBe(1);
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
it("should notify on local clear", async () => {
|
|
1284
|
+
const client = ClientDocument.make({
|
|
1285
|
+
schema: TestSchema,
|
|
1286
|
+
transport,
|
|
1287
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1288
|
+
presence: CursorPresenceSchema,
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
await client.connect();
|
|
1292
|
+
|
|
1293
|
+
let changeCount = 0;
|
|
1294
|
+
client.presence!.subscribe({
|
|
1295
|
+
onPresenceChange: () => {
|
|
1296
|
+
changeCount++;
|
|
1297
|
+
},
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
client.presence!.clear();
|
|
1301
|
+
|
|
1302
|
+
expect(changeCount).toBe(1);
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it("should allow unsubscribing", async () => {
|
|
1306
|
+
const client = ClientDocument.make({
|
|
1307
|
+
schema: TestSchema,
|
|
1308
|
+
transport,
|
|
1309
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1310
|
+
presence: CursorPresenceSchema,
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
await client.connect();
|
|
1314
|
+
|
|
1315
|
+
let changeCount = 0;
|
|
1316
|
+
const unsubscribe = client.presence!.subscribe({
|
|
1317
|
+
onPresenceChange: () => {
|
|
1318
|
+
changeCount++;
|
|
1319
|
+
},
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
client.presence!.set({ x: 100, y: 200 });
|
|
1323
|
+
expect(changeCount).toBe(1);
|
|
1324
|
+
|
|
1325
|
+
unsubscribe();
|
|
1326
|
+
|
|
1327
|
+
client.presence!.set({ x: 200, y: 300 });
|
|
1328
|
+
expect(changeCount).toBe(1); // Should not increment
|
|
1329
|
+
});
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
describe("disconnect behavior", () => {
|
|
1333
|
+
it("should clear presence state on disconnect", async () => {
|
|
1334
|
+
const client = ClientDocument.make({
|
|
1335
|
+
schema: TestSchema,
|
|
1336
|
+
transport,
|
|
1337
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1338
|
+
presence: CursorPresenceSchema,
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
await client.connect();
|
|
1342
|
+
|
|
1343
|
+
transport.simulateServerMessage({
|
|
1344
|
+
type: "presence_snapshot",
|
|
1345
|
+
selfId: "conn-me",
|
|
1346
|
+
presences: {
|
|
1347
|
+
"conn-other": { data: { x: 10, y: 20 } },
|
|
1348
|
+
},
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
client.presence!.set({ x: 100, y: 200 });
|
|
1352
|
+
|
|
1353
|
+
expect(client.presence!.selfId()).toBe("conn-me");
|
|
1354
|
+
expect(client.presence!.self()).toEqual({ x: 100, y: 200 });
|
|
1355
|
+
expect(client.presence!.others().size).toBe(1);
|
|
1356
|
+
|
|
1357
|
+
client.disconnect();
|
|
1358
|
+
|
|
1359
|
+
expect(client.presence!.selfId()).toBeUndefined();
|
|
1360
|
+
expect(client.presence!.self()).toBeUndefined();
|
|
1361
|
+
expect(client.presence!.others().size).toBe(0);
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it("should notify subscribers on disconnect", async () => {
|
|
1365
|
+
const client = ClientDocument.make({
|
|
1366
|
+
schema: TestSchema,
|
|
1367
|
+
transport,
|
|
1368
|
+
initialState: { title: "", count: 0, items: [] },
|
|
1369
|
+
presence: CursorPresenceSchema,
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
await client.connect();
|
|
1373
|
+
|
|
1374
|
+
transport.simulateServerMessage({
|
|
1375
|
+
type: "presence_snapshot",
|
|
1376
|
+
selfId: "conn-me",
|
|
1377
|
+
presences: {
|
|
1378
|
+
"conn-other": { data: { x: 10, y: 20 } },
|
|
1379
|
+
},
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
let changeCount = 0;
|
|
1383
|
+
client.presence!.subscribe({
|
|
1384
|
+
onPresenceChange: () => {
|
|
1385
|
+
changeCount++;
|
|
1386
|
+
},
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
// Reset count after snapshot notification
|
|
1390
|
+
changeCount = 0;
|
|
1391
|
+
|
|
1392
|
+
client.disconnect();
|
|
1393
|
+
|
|
1394
|
+
// Should notify when clearing presence
|
|
1395
|
+
expect(changeCount).toBe(1);
|
|
1396
|
+
});
|
|
1397
|
+
});
|
|
1398
|
+
});
|