@voidhash/mimic 0.0.4 → 0.0.6

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 (53) hide show
  1. package/.turbo/turbo-build.log +202 -198
  2. package/dist/Document.cjs +1 -2
  3. package/dist/Document.d.cts +9 -3
  4. package/dist/Document.d.cts.map +1 -1
  5. package/dist/Document.d.mts +9 -3
  6. package/dist/Document.d.mts.map +1 -1
  7. package/dist/Document.mjs +1 -2
  8. package/dist/Document.mjs.map +1 -1
  9. package/dist/Presence.d.mts.map +1 -1
  10. package/dist/Primitive.d.cts +2 -2
  11. package/dist/Primitive.d.mts +2 -2
  12. package/dist/_virtual/_@oxc-project_runtime@0.103.0/helpers/objectWithoutProperties.cjs +15 -0
  13. package/dist/_virtual/_@oxc-project_runtime@0.103.0/helpers/objectWithoutProperties.mjs +15 -0
  14. package/dist/_virtual/_@oxc-project_runtime@0.103.0/helpers/objectWithoutPropertiesLoose.cjs +14 -0
  15. package/dist/_virtual/_@oxc-project_runtime@0.103.0/helpers/objectWithoutPropertiesLoose.mjs +13 -0
  16. package/dist/client/ClientDocument.cjs +17 -12
  17. package/dist/client/ClientDocument.d.mts.map +1 -1
  18. package/dist/client/ClientDocument.mjs +17 -12
  19. package/dist/client/ClientDocument.mjs.map +1 -1
  20. package/dist/client/WebSocketTransport.cjs +6 -6
  21. package/dist/client/WebSocketTransport.mjs +6 -6
  22. package/dist/client/WebSocketTransport.mjs.map +1 -1
  23. package/dist/primitives/Tree.cjs +58 -8
  24. package/dist/primitives/Tree.d.cts +99 -10
  25. package/dist/primitives/Tree.d.cts.map +1 -1
  26. package/dist/primitives/Tree.d.mts +99 -10
  27. package/dist/primitives/Tree.d.mts.map +1 -1
  28. package/dist/primitives/Tree.mjs +58 -8
  29. package/dist/primitives/Tree.mjs.map +1 -1
  30. package/dist/primitives/shared.d.cts +9 -0
  31. package/dist/primitives/shared.d.cts.map +1 -1
  32. package/dist/primitives/shared.d.mts +9 -0
  33. package/dist/primitives/shared.d.mts.map +1 -1
  34. package/dist/primitives/shared.mjs.map +1 -1
  35. package/dist/server/ServerDocument.cjs +1 -1
  36. package/dist/server/ServerDocument.d.cts +3 -3
  37. package/dist/server/ServerDocument.d.cts.map +1 -1
  38. package/dist/server/ServerDocument.d.mts +3 -3
  39. package/dist/server/ServerDocument.d.mts.map +1 -1
  40. package/dist/server/ServerDocument.mjs +1 -1
  41. package/dist/server/ServerDocument.mjs.map +1 -1
  42. package/package.json +2 -2
  43. package/src/Document.ts +18 -5
  44. package/src/client/ClientDocument.ts +20 -21
  45. package/src/client/WebSocketTransport.ts +9 -9
  46. package/src/primitives/Tree.ts +213 -19
  47. package/src/primitives/shared.ts +10 -1
  48. package/src/server/ServerDocument.ts +4 -3
  49. package/tests/client/ClientDocument.test.ts +309 -2
  50. package/tests/client/WebSocketTransport.test.ts +228 -3
  51. package/tests/primitives/Tree.test.ts +296 -17
  52. package/tests/server/ServerDocument.test.ts +1 -1
  53. package/tsconfig.json +1 -1
@@ -201,18 +201,325 @@ describe("ClientDocument", () => {
201
201
  expect(transport.sentTransactions[0]!.ops[0]!.kind).toBe("number.set");
202
202
  });
203
203
 
