@voidhash/mimic 1.0.0-beta.15 → 1.0.0-beta.17

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 (112) hide show
  1. package/dist/Document.cjs +0 -3
  2. package/dist/Document.d.mts.map +1 -1
  3. package/dist/Document.mjs +0 -3
  4. package/dist/Document.mjs.map +1 -1
  5. package/dist/EffectSchema.cjs +3 -3
  6. package/dist/EffectSchema.d.cts +5 -5
  7. package/dist/EffectSchema.d.cts.map +1 -1
  8. package/dist/EffectSchema.d.mts +5 -5
  9. package/dist/EffectSchema.d.mts.map +1 -1
  10. package/dist/EffectSchema.mjs +3 -3
  11. package/dist/EffectSchema.mjs.map +1 -1
  12. package/dist/FractionalIndex.mjs.map +1 -1
  13. package/dist/Operation.d.cts +4 -4
  14. package/dist/Operation.d.cts.map +1 -1
  15. package/dist/Operation.d.mts +4 -4
  16. package/dist/Operation.d.mts.map +1 -1
  17. package/dist/Operation.mjs.map +1 -1
  18. package/dist/OperationDefinition.d.cts +2 -2
  19. package/dist/OperationDefinition.d.cts.map +1 -1
  20. package/dist/OperationDefinition.d.mts +2 -2
  21. package/dist/OperationDefinition.d.mts.map +1 -1
  22. package/dist/OperationDefinition.mjs.map +1 -1
  23. package/dist/Presence.mjs.map +1 -1
  24. package/dist/Primitive.d.cts +2 -2
  25. package/dist/Primitive.d.mts +2 -2
  26. package/dist/SchemaJSON.cjs +305 -0
  27. package/dist/SchemaJSON.d.cts +11 -0
  28. package/dist/SchemaJSON.d.cts.map +1 -0
  29. package/dist/SchemaJSON.d.mts +11 -0
  30. package/dist/SchemaJSON.d.mts.map +1 -0
  31. package/dist/SchemaJSON.mjs +301 -0
  32. package/dist/SchemaJSON.mjs.map +1 -0
  33. package/dist/index.cjs +7 -0
  34. package/dist/index.d.cts +2 -1
  35. package/dist/index.d.mts +2 -1
  36. package/dist/index.mjs +2 -1
  37. package/dist/primitives/Array.cjs +12 -2
  38. package/dist/primitives/Array.d.cts.map +1 -1
  39. package/dist/primitives/Array.d.mts.map +1 -1
  40. package/dist/primitives/Array.mjs +12 -2
  41. package/dist/primitives/Array.mjs.map +1 -1
  42. package/dist/primitives/Boolean.mjs.map +1 -1
  43. package/dist/primitives/Either.mjs.map +1 -1
  44. package/dist/primitives/Literal.mjs.map +1 -1
  45. package/dist/primitives/Number.cjs +27 -5
  46. package/dist/primitives/Number.d.cts.map +1 -1
  47. package/dist/primitives/Number.d.mts.map +1 -1
  48. package/dist/primitives/Number.mjs +27 -5
  49. package/dist/primitives/Number.mjs.map +1 -1
  50. package/dist/primitives/String.cjs +44 -13
  51. package/dist/primitives/String.d.cts.map +1 -1
  52. package/dist/primitives/String.d.mts.map +1 -1
  53. package/dist/primitives/String.mjs +44 -13
  54. package/dist/primitives/String.mjs.map +1 -1
  55. package/dist/primitives/Struct.cjs +48 -9
  56. package/dist/primitives/Struct.d.cts +22 -3
  57. package/dist/primitives/Struct.d.cts.map +1 -1
  58. package/dist/primitives/Struct.d.mts +22 -3
  59. package/dist/primitives/Struct.d.mts.map +1 -1
  60. package/dist/primitives/Struct.mjs +48 -9
  61. package/dist/primitives/Struct.mjs.map +1 -1
  62. package/dist/primitives/Union.mjs.map +1 -1
  63. package/dist/primitives/shared.cjs +2 -5
  64. package/dist/primitives/shared.d.cts +2 -4
  65. package/dist/primitives/shared.d.cts.map +1 -1
  66. package/dist/primitives/shared.d.mts +2 -4
  67. package/dist/primitives/shared.d.mts.map +1 -1
  68. package/dist/primitives/shared.mjs +2 -5
  69. package/dist/primitives/shared.mjs.map +1 -1
  70. package/package.json +15 -8
  71. package/src/Document.ts +13 -4
  72. package/src/EffectSchema.ts +3 -3
  73. package/src/FractionalIndex.ts +18 -18
  74. package/src/Operation.ts +5 -5
  75. package/src/OperationDefinition.ts +2 -2
  76. package/src/Presence.ts +3 -3
  77. package/src/SchemaJSON.ts +396 -0
  78. package/src/index.ts +1 -0
  79. package/src/primitives/Array.ts +18 -8
  80. package/src/primitives/Boolean.ts +2 -2
  81. package/src/primitives/Either.ts +2 -2
  82. package/src/primitives/Literal.ts +2 -2
  83. package/src/primitives/Number.ts +44 -22
  84. package/src/primitives/String.ts +61 -34
  85. package/src/primitives/Struct.ts +100 -12
  86. package/src/primitives/Union.ts +1 -1
  87. package/src/primitives/shared.ts +12 -2
  88. package/.turbo/turbo-build.log +0 -270
  89. package/tests/Document.test.ts +0 -557
  90. package/tests/EffectSchema.test.ts +0 -546
  91. package/tests/FractionalIndex.test.ts +0 -377
  92. package/tests/OperationPath.test.ts +0 -151
  93. package/tests/Presence.test.ts +0 -321
  94. package/tests/Primitive.test.ts +0 -381
  95. package/tests/client/ClientDocument.test.ts +0 -1981
  96. package/tests/client/WebSocketTransport.test.ts +0 -1217
  97. package/tests/primitives/Array.test.ts +0 -526
  98. package/tests/primitives/Boolean.test.ts +0 -126
  99. package/tests/primitives/Either.test.ts +0 -707
  100. package/tests/primitives/Lazy.test.ts +0 -143
  101. package/tests/primitives/Literal.test.ts +0 -122
  102. package/tests/primitives/Number.test.ts +0 -133
  103. package/tests/primitives/String.test.ts +0 -128
  104. package/tests/primitives/Struct.test.ts +0 -1044
  105. package/tests/primitives/Tree.test.ts +0 -1139
  106. package/tests/primitives/TreeNode.test.ts +0 -50
  107. package/tests/primitives/Union.test.ts +0 -554
  108. package/tests/server/ServerDocument.test.ts +0 -903
  109. package/tsconfig.build.json +0 -24
  110. package/tsconfig.json +0 -8
  111. package/tsdown.config.ts +0 -18
  112. package/vitest.mts +0 -11
