@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,429 @@
1
+ // record-text-spike — validate text-inside-struct patterns for Yjs backend.
2
+ //
3
+ // The Yjs analog of the Loro counter-in-record spike. Yjs doesn't support
4
+ // counter annotations, but it DOES support text annotations. The same
5
+ // structural bug exists: when a struct is dynamically inserted into a
6
+ // record or list via .set() or .push(), text fields declared in the schema
7
+ // but missing from the value object don't get Y.Text containers created.
8
+ //
9
+ // This spike tests:
10
+ // 1. record(struct({ name: string(), bio: text() }))
11
+ // 2. list(struct({ name: string(), bio: text() }))
12
+ //
13
+ // Key operations:
14
+ // 1. .set(key, { name }) — insert with text field omitted
15
+ // 2. .at(key).bio.insert(0, "Hello") — insert into the text field
16
+ // 3. Reading back: doc.profiles() should return { [key]: { name, bio } }
17
+ // 4. Sync: two peers should converge after exchanging deltas
18
+
19
+ import { describe, expect, it } from "vitest"
20
+ import {
21
+ createYjsDoc,
22
+ createYjsDocFromSnapshot,
23
+ version,
24
+ exportSnapshot,
25
+ exportSince,
26
+ importDelta,
27
+ change,
28
+ subscribe,
29
+ text,
30
+ Schema,
31
+ } from "../index.js"
32
+
33
+ // ===========================================================================
34
+ // Schemas
35
+ // ===========================================================================
36
+
37
+ const ProfileSchema = Schema.doc({
38
+ profiles: Schema.record(
39
+ Schema.struct({
40
+ displayName: Schema.string(),
41
+ bio: text(),
42
+ }),
43
+ ),
44
+ })
45
+
46
+ const PlainRecordSchema = Schema.doc({
47
+ profiles: Schema.record(
48
+ Schema.struct({
49
+ displayName: Schema.string(),
50
+ age: Schema.number(),
51
+ }),
52
+ ),
53
+ })
54
+
55
+ const ListProfileSchema = Schema.doc({
56
+ players: Schema.list(
57
+ Schema.struct({
58
+ name: Schema.string(),
59
+ bio: text(),
60
+ }),
61
+ ),
62
+ })
63
+
64
+ // ===========================================================================
65
+ // Baseline: record-of-struct (plain, no text)
66
+ // ===========================================================================
67
+
68
+ describe("record-of-struct (plain baseline)", () => {
69
+ it("set a record entry and read it back", () => {
70
+ const doc = createYjsDoc(PlainRecordSchema)
71
+
72
+ change(doc, (d: any) => {
73
+ d.profiles.set("alice", { displayName: "Alice", age: 30 })
74
+ })
75
+
76
+ const snapshot = doc.profiles()
77
+ expect(snapshot).toEqual({
78
+ alice: { displayName: "Alice", age: 30 },
79
+ })
80
+ })
81
+
82
+ it("set multiple entries and read all back", () => {
83
+ const doc = createYjsDoc(PlainRecordSchema)
84
+
85
+ change(doc, (d: any) => {
86
+ d.profiles.set("alice", { displayName: "Alice", age: 30 })
87
+ d.profiles.set("bob", { displayName: "Bob", age: 25 })
88
+ })
89
+
90
+ const snapshot = doc.profiles()
91
+ expect(snapshot).toEqual({
92
+ alice: { displayName: "Alice", age: 30 },
93
+ bob: { displayName: "Bob", age: 25 },
94
+ })
95
+ })
96
+
97
+ it("navigate into a record entry via .at()", () => {
98
+ const doc = createYjsDoc(PlainRecordSchema)
99
+
100
+ change(doc, (d: any) => {
101
+ d.profiles.set("alice", { displayName: "Alice", age: 30 })
102
+ })
103
+
104
+ const entry = (doc as any).profiles.at("alice")
105
+ expect(entry).toBeDefined()
106
+ expect(entry.displayName()).toBe("Alice")
107
+ expect(entry.age()).toBe(30)
108
+ })
109
+
110
+ it("syncs record-of-struct between two peers via snapshot", () => {
111
+ const docA = createYjsDoc(PlainRecordSchema)
112
+
113
+ change(docA, (d: any) => {
114
+ d.profiles.set("alice", { displayName: "Alice", age: 30 })
115
+ })
116
+
117
+ const snapshot = exportSnapshot(docA)
118
+ const docB = createYjsDocFromSnapshot(PlainRecordSchema, snapshot)
119
+
120
+ expect(docB.profiles()).toEqual({
121
+ alice: { displayName: "Alice", age: 30 },
122
+ })
123
+ })
124
+
125
+ it("syncs record-of-struct between two peers via delta", () => {
126
+ const docA = createYjsDoc(PlainRecordSchema)
127
+
128
+ change(docA, (d: any) => {
129
+ d.profiles.set("alice", { displayName: "Alice", age: 30 })
130
+ })
131
+
132
+ // Establish docB from snapshot (avoids Yjs clientID collision)
133
+ const docB = createYjsDocFromSnapshot(PlainRecordSchema, exportSnapshot(docA))
134
+
135
+ const v0 = version(docB)
136
+
137
+ change(docA, (d: any) => {
138
+ d.profiles.set("bob", { displayName: "Bob", age: 25 })
139
+ })
140
+
141
+ const delta = exportSince(docA, v0)
142
+ expect(delta).not.toBeNull()
143
+ importDelta(docB, delta!, "sync")
144
+
145
+ expect(docB.profiles()).toEqual({
146
+ alice: { displayName: "Alice", age: 30 },
147
+ bob: { displayName: "Bob", age: 25 },
148
+ })
149
+ })
150
+ })
151
+
152
+ // ===========================================================================
153
+ // text-inside-struct-inside-record
154
+ // ===========================================================================
155
+
156
+ describe("text-inside-struct-inside-record", () => {
157
+ it("set a record entry with text field omitted and read it back", () => {
158
+ const doc = createYjsDoc(ProfileSchema)
159
+
160
+ change(doc, (d: any) => {
161
+ d.profiles.set("alice", { displayName: "Alice" })
162
+ })
163
+
164
+ const snapshot = doc.profiles()
165
+ expect(snapshot).toEqual({
166
+ alice: { displayName: "Alice", bio: "" },
167
+ })
168
+ })
169
+
170
+ it("set a record entry with text field provided and read it back", () => {
171
+ const doc = createYjsDoc(ProfileSchema)
172
+
173
+ change(doc, (d: any) => {
174
+ d.profiles.set("alice", { displayName: "Alice", bio: "Hello world" })
175
+ })
176
+
177
+ const snapshot = doc.profiles()
178
+ expect(snapshot).toEqual({
179
+ alice: { displayName: "Alice", bio: "Hello world" },
180
+ })
181
+ })
182
+
183
+ it("navigate into a record entry and read the text", () => {
184
+ const doc = createYjsDoc(ProfileSchema)
185
+
186
+ change(doc, (d: any) => {
187
+ d.profiles.set("alice", { displayName: "Alice" })
188
+ })
189
+
190
+ const entry = (doc as any).profiles.at("alice")
191
+ expect(entry).toBeDefined()
192
+ expect(entry.displayName()).toBe("Alice")
193
+ expect(entry.bio()).toBe("")
194
+ })
195
+
196
+ it("insert text into a text field inside a record entry (field omitted at creation)", () => {
197
+ const doc = createYjsDoc(ProfileSchema)
198
+
199
+ change(doc, (d: any) => {
200
+ d.profiles.set("alice", { displayName: "Alice" })
201
+ })
202
+
203
+ change(doc, (d: any) => {
204
+ d.profiles.at("alice").bio.insert(0, "Hello world")
205
+ })
206
+
207
+ expect((doc as any).profiles.at("alice").bio()).toBe("Hello world")
208
+ })
209
+
210
+ it("insert text into a text field inside a record entry (field provided at creation)", () => {
211
+ const doc = createYjsDoc(ProfileSchema)
212
+
213
+ change(doc, (d: any) => {
214
+ d.profiles.set("alice", { displayName: "Alice", bio: "Initial" })
215
+ })
216
+
217
+ change(doc, (d: any) => {
218
+ d.profiles.at("alice").bio.insert(7, " bio")
219
+ })
220
+
221
+ expect((doc as any).profiles.at("alice").bio()).toBe("Initial bio")
222
+ })
223
+
224
+ it("set multiple entries and edit text independently", () => {
225
+ const doc = createYjsDoc(ProfileSchema)
226
+
227
+ change(doc, (d: any) => {
228
+ d.profiles.set("alice", { displayName: "Alice" })
229
+ d.profiles.set("bob", { displayName: "Bob" })
230
+ })
231
+
232
+ change(doc, (d: any) => {
233
+ d.profiles.at("alice").bio.insert(0, "Alice's bio")
234
+ d.profiles.at("bob").bio.insert(0, "Bob's bio")
235
+ })
236
+
237
+ expect((doc as any).profiles.at("alice").bio()).toBe("Alice's bio")
238
+ expect((doc as any).profiles.at("bob").bio()).toBe("Bob's bio")
239
+ })
240
+
241
+ it("subscribe fires on text edit inside record entry", () => {
242
+ const doc = createYjsDoc(ProfileSchema)
243
+
244
+ change(doc, (d: any) => {
245
+ d.profiles.set("alice", { displayName: "Alice" })
246
+ })
247
+
248
+ let fired = false
249
+ subscribe(doc, () => { fired = true })
250
+
251
+ change(doc, (d: any) => {
252
+ d.profiles.at("alice").bio.insert(0, "Hello")
253
+ })
254
+
255
+ expect(fired).toBe(true)
256
+ })
257
+
258
+ it("syncs text-inside-record via snapshot", () => {
259
+ const docA = createYjsDoc(ProfileSchema)
260
+
261
+ change(docA, (d: any) => {
262
+ d.profiles.set("alice", { displayName: "Alice" })
263
+ })
264
+ change(docA, (d: any) => {
265
+ d.profiles.at("alice").bio.insert(0, "Collaborative bio")
266
+ })
267
+
268
+ const snapshot = exportSnapshot(docA)
269
+ const docB = createYjsDocFromSnapshot(ProfileSchema, snapshot)
270
+
271
+ expect(docB.profiles()).toEqual({
272
+ alice: { displayName: "Alice", bio: "Collaborative bio" },
273
+ })
274
+
275
+ // Text navigation works on the reconstructed doc
276
+ expect((docB as any).profiles.at("alice").bio()).toBe("Collaborative bio")
277
+ })
278
+
279
+ it("syncs text-inside-record via delta", () => {
280
+ const docA = createYjsDoc(ProfileSchema)
281
+
282
+ change(docA, (d: any) => {
283
+ d.profiles.set("alice", { displayName: "Alice" })
284
+ })
285
+
286
+ // Establish docB from snapshot (consistent with Yjs test patterns —
287
+ // two independently-created Y.Docs may share a clientID, causing
288
+ // silent update drops)
289
+ const docB = createYjsDocFromSnapshot(ProfileSchema, exportSnapshot(docA))
290
+ const v0 = version(docB)
291
+
292
+ change(docA, (d: any) => {
293
+ d.profiles.at("alice").bio.insert(0, "Hello from A")
294
+ })
295
+
296
+ const delta = exportSince(docA, v0)
297
+ expect(delta).not.toBeNull()
298
+ importDelta(docB, delta!, "sync")
299
+
300
+ expect(docB.profiles()).toEqual({
301
+ alice: { displayName: "Alice", bio: "Hello from A" },
302
+ })
303
+
304
+ // Text on B is functional — can insert independently
305
+ change(docB, (d: any) => {
306
+ d.profiles.at("alice").bio.insert(12, "!")
307
+ })
308
+ expect((docB as any).profiles.at("alice").bio()).toBe("Hello from A!")
309
+ })
310
+
311
+ it("concurrent text edits inside record entries converge", () => {
312
+ const docA = createYjsDoc(ProfileSchema)
313
+
314
+ // Sync initial state: A creates the entry, B starts from snapshot
315
+ change(docA, (d: any) => {
316
+ d.profiles.set("alice", { displayName: "Alice" })
317
+ })
318
+ const docB = createYjsDocFromSnapshot(ProfileSchema, exportSnapshot(docA))
319
+
320
+ // Both peers edit concurrently
321
+ const vA = version(docA)
322
+ const vB = version(docB)
323
+
324
+ change(docA, (d: any) => {
325
+ d.profiles.at("alice").bio.insert(0, "Hello ")
326
+ })
327
+ change(docB, (d: any) => {
328
+ d.profiles.at("alice").bio.insert(0, "World")
329
+ })
330
+
331
+ // Sync both ways
332
+ const deltaAB = exportSince(docA, vB)
333
+ const deltaBA = exportSince(docB, vA)
334
+ expect(deltaAB).not.toBeNull()
335
+ expect(deltaBA).not.toBeNull()
336
+ importDelta(docB, deltaAB!, "sync")
337
+ importDelta(docA, deltaBA!, "sync")
338
+
339
+ // Both converge to the same value (order depends on client IDs)
340
+ expect((docA as any).profiles.at("alice").bio()).toBe(
341
+ (docB as any).profiles.at("alice").bio(),
342
+ )
343
+ })
344
+ })
345
+
346
+ // ===========================================================================
347
+ // text-inside-struct-inside-list
348
+ // ===========================================================================
349
+
350
+ describe("text-inside-struct-inside-list", () => {
351
+ it("push a struct with text field omitted and read it back", () => {
352
+ const doc = createYjsDoc(ListProfileSchema)
353
+
354
+ change(doc, (d: any) => {
355
+ d.players.push({ name: "Alice" })
356
+ })
357
+
358
+ expect(doc.players.length).toBe(1)
359
+ expect((doc as any).players.at(0).name()).toBe("Alice")
360
+ expect((doc as any).players.at(0).bio()).toBe("")
361
+ })
362
+
363
+ it("push a struct with text field provided and read it back", () => {
364
+ const doc = createYjsDoc(ListProfileSchema)
365
+
366
+ change(doc, (d: any) => {
367
+ d.players.push({ name: "Alice", bio: "Hi there" })
368
+ })
369
+
370
+ expect(doc.players.length).toBe(1)
371
+ expect((doc as any).players.at(0).name()).toBe("Alice")
372
+ expect((doc as any).players.at(0).bio()).toBe("Hi there")
373
+ })
374
+
375
+ it("insert text into a text field inside a list item (field omitted at creation)", () => {
376
+ const doc = createYjsDoc(ListProfileSchema)
377
+
378
+ change(doc, (d: any) => {
379
+ d.players.push({ name: "Alice" })
380
+ })
381
+
382
+ change(doc, (d: any) => {
383
+ d.players.at(0).bio.insert(0, "Alice's bio")
384
+ })
385
+
386
+ expect((doc as any).players.at(0).bio()).toBe("Alice's bio")
387
+ })
388
+
389
+ it("multiple list items with independent text fields", () => {
390
+ const doc = createYjsDoc(ListProfileSchema)
391
+
392
+ change(doc, (d: any) => {
393
+ d.players.push({ name: "Alice" })
394
+ d.players.push({ name: "Bob" })
395
+ })
396
+
397
+ change(doc, (d: any) => {
398
+ d.players.at(0).bio.insert(0, "Alice's bio")
399
+ d.players.at(1).bio.insert(0, "Bob's bio")
400
+ })
401
+
402
+ expect((doc as any).players.at(0).bio()).toBe("Alice's bio")
403
+ expect((doc as any).players.at(1).bio()).toBe("Bob's bio")
404
+ })
405
+
406
+ it("syncs list-of-struct-with-text via delta", () => {
407
+ const docA = createYjsDoc(ListProfileSchema)
408
+
409
+ change(docA, (d: any) => {
410
+ d.players.push({ name: "Alice" })
411
+ })
412
+
413
+ // Establish docB from snapshot (avoids Yjs clientID collision)
414
+ const docB = createYjsDocFromSnapshot(ListProfileSchema, exportSnapshot(docA))
415
+ const v0 = version(docB)
416
+
417
+ change(docA, (d: any) => {
418
+ d.players.at(0).bio.insert(0, "Synced bio")
419
+ })
420
+
421
+ const delta = exportSince(docA, v0)
422
+ expect(delta).not.toBeNull()
423
+ importDelta(docB, delta!, "sync")
424
+
425
+ expect(docB.players.length).toBe(1)
426
+ expect((docB as any).players.at(0).name()).toBe("Alice")
427
+ expect((docB as any).players.at(0).bio()).toBe("Synced bio")
428
+ })
429
+ })