204
- it("should throw when not connected", () => {
204
+ it("should queue transactions when not connected", () => {
205
205
  const client = ClientDocument.make({
206
206
  schema: TestSchema,
207
207
  transport,
208
208
  initialState: { title: "", count: 0, items: [] },
209
209
  });
210
210
 
211
+ // Transactions should work offline - they get queued in the transport
212
+ client.transaction((root) => {
213
+ root.title.set("Test");
214
+ });
215
+
216
+ // State should be optimistically updated
217
+ expect(client.get()?.title).toBe("Test");
218
+ // Transaction is pending
219
+ expect(client.hasPendingChanges()).toBe(true);
220
+ // Transaction was sent to transport (it will queue it)
221
+ expect(transport.sentTransactions.length).toBe(1);
222
+ });
223
+
224
+ it("should queue multiple transactions when not connected", () => {
225
+ const client = ClientDocument.make({
226
+ schema: TestSchema,
227
+ transport,
228
+ initialState: { title: "", count: 0, items: [] },
229
+ });
230
+
231
+ // Create multiple transactions while offline
232
+ client.transaction((root) => {
233
+ root.title.set("First");
234
+ });
235
+
236
+ client.transaction((root) => {
237
+ root.count.set(10);
238
+ });
239
+
240
+ client.transaction((root) => {
241
+ root.title.set("Second");
242
+ });
243
+
244
+ // All state changes should be applied optimistically
245
+ expect(client.get()?.title).toBe("Second");
246
+ expect(client.get()?.count).toBe(10);
247
+ // All transactions are pending
248
+ expect(client.getPendingCount()).toBe(3);
249
+ // All transactions were sent to transport
250
+ expect(transport.sentTransactions.length).toBe(3);
251
+ });
252
+
253
+ it("should throw when not ready (no initial state)", () => {
254
+ const client = ClientDocument.make({
255
+ schema: TestSchema,
256
+ transport,
257
+ // No initial state - client is not ready until snapshot received
258
+ });
259
+
211
260
  expect(() => {
212
261
  client.transaction((root) => {
213
262
  root.title.set("Test");
214
263
  });
215
- }).toThrow("Transport is not connected");
264
+ }).toThrow("Client is not ready");
265
+ });
266
+ });
267
+
268
+ describe("offline transaction handling", () => {
269
+ it("should confirm queued transactions after reconnection", async () => {
270
+ const client = ClientDocument.make({
271
+ schema: TestSchema,
272
+ transport,
273
+ initialState: { title: "", count: 0, items: [] },
274
+ });
275
+
276
+ // Create transaction while offline
277
+ client.transaction((root) => {
278
+ root.title.set("Offline Change");
279
+ });
280
+
281
+ expect(client.hasPendingChanges()).toBe(true);
282
+ const pendingTx = transport.sentTransactions[0]!;
283
+
284
+ // Connect and simulate server confirming the transaction
285
+ await client.connect();
286
+
287
+ // Server broadcasts our transaction (confirming it)
288
+ transport.simulateServerMessage({
289
+ type: "transaction",
290
+ transaction: pendingTx,
291
+ version: 1,
292
+ });
293
+
294
+ // Transaction should be confirmed
295
+ expect(client.hasPendingChanges()).toBe(false);
296
+ expect(client.get()?.title).toBe("Offline Change");
297
+ expect(client.getServerState()?.title).toBe("Offline Change");
298
+ });
299
+
300
+ it("should handle multiple queued transactions being confirmed in order", async () => {
301
+ const client = ClientDocument.make({
302
+ schema: TestSchema,
303
+ transport,
304
+ initialState: { title: "", count: 0, items: [] },
305
+ });
306
+
307
+ // Create multiple transactions while offline
308
+ client.transaction((root) => {
309
+ root.title.set("First");
310
+ });
311
+
312
+ client.transaction((root) => {
313
+ root.count.set(42);
314
+ });
315
+
316
+ expect(client.getPendingCount()).toBe(2);
317
+ const tx1 = transport.sentTransactions[0]!;
318
+ const tx2 = transport.sentTransactions[1]!;
319
+
320
+ // Connect
321
+ await client.connect();
322
+
323
+ // Server confirms first transaction
324
+ transport.simulateServerMessage({
325
+ type: "transaction",
326
+ transaction: tx1,
327
+ version: 1,
328
+ });
329
+
330
+ expect(client.getPendingCount()).toBe(1);
331
+ expect(client.getServerState()?.title).toBe("First");
332
+
333
+ // Server confirms second transaction
334
+ transport.simulateServerMessage({
335
+ type: "transaction",
336
+ transaction: tx2,
337
+ version: 2,
338
+ });
339
+
340
+ expect(client.getPendingCount()).toBe(0);
341
+ expect(client.getServerState()?.count).toBe(42);
342
+ });
343
+
344
+ it("should handle rejection of queued transactions", async () => {
345
+ let rejectedTx: Transaction.Transaction | null = null;
346
+ let rejectionReason: string | null = null;
347
+
348
+ const client = ClientDocument.make({
349
+ schema: TestSchema,
350
+ transport,
351
+ initialState: { title: "Original", count: 0, items: [] },
352
+ onRejection: (tx, reason) => {
353
+ rejectedTx = tx;
354
+ rejectionReason = reason;
355
+ },
356
+ });
357
+
358
+ // Create transaction while offline
359
+ client.transaction((root) => {
360
+ root.title.set("Offline Change");
361
+ });
362
+
363
+ const pendingTx = transport.sentTransactions[0]!;
364
+
365
+ // Connect
366
+ await client.connect();
367
+
368
+ // Server rejects the transaction
369
+ transport.simulateServerMessage({
370
+ type: "error",
371
+ transactionId: pendingTx.id,
372
+ reason: "Conflict with another user",
373
+ });
374
+
375
+ // Transaction should be removed from pending
376
+ expect(client.hasPendingChanges()).toBe(false);
377
+ // Optimistic state should revert to server state
378
+ expect(client.get()?.title).toBe("Original");
379
+ // Rejection callback should be called
380
+ expect(rejectedTx).not.toBeNull();
381
+ expect(rejectionReason).toBe("Conflict with another user");
382
+ });
383
+
384
+ it("should rebase queued transactions against concurrent server changes", async () => {
385
+ const client = ClientDocument.make({
386
+ schema: TestSchema,
387
+ transport,
388
+ initialState: { title: "", count: 0, items: [] },
389
+ });
390
+
391
+ // Create transaction while offline
392
+ client.transaction((root) => {
393
+ root.title.set("My Title");
394
+ });
395
+
396
+ await client.connect();
397
+
398
+ // Another user's change comes in first
399
+ const otherUserTx = Transaction.make([
400
+ {
401
+ kind: "number.set" as const,
402
+ path: OperationPath.make("count"),
403
+ payload: 100,
404
+ },
405
+ ]);
406
+
407
+ transport.simulateServerMessage({
408
+ type: "transaction",
409
+ transaction: otherUserTx,
410
+ version: 1,
411
+ });
412
+
413
+ // Our transaction should still be pending
414
+ expect(client.hasPendingChanges()).toBe(true);
415
+ // Server state should reflect other user's change
416
+ expect(client.getServerState()?.count).toBe(100);
417
+ // Optimistic state should have both changes
418
+ expect(client.get()?.count).toBe(100);
419
+ expect(client.get()?.title).toBe("My Title");
420
+ });
421
+
422
+ it("should work with disconnect and reconnect cycle", async () => {
423
+ const client = ClientDocument.make({
424
+ schema: TestSchema,
425
+ transport,
426
+ initialState: { title: "", count: 0, items: [] },
427
+ });
428
+
429
+ // Connect first
430
+ await client.connect();
431
+
432
+ client.transaction((root) => {
433
+ root.title.set("Online Change");
434
+ });
435
+
436
+ // Disconnect
437
+ client.disconnect();
438
+
439
+ // Create transaction while disconnected
440
+ client.transaction((root) => {
441
+ root.count.set(50);
442
+ });
443
+
444
+ // State should still be optimistically updated
445
+ expect(client.get()?.title).toBe("Online Change");
446
+ expect(client.get()?.count).toBe(50);
447
+ expect(client.getPendingCount()).toBe(2);
448
+
449
+ // Reconnect
450
+ await client.connect();
451
+
452
+ // Server confirms both transactions
453
+ transport.simulateServerMessage({
454
+ type: "transaction",
455
+ transaction: transport.sentTransactions[0]!,
456
+ version: 1,
457
+ });
458
+
459
+ transport.simulateServerMessage({
460
+ type: "transaction",
461
+ transaction: transport.sentTransactions[1]!,
462
+ version: 2,
463
+ });
464
+
465
+ expect(client.hasPendingChanges()).toBe(false);
466
+ expect(client.getServerState()?.title).toBe("Online Change");
467
+ expect(client.getServerState()?.count).toBe(50);
468
+ });
469
+
470
+ it("should preserve pending transactions during brief disconnection", async () => {
471
+ const client = ClientDocument.make({
472
+ schema: TestSchema,
473
+ transport,
474
+ initialState: { title: "", count: 0, items: [] },
475
+ });
476
+
477
+ await client.connect();
478
+
479
+ // Create a transaction
480
+ client.transaction((root) => {
481
+ root.title.set("Before Disconnect");
482
+ });
483
+
484
+ const pendingCount = client.getPendingCount();
485
+ expect(pendingCount).toBe(1);
486
+
487
+ // Simulate brief disconnection
488
+ client.disconnect();
489
+
490
+ // Pending transactions should still be there
491
+ expect(client.getPendingCount()).toBe(pendingCount);
492
+ expect(client.get()?.title).toBe("Before Disconnect");
493
+ });
494
+
495
+ it("should handle multiple field changes while offline", () => {
496
+ const client = ClientDocument.make({
497
+ schema: TestSchema,
498
+ transport,
499
+ initialState: { title: "", count: 0, items: [] },
500
+ });
501
+
502
+ // Create multiple transactions affecting different fields while offline
503
+ client.transaction((root) => {
504
+ root.title.set("First Title");
505
+ });
506
+
507
+ client.transaction((root) => {
508
+ root.count.set(10);
509
+ });
510
+
511
+ client.transaction((root) => {
512
+ root.title.set("Final Title");
513
+ root.count.set(20);
514
+ });
515
+
516
+ // All changes should be applied optimistically
517
+ expect(client.get()?.title).toBe("Final Title");
518
+ expect(client.get()?.count).toBe(20);
519
+
520
+ // All transactions queued
521
+ expect(transport.sentTransactions.length).toBe(3);
522
+ expect(client.getPendingCount()).toBe(3);
216
523
  });
217
524
  });
