@kyneta/yjs-schema 1.6.0 → 1.7.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.
@@ -1,685 +0,0 @@
1
- import { KIND, RawPath, Schema } from "@kyneta/schema"
2
- import { describe, expect, it } from "vitest"
3
- import * as Y from "yjs"
4
- import { ensureContainers } from "../populate.js"
5
- import { yjsReader } from "../reader.js"
6
-
7
- // ===========================================================================
8
- // Helpers
9
- // ===========================================================================
10
-
11
- /**
12
- * Create a Y.Doc with containers matching the schema, populate it using
13
- * direct Yjs API calls, and return the doc + reader.
14
- *
15
- * After `ensureContainers` the doc has the correct shared types but no
16
- * values. We populate values via raw Yjs API within a single transact.
17
- */
18
- function setup(schema: any, seed?: Record<string, unknown>) {
19
- const doc = new Y.Doc()
20
- ensureContainers(doc, schema)
21
- if (seed) {
22
- doc.transact(() => {
23
- const rootMap = doc.getMap("root")
24
- populateSeed(rootMap, schema, seed)
25
- })
26
- }
27
- const reader = yjsReader(doc, schema)
28
- return { doc, reader }
29
- }
30
-
31
- /**
32
- * Recursively populate a Y.Map from a seed object, guided by the schema.
33
- *
34
- * - text fields → Y.Text.insert(0, value)
35
- * - scalar fields → Y.Map.set(key, value)
36
- * - product (struct) fields → recurse into the existing Y.Map child
37
- * - sequence (list) fields → push items into the existing Y.Array child
38
- * - map (record) fields → set entries on the existing Y.Map child
39
- */
40
- function populateSeed(
41
- ymap: Y.Map<unknown>,
42
- schema: any,
43
- seed: Record<string, unknown>,
44
- ) {
45
- if (schema[KIND] !== "product") return
46
- const rootProduct = schema
47
-
48
- for (const [key, value] of Object.entries(seed)) {
49
- if (value === undefined) continue
50
- const fieldSchema = (rootProduct.fields as Record<string, any>)[key]
51
- if (!fieldSchema) continue
52
-
53
- populateField(ymap, key, fieldSchema, value)
54
- }
55
- }
56
-
57
- function populateField(
58
- ymap: Y.Map<unknown>,
59
- key: string,
60
- fieldSchema: any,
61
- value: unknown,
62
- ) {
63
- switch (fieldSchema[KIND]) {
64
- case "text": {
65
- // Text field — the Y.Text was already created by ensureContainers
66
- const text = ymap.get(key) as Y.Text
67
- if (text && typeof value === "string" && value.length > 0) {
68
- text.insert(0, value)
69
- }
70
- return
71
- }
72
- case "product": {
73
- // Struct — recurse into the existing Y.Map
74
- const childMap = ymap.get(key) as Y.Map<unknown>
75
- if (childMap && typeof value === "object" && value !== null) {
76
- for (const [childKey, childValue] of Object.entries(
77
- value as Record<string, unknown>,
78
- )) {
79
- const childFieldSchema = (fieldSchema.fields as Record<string, any>)[
80
- childKey
81
- ]
82
- if (!childFieldSchema) continue
83
- populateField(childMap, childKey, childFieldSchema, childValue)
84
- }
85
- }
86
- return
87
- }
88
-
89
- case "sequence": {
90
- // List — push items into the existing Y.Array
91
- const arr = ymap.get(key) as Y.Array<unknown>
92
- if (arr && Array.isArray(value)) {
93
- for (const item of value) {
94
- const itemSchema = fieldSchema.item
95
- if (itemSchema && itemSchema[KIND] === "product") {
96
- // Struct items: create a Y.Map for each
97
- const itemMap = buildStructMap(
98
- itemSchema,
99
- item as Record<string, unknown>,
100
- )
101
- arr.push([itemMap])
102
- } else {
103
- arr.push([item])
104
- }
105
- }
106
- }
107
- return
108
- }
109
-
110
- case "map": {
111
- // Record — set entries on the existing Y.Map
112
- const childMap = ymap.get(key) as Y.Map<unknown>
113
- if (childMap && typeof value === "object" && value !== null) {
114
- for (const [entryKey, entryValue] of Object.entries(
115
- value as Record<string, unknown>,
116
- )) {
117
- childMap.set(entryKey, entryValue)
118
- }
119
- }
120
- return
121
- }
122
-
123
- default: {
124
- // Scalar — set plain value
125
- ymap.set(key, value)
126
- return
127
- }
128
- }
129
- }
130
-
131
- /**
132
- * Build a Y.Map for a struct item (used inside Y.Array).
133
- */
134
- function buildStructMap(
135
- productSchema: any,
136
- seed: Record<string, unknown>,
137
- ): Y.Map<unknown> {
138
- const map = new Y.Map<unknown>()
139
- for (const [key, fieldSchema] of Object.entries(
140
- productSchema.fields as Record<string, any>,
141
- )) {
142
- const value = seed[key]
143
- if (value === undefined) continue
144
-
145
- switch (fieldSchema[KIND]) {
146
- case "text": {
147
- const text = new Y.Text()
148
- if (typeof value === "string" && value.length > 0) {
149
- text.insert(0, value)
150
- }
151
- map.set(key, text)
152
- break
153
- }
154
- case "product": {
155
- map.set(
156
- key,
157
- buildStructMap(fieldSchema, value as Record<string, unknown>),
158
- )
159
- break
160
- }
161
- case "sequence": {
162
- const arr = new Y.Array()
163
- if (Array.isArray(value)) {
164
- for (const item of value) {
165
- const itemSchema = fieldSchema.item
166
- if (itemSchema && itemSchema[KIND] === "product") {
167
- arr.push([
168
- buildStructMap(itemSchema, item as Record<string, unknown>),
169
- ])
170
- } else {
171
- arr.push([item])
172
- }
173
- }
174
- }
175
- map.set(key, arr)
176
- break
177
- }
178
- case "map": {
179
- const childMap = new Y.Map()
180
- if (typeof value === "object" && value !== null) {
181
- for (const [k, v] of Object.entries(
182
- value as Record<string, unknown>,
183
- )) {
184
- childMap.set(k, v)
185
- }
186
- }
187
- map.set(key, childMap)
188
- break
189
- }
190
- default:
191
- map.set(key, value)
192
- break
193
- }
194
- }
195
- return map
196
- }
197
-
198
- /** Build a RawPath from variadic key/index segments. */
199
- function p(...segs: (string | number)[]): RawPath {
200
- let path = RawPath.empty
201
- for (const s of segs) {
202
- path = typeof s === "string" ? path.field(s) : path.item(s)
203
- }
204
- return path
205
- }
206
-
207
- // ===========================================================================
208
- // Schemas used across tests
209
- // ===========================================================================
210
-
211
- const TextSchema = Schema.struct({
212
- title: Schema.text(),
213
- subtitle: Schema.text(),
214
- })
215
-
216
- const ScalarSchema = Schema.struct({
217
- name: Schema.string(),
218
- count: Schema.number(),
219
- active: Schema.boolean(),
220
- })
221
-
222
- const NestedStructSchema = Schema.struct({
223
- profile: Schema.struct({
224
- first: Schema.string(),
225
- last: Schema.string(),
226
- address: Schema.struct({
227
- city: Schema.string(),
228
- zip: Schema.string(),
229
- }),
230
- }),
231
- })
232
-
233
- const ListSchema = Schema.struct({
234
- items: Schema.list(Schema.string()),
235
- structs: Schema.list(
236
- Schema.struct({
237
- name: Schema.string(),
238
- done: Schema.boolean(),
239
- }),
240
- ),
241
- })
242
-
243
- const MapSchema = Schema.struct({
244
- labels: Schema.record(Schema.string()),
245
- })
246
-
247
- const MixedSchema = Schema.struct({
248
- title: Schema.text(),
249
- count: Schema.number(),
250
- items: Schema.list(
251
- Schema.struct({
252
- name: Schema.string(),
253
- done: Schema.boolean(),
254
- }),
255
- ),
256
- meta: Schema.struct({
257
- author: Schema.string(),
258
- tags: Schema.list(Schema.string()),
259
- }),
260
- labels: Schema.record(Schema.string()),
261
- })
262
-
263
- // ===========================================================================
264
- // Tests
265
- // ===========================================================================
266
-
267
- describe("YjsReader", () => {
268
- // -------------------------------------------------------------------------
269
- // read
270
- // -------------------------------------------------------------------------
271
-
272
- describe("read", () => {
273
- it("reads Y.Text as string", () => {
274
- const { reader } = setup(TextSchema, { title: "Hello", subtitle: "" })
275
- expect(reader.read(p("title"))).toBe("Hello")
276
- expect(reader.read(p("subtitle"))).toBe("")
277
- })
278
-
279
- it("reads Y.Text with default empty string", () => {
280
- const { reader } = setup(TextSchema)
281
- expect(reader.read(p("title"))).toBe("")
282
- })
283
-
284
- it("reads plain scalars (string, number, boolean)", () => {
285
- const { reader } = setup(ScalarSchema, {
286
- name: "Alice",
287
- count: 42,
288
- active: true,
289
- })
290
- expect(reader.read(p("name"))).toBe("Alice")
291
- expect(reader.read(p("count"))).toBe(42)
292
- expect(reader.read(p("active"))).toBe(true)
293
- })
294
-
295
- it("reads scalar defaults", () => {
296
- const { reader } = setup(ScalarSchema)
297
- expect(reader.read(p("name"))).toBe("")
298
- expect(reader.read(p("count"))).toBe(0)
299
- expect(reader.read(p("active"))).toBe(false)
300
- })
301
-
302
- it("reads nested struct fields", () => {
303
- const { reader } = setup(NestedStructSchema, {
304
- profile: {
305
- first: "Jane",
306
- last: "Doe",
307
- address: { city: "Portland", zip: "97201" },
308
- },
309
- })
310
- expect(reader.read(p("profile", "first"))).toBe("Jane")
311
- expect(reader.read(p("profile", "last"))).toBe("Doe")
312
- expect(reader.read(p("profile", "address", "city"))).toBe("Portland")
313
- expect(reader.read(p("profile", "address", "zip"))).toBe("97201")
314
- })
315
-
316
- it("reads nested struct as plain object", () => {
317
- const { reader } = setup(NestedStructSchema, {
318
- profile: {
319
- first: "Jane",
320
- last: "Doe",
321
- address: { city: "Portland", zip: "97201" },
322
- },
323
- })
324
- const profile = reader.read(p("profile")) as Record<string, unknown>
325
- expect(profile.first).toBe("Jane")
326
- expect(profile.last).toBe("Doe")
327
- expect((profile.address as Record<string, unknown>).city).toBe("Portland")
328
- })
329
-
330
- it("reads list items by index", () => {
331
- const { reader } = setup(ListSchema, {
332
- items: ["a", "b", "c"],
333
- structs: [],
334
- })
335
- expect(reader.read(p("items", 0))).toBe("a")
336
- expect(reader.read(p("items", 1))).toBe("b")
337
- expect(reader.read(p("items", 2))).toBe("c")
338
- })
339
-
340
- it("reads list as plain array", () => {
341
- const { reader } = setup(ListSchema, {
342
- items: ["x", "y"],
343
- structs: [],
344
- })
345
- expect(reader.read(p("items"))).toEqual(["x", "y"])
346
- })
347
-
348
- it("reads struct items within lists", () => {
349
- const { reader } = setup(ListSchema, {
350
- items: [],
351
- structs: [
352
- { name: "Task 1", done: false },
353
- { name: "Task 2", done: true },
354
- ],
355
- })
356
- expect(reader.read(p("structs", 0, "name"))).toBe("Task 1")
357
- expect(reader.read(p("structs", 1, "done"))).toBe(true)
358
- const item = reader.read(p("structs", 0)) as Record<string, unknown>
359
- expect(item.name).toBe("Task 1")
360
- expect(item.done).toBe(false)
361
- })
362
-
363
- it("reads map entries", () => {
364
- const { reader } = setup(MapSchema, {
365
- labels: { bug: "red", feature: "green" },
366
- })
367
- expect(reader.read(p("labels", "bug"))).toBe("red")
368
- expect(reader.read(p("labels", "feature"))).toBe("green")
369
- })
370
-
371
- it("reads map as plain object", () => {
372
- const { reader } = setup(MapSchema, {
373
- labels: { bug: "red", feature: "green" },
374
- })
375
- expect(reader.read(p("labels"))).toEqual({
376
- bug: "red",
377
- feature: "green",
378
- })
379
- })
380
-
381
- it("reads root as full JSON object", () => {
382
- const { reader } = setup(ScalarSchema, {
383
- name: "Bob",
384
- count: 7,
385
- active: false,
386
- })
387
- const root = reader.read(RawPath.empty) as Record<string, unknown>
388
- expect(root.name).toBe("Bob")
389
- expect(root.count).toBe(7)
390
- expect(root.active).toBe(false)
391
- })
392
- })
393
-
394
- // -------------------------------------------------------------------------
395
- // arrayLength
396
- // -------------------------------------------------------------------------
397
-
398
- describe("arrayLength", () => {
399
- it("returns 0 for empty list", () => {
400
- const { reader } = setup(ListSchema, { items: [], structs: [] })
401
- expect(reader.arrayLength(p("items"))).toBe(0)
402
- })
403
-
404
- it("returns correct length for populated list", () => {
405
- const { reader } = setup(ListSchema, {
406
- items: ["a", "b", "c"],
407
- structs: [],
408
- })
409
- expect(reader.arrayLength(p("items"))).toBe(3)
410
- })
411
-
412
- it("returns correct length for struct list", () => {
413
- const { reader } = setup(ListSchema, {
414
- items: [],
415
- structs: [
416
- { name: "A", done: false },
417
- { name: "B", done: true },
418
- ],
419
- })
420
- expect(reader.arrayLength(p("structs"))).toBe(2)
421
- })
422
-
423
- it("returns 0 for non-list paths", () => {
424
- const { reader } = setup(ScalarSchema, {
425
- name: "test",
426
- count: 0,
427
- active: true,
428
- })
429
- expect(reader.arrayLength(p("name"))).toBe(0)
430
- })
431
- })
432
-
433
- // -------------------------------------------------------------------------
434
- // keys
435
- // -------------------------------------------------------------------------
436
-
437
- describe("keys", () => {
438
- it("returns keys of a Y.Map (record)", () => {
439
- const { reader } = setup(MapSchema, {
440
- labels: { bug: "red", feature: "green", docs: "blue" },
441
- })
442
- const k = reader.keys(p("labels"))
443
- expect(k.sort()).toEqual(["bug", "docs", "feature"])
444
- })
445
-
446
- it("returns keys of empty map", () => {
447
- const { reader } = setup(MapSchema, { labels: {} })
448
- expect(reader.keys(p("labels"))).toEqual([])
449
- })
450
-
451
- it("returns keys of nested struct (product stored as Y.Map)", () => {
452
- const { reader } = setup(NestedStructSchema, {
453
- profile: {
454
- first: "Jane",
455
- last: "Doe",
456
- address: { city: "Portland", zip: "97201" },
457
- },
458
- })
459
- const k = reader.keys(p("profile"))
460
- expect(k.sort()).toEqual(["address", "first", "last"])
461
- })
462
-
463
- it("returns keys of nested struct's nested struct", () => {
464
- const { reader } = setup(NestedStructSchema, {
465
- profile: {
466
- first: "Jane",
467
- last: "Doe",
468
- address: { city: "Portland", zip: "97201" },
469
- },
470
- })
471
- const k = reader.keys(p("profile", "address"))
472
- expect(k.sort()).toEqual(["city", "zip"])
473
- })
474
-
475
- it("returns empty array for non-map paths", () => {
476
- const { reader } = setup(ScalarSchema, {
477
- name: "test",
478
- count: 0,
479
- active: true,
480
- })
481
- expect(reader.keys(p("name"))).toEqual([])
482
- })
483
- })
484
-
485
- // -------------------------------------------------------------------------
486
- // hasKey
487
- // -------------------------------------------------------------------------
488
-
489
- describe("hasKey", () => {
490
- it("returns true for existing key in record", () => {
491
- const { reader } = setup(MapSchema, {
492
- labels: { bug: "red" },
493
- })
494
- expect(reader.hasKey(p("labels"), "bug")).toBe(true)
495
- })
496
-
497
- it("returns false for missing key in record", () => {
498
- const { reader } = setup(MapSchema, {
499
- labels: { bug: "red" },
500
- })
501
- expect(reader.hasKey(p("labels"), "nonexistent")).toBe(false)
502
- })
503
-
504
- it("returns true for existing key in struct (Y.Map)", () => {
505
- const { reader } = setup(NestedStructSchema, {
506
- profile: {
507
- first: "Jane",
508
- last: "Doe",
509
- address: { city: "Portland", zip: "97201" },
510
- },
511
- })
512
- expect(reader.hasKey(p("profile"), "first")).toBe(true)
513
- expect(reader.hasKey(p("profile"), "address")).toBe(true)
514
- })
515
-
516
- it("returns false for missing key in struct", () => {
517
- const { reader } = setup(NestedStructSchema, {
518
- profile: {
519
- first: "Jane",
520
- last: "Doe",
521
- address: { city: "Portland", zip: "97201" },
522
- },
523
- })
524
- expect(reader.hasKey(p("profile"), "nonexistent")).toBe(false)
525
- })
526
-
527
- it("returns false for non-map paths", () => {
528
- const { reader } = setup(ScalarSchema, {
529
- name: "test",
530
- count: 0,
531
- active: true,
532
- })
533
- expect(reader.hasKey(p("name"), "anything")).toBe(false)
534
- })
535
- })
536
-
537
- // -------------------------------------------------------------------------
538
- // Liveness — mutations via raw Yjs API immediately visible
539
- // -------------------------------------------------------------------------
540
-
541
- describe("liveness", () => {
542
- it("text mutations are immediately visible", () => {
543
- const { doc, reader } = setup(TextSchema, { title: "Hello" })
544
- expect(reader.read(p("title"))).toBe("Hello")
545
-
546
- // Mutate via raw Yjs API
547
- const rootMap = doc.getMap("root")
548
- const text = rootMap.get("title") as Y.Text
549
- text.insert(5, " World")
550
- expect(reader.read(p("title"))).toBe("Hello World")
551
- })
552
-
553
- it("scalar mutations are immediately visible", () => {
554
- const { doc, reader } = setup(ScalarSchema, {
555
- name: "Alice",
556
- count: 0,
557
- active: false,
558
- })
559
- expect(reader.read(p("name"))).toBe("Alice")
560
-
561
- const rootMap = doc.getMap("root")
562
- rootMap.set("name", "Bob")
563
- expect(reader.read(p("name"))).toBe("Bob")
564
- })
565
-
566
- it("list mutations are immediately visible", () => {
567
- const { doc, reader } = setup(ListSchema, {
568
- items: ["a"],
569
- structs: [],
570
- })
571
- expect(reader.arrayLength(p("items"))).toBe(1)
572
-
573
- const rootMap = doc.getMap("root")
574
- const items = rootMap.get("items") as Y.Array<string>
575
- items.push(["b", "c"])
576
- expect(reader.arrayLength(p("items"))).toBe(3)
577
- expect(reader.read(p("items", 1))).toBe("b")
578
- expect(reader.read(p("items", 2))).toBe("c")
579
- })
580
-
581
- it("map mutations are immediately visible", () => {
582
- const { doc, reader } = setup(MapSchema, {
583
- labels: { bug: "red" },
584
- })
585
- expect(reader.hasKey(p("labels"), "feature")).toBe(false)
586
-
587
- const rootMap = doc.getMap("root")
588
- const labels = rootMap.get("labels") as Y.Map<string>
589
- labels.set("feature", "green")
590
- expect(reader.hasKey(p("labels"), "feature")).toBe(true)
591
- expect(reader.read(p("labels", "feature"))).toBe("green")
592
- expect(reader.keys(p("labels")).sort()).toEqual(["bug", "feature"])
593
- })
594
-
595
- it("struct field mutations are immediately visible", () => {
596
- const { doc, reader } = setup(NestedStructSchema, {
597
- profile: {
598
- first: "Jane",
599
- last: "Doe",
600
- address: { city: "Portland", zip: "97201" },
601
- },
602
- })
603
- expect(reader.read(p("profile", "first"))).toBe("Jane")
604
-
605
- const rootMap = doc.getMap("root")
606
- const profile = rootMap.get("profile") as Y.Map<unknown>
607
- profile.set("first", "John")
608
- expect(reader.read(p("profile", "first"))).toBe("John")
609
- })
610
-
611
- it("nested struct mutations are immediately visible", () => {
612
- const { doc, reader } = setup(NestedStructSchema, {
613
- profile: {
614
- first: "Jane",
615
- last: "Doe",
616
- address: { city: "Portland", zip: "97201" },
617
- },
618
- })
619
- const rootMap = doc.getMap("root")
620
- const profile = rootMap.get("profile") as Y.Map<unknown>
621
- const address = profile.get("address") as Y.Map<string>
622
- address.set("city", "Seattle")
623
- expect(reader.read(p("profile", "address", "city"))).toBe("Seattle")
624
- })
625
-
626
- it("list delete + insert mutations are immediately visible", () => {
627
- const { doc, reader } = setup(ListSchema, {
628
- items: ["a", "b", "c"],
629
- structs: [],
630
- })
631
- const rootMap = doc.getMap("root")
632
- const items = rootMap.get("items") as Y.Array<string>
633
-
634
- items.delete(1, 1) // remove "b"
635
- expect(reader.arrayLength(p("items"))).toBe(2)
636
- expect(reader.read(p("items"))).toEqual(["a", "c"])
637
-
638
- items.insert(1, ["x"])
639
- expect(reader.read(p("items"))).toEqual(["a", "x", "c"])
640
- })
641
- })
642
-
643
- // -------------------------------------------------------------------------
644
- // Mixed schema — complex document with all types
645
- // -------------------------------------------------------------------------
646
-
647
- describe("mixed schema", () => {
648
- it("reads all field types in a complex document", () => {
649
- const { reader } = setup(MixedSchema, {
650
- title: "My Doc",
651
- count: 7,
652
- items: [
653
- { name: "Task 1", done: false },
654
- { name: "Task 2", done: true },
655
- ],
656
- meta: {
657
- author: "Alice",
658
- tags: ["draft", "v2"],
659
- },
660
- labels: { priority: "high" },
661
- })
662
-
663
- // Text
664
- expect(reader.read(p("title"))).toBe("My Doc")
665
-
666
- // Scalar
667
- expect(reader.read(p("count"))).toBe(7)
668
-
669
- // List of structs
670
- expect(reader.arrayLength(p("items"))).toBe(2)
671
- expect(reader.read(p("items", 0, "name"))).toBe("Task 1")
672
- expect(reader.read(p("items", 1, "done"))).toBe(true)
673
-
674
- // Nested struct with nested list
675
- expect(reader.read(p("meta", "author"))).toBe("Alice")
676
- expect(reader.arrayLength(p("meta", "tags"))).toBe(2)
677
- expect(reader.read(p("meta", "tags", 0))).toBe("draft")
678
-
679
- // Record (map)
680
- expect(reader.read(p("labels", "priority"))).toBe("high")
681
- expect(reader.hasKey(p("labels"), "priority")).toBe(true)
682
- expect(reader.keys(p("labels"))).toEqual(["priority"])
683
- })
684
- })
685
- })