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