@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,604 @@
1
+ import { describe, expect, it, vi } from "vitest"
2
+ import * as Y from "yjs"
3
+ import { Schema, change, subscribe, RawPath } from "@kyneta/schema"
4
+ import { createYjsSubstrate, yjsSubstrateFactory } from "../substrate.js"
5
+ import { YjsVersion } from "../version.js"
6
+ import { createYjsDoc, createYjsDocFromSnapshot } from "../create.js"
7
+ import {
8
+ version,
9
+ exportSnapshot,
10
+ exportSince,
11
+ importDelta,
12
+ } from "../sync.js"
13
+ import { ensureContainers } from "../populate.js"
14
+
15
+ // ===========================================================================
16
+ // Helpers
17
+ // ===========================================================================
18
+
19
+
20
+
21
+ // ===========================================================================
22
+ // Schemas used across tests
23
+ // ===========================================================================
24
+
25
+ const SimpleSchema = Schema.doc({
26
+ title: Schema.annotated("text"),
27
+ count: Schema.number(),
28
+ items: Schema.list(Schema.string()),
29
+ })
30
+
31
+ const StructListSchema = Schema.doc({
32
+ tasks: Schema.list(
33
+ Schema.struct({
34
+ name: Schema.string(),
35
+ done: Schema.boolean(),
36
+ }),
37
+ ),
38
+ })
39
+
40
+ const FullSchema = Schema.doc({
41
+ title: Schema.annotated("text"),
42
+ count: Schema.number(),
43
+ active: Schema.boolean(),
44
+ items: Schema.list(Schema.string()),
45
+ tasks: Schema.list(
46
+ Schema.struct({
47
+ name: Schema.string(),
48
+ done: Schema.boolean(),
49
+ }),
50
+ ),
51
+ meta: Schema.struct({
52
+ author: Schema.string(),
53
+ }),
54
+ labels: Schema.record(Schema.string()),
55
+ })
56
+
57
+ // ===========================================================================
58
+ // Tests
59
+ // ===========================================================================
60
+
61
+ describe("YjsSubstrate", () => {
62
+ // -------------------------------------------------------------------------
63
+ // Factory create
64
+ // -------------------------------------------------------------------------
65
+
66
+ describe("factory create", () => {
67
+ it("creates a substrate with empty containers", () => {
68
+ const substrate = yjsSubstrateFactory.create(SimpleSchema)
69
+ expect(substrate.store.read(RawPath.empty.field("title"))).toBe("")
70
+ // Plain scalars return structural zeros
71
+ expect(substrate.store.read(RawPath.empty.field("count"))).toBe(0)
72
+ expect(substrate.store.read(RawPath.empty.field("items"))).toEqual([])
73
+ })
74
+
75
+ it("creates a substrate and populates via change()", () => {
76
+ const doc = createYjsDoc(SimpleSchema)
77
+ change(doc, (d: any) => {
78
+ d.title.insert(0, "Hello")
79
+ d.count.set(42)
80
+ })
81
+ // Separate change() calls for list pushes to preserve order
82
+ // (Yjs reverses order within a single transaction)
83
+ change(doc, (d: any) => d.items.push("a"))
84
+ change(doc, (d: any) => d.items.push("b"))
85
+ expect(doc.title()).toBe("Hello")
86
+ expect(doc.count()).toBe(42)
87
+ expect(doc.items()).toEqual(["a", "b"])
88
+ })
89
+
90
+ it("creates a substrate with partial values (unset fields stay empty)", () => {
91
+ const doc = createYjsDoc(SimpleSchema)
92
+ change(doc, (d: any) => {
93
+ d.title.insert(0, "Partial")
94
+ })
95
+ expect(doc.title()).toBe("Partial")
96
+ expect(doc.count()).toBe(0)
97
+ expect(doc.items()).toEqual([])
98
+ })
99
+
100
+ it("creates a substrate with nested struct values via change()", () => {
101
+ const doc = createYjsDoc(FullSchema)
102
+ change(doc, (d: any) => {
103
+ d.meta.author.set("Alice")
104
+ })
105
+ expect(doc.meta.author()).toBe("Alice")
106
+ })
107
+
108
+ it("creates a substrate with struct list values via change()", () => {
109
+ const doc = createYjsDoc(StructListSchema)
110
+ // Separate change() calls for list pushes to preserve order
111
+ change(doc, (d: any) => d.tasks.push({ name: "Task 1", done: false }))
112
+ change(doc, (d: any) => d.tasks.push({ name: "Task 2", done: true }))
113
+ expect((doc.tasks.at(0) as any).name()).toBe("Task 1")
114
+ expect((doc.tasks.at(1) as any).done()).toBe(true)
115
+ })
116
+ })
117
+
118
+ // -------------------------------------------------------------------------
119
+ // Write round-trip
120
+ // -------------------------------------------------------------------------
121
+
122
+ describe("write round-trip", () => {
123
+ it("text insert round-trips through prepare/flush", () => {
124
+ const doc = createYjsDoc(SimpleSchema)
125
+ change(doc, (d: any) => {
126
+ d.title.insert(0, "Hello")
127
+ })
128
+ expect(doc.title()).toBe("Hello")
129
+ })
130
+
131
+ it("scalar set round-trips through prepare/flush", () => {
132
+ const doc = createYjsDoc(SimpleSchema)
133
+ change(doc, (d: any) => {
134
+ d.count.set(42)
135
+ })
136
+ expect(doc.count()).toBe(42)
137
+ })
138
+
139
+ it("list push round-trips through prepare/flush", () => {
140
+ const doc = createYjsDoc(SimpleSchema)
141
+ change(doc, (d: any) => {
142
+ d.items.push("a")
143
+ })
144
+ change(doc, (d: any) => {
145
+ d.items.push("b")
146
+ })
147
+ expect(doc.items()).toEqual(["a", "b"])
148
+ expect(doc.items.length).toBe(2)
149
+ })
150
+ })
151
+
152
+ // -------------------------------------------------------------------------
153
+ // Version tracking
154
+ // -------------------------------------------------------------------------
155
+
156
+ describe("version tracking", () => {
157
+ it("version advances after mutations", () => {
158
+ const doc = createYjsDoc(SimpleSchema)
159
+ const v1 = version(doc)
160
+
161
+ change(doc, (d: any) => {
162
+ d.title.insert(0, "Hi")
163
+ })
164
+ const v2 = version(doc)
165
+
166
+ expect(v1.compare(v2)).toBe("behind")
167
+ expect(v2.compare(v1)).toBe("ahead")
168
+ })
169
+
170
+ it("version serialize/parse round-trips", () => {
171
+ const doc = createYjsDoc(SimpleSchema)
172
+ change(doc, (d: any) => {
173
+ d.title.insert(0, "Test")
174
+ d.count.set(5)
175
+ })
176
+
177
+ const v = version(doc)
178
+ const serialized = v.serialize()
179
+ const parsed = YjsVersion.parse(serialized)
180
+ expect(parsed.compare(v)).toBe("equal")
181
+ })
182
+ })
183
+
184
+ // -------------------------------------------------------------------------
185
+ // Export/import snapshot
186
+ // -------------------------------------------------------------------------
187
+
188
+ describe("export/import snapshot", () => {
189
+ it("exports a binary payload", () => {
190
+ const doc = createYjsDoc(SimpleSchema)
191
+ change(doc, (d: any) => { d.title.insert(0, "Snapshot") })
192
+ const payload = exportSnapshot(doc)
193
+ expect(payload.encoding).toBe("binary")
194
+ expect(payload.data).toBeInstanceOf(Uint8Array)
195
+ })
196
+
197
+ it("reconstructs equivalent state from snapshot", () => {
198
+ const doc1 = createYjsDoc(SimpleSchema)
199
+ change(doc1, (d: any) => {
200
+ d.title.insert(0, "Hello")
201
+ d.count.set(42)
202
+ })
203
+ // Separate change() calls for list pushes to preserve order
204
+ change(doc1, (d: any) => d.items.push("a"))
205
+ change(doc1, (d: any) => d.items.push("b"))
206
+ change(doc1, (d: any) => {
207
+ d.title.insert(5, " World")
208
+ })
209
+
210
+ const payload = exportSnapshot(doc1)
211
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
212
+
213
+ expect(doc2.title()).toBe("Hello World")
214
+ expect(doc2.count()).toBe(42)
215
+ expect(doc2.items()).toEqual(["a", "b"])
216
+ })
217
+ })
218
+
219
+ // -------------------------------------------------------------------------
220
+ // Delta sync
221
+ // -------------------------------------------------------------------------
222
+
223
+ describe("delta sync", () => {
224
+ it("exportSince → importDelta syncs state", () => {
225
+ const doc1 = createYjsDoc(SimpleSchema)
226
+ change(doc1, (d: any) => { d.title.insert(0, "Start") })
227
+ const doc2 = createYjsDocFromSnapshot(
228
+ SimpleSchema,
229
+ exportSnapshot(doc1),
230
+ )
231
+
232
+ const v1Before = version(doc1)
233
+
234
+ change(doc1, (d: any) => {
235
+ d.title.insert(5, " Edited")
236
+ d.count.set(99)
237
+ })
238
+
239
+ const delta = exportSince(doc1, v1Before)
240
+ expect(delta).not.toBeNull()
241
+
242
+ importDelta(doc2, delta!)
243
+ expect(doc2.title()).toBe("Start Edited")
244
+ expect(doc2.count()).toBe(99)
245
+ })
246
+
247
+ it("concurrent sync — two substrates converge after bidirectional sync", () => {
248
+ const doc1 = createYjsDoc(SimpleSchema)
249
+ const doc2 = createYjsDocFromSnapshot(
250
+ SimpleSchema,
251
+ exportSnapshot(doc1),
252
+ )
253
+
254
+ const v1Before = version(doc1)
255
+ const v2Before = version(doc2)
256
+
257
+ // Independent mutations
258
+ change(doc1, (d: any) => {
259
+ d.title.insert(0, "A")
260
+ })
261
+ change(doc2, (d: any) => {
262
+ d.count.set(7)
263
+ })
264
+
265
+ // Versions should be concurrent
266
+ const v1After = version(doc1)
267
+ const v2After = version(doc2)
268
+ expect(v1After.compare(v2After)).toBe("concurrent")
269
+
270
+ // Bidirectional sync
271
+ const d1to2 = exportSince(doc1, v2Before)
272
+ const d2to1 = exportSince(doc2, v1Before)
273
+
274
+ importDelta(doc2, d1to2!)
275
+ importDelta(doc1, d2to1!)
276
+
277
+ // Should now be equal
278
+ expect(version(doc1).compare(version(doc2))).toBe("equal")
279
+
280
+ // Both should have both mutations
281
+ // Note: concurrent text inserts at the same position resolve
282
+ // per Yjs's conflict resolution algorithm. Both will have
283
+ // the "A" insert. Count should be 7 on both.
284
+ expect(doc1.count()).toBe(7)
285
+ expect(doc2.count()).toBe(7)
286
+ expect(doc1.title()).toContain("A")
287
+ expect(doc2.title()).toContain("A")
288
+ })
289
+ })
290
+
291
+ // -------------------------------------------------------------------------
292
+ // Changefeed
293
+ // -------------------------------------------------------------------------
294
+
295
+ describe("changefeed", () => {
296
+ it("fires on importDelta", () => {
297
+ const doc1 = createYjsDoc(SimpleSchema)
298
+ change(doc1, (d: any) => { d.title.insert(0, "A") })
299
+ const doc2 = createYjsDocFromSnapshot(
300
+ SimpleSchema,
301
+ exportSnapshot(doc1),
302
+ )
303
+
304
+ const v2Before = version(doc2)
305
+
306
+ change(doc1, (d: any) => {
307
+ d.count.set(42)
308
+ })
309
+
310
+ const received: any[] = []
311
+ subscribe(doc2, (changeset: any) => {
312
+ received.push(changeset)
313
+ })
314
+
315
+ const delta = exportSince(doc1, v2Before)
316
+ importDelta(doc2, delta!)
317
+
318
+ expect(received.length).toBeGreaterThanOrEqual(1)
319
+ expect(doc2.count()).toBe(42)
320
+ })
321
+
322
+ it("fires on external Y.Doc mutation (raw Yjs API)", () => {
323
+ const yjsDoc = new Y.Doc()
324
+ ensureContainers(yjsDoc, SimpleSchema)
325
+ const doc = createYjsDoc(SimpleSchema, yjsDoc)
326
+
327
+ const received: any[] = []
328
+ subscribe(doc, (changeset: any) => {
329
+ received.push(changeset)
330
+ })
331
+
332
+ // Mutate via raw Yjs API (not through kyneta)
333
+ const rootMap = yjsDoc.getMap("root")
334
+ rootMap.set("count", 99)
335
+
336
+ expect(received.length).toBeGreaterThanOrEqual(1)
337
+ expect(doc.count()).toBe(99)
338
+ })
339
+
340
+ it("no double-fire on kyneta local writes", () => {
341
+ const doc = createYjsDoc(SimpleSchema)
342
+
343
+ const received: any[] = []
344
+ subscribe(doc, (changeset: any) => {
345
+ received.push(changeset)
346
+ })
347
+
348
+ change(doc, (d: any) => {
349
+ d.count.set(42)
350
+ })
351
+
352
+ // Should fire exactly once (from the changefeed layer's flush),
353
+ // not twice (not also from the event bridge).
354
+ expect(received.length).toBe(1)
355
+ })
356
+
357
+ it("nested struct field changefeed fires on importDelta", () => {
358
+ const doc1 = createYjsDoc(StructListSchema)
359
+ const doc2 = createYjsDocFromSnapshot(
360
+ StructListSchema,
361
+ exportSnapshot(doc1),
362
+ )
363
+
364
+ // Add a struct item on doc1, sync to doc2
365
+ change(doc1, (d: any) => {
366
+ d.tasks.push({ name: "Buy milk", done: false })
367
+ })
368
+ const snap = exportSnapshot(doc1)
369
+ const doc2b = createYjsDocFromSnapshot(StructListSchema, snap)
370
+
371
+ const taskB = [...doc2b.tasks][0] as any
372
+ expect(taskB.done()).toBe(false)
373
+
374
+ // Subscribe to the FIELD-LEVEL changefeed on doc2b's task
375
+ const v2 = version(doc2b)
376
+ const fieldChanges: unknown[] = []
377
+ const cf = (taskB.done as any)[Symbol.for("kyneta:changefeed")]
378
+ expect(cf).toBeDefined()
379
+ const unsub = cf.subscribe((cs: unknown) => fieldChanges.push(cs))
380
+
381
+ // Toggle done on doc1
382
+ change(doc1, (d: any) => {
383
+ d.tasks.at(0).done.set(true)
384
+ })
385
+
386
+ // Sync the toggle to doc2b
387
+ const delta = exportSince(doc1, v2)!
388
+ importDelta(doc2b, delta)
389
+
390
+ // Value should be updated
391
+ expect(taskB.done()).toBe(true)
392
+
393
+ // The field-level changefeed should have fired
394
+ expect(fieldChanges.length).toBeGreaterThanOrEqual(1)
395
+
396
+ unsub()
397
+ })
398
+
399
+ it("multi-key struct update fires per-field changefeeds on importDelta", () => {
400
+ const doc1 = createYjsDoc(StructListSchema)
401
+
402
+ // Add a struct item, sync to doc2
403
+ change(doc1, (d: any) => {
404
+ d.tasks.push({ name: "Buy milk", done: false })
405
+ })
406
+ const doc2 = createYjsDocFromSnapshot(
407
+ StructListSchema,
408
+ exportSnapshot(doc1),
409
+ )
410
+
411
+ const taskB = [...doc2.tasks][0] as any
412
+ const v2 = version(doc2)
413
+
414
+ // Subscribe to BOTH field-level changefeeds
415
+ const nameChanges: unknown[] = []
416
+ const doneChanges: unknown[] = []
417
+ const cfName = (taskB.name as any)[Symbol.for("kyneta:changefeed")]
418
+ const cfDone = (taskB.done as any)[Symbol.for("kyneta:changefeed")]
419
+ const unsub1 = cfName.subscribe((cs: unknown) => nameChanges.push(cs))
420
+ const unsub2 = cfDone.subscribe((cs: unknown) => doneChanges.push(cs))
421
+
422
+ // Update both fields in a single change() on doc1
423
+ change(doc1, (d: any) => {
424
+ const task = d.tasks.at(0)
425
+ task.name.set("Buy oat milk")
426
+ task.done.set(true)
427
+ })
428
+
429
+ // Sync to doc2
430
+ const delta = exportSince(doc1, v2)!
431
+ importDelta(doc2, delta)
432
+
433
+ // Both field-level changefeeds should have fired
434
+ expect(nameChanges.length).toBeGreaterThanOrEqual(1)
435
+ expect(doneChanges.length).toBeGreaterThanOrEqual(1)
436
+
437
+ expect(taskB.name()).toBe("Buy oat milk")
438
+ expect(taskB.done()).toBe(true)
439
+
440
+ unsub1()
441
+ unsub2()
442
+ })
443
+ })
444
+
445
+ // -------------------------------------------------------------------------
446
+ // Transaction support
447
+ // -------------------------------------------------------------------------
448
+
449
+ describe("transaction support", () => {
450
+ it("multi-op change() is atomic", () => {
451
+ const doc = createYjsDoc(SimpleSchema)
452
+
453
+ const received: any[] = []
454
+ subscribe(doc, (changeset: any) => {
455
+ received.push(changeset)
456
+ })
457
+
458
+ change(doc, (d: any) => {
459
+ d.title.insert(0, "Hello")
460
+ d.count.set(42)
461
+ d.items.push("a")
462
+ d.items.push("b")
463
+ })
464
+
465
+ // Tree-level subscribe fires once per affected container in the
466
+ // flush cycle. Three containers changed (title + count + items) → 3 fires.
467
+ // This matches LoroSubstrate and PlainSubstrate behavior.
468
+ expect(received.length).toBe(3)
469
+ expect(doc.title()).toBe("Hello")
470
+ expect(doc.count()).toBe(42)
471
+ // Both items present. Order within a single transaction batch is
472
+ // not guaranteed because deferred-flush applies all SequenceChanges
473
+ // atomically — both pushes see arrayLength=0 at prepare time.
474
+ const items = doc.items() as string[]
475
+ expect(items).toHaveLength(2)
476
+ expect(items).toContain("a")
477
+ expect(items).toContain("b")
478
+ })
479
+ })
480
+
481
+ // -------------------------------------------------------------------------
482
+ // Nested structure
483
+ // -------------------------------------------------------------------------
484
+
485
+ describe("nested structure", () => {
486
+ it("push struct into list, read back via navigation", () => {
487
+ const doc = createYjsDoc(StructListSchema)
488
+
489
+ change(doc, (d: any) => {
490
+ d.tasks.push({ name: "Task 1", done: false })
491
+ })
492
+
493
+ expect(doc.tasks.length).toBe(1)
494
+ expect((doc.tasks.at(0) as any).name()).toBe("Task 1")
495
+ expect((doc.tasks.at(0) as any).done()).toBe(false)
496
+
497
+ change(doc, (d: any) => {
498
+ d.tasks.push({ name: "Task 2", done: true })
499
+ })
500
+
501
+ expect(doc.tasks.length).toBe(2)
502
+ expect((doc.tasks.at(1) as any).name()).toBe("Task 2")
503
+ expect((doc.tasks.at(1) as any).done()).toBe(true)
504
+ })
505
+
506
+ it("nested struct write round-trip", () => {
507
+ const doc = createYjsDoc(FullSchema)
508
+ change(doc, (d: any) => {
509
+ d.meta.author.set("Alice")
510
+ })
511
+ expect(doc.meta.author()).toBe("Alice")
512
+
513
+ change(doc, (d: any) => {
514
+ d.meta.author.set("Bob")
515
+ })
516
+
517
+ expect(doc.meta.author()).toBe("Bob")
518
+ })
519
+ })
520
+
521
+ // -------------------------------------------------------------------------
522
+ // Counter annotation throws
523
+ // -------------------------------------------------------------------------
524
+
525
+ describe("unsupported annotations", () => {
526
+ it("counter annotation throws clear error at construction", () => {
527
+ const CounterSchema = Schema.doc({
528
+ count: Schema.annotated("counter"),
529
+ })
530
+
531
+ expect(() =>
532
+ yjsSubstrateFactory.create(CounterSchema),
533
+ ).toThrow("counter")
534
+ })
535
+
536
+ it("movable annotation throws clear error at construction", () => {
537
+ const MovableSchema = Schema.doc({
538
+ items: Schema.annotated("movable", Schema.list(Schema.string())),
539
+ })
540
+
541
+ expect(() =>
542
+ yjsSubstrateFactory.create(MovableSchema),
543
+ ).toThrow("movable")
544
+ })
545
+
546
+ it("tree annotation throws clear error at construction", () => {
547
+ const TreeSchema = Schema.doc({
548
+ tree: Schema.annotated(
549
+ "tree",
550
+ Schema.struct({ label: Schema.string() }),
551
+ ),
552
+ })
553
+
554
+ expect(() =>
555
+ yjsSubstrateFactory.create(TreeSchema),
556
+ ).toThrow("tree")
557
+ })
558
+ })
559
+
560
+ // -------------------------------------------------------------------------
561
+ // fromSnapshot
562
+ // -------------------------------------------------------------------------
563
+
564
+ describe("fromSnapshot", () => {
565
+ it("rejects non-binary payloads", () => {
566
+ expect(() =>
567
+ yjsSubstrateFactory.fromSnapshot(
568
+ { encoding: "json", data: "{}" },
569
+ SimpleSchema,
570
+ ),
571
+ ).toThrow("binary")
572
+ })
573
+
574
+ it("reconstructs from snapshot with correct state", () => {
575
+ const doc = createYjsDoc(SimpleSchema)
576
+ change(doc, (d: any) => {
577
+ d.title.insert(0, "Snapshot Test")
578
+ d.count.set(77)
579
+ d.items.push("x")
580
+ })
581
+
582
+ const payload = exportSnapshot(doc)
583
+ const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
584
+
585
+ expect(doc2.title()).toBe("Snapshot Test")
586
+ expect(doc2.count()).toBe(77)
587
+ expect(doc2.items()).toEqual(["x"])
588
+ })
589
+ })
590
+
591
+ // -------------------------------------------------------------------------
592
+ // parseVersion
593
+ // -------------------------------------------------------------------------
594
+
595
+ describe("parseVersion", () => {
596
+ it("round-trips through factory.parseVersion", () => {
597
+ const substrate = yjsSubstrateFactory.create(SimpleSchema)
598
+ const v = substrate.version()
599
+ const serialized = v.serialize()
600
+ const parsed = yjsSubstrateFactory.parseVersion(serialized)
601
+ expect(parsed.compare(v)).toBe("equal")
602
+ })
603
+ })
604
+ })