@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,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
|
+
})
|