@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.
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/dist/index.d.ts +351 -0
- package/dist/index.js +865 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/__tests__/bind-yjs.test.ts +266 -0
- package/src/__tests__/create.test.ts +632 -0
- package/src/__tests__/record-text-spike.test.ts +429 -0
- package/src/__tests__/store-reader.test.ts +722 -0
- package/src/__tests__/substrate.test.ts +604 -0
- package/src/__tests__/version.test.ts +227 -0
- package/src/bind-yjs.ts +147 -0
- package/src/change-mapping.ts +612 -0
- package/src/create.ts +172 -0
- package/src/index.ts +83 -0
- package/src/populate.ts +208 -0
- package/src/store-reader.ts +123 -0
- package/src/substrate.ts +252 -0
- package/src/sync.ts +107 -0
- package/src/version.ts +138 -0
- package/src/yjs-escape.ts +100 -0
- package/src/yjs-resolve.ts +108 -0
|
@@ -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
|
+
})
|