@@ -1,1981 +0,0 @@
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
- import * as Document from "../../src/Document";
12
-
13
- // =============================================================================
14
- // Mock Transport
15
- // =============================================================================
16
-
17
- interface MockTransport extends Transport.Transport {
18
- sentTransactions: Transaction.Transaction[];
19
- handlers: Set<(message: Transport.ServerMessage) => void>;
20
- simulateServerMessage: (message: Transport.ServerMessage) => void;
21
- snapshotRequested: boolean;
22
- autoSendSnapshot?: { state: unknown; version: number };
23
- // Presence tracking
24
- presenceSetCalls: unknown[];
25
- presenceClearCalls: number;
26
- }
27
-
28
- const createMockTransport = (options?: {
29
- autoSendSnapshot?: { state: unknown; version: number };
30
- }): MockTransport => {
31
- const handlers = new Set<(message: Transport.ServerMessage) => void>();
32
- const sentTransactions: Transaction.Transaction[] = [];
33
- let _connected = false;
34
- let snapshotRequested = false;
35
- const presenceSetCalls: unknown[] = [];
36
- let presenceClearCalls = 0;
37
-
38
- const transport: MockTransport = {
39
- sentTransactions,
40
- handlers,
41
- snapshotRequested,
42
- autoSendSnapshot: options?.autoSendSnapshot,
43
- get presenceSetCalls() { return presenceSetCalls; },
44
- get presenceClearCalls() { return presenceClearCalls; },
45
-
46
- send: (transaction) => {
47
- sentTransactions.push(transaction);
48
- },
49
-
50
- requestSnapshot: () => {
51
- snapshotRequested = true;
52
- // If autoSendSnapshot is configured, send it immediately
53
- if (transport.autoSendSnapshot) {
54
- // Use setTimeout to simulate async behavior
55
- setTimeout(() => {
56
- transport.simulateServerMessage({
57
- type: "snapshot",
58
- state: transport.autoSendSnapshot!.state,
59
- version: transport.autoSendSnapshot!.version,
60
- });
61
- }, 0);
62
- }
63
- },
64
-
65
- subscribe: (handler) => {
66
- handlers.add(handler);
67
- return () => handlers.delete(handler);
68
- },
69
-
70
- connect: async () => {
71
- _connected = true;
72
- },
73
-
74
- disconnect: () => {
75
- _connected = false;
76
- },
77
-
78
- isConnected: () => _connected,
79
-
80
- sendPresenceSet: (data: unknown) => {
81
- presenceSetCalls.push(data);
82
- },
83
-
84
- sendPresenceClear: () => {
85
- presenceClearCalls++;
86
- },
87
-
88
- simulateServerMessage: (message) => {
89
- for (const handler of handlers) {
90
- handler(message);
91
- }
92
- },
93
- };
94
-
95
- return transport;
96
- };
97
-
98
- // =============================================================================
99
- // Test Schema
100
- // =============================================================================
101
-
102
- const TestSchema = Primitive.Struct({
103
- title: Primitive.String().default(""),
104
- count: Primitive.Number().default(0),
105
- items: Primitive.Array(
106
- Primitive.Struct({
107
- name: Primitive.String(),
108
- done: Primitive.Boolean().default(false),
109
- })
110
- ),
111
- });
112
-
113
- type TestState = Primitive.InferState<typeof TestSchema>;
114
-
115
- // =============================================================================
116
- // ClientDocument Tests
117
- // =============================================================================
118
-
119
- describe("ClientDocument", () => {
120
- let transport: ReturnType<typeof createMockTransport>;
121
-
122
- beforeEach(() => {
123
- transport = createMockTransport();
124
- });
125
-
126
- describe("make", () => {
127
- it("should create a client document with initial state", async () => {
128
- const initialState: TestState = {
129
- title: "Test",
130
- count: 5,
131
- items: [],
132
- };
133
-
134
- const client = ClientDocument.make({
135
- schema: TestSchema,
136
- transport,
137
- initialState,
138
- });
139
-
140
- await client.connect();
141
-
142
- expect(client.get()).toEqual(initialState);
143
- expect(client.getServerState()).toEqual(initialState);
144
- });
145
-
146
- it("should create a client document without initial state and wait for snapshot", async () => {
147
- // Create transport that auto-sends snapshot
148
- const transportWithSnapshot = createMockTransport({
149
- autoSendSnapshot: { state: { title: "From Server", count: 42, items: [] }, version: 1 },
150
- });
151
-
152
- const client = ClientDocument.make({
153
- schema: TestSchema,
154
- transport: transportWithSnapshot,
155
- });
156
-
157
- expect(client.isReady()).toBe(false);
158
-
159
- await client.connect();
160
-
161
- // Should have state from server snapshot
162
- expect(client.isReady()).toBe(true);
163
- expect(client.get()?.title).toBe("From Server");
164
- expect(client.get()?.count).toBe(42);
165
- });
166
- });
167
-
168
- describe("transaction", () => {
169
- it("should apply changes optimistically", async () => {
170
- const client = ClientDocument.make({
171
- schema: TestSchema,
172
- transport,
173
- initialState: { title: "", count: 0, items: [] },
174
- });
175
-
176
- await client.connect();
177
-
178
- client.transaction((root) => {
179
- root.title.set("New Title");
180
- });
181
-
182
- expect(client.get()?.title).toBe("New Title");
183
- expect(client.hasPendingChanges()).toBe(true);
184
- expect(client.getPendingCount()).toBe(1);
185
- });
186
-
187
- it("should send transaction to server", async () => {
188
- const client = ClientDocument.make({
189
- schema: TestSchema,
190
- transport,
191
- initialState: { title: "", count: 0, items: [] },
192
- });
193
-
194
- await client.connect();
195
-
196
- client.transaction((root) => {
197
- root.count.set(42);
198
- });
199
-
200
- expect(transport.sentTransactions.length).toBe(1);
201
- expect(transport.sentTransactions[0]!.ops.length).toBe(1);
202
- expect(transport.sentTransactions[0]!.ops[0]!.kind).toBe("number.set");
203
- });
204
-
205
- it("should queue transactions when not connected", () => {
206
- const client = ClientDocument.make({
207
- schema: TestSchema,
208
- transport,
209
- initialState: { title: "", count: 0, items: [] },
210
- });
211
-
212
- // Transactions should work offline - they get queued in the transport
213
- client.transaction((root) => {
214
- root.title.set("Test");
215
- });
216
-
217
- // State should be optimistically updated
218
- expect(client.get()?.title).toBe("Test");
219
- // Transaction is pending
220
- expect(client.hasPendingChanges()).toBe(true);
221
- // Transaction was sent to transport (it will queue it)
222
- expect(transport.sentTransactions.length).toBe(1);
223
- });
224
-
225
- it("should queue multiple transactions when not connected", () => {
226
- const client = ClientDocument.make({
227
- schema: TestSchema,
228
- transport,
229
- initialState: { title: "", count: 0, items: [] },
230
- });
231
-
232
- // Create multiple transactions while offline
233
- client.transaction((root) => {
234
- root.title.set("First");
235
- });
236
-
237
- client.transaction((root) => {
238
- root.count.set(10);
239
- });
240
-
241
- client.transaction((root) => {
242
- root.title.set("Second");
243
- });
244
-
245
- // All state changes should be applied optimistically
246
- expect(client.get()?.title).toBe("Second");
247
- expect(client.get()?.count).toBe(10);
248
- // All transactions are pending
249
- expect(client.getPendingCount()).toBe(3);
250
- // All transactions were sent to transport
251
- expect(transport.sentTransactions.length).toBe(3);
252
- });
253
-
254
- it("should throw when not ready (no initial state)", () => {
255
- const client = ClientDocument.make({
256
- schema: TestSchema,
257
- transport,
258
- // No initial state - client is not ready until snapshot received
259
- });
260
-
261
- expect(() => {
262
- client.transaction((root) => {
263
- root.title.set("Test");
264
- });
265
- }).toThrow("Client is not ready");
266
- });
267
- });
268
-
269
- describe("offline transaction handling", () => {
270
- it("should confirm queued transactions after reconnection", async () => {
271
- const client = ClientDocument.make({
272
- schema: TestSchema,
273
- transport,
274
- initialState: { title: "", count: 0, items: [] },
275
- });
276
-
277
- // Create transaction while offline
278
- client.transaction((root) => {
279
- root.title.set("Offline Change");
280
- });
281
-
282
- expect(client.hasPendingChanges()).toBe(true);
283
- const pendingTx = transport.sentTransactions[0]!;
284
-
285
- // Connect and simulate server confirming the transaction
286
- await client.connect();
287
-
288
- // Server broadcasts our transaction (confirming it)
289
- transport.simulateServerMessage({
290
- type: "transaction",
291
- transaction: pendingTx,
292
- version: 1,
293
- });
294
-
295
- // Transaction should be confirmed
296
- expect(client.hasPendingChanges()).toBe(false);
297
- expect(client.get()?.title).toBe("Offline Change");
298
- expect(client.getServerState()?.title).toBe("Offline Change");
299
- });
300
-
301
- it("should handle multiple queued transactions being confirmed in order", async () => {
302
- const client = ClientDocument.make({
303
- schema: TestSchema,
304
- transport,
305
- initialState: { title: "", count: 0, items: [] },
306
- });
307
-
308
- // Create multiple transactions while offline
309
- client.transaction((root) => {
310
- root.title.set("First");
311
- });
312
-
313
- client.transaction((root) => {
314
- root.count.set(42);
315
- });
316
-
317
- expect(client.getPendingCount()).toBe(2);
318
- const tx1 = transport.sentTransactions[0]!;
319
- const tx2 = transport.sentTransactions[1]!;
320
-
321
- // Connect
322
- await client.connect();
323
-
324
- // Server confirms first transaction
325
- transport.simulateServerMessage({
326
- type: "transaction",
327
- transaction: tx1,
328
- version: 1,
329
- });
330
-
331
- expect(client.getPendingCount()).toBe(1);
332
- expect(client.getServerState()?.title).toBe("First");
333
-
334
- // Server confirms second transaction
335
- transport.simulateServerMessage({
336
- type: "transaction",
337
- transaction: tx2,
338
- version: 2,
339
- });
340
-
341
- expect(client.getPendingCount()).toBe(0);
342
- expect(client.getServerState()?.count).toBe(42);
343
- });
344
-
345
- it("should handle rejection of queued transactions", async () => {
346
- let rejectedTx: Transaction.Transaction | null = null;
347
- let rejectionReason: string | null = null;
348
-
349
- const client = ClientDocument.make({
350
- schema: TestSchema,
351
- transport,
352
- initialState: { title: "Original", count: 0, items: [] },
353
- onRejection: (tx, reason) => {
354
- rejectedTx = tx;
355
- rejectionReason = reason;
356
- },
357
- });
358
-
359
- // Create transaction while offline
360
- client.transaction((root) => {
361
- root.title.set("Offline Change");
362
- });
363
-
364
- const pendingTx = transport.sentTransactions[0]!;
365
-
366
- // Connect
367
- await client.connect();
368
-
369
- // Server rejects the transaction
370
- transport.simulateServerMessage({
371
- type: "error",
372
- transactionId: pendingTx.id,
373
- reason: "Conflict with another user",
374
- });
375
-
376
- // Transaction should be removed from pending
377
- expect(client.hasPendingChanges()).toBe(false);
378
- // Optimistic state should revert to server state
379
- expect(client.get()?.title).toBe("Original");
380
- // Rejection callback should be called
381
- expect(rejectedTx).not.toBeNull();
382
- expect(rejectionReason).toBe("Conflict with another user");
383
- });
384
-
385
- it("should rebase queued transactions against concurrent server changes", async () => {
386
- const client = ClientDocument.make({
387
- schema: TestSchema,
388
- transport,
389
- initialState: { title: "", count: 0, items: [] },
390
- });
391
-
392
- // Create transaction while offline
393
- client.transaction((root) => {
394
- root.title.set("My Title");
395
- });
396
-
397
- await client.connect();
398
-
399
- // Another user's change comes in first
400
- const otherUserTx = Transaction.make([
401
- {
402
- kind: "number.set" as const,
403
- path: OperationPath.make("count"),
404
- payload: 100,
405
- },
406
- ]);
407
-
408
- transport.simulateServerMessage({
409
- type: "transaction",
410
- transaction: otherUserTx,
411
- version: 1,
412
- });
413
-
414
- // Our transaction should still be pending
415
- expect(client.hasPendingChanges()).toBe(true);
416
- // Server state should reflect other user's change
417
- expect(client.getServerState()?.count).toBe(100);
418
- // Optimistic state should have both changes
419
- expect(client.get()?.count).toBe(100);
420
- expect(client.get()?.title).toBe("My Title");
421
- });
422
-
423
- it("should work with disconnect and reconnect cycle", async () => {
424
- const client = ClientDocument.make({
425
- schema: TestSchema,
426
- transport,
427
- initialState: { title: "", count: 0, items: [] },
428
- });
429
-
430
- // Connect first
431
- await client.connect();
432
-
433
- client.transaction((root) => {
434
- root.title.set("Online Change");
435
- });
436
-
437
- // Disconnect
438
- client.disconnect();
439
-
440
- // Create transaction while disconnected
441
- client.transaction((root) => {
442
- root.count.set(50);
443
- });
444
-
445
- // State should still be optimistically updated
446
- expect(client.get()?.title).toBe("Online Change");
447
- expect(client.get()?.count).toBe(50);
448
- expect(client.getPendingCount()).toBe(2);
449
-
450
- // Reconnect
451
- await client.connect();
452
-
453
- // Server confirms both transactions
454
- transport.simulateServerMessage({
455
- type: "transaction",
456
- transaction: transport.sentTransactions[0]!,
457
- version: 1,
458
- });
459
-
460
- transport.simulateServerMessage({
461
- type: "transaction",
462
- transaction: transport.sentTransactions[1]!,
463
- version: 2,
464
- });
465
-
466
- expect(client.hasPendingChanges()).toBe(false);
467
- expect(client.getServerState()?.title).toBe("Online Change");
468
- expect(client.getServerState()?.count).toBe(50);
469
- });
470
-
471
- it("should preserve pending transactions during brief disconnection", async () => {
472
- const client = ClientDocument.make({
473
- schema: TestSchema,
474
- transport,
475
- initialState: { title: "", count: 0, items: [] },
476
- });
477
-
478
- await client.connect();
479
-
480
- // Create a transaction
481
- client.transaction((root) => {
482
- root.title.set("Before Disconnect");
483
- });
484
-
485
- const pendingCount = client.getPendingCount();
486
- expect(pendingCount).toBe(1);
487
-
488
- // Simulate brief disconnection
489
- client.disconnect();
490
-
491
- // Pending transactions should still be there
492
- expect(client.getPendingCount()).toBe(pendingCount);
493
- expect(client.get()?.title).toBe("Before Disconnect");
494
- });
495
-
496
- it("should handle multiple field changes while offline", () => {
497
- const client = ClientDocument.make({
498
- schema: TestSchema,
499
- transport,
500
- initialState: { title: "", count: 0, items: [] },
501
- });
502
-
503
- // Create multiple transactions affecting different fields while offline
504
- client.transaction((root) => {
505
- root.title.set("First Title");
506
- });
507
-
508
- client.transaction((root) => {
509
- root.count.set(10);
510
- });
511
-
512
- client.transaction((root) => {
513
- root.title.set("Final Title");
514
- root.count.set(20);
515
- });
516
-
517
- // All changes should be applied optimistically
518
- expect(client.get()?.title).toBe("Final Title");
519
- expect(client.get()?.count).toBe(20);
520
-
521
- // All transactions queued
522
- expect(transport.sentTransactions.length).toBe(3);
523
- expect(client.getPendingCount()).toBe(3);
524
- });
525
- });
526
-
527
- describe("server transaction handling", () => {
528
- it("should confirm our pending transaction when server broadcasts it", async () => {
529
- const client = ClientDocument.make({
530
- schema: TestSchema,
531
- transport,
532
- initialState: { title: "", count: 0, items: [] },
533
- });
534
-
535
- await client.connect();
536
-
537
- client.transaction((root) => {
538
- root.title.set("My Change");
539
- });
540
-
541
- const sentTx = transport.sentTransactions[0]!;
542
- expect(client.hasPendingChanges()).toBe(true);
543
-
544
- // Server broadcasts our transaction
545
- transport.simulateServerMessage({
546
- type: "transaction",
547
- transaction: sentTx,
548
- version: 1,
549
- });
550
-
551
- expect(client.hasPendingChanges()).toBe(false);
552
- expect(client.get()?.title).toBe("My Change");
553
- expect(client.getServerState()?.title).toBe("My Change");
554
- });
555
-
556
- it("should rebase pending changes when server transaction arrives", async () => {
557
- const client = ClientDocument.make({
558
- schema: TestSchema,
559
- transport,
560
- initialState: { title: "Original", count: 0, items: [] },
561
- });
562
-
563
- await client.connect();
564
-
565
- // Make a local change to title
566
- client.transaction((root) => {
567
- root.title.set("Client Title");
568
- });
569
-
570
- expect(client.get()?.title).toBe("Client Title");
571
-
572
- // Server sends a different transaction (e.g., count change)
573
- const serverTx = Transaction.make([
574
- {
575
- kind: "number.set",
576
- path: { _tag: "OperationPath" as const, toTokens: () => ["count"], concat: () => ({} as any), append: () => ({} as any), pop: () => ({} as any), shift: () => ({} as any) },
577
- payload: 100,
578
- },
579
- ]);
580
-
581
- transport.simulateServerMessage({
582
- type: "transaction",
583
- transaction: serverTx,
584
- version: 1,
585
- });
586
-
587
- // Our pending change should still be there
588
- expect(client.hasPendingChanges()).toBe(true);
589
- expect(client.get()?.title).toBe("Client Title");
590
- expect(client.getServerState()?.count).toBe(100);
591
- });
592
- });
593
-
594
- describe("rejection handling", () => {
595
- it("should handle transaction rejection and notify callback", async () => {
596
- let rejectedTx: Transaction.Transaction | null = null;
597
- let rejectionReason: string | null = null;
598
-
599
- const client = ClientDocument.make({
600
- schema: TestSchema,
601
- transport,
602
- initialState: { title: "Original", count: 0, items: [] },
603
- onRejection: (tx, reason) => {
604
- rejectedTx = tx;
605
- rejectionReason = reason;
606
- },
607
- });
608
-
609
- await client.connect();
610
-
611
- client.transaction((root) => {
612
- root.title.set("Rejected Change");
613
- });
614
-
615
- const sentTx = transport.sentTransactions[0]!;
616
-
617
- // Server rejects the transaction
618
- transport.simulateServerMessage({
619
- type: "error",
620
- transactionId: sentTx.id,
621
- reason: "Invalid operation",
622
- });
623
-
624
- expect(client.hasPendingChanges()).toBe(false);
625
- expect(client.get()?.title).toBe("Original"); // Rolled back
626
- expect((rejectedTx as unknown as Transaction.Transaction | null)?.id).toBe(sentTx.id);
627
- expect(rejectionReason).toBe("Invalid operation");
628
- });
629
- });
630
-
631
- describe("snapshot handling", () => {
632
- it("should reset state when receiving snapshot", async () => {
633
- let rejectionCount = 0;
634
-
635
- const client = ClientDocument.make({
636
- schema: TestSchema,
637
- transport,
638
- initialState: { title: "Old", count: 0, items: [] },
639
- onRejection: () => {
640
- rejectionCount++;
641
- },
642
- });
643
-
644
- await client.connect();
645
-
646
- // Make some pending changes
647
- client.transaction((root) => {
648
- root.title.set("Pending 1");
649
- });
650
- client.transaction((root) => {
651
- root.count.set(50);
652
- });
653
-
654
- expect(client.getPendingCount()).toBe(2);
655
-
656
- // Server sends snapshot
657
- transport.simulateServerMessage({
658
- type: "snapshot",
659
- state: { title: "Server Title", count: 100, items: [] },
660
- version: 10,
661
- });
662
-
663
- expect(client.hasPendingChanges()).toBe(false);
664
- expect(client.get()?.title).toBe("Server Title");
665
- expect(client.get()?.count).toBe(100);
666
- expect(client.getServerVersion()).toBe(10);
667
- expect(rejectionCount).toBe(2); // Both pending were rejected
668
- });
669
- });
670
-
671
- describe("connection management", () => {
672
- it("should track connection status", async () => {
673
- const client = ClientDocument.make({
674
- schema: TestSchema,
675
- transport,
676
- initialState: { title: "", count: 0, items: [] },
677
- });
678
-
679
- expect(client.isConnected()).toBe(false);
680
-
681
- await client.connect();
682
- expect(client.isConnected()).toBe(true);
683
-
684
- client.disconnect();
685
- expect(client.isConnected()).toBe(false);
686
- });
687
- });
688
-
689
- describe("initialization", () => {
690
- it("should be ready immediately with initial state", async () => {
691
- const client = ClientDocument.make({
692
- schema: TestSchema,
693
- transport,
694
- initialState: { title: "Initial", count: 0, items: [] },
695
- });
696
-
697
- expect(client.isReady()).toBe(true);
698
- await client.connect();
699
- expect(client.isReady()).toBe(true);
700
- });
701
-
702
- it("should buffer transactions during initialization", async () => {
703
- let readyCalled = false;
704
-
705
- // Create a transport that doesn't auto-send snapshot
706
- const manualTransport = createMockTransport();
707
-
708
- const client = ClientDocument.make({
709
- schema: TestSchema,
710
- transport: manualTransport,
711
- onReady: () => {
712
- readyCalled = true;
713
- },
714
- });
715
-
716
- // Start connecting (this will enter initializing state and request snapshot)
717
- const connectPromise = client.connect();
718
-
719
- // Wait a tick for the connection to start
720
- await new Promise((resolve) => setTimeout(resolve, 10));
721
-
722
- // Simulate transactions arriving before snapshot
723
- manualTransport.simulateServerMessage({
724
- type: "transaction",
725
- transaction: Transaction.make([
726
- {
727
- kind: "string.set" as const,
728
- path: OperationPath.make("title"),
729
- payload: "From TX v2",
730
- },
731
- ]),
732
- version: 2,
733
- });
734
-
735
- manualTransport.simulateServerMessage({
736
- type: "transaction",
737
- transaction: Transaction.make([
738
- {
739
- kind: "number.set" as const,
740
- path: OperationPath.make("count"),
741
- payload: 100,
742
- },
743
- ]),
744
- version: 3,
745
- });
746
-
747
- // Now send snapshot at version 1 (older than buffered transactions)
748
- manualTransport.simulateServerMessage({
749
- type: "snapshot",
750
- state: { title: "Snapshot Title", count: 0, items: [] },
751
- version: 1,
752
- });
753
-
754
- // Wait for connect to complete
755
- await connectPromise;
756
-
757
- // Should be ready now
758
- expect(client.isReady()).toBe(true);
759
- expect(readyCalled).toBe(true);
760
-
761
- // State should include buffered transactions applied on top of snapshot
762
- expect(client.get()?.title).toBe("From TX v2");
763
- expect(client.get()?.count).toBe(100);
764
- expect(client.getServerVersion()).toBe(3);
765
- });
766
-
767
- it("should ignore buffered transactions older than snapshot", async () => {
768
- const manualTransport = createMockTransport();
769
-
770
- const client = ClientDocument.make({
771
- schema: TestSchema,
772
- transport: manualTransport,
773
- });
774
-
775
- const connectPromise = client.connect();
776
- await new Promise((resolve) => setTimeout(resolve, 10));
777
-
778
- // Simulate old transaction arriving before snapshot
779
- manualTransport.simulateServerMessage({
780
- type: "transaction",
781
- transaction: Transaction.make([
782
- {
783
- kind: "string.set" as const,
784
- path: OperationPath.make("title"),
785
- payload: "Old Title",
786
- },
787
- ]),
788
- version: 1,
789
- });
790
-
791
- // Send snapshot at version 5 (newer than buffered transaction)
792
- manualTransport.simulateServerMessage({
793
- type: "snapshot",
794
- state: { title: "Snapshot Title", count: 50, items: [] },
795
- version: 5,
796
- });
797
-
798
- await connectPromise;
799
-
800
- // State should be from snapshot, old transaction should be ignored
801
- expect(client.get()?.title).toBe("Snapshot Title");
802
- expect(client.get()?.count).toBe(50);
803
- expect(client.getServerVersion()).toBe(5);
804
- });
805
-
806
- it("should throw when creating transaction before ready", async () => {
807
- const manualTransport = createMockTransport();
808
-
809
- const client = ClientDocument.make({
810
- schema: TestSchema,
811
- transport: manualTransport,
812
- });
813
-
814
- // Start connecting but don't complete
815
- const connectPromise = client.connect();
816
- await new Promise((resolve) => setTimeout(resolve, 10));
817
-
818
- // Try to create transaction - should fail
819
- expect(() => {
820
- client.transaction((root) => {
821
- root.title.set("Test");
822
- });
823
- }).toThrow("Client is not ready");
824
-
825
- // Complete initialization
826
- manualTransport.simulateServerMessage({
827
- type: "snapshot",
828
- state: { title: "", count: 0, items: [] },
829
- version: 1,
830
- });
831
-
832
- await connectPromise;
833
-
834
- // Now transaction should work
835
- expect(() => {
836
- client.transaction((root) => {
837
- root.title.set("Test");
838
- });
839
- }).not.toThrow();
840
- });
841
-
842
- it("should timeout initialization if snapshot never arrives", async () => {
843
- const manualTransport = createMockTransport();
844
-
845
- const client = ClientDocument.make({
846
- schema: TestSchema,
847
- transport: manualTransport,
848
- initTimeout: 50, // Very short timeout for testing
849
- });
850
-
851
- // Start connecting - should timeout
852
- await expect(client.connect()).rejects.toThrow("Initialization timed out");
853
-
854
- // Should not be ready
855
- expect(client.isReady()).toBe(false);
856
- });
857
-
858
- it("should handle disconnect during initialization", async () => {
859
- const manualTransport = createMockTransport();
860
-
861
- const client = ClientDocument.make({
862
- schema: TestSchema,
863
- transport: manualTransport,
864
- });
865
-
866
- const connectPromise = client.connect();
867
- await new Promise((resolve) => setTimeout(resolve, 10));
868
-
869
- // Disconnect while waiting for snapshot
870
- client.disconnect();
871
-
872
- // Connect should reject
873
- await expect(connectPromise).rejects.toThrow("Disconnected during initialization");
874
-
875
- expect(client.isReady()).toBe(false);
876
- });
877
- });
878
- });
879
-
880
- // =============================================================================
881
- // Rebase Tests
882
- // =============================================================================
883
-
884
- describe("Rebase", () => {
885
- describe("transformOperation", () => {
886
- it("should not transform operations on different paths", () => {
887
- const clientOp = {
888
- kind: "string.set" as const,
889
- path: OperationPath.make("title"),
890
- payload: "client",
891
- };
892
-
893
- const serverOp = {
894
- kind: "number.set" as const,
895
- path: OperationPath.make("count"),
896
- payload: 100,
897
- };
898
-
899
- const result = Rebase.transformOperation(clientOp, serverOp);
900
-
901
- expect(result.type).toBe("transformed");
902
- if (result.type === "transformed") {
903
- expect(result.operation).toBe(clientOp);
904
- }
905
- });
906
-
907
- it("should handle same-path operations (client wins)", () => {
908
- const clientOp = {
909
- kind: "string.set" as const,
910
- path: OperationPath.make("title"),
911
- payload: "client",
912
- };
913
-
914
- const serverOp = {
915
- kind: "string.set" as const,
916
- path: OperationPath.make("title"),
917
- payload: "server",
918
- };
919
-
920
- const result = Rebase.transformOperation(clientOp, serverOp);
921
-
922
- expect(result.type).toBe("transformed");
923
- if (result.type === "transformed") {
924
- expect(result.operation.payload).toBe("client");
925
- }
926
- });
927
-
928
- it("should make client op noop when server removes target element", () => {
929
- const clientOp = {
930
- kind: "string.set" as const,
931
- path: OperationPath.make("items/item-1/name"),
932
- payload: "new name",
933
- };
934
-
935
- const serverOp = {
936
- kind: "array.remove" as const,
937
- path: OperationPath.make("items"),
938
- payload: { id: "item-1" },
939
- };
940
-
941
- const result = Rebase.transformOperation(clientOp, serverOp);
942
-
943
- expect(result.type).toBe("noop");
944
- });
945
- });
946
-
947
- describe("rebasePendingTransactions", () => {
948
- it("should transform all pending transactions against server transaction", () => {
949
- const pending1 = Transaction.make([
950
- {
951
- kind: "string.set" as const,
952
- path: OperationPath.make("title"),
953
- payload: "pending1",
954
- },
955
- ]);
956
-
957
- const pending2 = Transaction.make([
958
- {
959
- kind: "number.set" as const,
960
- path: OperationPath.make("count"),
961
- payload: 10,
962
- },
963
- ]);
964
-
965
- const serverTx = Transaction.make([
966
- {
967
- kind: "string.set" as const,
968
- path: OperationPath.make("description"),
969
- payload: "server desc",
970
- },
971
- ]);
972
-
973
- const rebased = Rebase.rebasePendingTransactions([pending1, pending2], serverTx);
974
-
975
- expect(rebased.length).toBe(2);
976
- expect(rebased[0]!.id).toBe(pending1.id);
977
- expect(rebased[1]!.id).toBe(pending2.id);
978
- });
979
- });
980
- });
981
-
982
- // =============================================================================
983
- // StateMonitor Tests
984
- // =============================================================================
985
-
986
- describe("StateMonitor", () => {
987
- describe("version tracking", () => {
988
- it("should accept sequential versions", () => {
989
- const monitor = StateMonitor.make();
990
-
991
- expect(monitor.onServerVersion(1)).toBe(true);
992
- expect(monitor.onServerVersion(2)).toBe(true);
993
- expect(monitor.onServerVersion(3)).toBe(true);
994
- });
995
-
996
- it("should detect large version gaps", () => {
997
- let driftDetected = false;
998
-
999
- const monitor = StateMonitor.make({
1000
- maxVersionGap: 5,
1001
- onEvent: (event) => {
1002
- if (event.type === "drift_detected") {
1003
- driftDetected = true;
1004
- }
1005
- },
1006
- });
1007
-
1008
- monitor.onServerVersion(1);
1009
- const result = monitor.onServerVersion(20); // Gap of 19
1010
-
1011
- expect(result).toBe(false);
1012
- expect(driftDetected).toBe(true);
1013
- });
1014
- });
1015
-
1016
- describe("pending tracking", () => {
1017
- it("should track and untrack pending transactions", () => {
1018
- const monitor = StateMonitor.make();
1019
-
1020
- monitor.trackPending({ id: "tx-1", sentAt: Date.now() });
1021
- monitor.trackPending({ id: "tx-2", sentAt: Date.now() });
1022
-
1023
- expect(monitor.getStatus().pendingCount).toBe(2);
1024
-
1025
- monitor.untrackPending("tx-1");
1026
-
1027
- expect(monitor.getStatus().pendingCount).toBe(1);
1028
- });
1029
-
1030
- it("should identify stale pending transactions", () => {
1031
- const monitor = StateMonitor.make({
1032
- stalePendingThreshold: 100, // 100ms for testing
1033
- });
1034
-
1035
- const oldTime = Date.now() - 200; // 200ms ago
1036
- monitor.trackPending({ id: "tx-old", sentAt: oldTime });
1037
- monitor.trackPending({ id: "tx-new", sentAt: Date.now() });
1038
-
1039
- const stale = monitor.getStalePending();
1040
-
1041
- expect(stale.length).toBe(1);
1042
- expect(stale[0]!.id).toBe("tx-old");
1043
- });
1044
- });
1045
-
1046
- describe("reset", () => {
1047
- it("should clear state on reset", () => {
1048
- let recoveryCompleted = false;
1049
-
1050
- const monitor = StateMonitor.make({
1051
- onEvent: (event) => {
1052
- if (event.type === "recovery_completed") {
1053
- recoveryCompleted = true;
1054
- }
1055
- },
1056
- });
1057
-
1058
- monitor.trackPending({ id: "tx-1", sentAt: Date.now() });
1059
- monitor.onServerVersion(5);
1060
-
1061
- monitor.reset(10);
1062
-
1063
- expect(monitor.getStatus().pendingCount).toBe(0);
1064
- expect(monitor.getStatus().expectedVersion).toBe(10);
1065
- expect(recoveryCompleted).toBe(true);
1066
- });
1067
- });
1068
- });
1069
-
1070
- // =============================================================================
1071
- // ClientDocument Presence Tests
1072
- // =============================================================================
1073
-
1074
- const CursorPresenceSchema = Presence.make({
1075
- schema: Schema.Struct({
1076
- x: Schema.Number,
1077
- y: Schema.Number,
1078
- name: Schema.optional(Schema.String),
1079
- }),
1080
- });
1081
-
1082
- describe("ClientDocument Presence", () => {
1083
- let transport: ReturnType<typeof createMockTransport>;
1084
-
1085
- beforeEach(() => {
1086
- transport = createMockTransport();
1087
- });
1088
-
1089
- describe("presence API availability", () => {
1090
- it("should have undefined presence when no presence schema provided", async () => {
1091
- const client = ClientDocument.make({
1092
- schema: TestSchema,
1093
- transport,
1094
- initialState: { title: "", count: 0, items: [] },
1095
- });
1096
-
1097
- await client.connect();
1098
-
1099
- expect(client.presence).toBeUndefined();
1100
- });
1101
-
1102
- it("should have defined presence when presence schema provided", async () => {
1103
- const client = ClientDocument.make({
1104
- schema: TestSchema,
1105
- transport,
1106
- initialState: { title: "", count: 0, items: [] },
1107
- presence: CursorPresenceSchema,
1108
- });
1109
-
1110
- await client.connect();
1111
-
1112
- expect(client.presence).toBeDefined();
1113
- expect(typeof client.presence!.selfId).toBe("function");
1114
- expect(typeof client.presence!.self).toBe("function");
1115
- expect(typeof client.presence!.others).toBe("function");
1116
- expect(typeof client.presence!.all).toBe("function");
1117
- expect(typeof client.presence!.set).toBe("function");
1118
- expect(typeof client.presence!.clear).toBe("function");
1119
- expect(typeof client.presence!.subscribe).toBe("function");
1120
- });
1121
- });
1122
-
1123
- describe("selfId", () => {
1124
- it("should return undefined before presence_snapshot received", async () => {
1125
- const client = ClientDocument.make({
1126
- schema: TestSchema,
1127
- transport,
1128
- initialState: { title: "", count: 0, items: [] },
1129
- presence: CursorPresenceSchema,
1130
- });
1131
-
1132
- await client.connect();
1133
-
1134
- expect(client.presence!.selfId()).toBeUndefined();
1135
- });
1136
-
1137
- it("should return correct id after presence_snapshot received", async () => {
1138
- const client = ClientDocument.make({
1139
- schema: TestSchema,
1140
- transport,
1141
- initialState: { title: "", count: 0, items: [] },
1142
- presence: CursorPresenceSchema,
1143
- });
1144
-
1145
- await client.connect();
1146
-
1147
- transport.simulateServerMessage({
1148
- type: "presence_snapshot",
1149
- selfId: "conn-my-id",
1150
- presences: {},
1151
- });
1152
-
1153
- expect(client.presence!.selfId()).toBe("conn-my-id");
1154
- });
1155
- });
1156
-
1157
- describe("self", () => {
1158
- it("should return undefined before set is called", async () => {
1159
- const client = ClientDocument.make({
1160
- schema: TestSchema,
1161
- transport,
1162
- initialState: { title: "", count: 0, items: [] },
1163
- presence: CursorPresenceSchema,
1164
- });
1165
-
1166
- await client.connect();
1167
-
1168
- expect(client.presence!.self()).toBeUndefined();
1169
- });
1170
-
1171
- it("should return data after set is called", async () => {
1172
- const client = ClientDocument.make({
1173
- schema: TestSchema,
1174
- transport,
1175
- initialState: { title: "", count: 0, items: [] },
1176
- presence: CursorPresenceSchema,
1177
- });
1178
-
1179
- await client.connect();
1180
-
1181
- client.presence!.set({ x: 100, y: 200 });
1182
-
1183
- expect(client.presence!.self()).toEqual({ x: 100, y: 200 });
1184
- });
1185
- });
1186
-
1187
- describe("others", () => {
1188
- it("should return empty map initially", async () => {
1189
- const client = ClientDocument.make({
1190
- schema: TestSchema,
1191
- transport,
1192
- initialState: { title: "", count: 0, items: [] },
1193
- presence: CursorPresenceSchema,
1194
- });
1195
-
1196
- await client.connect();
1197
-
1198
- expect(client.presence!.others().size).toBe(0);
1199
- });
1200
-
1201
- it("should return other presences from snapshot", async () => {
1202
- const client = ClientDocument.make({
1203
- schema: TestSchema,
1204
- transport,
1205
- initialState: { title: "", count: 0, items: [] },
1206
- presence: CursorPresenceSchema,
1207
- });
1208
-
1209
- await client.connect();
1210
-
1211
- transport.simulateServerMessage({
1212
- type: "presence_snapshot",
1213
- selfId: "conn-me",
1214
- presences: {
1215
- "conn-other-1": { data: { x: 10, y: 20 }, userId: "user-1" },
1216
- "conn-other-2": { data: { x: 30, y: 40 } },
1217
- },
1218
- });
1219
-
1220
- const others = client.presence!.others();
1221
- expect(others.size).toBe(2);
1222
- expect(others.get("conn-other-1")).toEqual({ data: { x: 10, y: 20 }, userId: "user-1" });
1223
- expect(others.get("conn-other-2")).toEqual({ data: { x: 30, y: 40 } });
1224
- });
1225
-
1226
- it("should update on presence_update", async () => {
1227
- const client = ClientDocument.make({
1228
- schema: TestSchema,
1229
- transport,
1230
- initialState: { title: "", count: 0, items: [] },
1231
- presence: CursorPresenceSchema,
1232
- });
1233
-
1234
- await client.connect();
1235
-
1236
- transport.simulateServerMessage({
1237
- type: "presence_snapshot",
1238
- selfId: "conn-me",
1239
- presences: {},
1240
- });
1241
-
1242
- transport.simulateServerMessage({
1243
- type: "presence_update",
1244
- id: "conn-new-user",
1245
- data: { x: 50, y: 60 },
1246
- userId: "user-new",
1247
- });
1248
-
1249
- const others = client.presence!.others();
1250
- expect(others.size).toBe(1);
1251
- expect(others.get("conn-new-user")).toEqual({ data: { x: 50, y: 60 }, userId: "user-new" });
1252
- });
1253
-
1254
- it("should remove on presence_remove", async () => {
1255
- const client = ClientDocument.make({
1256
- schema: TestSchema,
1257
- transport,
1258
- initialState: { title: "", count: 0, items: [] },
1259
- presence: CursorPresenceSchema,
1260
- });
1261
-
1262
- await client.connect();
1263
-
1264
- transport.simulateServerMessage({
1265
- type: "presence_snapshot",
1266
- selfId: "conn-me",
1267
- presences: {
1268
- "conn-leaving": { data: { x: 10, y: 20 } },
1269
- },
1270
- });
1271
-
1272
- expect(client.presence!.others().size).toBe(1);
1273
-
1274
- transport.simulateServerMessage({
1275
- type: "presence_remove",
1276
- id: "conn-leaving",
1277
- });
1278
-
1279
- expect(client.presence!.others().size).toBe(0);
1280
- });
1281
- });
1282
-
1283
- describe("all", () => {
1284
- it("should combine self and others", async () => {
1285
- const client = ClientDocument.make({
1286
- schema: TestSchema,
1287
- transport,
1288
- initialState: { title: "", count: 0, items: [] },
1289
- presence: CursorPresenceSchema,
1290
- });
1291
-
1292
- await client.connect();
1293
-
1294
- transport.simulateServerMessage({
1295
- type: "presence_snapshot",
1296
- selfId: "conn-me",
1297
- presences: {
1298
- "conn-other": { data: { x: 10, y: 20 } },
1299
- },
1300
- });
1301
-
1302
- client.presence!.set({ x: 100, y: 200 });
1303
-
1304
- const all = client.presence!.all();
1305
- expect(all.size).toBe(2);
1306
- expect(all.get("conn-me")).toEqual({ data: { x: 100, y: 200 } });
1307
- expect(all.get("conn-other")).toEqual({ data: { x: 10, y: 20 } });
1308
- });
1309
-
1310
- it("should not include self if self data not set", async () => {
1311
- const client = ClientDocument.make({
1312
- schema: TestSchema,
1313
- transport,
1314
- initialState: { title: "", count: 0, items: [] },
1315
- presence: CursorPresenceSchema,
1316
- });
1317
-
1318
- await client.connect();
1319
-
1320
- transport.simulateServerMessage({
1321
- type: "presence_snapshot",
1322
- selfId: "conn-me",
1323
- presences: {
1324
- "conn-other": { data: { x: 10, y: 20 } },
1325
- },
1326
- });
1327
-
1328
- const all = client.presence!.all();
1329
- expect(all.size).toBe(1);
1330
- expect(all.has("conn-me")).toBe(false);
1331
- expect(all.get("conn-other")).toEqual({ data: { x: 10, y: 20 } });
1332
- });
1333
- });
1334
-
1335
- describe("initialPresence", () => {
1336
- it("should set presence to initialPresence value on connect", async () => {
1337
- const client = ClientDocument.make({
1338
- schema: TestSchema,
1339
- transport,
1340
- initialState: { title: "", count: 0, items: [] },
1341
- presence: CursorPresenceSchema,
1342
- initialPresence: { x: 50, y: 100, name: "Initial User" },
1343
- });
1344
-
1345
- await client.connect();
1346
-
1347
- expect(client.presence!.self()).toEqual({ x: 50, y: 100, name: "Initial User" });
1348
- });
1349
-
1350
- it("should send initialPresence to transport on connect", async () => {
1351
- const client = ClientDocument.make({
1352
- schema: TestSchema,
1353
- transport,
1354
- initialState: { title: "", count: 0, items: [] },
1355
- presence: CursorPresenceSchema,
1356
- initialPresence: { x: 25, y: 75 },
1357
- });
1358
-
1359
- await client.connect();
1360
-
1361
- expect(transport.presenceSetCalls.length).toBe(1);
1362
- expect(transport.presenceSetCalls[0]).toEqual({ x: 25, y: 75 });
1363
- });
1364
-
1365
- it("should notify subscribers when initialPresence is set", async () => {
1366
- const client = ClientDocument.make({
1367
- schema: TestSchema,
1368
- transport,
1369
- initialState: { title: "", count: 0, items: [] },
1370
- presence: CursorPresenceSchema,
1371
- initialPresence: { x: 10, y: 20 },
1372
- });
1373
-
1374
- let changeCount = 0;
1375
- client.presence!.subscribe({
1376
- onPresenceChange: () => {
1377
- changeCount++;
1378
- },
1379
- });
1380
-
1381
- await client.connect();
1382
-
1383
- expect(changeCount).toBe(1);
1384
- });
1385
-
1386
- it("should not set presence when initialPresence is not provided", async () => {
1387
- const client = ClientDocument.make({
1388
- schema: TestSchema,
1389
- transport,
1390
- initialState: { title: "", count: 0, items: [] },
1391
- presence: CursorPresenceSchema,
1392
- });
1393
-
1394
- await client.connect();
1395
-
1396
- expect(client.presence!.self()).toBeUndefined();
1397
- expect(transport.presenceSetCalls.length).toBe(0);
1398
- });
1399
- });
1400
-
1401
- describe("set", () => {
1402
- it("should validate data against schema", async () => {
1403
- const client = ClientDocument.make({
1404
- schema: TestSchema,
1405
- transport,
1406
- initialState: { title: "", count: 0, items: [] },
1407
- presence: CursorPresenceSchema,
1408
- });
1409
-
1410
- await client.connect();
1411
-
1412
- // Valid data should not throw
1413
- expect(() => {
1414
- client.presence!.set({ x: 100, y: 200 });
1415
- }).not.toThrow();
1416
-
1417
- // Invalid data should throw
1418
- expect(() => {
1419
- client.presence!.set({ x: "invalid", y: 200 } as any);
1420
- }).toThrow();
1421
- });
1422
-
1423
- it("should send presence data to transport", async () => {
1424
- const client = ClientDocument.make({
1425
- schema: TestSchema,
1426
- transport,
1427
- initialState: { title: "", count: 0, items: [] },
1428
- presence: CursorPresenceSchema,
1429
- });
1430
-
1431
- await client.connect();
1432
-
1433
- client.presence!.set({ x: 100, y: 200, name: "Alice" });
1434
-
1435
- expect(transport.presenceSetCalls.length).toBe(1);
1436
- expect(transport.presenceSetCalls[0]).toEqual({ x: 100, y: 200, name: "Alice" });
1437
- });
1438
-
1439
- it("should update local self state", async () => {
1440
- const client = ClientDocument.make({
1441
- schema: TestSchema,
1442
- transport,
1443
- initialState: { title: "", count: 0, items: [] },
1444
- presence: CursorPresenceSchema,
1445
- });
1446
-
1447
- await client.connect();
1448
-
1449
- expect(client.presence!.self()).toBeUndefined();
1450
-
1451
- client.presence!.set({ x: 50, y: 75 });
1452
-
1453
- expect(client.presence!.self()).toEqual({ x: 50, y: 75 });
1454
- });
1455
- });
1456
-
1457
- describe("clear", () => {
1458
- it("should send presence_clear to transport", async () => {
1459
- const client = ClientDocument.make({
1460
- schema: TestSchema,
1461
- transport,
1462
- initialState: { title: "", count: 0, items: [] },
1463
- presence: CursorPresenceSchema,
1464
- });
1465
-
1466
- await client.connect();
1467
-
1468
- client.presence!.clear();
1469
-
1470
- expect(transport.presenceClearCalls).toBe(1);
1471
- });
1472
-
1473
- it("should clear local self state", async () => {
1474
- const client = ClientDocument.make({
1475
- schema: TestSchema,
1476
- transport,
1477
- initialState: { title: "", count: 0, items: [] },
1478
- presence: CursorPresenceSchema,
1479
- });
1480
-
1481
- await client.connect();
1482
-
1483
- client.presence!.set({ x: 100, y: 200 });
1484
- expect(client.presence!.self()).toEqual({ x: 100, y: 200 });
1485
-
1486
- client.presence!.clear();
1487
- expect(client.presence!.self()).toBeUndefined();
1488
- });
1489
- });
1490
-
1491
- describe("subscribe", () => {
1492
- it("should notify on presence_snapshot", async () => {
1493
- const client = ClientDocument.make({
1494
- schema: TestSchema,
1495
- transport,
1496
- initialState: { title: "", count: 0, items: [] },
1497
- presence: CursorPresenceSchema,
1498
- });
1499
-
1500
- await client.connect();
1501
-
1502
- let changeCount = 0;
1503
- client.presence!.subscribe({
1504
- onPresenceChange: () => {
1505
- changeCount++;
1506
- },
1507
- });
1508
-
1509
- transport.simulateServerMessage({
1510
- type: "presence_snapshot",
1511
- selfId: "conn-me",
1512
- presences: {},
1513
- });
1514
-
1515
- expect(changeCount).toBe(1);
1516
- });
1517
-
1518
- it("should notify on presence_update", async () => {
1519
- const client = ClientDocument.make({
1520
- schema: TestSchema,
1521
- transport,
1522
- initialState: { title: "", count: 0, items: [] },
1523
- presence: CursorPresenceSchema,
1524
- });
1525
-
1526
- await client.connect();
1527
-
1528
- let changeCount = 0;
1529
- client.presence!.subscribe({
1530
- onPresenceChange: () => {
1531
- changeCount++;
1532
- },
1533
- });
1534
-
1535
- transport.simulateServerMessage({
1536
- type: "presence_update",
1537
- id: "conn-other",
1538
- data: { x: 10, y: 20 },
1539
- });
1540
-
1541
- expect(changeCount).toBe(1);
1542
- });
1543
-
1544
- it("should notify on presence_remove", async () => {
1545
- const client = ClientDocument.make({
1546
- schema: TestSchema,
1547
- transport,
1548
- initialState: { title: "", count: 0, items: [] },
1549
- presence: CursorPresenceSchema,
1550
- });
1551
-
1552
- await client.connect();
1553
-
1554
- let changeCount = 0;
1555
- client.presence!.subscribe({
1556
- onPresenceChange: () => {
1557
- changeCount++;
1558
- },
1559
- });
1560
-
1561
- transport.simulateServerMessage({
1562
- type: "presence_remove",
1563
- id: "conn-other",
1564
- });
1565
-
1566
- expect(changeCount).toBe(1);
1567
- });
1568
-
1569
- it("should notify on local set", async () => {
1570
- const client = ClientDocument.make({
1571
- schema: TestSchema,
1572
- transport,
1573
- initialState: { title: "", count: 0, items: [] },
1574
- presence: CursorPresenceSchema,
1575
- });
1576
-
1577
- await client.connect();
1578
-
1579
- let changeCount = 0;
1580
- client.presence!.subscribe({
1581
- onPresenceChange: () => {
1582
- changeCount++;
1583
- },
1584
- });
1585
-
1586
- client.presence!.set({ x: 100, y: 200 });
1587
-
1588
- expect(changeCount).toBe(1);
1589
- });
1590
-
1591
- it("should notify on local clear", async () => {
1592
- const client = ClientDocument.make({
1593
- schema: TestSchema,
1594
- transport,
1595
- initialState: { title: "", count: 0, items: [] },
1596
- presence: CursorPresenceSchema,
1597
- });
1598
-
1599
- await client.connect();
1600
-
1601
- let changeCount = 0;
1602
- client.presence!.subscribe({
1603
- onPresenceChange: () => {
1604
- changeCount++;
1605
- },
1606
- });
1607
-
1608
- client.presence!.clear();
1609
-
1610
- expect(changeCount).toBe(1);
1611
- });
1612
-
1613
- it("should allow unsubscribing", async () => {
1614
- const client = ClientDocument.make({
1615
- schema: TestSchema,
1616
- transport,
1617
- initialState: { title: "", count: 0, items: [] },
1618
- presence: CursorPresenceSchema,
1619
- });
1620
-
1621
- await client.connect();
1622
-
1623
- let changeCount = 0;
1624
- const unsubscribe = client.presence!.subscribe({
1625
- onPresenceChange: () => {
1626
- changeCount++;
1627
- },
1628
- });
1629
-
1630
- client.presence!.set({ x: 100, y: 200 });
1631
- expect(changeCount).toBe(1);
1632
-
1633
- unsubscribe();
1634
-
1635
- client.presence!.set({ x: 200, y: 300 });
1636
- expect(changeCount).toBe(1); // Should not increment
1637
- });
1638
- });
1639
-
1640
- describe("disconnect behavior", () => {
1641
- it("should clear presence state on disconnect", async () => {
1642
- const client = ClientDocument.make({
1643
- schema: TestSchema,
1644
- transport,
1645
- initialState: { title: "", count: 0, items: [] },
1646
- presence: CursorPresenceSchema,
1647
- });
1648
-
1649
- await client.connect();
1650
-
1651
- transport.simulateServerMessage({
1652
- type: "presence_snapshot",
1653
- selfId: "conn-me",
1654
- presences: {
1655
- "conn-other": { data: { x: 10, y: 20 } },
1656
- },
1657
- });
1658
-
1659
- client.presence!.set({ x: 100, y: 200 });
1660
-
1661
- expect(client.presence!.selfId()).toBe("conn-me");
1662
- expect(client.presence!.self()).toEqual({ x: 100, y: 200 });
1663
- expect(client.presence!.others().size).toBe(1);
1664
-
1665
- client.disconnect();
1666
-
1667
- expect(client.presence!.selfId()).toBeUndefined();
1668
- expect(client.presence!.self()).toBeUndefined();
1669
- expect(client.presence!.others().size).toBe(0);
1670
- });
1671
-
1672
- it("should notify subscribers on disconnect", async () => {
1673
- const client = ClientDocument.make({
1674
- schema: TestSchema,
1675
- transport,
1676
- initialState: { title: "", count: 0, items: [] },
1677
- presence: CursorPresenceSchema,
1678
- });
1679
-
1680
- await client.connect();
1681
-
1682
- transport.simulateServerMessage({
1683
- type: "presence_snapshot",
1684
- selfId: "conn-me",
1685
- presences: {
1686
- "conn-other": { data: { x: 10, y: 20 } },
1687
- },
1688
- });
1689
-
1690
- let changeCount = 0;
1691
- client.presence!.subscribe({
1692
- onPresenceChange: () => {
1693
- changeCount++;
1694
- },
1695
- });
1696
-
1697
- // Reset count after snapshot notification
1698
- changeCount = 0;
1699
-
1700
- client.disconnect();
1701
-
1702
- // Should notify when clearing presence
1703
- expect(changeCount).toBe(1);
1704
- });
1705
- });
1706
-
1707
- // ===========================================================================
1708
- // Draft Tests
1709
- // ===========================================================================
1710
-
1711
- describe("drafts", () => {
1712
- it("should create a draft and preview changes without sending to server", async () => {
1713
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1714
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1715
- await client.connect();
1716
-
1717
- const draft = client.createDraft();
1718
- draft.update((root) => root.title.set("Draft Title"));
1719
-
1720
- // Optimistic state should include draft
1721
- expect(client.get()?.title).toBe("Draft Title");
1722
- // No transaction sent to server
1723
- expect(transport.sentTransactions.length).toBe(0);
1724
- });
1725
-
1726
- it("should replace per-field ops on same path", async () => {
1727
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1728
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1729
- await client.connect();
1730
-
1731
- const draft = client.createDraft();
1732
- draft.update((root) => root.title.set("First"));
1733
- draft.update((root) => root.title.set("Second"));
1734
-
1735
- expect(client.get()?.title).toBe("Second");
1736
- expect(transport.sentTransactions.length).toBe(0);
1737
- });
1738
-
1739
- it("should accumulate ops across different fields", async () => {
1740
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1741
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1742
- await client.connect();
1743
-
1744
- const draft = client.createDraft();
1745
- draft.update((root) => root.title.set("New Title"));
1746
- draft.update((root) => root.count.set(42));
1747
-
1748
- expect(client.get()?.title).toBe("New Title");
1749
- expect(client.get()?.count).toBe(42);
1750
- });
1751
-
1752
- it("should commit draft as a single transaction", async () => {
1753
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1754
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1755
- await client.connect();
1756
-
1757
- const draft = client.createDraft();
1758
- draft.update((root) => root.title.set("Committed"));
1759
- draft.update((root) => root.count.set(10));
1760
- draft.commit();
1761
-
1762
- // Should have sent exactly one transaction
1763
- expect(transport.sentTransactions.length).toBe(1);
1764
- // State should still reflect the changes
1765
- expect(client.get()?.title).toBe("Committed");
1766
- expect(client.get()?.count).toBe(10);
1767
- // Draft should be consumed
1768
- expect(client.getActiveDraftIds().size).toBe(0);
1769
- });
1770
-
1771
- it("should discard draft and revert to non-draft state", async () => {
1772
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1773
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1774
- await client.connect();
1775
-
1776
- const draft = client.createDraft();
1777
- draft.update((root) => root.title.set("Draft"));
1778
-
1779
- expect(client.get()?.title).toBe("Draft");
1780
-
1781
- draft.discard();
1782
-
1783
- expect(client.get()?.title).toBe("Hello");
1784
- expect(transport.sentTransactions.length).toBe(0);
1785
- expect(client.getActiveDraftIds().size).toBe(0);
1786
- });
1787
-
1788
- it("should throw when using a consumed draft", async () => {
1789
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1790
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1791
- await client.connect();
1792
-
1793
- const draft = client.createDraft();
1794
- draft.commit();
1795
-
1796
- expect(() => draft.update((root) => root.title.set("x"))).toThrow();
1797
- expect(() => draft.commit()).toThrow();
1798
- expect(() => draft.discard()).toThrow();
1799
- });
1800
-
1801
- it("should support multiple concurrent drafts", async () => {
1802
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1803
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1804
- await client.connect();
1805
-
1806
- const draft1 = client.createDraft();
1807
- const draft2 = client.createDraft();
1808
-
1809
- draft1.update((root) => root.title.set("Draft1"));
1810
- draft2.update((root) => root.count.set(99));
1811
-
1812
- expect(client.get()?.title).toBe("Draft1");
1813
- expect(client.get()?.count).toBe(99);
1814
- expect(client.getActiveDraftIds().size).toBe(2);
1815
-
1816
- draft1.discard();
1817
- expect(client.get()?.title).toBe("Hello");
1818
- expect(client.get()?.count).toBe(99);
1819
-
1820
- draft2.commit();
1821
- expect(client.get()?.count).toBe(99);
1822
- expect(transport.sentTransactions.length).toBe(1);
1823
- });
1824
-
1825
- it("should rebase draft ops when server transaction arrives", async () => {
1826
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1827
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1828
- await client.connect();
1829
-
1830
- const draft = client.createDraft();
1831
- draft.update((root) => root.title.set("My Draft"));
1832
-
1833
- // Create a proper server transaction by using a scratch document
1834
- const scratchDoc = Document.make(TestSchema, { initialState });
1835
- scratchDoc.transaction((root) => root.count.set(50));
1836
- const serverTx = scratchDoc.flush();
1837
- // Override the ID for clarity
1838
- const serverTxWithId = { ...serverTx, id: "server-tx-1" };
1839
-
1840
- transport.simulateServerMessage({
1841
- type: "transaction",
1842
- transaction: serverTxWithId,
1843
- version: 1,
1844
- });
1845
-
1846
- // Draft title should survive, server count should be applied
1847
- expect(client.get()?.title).toBe("My Draft");
1848
- expect(client.get()?.count).toBe(50);
1849
- });
1850
-
1851
- it("should notify onDraftChange listeners", async () => {
1852
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1853
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1854
- await client.connect();
1855
-
1856
- let draftChangeCount = 0;
1857
- client.subscribe({
1858
- onDraftChange: () => { draftChangeCount++; },
1859
- });
1860
-
1861
- const draft = client.createDraft();
1862
- expect(draftChangeCount).toBe(1); // createDraft
1863
-
1864
- draft.update((root) => root.title.set("x"));
1865
- expect(draftChangeCount).toBe(2); // update
1866
-
1867
- draft.discard();
1868
- expect(draftChangeCount).toBe(3); // discard
1869
- });
1870
-
1871
- it("should commit empty draft without sending transaction", async () => {
1872
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1873
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1874
- await client.connect();
1875
-
1876
- const draft = client.createDraft();
1877
- draft.commit();
1878
-
1879
- expect(transport.sentTransactions.length).toBe(0);
1880
- });
1881
-
1882
- it("should NEVER send transactions to server during draft.update() - explicit verification", async () => {
1883
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1884
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1885
- await client.connect();
1886
-
1887
- // Track when transport.send is called
1888
- const sendCalls: Transaction.Transaction[] = [];
1889
- const originalSend = transport.send;
1890
- transport.send = (tx) => {
1891
- sendCalls.push(tx);
1892
- originalSend.call(transport, tx);
1893
- };
1894
-
1895
- const draft = client.createDraft();
1896
-
1897
- // Perform multiple updates
1898
- draft.update((root) => root.title.set("Update 1"));
1899
- expect(sendCalls.length).toBe(0);
1900
-
1901
- draft.update((root) => root.count.set(10));
1902
- expect(sendCalls.length).toBe(0);
1903
-
1904
- draft.update((root) => root.title.set("Update 2"));
1905
- expect(sendCalls.length).toBe(0);
1906
-
1907
- draft.update((root) => {
1908
- root.title.set("Update 3");
1909
- root.count.set(20);
1910
- });
1911
- expect(sendCalls.length).toBe(0);
1912
-
1913
- // Verify optimistic state is updated
1914
- expect(client.get()?.title).toBe("Update 3");
1915
- expect(client.get()?.count).toBe(20);
1916
-
1917
- // Still no transactions sent
1918
- expect(sendCalls.length).toBe(0);
1919
- expect(transport.sentTransactions.length).toBe(0);
1920
-
1921
- // Only after commit should transaction be sent
1922
- draft.commit();
1923
- expect(sendCalls.length).toBe(1);
1924
- expect(transport.sentTransactions.length).toBe(1);
1925
- });
1926
-
1927
- it("should never call transport.send during draft lifecycle until commit", async () => {
1928
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1929
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1930
- await client.connect();
1931
-
1932
- // Create a spy to track exact moments of transport.send calls
1933
- const sendTimestamps: { time: number; action: string }[] = [];
1934
- const originalSend = transport.send;
1935
- transport.send = (tx) => {
1936
- sendTimestamps.push({ time: Date.now(), action: "send" });
1937
- originalSend.call(transport, tx);
1938
- };
1939
-
1940
- // Draft operations should NOT trigger send
1941
- const draft = client.createDraft();
1942
- expect(sendTimestamps.length).toBe(0);
1943
-
1944
- draft.update((root) => root.title.set("Draft"));
1945
- expect(sendTimestamps.length).toBe(0);
1946
-
1947
- // Regular transaction SHOULD trigger send
1948
- client.transaction((root) => root.count.set(5));
1949
- expect(sendTimestamps.length).toBe(1);
1950
-
1951
- // More draft updates should NOT trigger send
1952
- draft.update((root) => root.title.set("Draft 2"));
1953
- expect(sendTimestamps.length).toBe(1);
1954
-
1955
- // Commit SHOULD trigger send
1956
- draft.commit();
1957
- expect(sendTimestamps.length).toBe(2);
1958
- });
1959
-
1960
- it("should not send transactions when draft is discarded", async () => {
1961
- const initialState: TestState = { title: "Hello", count: 0, items: [] };
1962
- const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
1963
- await client.connect();
1964
-
1965
- const draft = client.createDraft();
1966
- draft.update((root) => root.title.set("Will be discarded"));
1967
- draft.update((root) => root.count.set(999));
1968
-
1969
- expect(transport.sentTransactions.length).toBe(0);
1970
-
1971
- draft.discard();
1972
-
1973
- // Still no transactions should be sent
1974
- expect(transport.sentTransactions.length).toBe(0);
1975
-
1976
- // State should revert
1977
- expect(client.get()?.title).toBe("Hello");
1978
- expect(client.get()?.count).toBe(0);
1979
- });
1980
- });
1981
- });