@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.
Files changed (57) hide show
  1. package/README.md +17 -0
  2. package/package.json +33 -0
  3. package/src/Document.ts +256 -0
  4. package/src/FractionalIndex.ts +1249 -0
  5. package/src/Operation.ts +59 -0
  6. package/src/OperationDefinition.ts +23 -0
  7. package/src/OperationPath.ts +197 -0
  8. package/src/Presence.ts +142 -0
  9. package/src/Primitive.ts +32 -0
  10. package/src/Proxy.ts +8 -0
  11. package/src/ProxyEnvironment.ts +52 -0
  12. package/src/Transaction.ts +72 -0
  13. package/src/Transform.ts +13 -0
  14. package/src/client/ClientDocument.ts +1163 -0
  15. package/src/client/Rebase.ts +309 -0
  16. package/src/client/StateMonitor.ts +307 -0
  17. package/src/client/Transport.ts +318 -0
  18. package/src/client/WebSocketTransport.ts +572 -0
  19. package/src/client/errors.ts +145 -0
  20. package/src/client/index.ts +61 -0
  21. package/src/index.ts +12 -0
  22. package/src/primitives/Array.ts +457 -0
  23. package/src/primitives/Boolean.ts +128 -0
  24. package/src/primitives/Lazy.ts +89 -0
  25. package/src/primitives/Literal.ts +128 -0
  26. package/src/primitives/Number.ts +169 -0
  27. package/src/primitives/String.ts +189 -0
  28. package/src/primitives/Struct.ts +348 -0
  29. package/src/primitives/Tree.ts +1120 -0
  30. package/src/primitives/TreeNode.ts +113 -0
  31. package/src/primitives/Union.ts +329 -0
  32. package/src/primitives/shared.ts +122 -0
  33. package/src/server/ServerDocument.ts +267 -0
  34. package/src/server/errors.ts +90 -0
  35. package/src/server/index.ts +40 -0
  36. package/tests/Document.test.ts +556 -0
  37. package/tests/FractionalIndex.test.ts +377 -0
  38. package/tests/OperationPath.test.ts +151 -0
  39. package/tests/Presence.test.ts +321 -0
  40. package/tests/Primitive.test.ts +381 -0
  41. package/tests/client/ClientDocument.test.ts +1398 -0
  42. package/tests/client/WebSocketTransport.test.ts +992 -0
  43. package/tests/primitives/Array.test.ts +418 -0
  44. package/tests/primitives/Boolean.test.ts +126 -0
  45. package/tests/primitives/Lazy.test.ts +143 -0
  46. package/tests/primitives/Literal.test.ts +122 -0
  47. package/tests/primitives/Number.test.ts +133 -0
  48. package/tests/primitives/String.test.ts +128 -0
  49. package/tests/primitives/Struct.test.ts +311 -0
  50. package/tests/primitives/Tree.test.ts +467 -0
  51. package/tests/primitives/TreeNode.test.ts +50 -0
  52. package/tests/primitives/Union.test.ts +210 -0
  53. package/tests/server/ServerDocument.test.ts +528 -0
  54. package/tsconfig.build.json +24 -0
  55. package/tsconfig.json +8 -0
  56. package/tsdown.config.ts +18 -0
  57. 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
+ });