218
525
 
@@ -349,6 +349,196 @@ describe("WebSocketTransport", () => {
349
349
  const sent = JSON.parse(newWs.sentMessages[1]!);
350
350
  expect(sent.type).toBe("submit");
351
351
  });
352
+
353
+ it("should queue messages when disconnected (never connected)", () => {
354
+ const transport = WebSocketTransport.make({
355
+ url: "ws://localhost:8080",
356
+ });
357
+
358
+ // Queue message before connecting
359
+ const tx = Transaction.make([
360
+ {
361
+ kind: "string.set" as const,
362
+ path: OperationPath.make("title"),
363
+ payload: "offline",
364
+ },
365
+ ]);
366
+ transport.send(tx);
367
+
368
+ // No WebSocket created yet
369
+ expect(MockWebSocket.instances.length).toBe(0);
370
+
371
+ // Message should be queued internally
372
+ // (We can't directly verify the queue, but we can verify it's sent after connect)
373
+ });
374
+
375
+ it("should send queued messages after initial connection", async () => {
376
+ const transport = WebSocketTransport.make({
377
+ url: "ws://localhost:8080",
378
+ });
379
+
380
+ // Queue messages before connecting
381
+ const tx1 = Transaction.make([
382
+ {
383
+ kind: "string.set" as const,
384
+ path: OperationPath.make("title"),
385
+ payload: "first",
386
+ },
387
+ ]);
388
+ const tx2 = Transaction.make([
389
+ {
390
+ kind: "number.set" as const,
391
+ path: OperationPath.make("count"),
392
+ payload: 42,
393
+ },
394
+ ]);
395
+ transport.send(tx1);
396
+ transport.send(tx2);
397
+
398
+ // Now connect
399
+ const connectPromise = transport.connect();
400
+ const ws = MockWebSocket.getLatest()!;
401
+ await ws.simulateOpenWithAuth();
402
+ await connectPromise;
403
+
404
+ // Should have sent: auth + 2 queued transactions
405
+ expect(ws.sentMessages.length).toBe(3);
406
+
407
+ const authMsg = JSON.parse(ws.sentMessages[0]!);
408
+ expect(authMsg.type).toBe("auth");
409
+
410
+ const sent1 = JSON.parse(ws.sentMessages[1]!);
411
+ expect(sent1.type).toBe("submit");
412
+ expect(sent1.transaction.id).toBe(tx1.id);
413
+
414
+ const sent2 = JSON.parse(ws.sentMessages[2]!);
415
+ expect(sent2.type).toBe("submit");
416
+ expect(sent2.transaction.id).toBe(tx2.id);
417
+ });
418
+
419
+ it("should queue multiple messages during disconnection", async () => {
420
+ const transport = WebSocketTransport.make({
421
+ url: "ws://localhost:8080",
422
+ autoReconnect: true,
423
+ });
424
+
425
+ const connectPromise = transport.connect();
426
+ const ws = MockWebSocket.getLatest()!;
427
+ await ws.simulateOpenWithAuth();
428
+ await connectPromise;
429
+
430
+ // Simulate connection lost
431
+ ws.simulateClose(1006, "Connection lost");
432
+
433
+ // Queue multiple messages during disconnection
434
+ const tx1 = Transaction.make([
435
+ {
436
+ kind: "string.set" as const,
437
+ path: OperationPath.make("title"),
438
+ payload: "change1",
439
+ },
440
+ ]);
441
+ const tx2 = Transaction.make([
442
+ {
443
+ kind: "string.set" as const,
444
+ path: OperationPath.make("title"),
445
+ payload: "change2",
446
+ },
447
+ ]);
448
+ const tx3 = Transaction.make([
449
+ {
450
+ kind: "number.set" as const,
451
+ path: OperationPath.make("count"),
452
+ payload: 100,
453
+ },
454
+ ]);
455
+
456
+ transport.send(tx1);
457
+ transport.send(tx2);
458
+ transport.send(tx3);
459
+
460
+ // Reconnect
461
+ vi.advanceTimersByTime(1000);
462
+ const newWs = MockWebSocket.getLatest()!;
463
+ await newWs.simulateOpenWithAuth();
464
+
465
+ // Should have sent: auth + 3 queued transactions
466
+ expect(newWs.sentMessages.length).toBe(4);
467
+
468
+ const authMsg = JSON.parse(newWs.sentMessages[0]!);
469
+ expect(authMsg.type).toBe("auth");
470
+
471
+ // Verify all transactions were sent in order
472
+ const sent1 = JSON.parse(newWs.sentMessages[1]!);
473
+ expect(sent1.transaction.id).toBe(tx1.id);
474
+
475
+ const sent2 = JSON.parse(newWs.sentMessages[2]!);
476
+ expect(sent2.transaction.id).toBe(tx2.id);
477
+
478
+ const sent3 = JSON.parse(newWs.sentMessages[3]!);
479
+ expect(sent3.transaction.id).toBe(tx3.id);
480
+ });
481
+
482
+ it("should preserve queue across multiple reconnection attempts", async () => {
483
+ const transport = WebSocketTransport.make({
484
+ url: "ws://localhost:8080",
485
+ autoReconnect: true,
486
+ reconnectDelay: 100,
487
+ });
488
+
489
+ const connectPromise = transport.connect();
490
+ const ws = MockWebSocket.getLatest()!;
491
+ await ws.simulateOpenWithAuth();
492
+ await connectPromise;
493
+
494
+ // Simulate connection lost
495
+ ws.simulateClose(1006, "Connection lost");
496
+
497
+ // Queue message
498
+ const tx = Transaction.make([
499
+ {
500
+ kind: "string.set" as const,
501
+ path: OperationPath.make("title"),
502
+ payload: "important",
503
+ },
504
+ ]);
505
+ transport.send(tx);
506
+
507
+ // First reconnection attempt fails
508
+ vi.advanceTimersByTime(100);
509
+ MockWebSocket.getLatest()!.simulateClose(1006, "Failed again");
510
+
511
+ // Second reconnection attempt succeeds
512
+ vi.advanceTimersByTime(200);
513
+ const finalWs = MockWebSocket.getLatest()!;
514
+ await finalWs.simulateOpenWithAuth();
515
+
516
+ // Queued message should still be sent
517
+ expect(finalWs.sentMessages.length).toBe(2);
518
+ const sent = JSON.parse(finalWs.sentMessages[1]!);
519
+ expect(sent.type).toBe("submit");
520
+ expect(sent.transaction.id).toBe(tx.id);
521
+ });
522
+
523
+ it("should queue snapshot requests when disconnected", async () => {
524
+ const transport = WebSocketTransport.make({
525
+ url: "ws://localhost:8080",
526
+ });
527
+
528
+ // Queue snapshot request before connecting
529
+ transport.requestSnapshot();
530
+
531
+ // Connect
532
+ const connectPromise = transport.connect();
533
+ const ws = MockWebSocket.getLatest()!;
534
+ await ws.simulateOpenWithAuth();
535
+ await connectPromise;
536
+
537
+ // Should have sent: auth + request_snapshot
538
+ expect(ws.sentMessages.length).toBe(2);
539
+ const sent = JSON.parse(ws.sentMessages[1]!);
540
+ expect(sent.type).toBe("request_snapshot");
541
+ });
352
542
  });
