@kyneta/yjs-schema 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,632 @@
1
+ import { describe, expect, it, vi } from "vitest"
2
+ import * as Y from "yjs"
3
+ import { Schema, change, subscribe } from "@kyneta/schema"
4
+ import { createYjsDoc, createYjsDocFromSnapshot } from "../create.js"
5
+ import { version, exportSnapshot, exportSince, importDelta } from "../sync.js"
6
+ import { YjsVersion } from "../version.js"
7
+ import { ensureContainers } from "../populate.js"
8
+ import { yjsSubstrateFactory } from "../substrate.js"
9
+ import { yjs } from "../yjs-escape.js"
10
+
11
+ // ===========================================================================
12
+ // Schemas used across tests
13
+ // ===========================================================================
14
+
15
+ const SimpleSchema = Schema.doc({
16
+ title: Schema.annotated("text"),
17
+ count: Schema.number(),
18
+ items: Schema.list(Schema.string()),
19
+ })
20
+
21
+ const StructListSchema = Schema.doc({
22
+ tasks: Schema.list(
23
+ Schema.struct({
24
+ name: Schema.string(),
25
+ done: Schema.boolean(),
26
+ }),
27
+ ),
28
+ })
29
+
30
+ const NestedSchema = Schema.doc({
31
+ title: Schema.annotated("text"),
32
+ meta: Schema.struct({
33
+ author: Schema.string(),
34
+ tags: Schema.list(Schema.string()),
35
+ }),
36
+ labels: Schema.record(Schema.string()),
37
+ })
38
+
39
+ // ===========================================================================
40
+ // Tests
41
+ // ===========================================================================
42
+
43
+ describe("createYjsDoc", () => {
44
+ // -------------------------------------------------------------------------
45
+ // Default values
46
+ // -------------------------------------------------------------------------
47
+
48
+ describe("with defaults", () => {
49
+ it("creates a doc with empty containers for shared types", () => {
50
+ const doc = createYjsDoc(SimpleSchema)
51
+ // Text annotation returns "" (empty Y.Text)
52
+ expect(doc.title()).toBe("")
53
+ // Plain scalars return structural zeros
54
+ expect(doc.count()).toBe(0)
55
+ // Sequence containers are created empty
56
+ expect(doc.items()).toEqual([])
57
+ })
58
+
59
+ it("creates a doc with nested struct empty containers", () => {
60
+ const doc = createYjsDoc(NestedSchema)
61
+ expect(doc.title()).toBe("")
62
+ // Plain scalar inside struct returns structural zero
63
+ expect(doc.meta.author()).toBe("")
64
+ expect(doc.meta.tags()).toEqual([])
65
+ expect(doc.labels()).toEqual({})
66
+ })
67
+
68
+ it("creates a doc with struct list defaults", () => {
69
+ const doc = createYjsDoc(StructListSchema)
70
+ expect(doc.tasks()).toEqual([])
71
+ expect(doc.tasks.length).toBe(0)
72
+ })
73
+ })
74
+
75
+ // -------------------------------------------------------------------------
76
+ // With seed values
77
+ // -------------------------------------------------------------------------
78
+
79
+ describe("with seeds", () => {
80
+ it("creates a doc with scalar seed values", () => {
81
+ const doc = createYjsDoc(SimpleSchema)
82
+ change(doc, (d: any) => {
83
+ d.title.insert(0, "Hello")
84
+ d.count.set(42)
85
+ })
86
+ // Separate change() calls for list pushes to preserve order
87
+ // (Yjs reverses order within a single transaction)
88
+ change(doc, (d: any) => d.items.push("a"))
89
+ change(doc, (d: any) => d.items.push("b"))
90
+ change(doc, (d: any) => d.items.push("c"))
91
+ expect(doc.title()).toBe("Hello")
92
+ expect(doc.count()).toBe(42)
93
+ expect(doc.items()).toEqual(["a", "b", "c"])
94
+ })
95
+
96
+ it("creates a doc with partial seed (defaults fill gaps)", () => {
97
+ const doc = createYjsDoc(SimpleSchema)
98
+ change(doc, (d: any) => {
99
+ d.title.insert(0, "Partial")
100
+ })
101
+ expect(doc.title()).toBe("Partial")
102
+ expect(doc.count()).toBe(0)
103
+ expect(doc.items()).toEqual([])
104
+ })
105
+
106
+ it("creates a doc with nested struct seed", () => {
107
+ const doc = createYjsDoc(NestedSchema)
108
+ change(doc, (d: any) => {
109
+ d.title.insert(0, "Doc")
110
+ d.meta.author.set("Alice")
111
+ d.meta.tags.push("draft")
112
+ d.labels.set("priority", "high")
113
+ })
114
+ expect(doc.title()).toBe("Doc")
115
+ expect(doc.meta.author()).toBe("Alice")
116
+ expect(doc.meta.tags()).toEqual(["draft"])
117
+ const labels = doc.labels() as Record<string, unknown>
118
+ expect(labels.priority).toBe("high")
119
+ })
120
+
121
+ it("creates a doc with struct list seed items", () => {
122
+ const doc = createYjsDoc(StructListSchema)
123
+ // Separate change() calls for list pushes to preserve order
124
+ change(doc, (d: any) => d.tasks.push({ name: "Task 1", done: false }))
125
+ change(doc, (d: any) => d.tasks.push({ name: "Task 2", done: true }))
126
+ expect(doc.tasks.length).toBe(2)
127
+ expect(doc.tasks.at(0)?.name()).toBe("Task 1")
128
+ expect(doc.tasks.at(0)?.done()).toBe(false)
129
+ expect(doc.tasks.at(1)?.name()).toBe("Task 2")
130
+ expect(doc.tasks.at(1)?.done()).toBe(true)
131
+ })
132
+ })
133
+
134
+ // -------------------------------------------------------------------------
135
+ // Bring your own Y.Doc
136
+ // -------------------------------------------------------------------------
137
+
138
+ describe("with existing Y.Doc", () => {
139
+ it("wraps an existing Y.Doc", () => {
140
+ const yjsDoc = new Y.Doc()
141
+ ensureContainers(yjsDoc, SimpleSchema)
142
+ yjsDoc.transact(() => {
143
+ const rootMap = yjsDoc.getMap("root")
144
+ ;(rootMap.get("title") as Y.Text).insert(0, "External")
145
+ rootMap.set("count", 99)
146
+ ;(rootMap.get("items") as Y.Array<string>).push(["x"])
147
+ })
148
+
149
+ const doc = createYjsDoc(SimpleSchema, yjsDoc)
150
+ expect(doc.title()).toBe("External")
151
+ expect(doc.count()).toBe(99)
152
+ expect(doc.items()).toEqual(["x"])
153
+ })
154
+
155
+ it("mutations through kyneta change are visible on the Y.Doc", () => {
156
+ const yjsDoc = new Y.Doc()
157
+ ensureContainers(yjsDoc, SimpleSchema)
158
+
159
+ const doc = createYjsDoc(SimpleSchema, yjsDoc)
160
+ change(doc, (d: any) => {
161
+ d.title.insert(0, "Hello")
162
+ d.count.set(42)
163
+ })
164
+
165
+ const rootMap = yjsDoc.getMap("root")
166
+ expect((rootMap.get("title") as Y.Text).toJSON()).toBe("Hello")
167
+ expect(rootMap.get("count")).toBe(42)
168
+ })
169
+
170
+ it("mutations through raw Yjs API are visible on the kyneta ref", () => {
171
+ const yjsDoc = new Y.Doc()
172
+ ensureContainers(yjsDoc, SimpleSchema)
173
+
174
+ const doc = createYjsDoc(SimpleSchema, yjsDoc)
175
+
176
+ const rootMap = yjsDoc.getMap("root")
177
+ rootMap.set("count", 77)
178
+
179
+ expect(doc.count()).toBe(77)
180
+ })
181
+
182
+ it("yjs() escape hatch returns the same Y.Doc", () => {
183
+ const yjsDoc = new Y.Doc()
184
+ ensureContainers(yjsDoc, SimpleSchema)
185
+
186
+ const doc = createYjsDoc(SimpleSchema, yjsDoc)
187
+ const escaped = yjs(doc)
188
+
189
+ expect(escaped).toBe(yjsDoc)
190
+ })
191
+ })
192
+ })
193
+
194
+ // ===========================================================================
195
+ // createYjsDocFromSnapshot
196
+ // ===========================================================================
197
+
198
+ describe("createYjsDocFromSnapshot", () => {
199
+ it("reconstructs state from a snapshot", () => {
200
+ const doc1 = createYjsDoc(SimpleSchema)
201
+ change(doc1, (d: any) => {
202
+ d.title.insert(0, "Snapshot")
203
+ d.count.set(42)
204
+ })
205
+ change(doc1, (d: any) => d.items.push("a"))
206
+ change(doc1, (d: any) => d.items.push("b"))
207
+
208
+ const payload = exportSnapshot(doc1)
209
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
210
+
211
+ expect(doc2.title()).toBe("Snapshot")
212
+ expect(doc2.count()).toBe(42)
213
+ expect(doc2.items()).toEqual(["a", "b"])
214
+ })
215
+
216
+ it("reconstructs state after mutations", () => {
217
+ const doc1 = createYjsDoc(SimpleSchema)
218
+ change(doc1, (d: any) => {
219
+ d.title.insert(0, "Start")
220
+ })
221
+
222
+ change(doc1, (d: any) => {
223
+ d.title.insert(5, " End")
224
+ d.count.set(99)
225
+ d.items.push("x")
226
+ })
227
+
228
+ const payload = exportSnapshot(doc1)
229
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
230
+
231
+ expect(doc2.title()).toBe("Start End")
232
+ expect(doc2.count()).toBe(99)
233
+ expect(doc2.items()).toEqual(["x"])
234
+ })
235
+
236
+ it("reconstructs nested struct state from snapshot", () => {
237
+ const doc1 = createYjsDoc(NestedSchema)
238
+ change(doc1, (d: any) => {
239
+ d.title.insert(0, "Nested")
240
+ d.meta.author.set("Alice")
241
+ d.labels.set("bug", "red")
242
+ })
243
+ change(doc1, (d: any) => d.meta.tags.push("v1"))
244
+ change(doc1, (d: any) => d.meta.tags.push("v2"))
245
+
246
+ const payload = exportSnapshot(doc1)
247
+ const doc2 = createYjsDocFromSnapshot(NestedSchema, payload)
248
+
249
+ expect(doc2.title()).toBe("Nested")
250
+ expect(doc2.meta.author()).toBe("Alice")
251
+ expect(doc2.meta.tags()).toEqual(["v1", "v2"])
252
+ const labels = doc2.labels() as Record<string, unknown>
253
+ expect(labels.bug).toBe("red")
254
+ })
255
+
256
+ it("reconstructs struct list state from snapshot", () => {
257
+ const doc1 = createYjsDoc(StructListSchema)
258
+ change(doc1, (d: any) => d.tasks.push({ name: "Task A", done: false }))
259
+ change(doc1, (d: any) => d.tasks.push({ name: "Task B", done: true }))
260
+
261
+ const payload = exportSnapshot(doc1)
262
+ const doc2 = createYjsDocFromSnapshot(StructListSchema, payload)
263
+
264
+ expect(doc2.tasks.length).toBe(2)
265
+ expect((doc2.tasks.at(0) as any).name()).toBe("Task A")
266
+ expect((doc2.tasks.at(1) as any).done()).toBe(true)
267
+ })
268
+
269
+ it("is writable after reconstruction", () => {
270
+ const doc1 = createYjsDoc(SimpleSchema)
271
+ change(doc1, (d: any) => {
272
+ d.title.insert(0, "Original")
273
+ })
274
+ const payload = exportSnapshot(doc1)
275
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
276
+
277
+ change(doc2, (d: any) => {
278
+ d.title.insert(8, " Copy")
279
+ d.count.set(7)
280
+ })
281
+
282
+ expect(doc2.title()).toBe("Original Copy")
283
+ expect(doc2.count()).toBe(7)
284
+ })
285
+
286
+ it("is observable after reconstruction", () => {
287
+ const doc1 = createYjsDoc(SimpleSchema)
288
+ change(doc1, (d: any) => {
289
+ d.title.insert(0, "Original")
290
+ })
291
+ const payload = exportSnapshot(doc1)
292
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
293
+
294
+ const received: any[] = []
295
+ subscribe(doc2, (changeset: any) => {
296
+ received.push(changeset)
297
+ })
298
+
299
+ change(doc2, (d: any) => {
300
+ d.count.set(42)
301
+ })
302
+
303
+ expect(received.length).toBeGreaterThanOrEqual(1)
304
+ })
305
+ })
306
+
307
+ // ===========================================================================
308
+ // Sync primitives
309
+ // ===========================================================================
310
+
311
+ describe("sync primitives", () => {
312
+ describe("version", () => {
313
+ it("returns a YjsVersion", () => {
314
+ const doc = createYjsDoc(SimpleSchema)
315
+ const v = version(doc)
316
+ expect(v).toBeInstanceOf(YjsVersion)
317
+ })
318
+
319
+ it("advances after mutations", () => {
320
+ const doc = createYjsDoc(SimpleSchema)
321
+ const v1 = version(doc)
322
+
323
+ change(doc, (d: any) => {
324
+ d.count.set(1)
325
+ })
326
+ const v2 = version(doc)
327
+
328
+ expect(v1.compare(v2)).toBe("behind")
329
+ })
330
+
331
+ it("serialize/parse round-trips", () => {
332
+ const doc = createYjsDoc(SimpleSchema)
333
+ change(doc, (d: any) => { d.title.insert(0, "Test") })
334
+ const v = version(doc)
335
+ const serialized = v.serialize()
336
+ const parsed = YjsVersion.parse(serialized)
337
+ expect(parsed.compare(v)).toBe("equal")
338
+ })
339
+ })
340
+
341
+ describe("exportSnapshot", () => {
342
+ it("returns a binary payload", () => {
343
+ const doc = createYjsDoc(SimpleSchema)
344
+ change(doc, (d: any) => { d.title.insert(0, "Snap") })
345
+ const payload = exportSnapshot(doc)
346
+ expect(payload.encoding).toBe("binary")
347
+ expect(payload.data).toBeInstanceOf(Uint8Array)
348
+ expect((payload.data as Uint8Array).byteLength).toBeGreaterThan(0)
349
+ })
350
+ })
351
+
352
+ describe("exportSince + importDelta", () => {
353
+ it("syncs incremental changes between two docs", () => {
354
+ const doc1 = createYjsDoc(SimpleSchema)
355
+ change(doc1, (d: any) => { d.title.insert(0, "Start") })
356
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
357
+
358
+ const v2Before = version(doc2)
359
+
360
+ // Mutate doc1
361
+ change(doc1, (d: any) => {
362
+ d.title.insert(5, " Edited")
363
+ d.count.set(42)
364
+ d.items.push("new-item")
365
+ })
366
+
367
+ // Export delta and apply to doc2
368
+ const delta = exportSince(doc1, v2Before)
369
+ expect(delta).not.toBeNull()
370
+ expect(delta!.encoding).toBe("binary")
371
+
372
+ importDelta(doc2, delta!)
373
+
374
+ expect(doc2.title()).toBe("Start Edited")
375
+ expect(doc2.count()).toBe(42)
376
+ expect(doc2.items()).toEqual(["new-item"])
377
+ })
378
+
379
+ it("syncs multiple incremental deltas", () => {
380
+ const doc1 = createYjsDoc(SimpleSchema)
381
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
382
+
383
+ // First round
384
+ let vBefore = version(doc2)
385
+ change(doc1, (d: any) => {
386
+ d.title.insert(0, "A")
387
+ })
388
+ importDelta(doc2, exportSince(doc1, vBefore)!)
389
+
390
+ // Second round
391
+ vBefore = version(doc2)
392
+ change(doc1, (d: any) => {
393
+ d.title.insert(1, "B")
394
+ })
395
+ importDelta(doc2, exportSince(doc1, vBefore)!)
396
+
397
+ // Third round
398
+ vBefore = version(doc2)
399
+ change(doc1, (d: any) => {
400
+ d.count.set(3)
401
+ })
402
+ importDelta(doc2, exportSince(doc1, vBefore)!)
403
+
404
+ expect(doc2.title()).toBe("AB")
405
+ expect(doc2.count()).toBe(3)
406
+ })
407
+
408
+ it("changefeed fires on importDelta", () => {
409
+ const doc1 = createYjsDoc(SimpleSchema)
410
+ change(doc1, (d: any) => { d.title.insert(0, "Source") })
411
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
412
+
413
+ const v2Before = version(doc2)
414
+
415
+ change(doc1, (d: any) => {
416
+ d.count.set(77)
417
+ })
418
+
419
+ const delta = exportSince(doc1, v2Before)
420
+
421
+ const received: any[] = []
422
+ subscribe(doc2, (changeset: any) => {
423
+ received.push(changeset)
424
+ })
425
+
426
+ importDelta(doc2, delta!)
427
+
428
+ expect(received.length).toBeGreaterThanOrEqual(1)
429
+ expect(doc2.count()).toBe(77)
430
+ })
431
+
432
+ it("importDelta passes origin to changefeed", () => {
433
+ const doc1 = createYjsDoc(SimpleSchema)
434
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
435
+
436
+ const v2Before = version(doc2)
437
+ change(doc1, (d: any) => {
438
+ d.count.set(1)
439
+ })
440
+
441
+ const receivedOrigins: (string | undefined)[] = []
442
+ subscribe(doc2, (changeset: any) => {
443
+ receivedOrigins.push(changeset.origin)
444
+ })
445
+
446
+ importDelta(doc2, exportSince(doc1, v2Before)!, "my-sync-origin")
447
+
448
+ expect(receivedOrigins).toContain("my-sync-origin")
449
+ })
450
+ })
451
+
452
+ describe("versions equal after sync", () => {
453
+ it("versions equal after full snapshot sync", () => {
454
+ const doc1 = createYjsDoc(SimpleSchema)
455
+ change(doc1, (d: any) => { d.title.insert(0, "Same") })
456
+ change(doc1, (d: any) => {
457
+ d.count.set(42)
458
+ })
459
+
460
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
461
+
462
+ expect(version(doc1).compare(version(doc2))).toBe("equal")
463
+ })
464
+
465
+ it("versions equal after bidirectional delta sync", () => {
466
+ const doc1 = createYjsDoc(SimpleSchema)
467
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
468
+
469
+ const v1Before = version(doc1)
470
+ const v2Before = version(doc2)
471
+
472
+ // Independent mutations
473
+ change(doc1, (d: any) => {
474
+ d.title.insert(0, "A")
475
+ })
476
+ change(doc2, (d: any) => {
477
+ d.count.set(7)
478
+ })
479
+
480
+ // Bidirectional sync
481
+ const d1to2 = exportSince(doc1, v2Before)
482
+ const d2to1 = exportSince(doc2, v1Before)
483
+ importDelta(doc2, d1to2!)
484
+ importDelta(doc1, d2to1!)
485
+
486
+ expect(version(doc1).compare(version(doc2))).toBe("equal")
487
+ })
488
+ })
489
+ })
490
+
491
+ // ===========================================================================
492
+ // Full workflow
493
+ // ===========================================================================
494
+
495
+ describe("full workflow", () => {
496
+ it("create → mutate → sync → observe", () => {
497
+ // 1. Create two docs
498
+ const doc1 = createYjsDoc(StructListSchema)
499
+ const doc2 = createYjsDocFromSnapshot(
500
+ StructListSchema,
501
+ exportSnapshot(doc1),
502
+ )
503
+
504
+ // 2. Set up observer on doc2
505
+ const changes: any[] = []
506
+ subscribe(doc2, (changeset: any) => {
507
+ changes.push(changeset)
508
+ })
509
+
510
+ // 3. Mutate doc1
511
+ const vBefore = version(doc2)
512
+
513
+ change(doc1, (d: any) => {
514
+ d.tasks.push({ name: "Buy milk", done: false })
515
+ })
516
+
517
+ change(doc1, (d: any) => {
518
+ d.tasks.push({ name: "Walk dog", done: false })
519
+ })
520
+
521
+ // 4. Sync doc1 → doc2
522
+ const delta = exportSince(doc1, vBefore)
523
+ importDelta(doc2, delta!)
524
+
525
+ // 5. Verify state converged
526
+ expect(doc2.tasks.length).toBe(2)
527
+ expect((doc2.tasks.at(0) as any).name()).toBe("Buy milk")
528
+ expect((doc2.tasks.at(1) as any).name()).toBe("Walk dog")
529
+
530
+ // 6. Verify observer was called
531
+ expect(changes.length).toBeGreaterThan(0)
532
+
533
+ // 7. Verify versions match
534
+ expect(version(doc1).compare(version(doc2))).toBe("equal")
535
+
536
+ // 8. Mutate doc2 and sync back
537
+ const v1Before = version(doc1)
538
+
539
+ change(doc2, (d: any) => {
540
+ d.tasks.push({ name: "Read book", done: false })
541
+ })
542
+
543
+ const delta2 = exportSince(doc2, v1Before)
544
+ importDelta(doc1, delta2!)
545
+
546
+ expect(doc1.tasks.length).toBe(3)
547
+ expect((doc1.tasks.at(2) as any).name()).toBe("Read book")
548
+ expect(version(doc1).compare(version(doc2))).toBe("equal")
549
+ })
550
+
551
+ it("create → mutate → snapshot → reconstruct → continue", () => {
552
+ // 1. Create and mutate
553
+ const doc1 = createYjsDoc(SimpleSchema)
554
+ change(doc1, (d: any) => {
555
+ d.title.insert(0, "Start")
556
+ })
557
+ change(doc1, (d: any) => {
558
+ d.title.insert(5, " Middle")
559
+ d.count.set(10)
560
+ d.items.push("first")
561
+ })
562
+
563
+ // 2. Snapshot
564
+ const snapshot = exportSnapshot(doc1)
565
+
566
+ // 3. Reconstruct
567
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, snapshot)
568
+ expect(doc2.title()).toBe("Start Middle")
569
+ expect(doc2.count()).toBe(10)
570
+ expect(doc2.items()).toEqual(["first"])
571
+
572
+ // 4. Continue mutating the reconstructed doc
573
+ change(doc2, (d: any) => {
574
+ d.title.insert(12, " End")
575
+ d.count.set(20)
576
+ d.items.push("second")
577
+ })
578
+
579
+ expect(doc2.title()).toBe("Start Middle End")
580
+ expect(doc2.count()).toBe(20)
581
+ expect(doc2.items()).toEqual(["first", "second"])
582
+
583
+ // 5. Version should be ahead of the snapshot version
584
+ const snapshotVersion = version(doc1)
585
+ const currentVersion = version(doc2)
586
+ // doc2 has additional mutations beyond doc1
587
+ expect(snapshotVersion.compare(currentVersion)).toBe("behind")
588
+ })
589
+
590
+ it("concurrent edits converge correctly", () => {
591
+ // 1. Create two peers from the same initial state
592
+ const doc1 = createYjsDoc(SimpleSchema)
593
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
594
+
595
+ const v1Before = version(doc1)
596
+ const v2Before = version(doc2)
597
+
598
+ // 2. Both peers edit concurrently
599
+ change(doc1, (d: any) => {
600
+ d.title.insert(0, "Peer1")
601
+ d.items.push("from-1")
602
+ })
603
+ change(doc2, (d: any) => {
604
+ d.count.set(42)
605
+ d.items.push("from-2")
606
+ })
607
+
608
+ // 3. Versions are concurrent
609
+ expect(version(doc1).compare(version(doc2))).toBe("concurrent")
610
+
611
+ // 4. Bidirectional sync
612
+ const d1to2 = exportSince(doc1, v2Before)
613
+ const d2to1 = exportSince(doc2, v1Before)
614
+ importDelta(doc2, d1to2!)
615
+ importDelta(doc1, d2to1!)
616
+
617
+ // 5. Versions converge
618
+ expect(version(doc1).compare(version(doc2))).toBe("equal")
619
+
620
+ // 6. Both docs have all the data
621
+ expect(doc1.title()).toContain("Peer1")
622
+ expect(doc2.title()).toContain("Peer1")
623
+ expect(doc1.count()).toBe(42)
624
+ expect(doc2.count()).toBe(42)
625
+
626
+ // Both items should be present (order determined by Yjs conflict resolution)
627
+ const items1 = doc1.items() as string[]
628
+ const items2 = doc2.items() as string[]
629
+ expect(items1.sort()).toEqual(["from-1", "from-2"])
630
+ expect(items2.sort()).toEqual(["from-1", "from-2"])
631
+ })
632
+ })