353
543
 
354
544
  describe("requestSnapshot", () => {
@@ -811,16 +1001,51 @@ describe("WebSocketTransport", () => {
811
1001
  expect(sent.data).toEqual({ cursor: { x: 50, y: 75 } });
812
1002
  });
813
1003
 
814
- it("should not send when disconnected", async () => {
1004
+ it("should queue presence_set when disconnected and send after connect", async () => {
815
1005
  const transport = WebSocketTransport.make({
816
1006
  url: "ws://localhost:8080",
817
1007
  });
818
1008
 
819
- // Never connect - sendPresenceSet should be silently ignored
1009
+ // Queue presence before connecting
820
1010
  transport.sendPresenceSet({ x: 100, y: 200 });
821
1011
 
822
- // No WebSocket created, nothing sent
1012
+ // No WebSocket created yet
823
1013
  expect(MockWebSocket.instances.length).toBe(0);
1014
+
1015
+ // Now connect
1016
+ const connectPromise = transport.connect();
1017
+ const ws = MockWebSocket.getLatest()!;
1018
+ await ws.simulateOpenWithAuth();
1019
+ await connectPromise;
1020
+
1021
+ // Should have sent: auth + presence_set
1022
+ expect(ws.sentMessages.length).toBe(2);
1023
+ const sent = JSON.parse(ws.sentMessages[1]!);
1024
+ expect(sent.type).toBe("presence_set");
1025
+ expect(sent.data).toEqual({ x: 100, y: 200 });
1026
+ });
1027
+
1028
+ it("should only keep latest presence_set when multiple are queued", async () => {
1029
+ const transport = WebSocketTransport.make({
1030
+ url: "ws://localhost:8080",
1031
+ });
1032
+
1033
+ // Queue multiple presence updates before connecting
1034
+ transport.sendPresenceSet({ x: 100, y: 200 });
1035
+ transport.sendPresenceSet({ x: 150, y: 250 });
1036
+ transport.sendPresenceSet({ x: 200, y: 300 });
1037
+
1038
+ // Now connect
1039
+ const connectPromise = transport.connect();
1040
+ const ws = MockWebSocket.getLatest()!;
1041
+ await ws.simulateOpenWithAuth();
1042
+ await connectPromise;
1043
+
1044
+ // Should have sent: auth + only the latest presence_set
1045
+ expect(ws.sentMessages.length).toBe(2);
1046
+ const sent = JSON.parse(ws.sentMessages[1]!);
1047
+ expect(sent.type).toBe("presence_set");
1048
+ expect(sent.data).toEqual({ x: 200, y: 300 });
824
1049
  });
825
1050
  });
826
1051