@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,632 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest"
|
|
2
|
+
import * as Y from "yjs"
|
|
3
|
+
import { Schema, change, subscribe } from "@kyneta/schema"
|
|
4
|
+
import { createYjsDoc, createYjsDocFromSnapshot } from "../create.js"
|
|
5
|
+
import { version, exportSnapshot, exportSince, importDelta } from "../sync.js"
|
|
6
|
+
import { YjsVersion } from "../version.js"
|
|
7
|
+
import { ensureContainers } from "../populate.js"
|
|
8
|
+
import { yjsSubstrateFactory } from "../substrate.js"
|
|
9
|
+
import { yjs } from "../yjs-escape.js"
|
|
10
|
+
|
|
11
|
+
// ===========================================================================
|
|
12
|
+
// Schemas used across tests
|
|
13
|
+
// ===========================================================================
|
|
14
|
+
|
|
15
|
+
const SimpleSchema = Schema.doc({
|
|
16
|
+
title: Schema.annotated("text"),
|
|
17
|
+
count: Schema.number(),
|
|
18
|
+
items: Schema.list(Schema.string()),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const StructListSchema = Schema.doc({
|
|
22
|
+
tasks: Schema.list(
|
|
23
|
+
Schema.struct({
|
|
24
|
+
name: Schema.string(),
|
|
25
|
+
done: Schema.boolean(),
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const NestedSchema = Schema.doc({
|
|
31
|
+
title: Schema.annotated("text"),
|
|
32
|
+
meta: Schema.struct({
|
|
33
|
+
author: Schema.string(),
|
|
34
|
+
tags: Schema.list(Schema.string()),
|
|
35
|
+
}),
|
|
36
|
+
labels: Schema.record(Schema.string()),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// ===========================================================================
|
|
40
|
+
// Tests
|
|
41
|
+
// ===========================================================================
|
|
42
|
+
|
|
43
|
+
describe("createYjsDoc", () => {
|
|
44
|
+
// -------------------------------------------------------------------------
|
|
45
|
+
// Default values
|
|
46
|
+
// -------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe("with defaults", () => {
|
|
49
|
+
it("creates a doc with empty containers for shared types", () => {
|
|
50
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
51
|
+
// Text annotation returns "" (empty Y.Text)
|
|
52
|
+
expect(doc.title()).toBe("")
|
|
53
|
+
// Plain scalars return structural zeros
|
|
54
|
+
expect(doc.count()).toBe(0)
|
|
55
|
+
// Sequence containers are created empty
|
|
56
|
+
expect(doc.items()).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("creates a doc with nested struct empty containers", () => {
|
|
60
|
+
const doc = createYjsDoc(NestedSchema)
|
|
61
|
+
expect(doc.title()).toBe("")
|
|
62
|
+
// Plain scalar inside struct returns structural zero
|
|
63
|
+
expect(doc.meta.author()).toBe("")
|
|
64
|
+
expect(doc.meta.tags()).toEqual([])
|
|
65
|
+
expect(doc.labels()).toEqual({})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("creates a doc with struct list defaults", () => {
|
|
69
|
+
const doc = createYjsDoc(StructListSchema)
|
|
70
|
+
expect(doc.tasks()).toEqual([])
|
|
71
|
+
expect(doc.tasks.length).toBe(0)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// -------------------------------------------------------------------------
|
|
76
|
+
// With seed values
|
|
77
|
+
// -------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
describe("with seeds", () => {
|
|
80
|
+
it("creates a doc with scalar seed values", () => {
|
|
81
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
82
|
+
change(doc, (d: any) => {
|
|
83
|
+
d.title.insert(0, "Hello")
|
|
84
|
+
d.count.set(42)
|
|
85
|
+
})
|
|
86
|
+
// Separate change() calls for list pushes to preserve order
|
|
87
|
+
// (Yjs reverses order within a single transaction)
|
|
88
|
+
change(doc, (d: any) => d.items.push("a"))
|
|
89
|
+
change(doc, (d: any) => d.items.push("b"))
|
|
90
|
+
change(doc, (d: any) => d.items.push("c"))
|
|
91
|
+
expect(doc.title()).toBe("Hello")
|
|
92
|
+
expect(doc.count()).toBe(42)
|
|
93
|
+
expect(doc.items()).toEqual(["a", "b", "c"])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("creates a doc with partial seed (defaults fill gaps)", () => {
|
|
97
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
98
|
+
change(doc, (d: any) => {
|
|
99
|
+
d.title.insert(0, "Partial")
|
|
100
|
+
})
|
|
101
|
+
expect(doc.title()).toBe("Partial")
|
|
102
|
+
expect(doc.count()).toBe(0)
|
|
103
|
+
expect(doc.items()).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("creates a doc with nested struct seed", () => {
|
|
107
|
+
const doc = createYjsDoc(NestedSchema)
|
|
108
|
+
change(doc, (d: any) => {
|
|
109
|
+
d.title.insert(0, "Doc")
|
|
110
|
+
d.meta.author.set("Alice")
|
|
111
|
+
d.meta.tags.push("draft")
|
|
112
|
+
d.labels.set("priority", "high")
|
|
113
|
+
})
|
|
114
|
+
expect(doc.title()).toBe("Doc")
|
|
115
|
+
expect(doc.meta.author()).toBe("Alice")
|
|
116
|
+
expect(doc.meta.tags()).toEqual(["draft"])
|
|
117
|
+
const labels = doc.labels() as Record<string, unknown>
|
|
118
|
+
expect(labels.priority).toBe("high")
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("creates a doc with struct list seed items", () => {
|
|
122
|
+
const doc = createYjsDoc(StructListSchema)
|
|
123
|
+
// Separate change() calls for list pushes to preserve order
|
|
124
|
+
change(doc, (d: any) => d.tasks.push({ name: "Task 1", done: false }))
|
|
125
|
+
change(doc, (d: any) => d.tasks.push({ name: "Task 2", done: true }))
|
|
126
|
+
expect(doc.tasks.length).toBe(2)
|
|
127
|
+
expect(doc.tasks.at(0)?.name()).toBe("Task 1")
|
|
128
|
+
expect(doc.tasks.at(0)?.done()).toBe(false)
|
|
129
|
+
expect(doc.tasks.at(1)?.name()).toBe("Task 2")
|
|
130
|
+
expect(doc.tasks.at(1)?.done()).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// -------------------------------------------------------------------------
|
|
135
|
+
// Bring your own Y.Doc
|
|
136
|
+
// -------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
describe("with existing Y.Doc", () => {
|
|
139
|
+
it("wraps an existing Y.Doc", () => {
|
|
140
|
+
const yjsDoc = new Y.Doc()
|
|
141
|
+
ensureContainers(yjsDoc, SimpleSchema)
|
|
142
|
+
yjsDoc.transact(() => {
|
|
143
|
+
const rootMap = yjsDoc.getMap("root")
|
|
144
|
+
;(rootMap.get("title") as Y.Text).insert(0, "External")
|
|
145
|
+
rootMap.set("count", 99)
|
|
146
|
+
;(rootMap.get("items") as Y.Array<string>).push(["x"])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const doc = createYjsDoc(SimpleSchema, yjsDoc)
|
|
150
|
+
expect(doc.title()).toBe("External")
|
|
151
|
+
expect(doc.count()).toBe(99)
|
|
152
|
+
expect(doc.items()).toEqual(["x"])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("mutations through kyneta change are visible on the Y.Doc", () => {
|
|
156
|
+
const yjsDoc = new Y.Doc()
|
|
157
|
+
ensureContainers(yjsDoc, SimpleSchema)
|
|
158
|
+
|
|
159
|
+
const doc = createYjsDoc(SimpleSchema, yjsDoc)
|
|
160
|
+
change(doc, (d: any) => {
|
|
161
|
+
d.title.insert(0, "Hello")
|
|
162
|
+
d.count.set(42)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const rootMap = yjsDoc.getMap("root")
|
|
166
|
+
expect((rootMap.get("title") as Y.Text).toJSON()).toBe("Hello")
|
|
167
|
+
expect(rootMap.get("count")).toBe(42)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("mutations through raw Yjs API are visible on the kyneta ref", () => {
|
|
171
|
+
const yjsDoc = new Y.Doc()
|
|
172
|
+
ensureContainers(yjsDoc, SimpleSchema)
|
|
173
|
+
|
|
174
|
+
const doc = createYjsDoc(SimpleSchema, yjsDoc)
|
|
175
|
+
|
|
176
|
+
const rootMap = yjsDoc.getMap("root")
|
|
177
|
+
rootMap.set("count", 77)
|
|
178
|
+
|
|
179
|
+
expect(doc.count()).toBe(77)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("yjs() escape hatch returns the same Y.Doc", () => {
|
|
183
|
+
const yjsDoc = new Y.Doc()
|
|
184
|
+
ensureContainers(yjsDoc, SimpleSchema)
|
|
185
|
+
|
|
186
|
+
const doc = createYjsDoc(SimpleSchema, yjsDoc)
|
|
187
|
+
const escaped = yjs(doc)
|
|
188
|
+
|
|
189
|
+
expect(escaped).toBe(yjsDoc)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// ===========================================================================
|
|
195
|
+
// createYjsDocFromSnapshot
|
|
196
|
+
// ===========================================================================
|
|
197
|
+
|
|
198
|
+
describe("createYjsDocFromSnapshot", () => {
|
|
199
|
+
it("reconstructs state from a snapshot", () => {
|
|
200
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
201
|
+
change(doc1, (d: any) => {
|
|
202
|
+
d.title.insert(0, "Snapshot")
|
|
203
|
+
d.count.set(42)
|
|
204
|
+
})
|
|
205
|
+
change(doc1, (d: any) => d.items.push("a"))
|
|
206
|
+
change(doc1, (d: any) => d.items.push("b"))
|
|
207
|
+
|
|
208
|
+
const payload = exportSnapshot(doc1)
|
|
209
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
|
|
210
|
+
|
|
211
|
+
expect(doc2.title()).toBe("Snapshot")
|
|
212
|
+
expect(doc2.count()).toBe(42)
|
|
213
|
+
expect(doc2.items()).toEqual(["a", "b"])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("reconstructs state after mutations", () => {
|
|
217
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
218
|
+
change(doc1, (d: any) => {
|
|
219
|
+
d.title.insert(0, "Start")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
change(doc1, (d: any) => {
|
|
223
|
+
d.title.insert(5, " End")
|
|
224
|
+
d.count.set(99)
|
|
225
|
+
d.items.push("x")
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const payload = exportSnapshot(doc1)
|
|
229
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
|
|
230
|
+
|
|
231
|
+
expect(doc2.title()).toBe("Start End")
|
|
232
|
+
expect(doc2.count()).toBe(99)
|
|
233
|
+
expect(doc2.items()).toEqual(["x"])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("reconstructs nested struct state from snapshot", () => {
|
|
237
|
+
const doc1 = createYjsDoc(NestedSchema)
|
|
238
|
+
change(doc1, (d: any) => {
|
|
239
|
+
d.title.insert(0, "Nested")
|
|
240
|
+
d.meta.author.set("Alice")
|
|
241
|
+
d.labels.set("bug", "red")
|
|
242
|
+
})
|
|
243
|
+
change(doc1, (d: any) => d.meta.tags.push("v1"))
|
|
244
|
+
change(doc1, (d: any) => d.meta.tags.push("v2"))
|
|
245
|
+
|
|
246
|
+
const payload = exportSnapshot(doc1)
|
|
247
|
+
const doc2 = createYjsDocFromSnapshot(NestedSchema, payload)
|
|
248
|
+
|
|
249
|
+
expect(doc2.title()).toBe("Nested")
|
|
250
|
+
expect(doc2.meta.author()).toBe("Alice")
|
|
251
|
+
expect(doc2.meta.tags()).toEqual(["v1", "v2"])
|
|
252
|
+
const labels = doc2.labels() as Record<string, unknown>
|
|
253
|
+
expect(labels.bug).toBe("red")
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("reconstructs struct list state from snapshot", () => {
|
|
257
|
+
const doc1 = createYjsDoc(StructListSchema)
|
|
258
|
+
change(doc1, (d: any) => d.tasks.push({ name: "Task A", done: false }))
|
|
259
|
+
change(doc1, (d: any) => d.tasks.push({ name: "Task B", done: true }))
|
|
260
|
+
|
|
261
|
+
const payload = exportSnapshot(doc1)
|
|
262
|
+
const doc2 = createYjsDocFromSnapshot(StructListSchema, payload)
|
|
263
|
+
|
|
264
|
+
expect(doc2.tasks.length).toBe(2)
|
|
265
|
+
expect((doc2.tasks.at(0) as any).name()).toBe("Task A")
|
|
266
|
+
expect((doc2.tasks.at(1) as any).done()).toBe(true)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("is writable after reconstruction", () => {
|
|
270
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
271
|
+
change(doc1, (d: any) => {
|
|
272
|
+
d.title.insert(0, "Original")
|
|
273
|
+
})
|
|
274
|
+
const payload = exportSnapshot(doc1)
|
|
275
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
|
|
276
|
+
|
|
277
|
+
change(doc2, (d: any) => {
|
|
278
|
+
d.title.insert(8, " Copy")
|
|
279
|
+
d.count.set(7)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
expect(doc2.title()).toBe("Original Copy")
|
|
283
|
+
expect(doc2.count()).toBe(7)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it("is observable after reconstruction", () => {
|
|
287
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
288
|
+
change(doc1, (d: any) => {
|
|
289
|
+
d.title.insert(0, "Original")
|
|
290
|
+
})
|
|
291
|
+
const payload = exportSnapshot(doc1)
|
|
292
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
|
|
293
|
+
|
|
294
|
+
const received: any[] = []
|
|
295
|
+
subscribe(doc2, (changeset: any) => {
|
|
296
|
+
received.push(changeset)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
change(doc2, (d: any) => {
|
|
300
|
+
d.count.set(42)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
expect(received.length).toBeGreaterThanOrEqual(1)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// ===========================================================================
|
|
308
|
+
// Sync primitives
|
|
309
|
+
// ===========================================================================
|
|
310
|
+
|
|
311
|
+
describe("sync primitives", () => {
|
|
312
|
+
describe("version", () => {
|
|
313
|
+
it("returns a YjsVersion", () => {
|
|
314
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
315
|
+
const v = version(doc)
|
|
316
|
+
expect(v).toBeInstanceOf(YjsVersion)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it("advances after mutations", () => {
|
|
320
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
321
|
+
const v1 = version(doc)
|
|
322
|
+
|
|
323
|
+
change(doc, (d: any) => {
|
|
324
|
+
d.count.set(1)
|
|
325
|
+
})
|
|
326
|
+
const v2 = version(doc)
|
|
327
|
+
|
|
328
|
+
expect(v1.compare(v2)).toBe("behind")
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it("serialize/parse round-trips", () => {
|
|
332
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
333
|
+
change(doc, (d: any) => { d.title.insert(0, "Test") })
|
|
334
|
+
const v = version(doc)
|
|
335
|
+
const serialized = v.serialize()
|
|
336
|
+
const parsed = YjsVersion.parse(serialized)
|
|
337
|
+
expect(parsed.compare(v)).toBe("equal")
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
describe("exportSnapshot", () => {
|
|
342
|
+
it("returns a binary payload", () => {
|
|
343
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
344
|
+
change(doc, (d: any) => { d.title.insert(0, "Snap") })
|
|
345
|
+
const payload = exportSnapshot(doc)
|
|
346
|
+
expect(payload.encoding).toBe("binary")
|
|
347
|
+
expect(payload.data).toBeInstanceOf(Uint8Array)
|
|
348
|
+
expect((payload.data as Uint8Array).byteLength).toBeGreaterThan(0)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe("exportSince + importDelta", () => {
|
|
353
|
+
it("syncs incremental changes between two docs", () => {
|
|
354
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
355
|
+
change(doc1, (d: any) => { d.title.insert(0, "Start") })
|
|
356
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
|
|
357
|
+
|
|
358
|
+
const v2Before = version(doc2)
|
|
359
|
+
|
|
360
|
+
// Mutate doc1
|
|
361
|
+
change(doc1, (d: any) => {
|
|
362
|
+
d.title.insert(5, " Edited")
|
|
363
|
+
d.count.set(42)
|
|
364
|
+
d.items.push("new-item")
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
// Export delta and apply to doc2
|
|
368
|
+
const delta = exportSince(doc1, v2Before)
|
|
369
|
+
expect(delta).not.toBeNull()
|
|
370
|
+
expect(delta!.encoding).toBe("binary")
|
|
371
|
+
|
|
372
|
+
importDelta(doc2, delta!)
|
|
373
|
+
|
|
374
|
+
expect(doc2.title()).toBe("Start Edited")
|
|
375
|
+
expect(doc2.count()).toBe(42)
|
|
376
|
+
expect(doc2.items()).toEqual(["new-item"])
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it("syncs multiple incremental deltas", () => {
|
|
380
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
381
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
|
|
382
|
+
|
|
383
|
+
// First round
|
|
384
|
+
let vBefore = version(doc2)
|
|
385
|
+
change(doc1, (d: any) => {
|
|
386
|
+
d.title.insert(0, "A")
|
|
387
|
+
})
|
|
388
|
+
importDelta(doc2, exportSince(doc1, vBefore)!)
|
|
389
|
+
|
|
390
|
+
// Second round
|
|
391
|
+
vBefore = version(doc2)
|
|
392
|
+
change(doc1, (d: any) => {
|
|
393
|
+
d.title.insert(1, "B")
|
|
394
|
+
})
|
|
395
|
+
importDelta(doc2, exportSince(doc1, vBefore)!)
|
|
396
|
+
|
|
397
|
+
// Third round
|
|
398
|
+
vBefore = version(doc2)
|
|
399
|
+
change(doc1, (d: any) => {
|
|
400
|
+
d.count.set(3)
|
|
401
|
+
})
|
|
402
|
+
importDelta(doc2, exportSince(doc1, vBefore)!)
|
|
403
|
+
|
|
404
|
+
expect(doc2.title()).toBe("AB")
|
|
405
|
+
expect(doc2.count()).toBe(3)
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it("changefeed fires on importDelta", () => {
|
|
409
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
410
|
+
change(doc1, (d: any) => { d.title.insert(0, "Source") })
|
|
411
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
|
|
412
|
+
|
|
413
|
+
const v2Before = version(doc2)
|
|
414
|
+
|
|
415
|
+
change(doc1, (d: any) => {
|
|
416
|
+
d.count.set(77)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
const delta = exportSince(doc1, v2Before)
|
|
420
|
+
|
|
421
|
+
const received: any[] = []
|
|
422
|
+
subscribe(doc2, (changeset: any) => {
|
|
423
|
+
received.push(changeset)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
importDelta(doc2, delta!)
|
|
427
|
+
|
|
428
|
+
expect(received.length).toBeGreaterThanOrEqual(1)
|
|
429
|
+
expect(doc2.count()).toBe(77)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it("importDelta passes origin to changefeed", () => {
|
|
433
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
434
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
|
|
435
|
+
|
|
436
|
+
const v2Before = version(doc2)
|
|
437
|
+
change(doc1, (d: any) => {
|
|
438
|
+
d.count.set(1)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
const receivedOrigins: (string | undefined)[] = []
|
|
442
|
+
subscribe(doc2, (changeset: any) => {
|
|
443
|
+
receivedOrigins.push(changeset.origin)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
importDelta(doc2, exportSince(doc1, v2Before)!, "my-sync-origin")
|
|
447
|
+
|
|
448
|
+
expect(receivedOrigins).toContain("my-sync-origin")
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
describe("versions equal after sync", () => {
|
|
453
|
+
it("versions equal after full snapshot sync", () => {
|
|
454
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
455
|
+
change(doc1, (d: any) => { d.title.insert(0, "Same") })
|
|
456
|
+
change(doc1, (d: any) => {
|
|
457
|
+
d.count.set(42)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
|
|
461
|
+
|
|
462
|
+
expect(version(doc1).compare(version(doc2))).toBe("equal")
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it("versions equal after bidirectional delta sync", () => {
|
|
466
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
467
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
|
|
468
|
+
|
|
469
|
+
const v1Before = version(doc1)
|
|
470
|
+
const v2Before = version(doc2)
|
|
471
|
+
|
|
472
|
+
// Independent mutations
|
|
473
|
+
change(doc1, (d: any) => {
|
|
474
|
+
d.title.insert(0, "A")
|
|
475
|
+
})
|
|
476
|
+
change(doc2, (d: any) => {
|
|
477
|
+
d.count.set(7)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
// Bidirectional sync
|
|
481
|
+
const d1to2 = exportSince(doc1, v2Before)
|
|
482
|
+
const d2to1 = exportSince(doc2, v1Before)
|
|
483
|
+
importDelta(doc2, d1to2!)
|
|
484
|
+
importDelta(doc1, d2to1!)
|
|
485
|
+
|
|
486
|
+
expect(version(doc1).compare(version(doc2))).toBe("equal")
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
// ===========================================================================
|
|
492
|
+
// Full workflow
|
|
493
|
+
// ===========================================================================
|
|
494
|
+
|
|
495
|
+
describe("full workflow", () => {
|
|
496
|
+
it("create → mutate → sync → observe", () => {
|
|
497
|
+
// 1. Create two docs
|
|
498
|
+
const doc1 = createYjsDoc(StructListSchema)
|
|
499
|
+
const doc2 = createYjsDocFromSnapshot(
|
|
500
|
+
StructListSchema,
|
|
501
|
+
exportSnapshot(doc1),
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
// 2. Set up observer on doc2
|
|
505
|
+
const changes: any[] = []
|
|
506
|
+
subscribe(doc2, (changeset: any) => {
|
|
507
|
+
changes.push(changeset)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
// 3. Mutate doc1
|
|
511
|
+
const vBefore = version(doc2)
|
|
512
|
+
|
|
513
|
+
change(doc1, (d: any) => {
|
|
514
|
+
d.tasks.push({ name: "Buy milk", done: false })
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
change(doc1, (d: any) => {
|
|
518
|
+
d.tasks.push({ name: "Walk dog", done: false })
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
// 4. Sync doc1 → doc2
|
|
522
|
+
const delta = exportSince(doc1, vBefore)
|
|
523
|
+
importDelta(doc2, delta!)
|
|
524
|
+
|
|
525
|
+
// 5. Verify state converged
|
|
526
|
+
expect(doc2.tasks.length).toBe(2)
|
|
527
|
+
expect((doc2.tasks.at(0) as any).name()).toBe("Buy milk")
|
|
528
|
+
expect((doc2.tasks.at(1) as any).name()).toBe("Walk dog")
|
|
529
|
+
|
|
530
|
+
// 6. Verify observer was called
|
|
531
|
+
expect(changes.length).toBeGreaterThan(0)
|
|
532
|
+
|
|
533
|
+
// 7. Verify versions match
|
|
534
|
+
expect(version(doc1).compare(version(doc2))).toBe("equal")
|
|
535
|
+
|
|
536
|
+
// 8. Mutate doc2 and sync back
|
|
537
|
+
const v1Before = version(doc1)
|
|
538
|
+
|
|
539
|
+
change(doc2, (d: any) => {
|
|
540
|
+
d.tasks.push({ name: "Read book", done: false })
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
const delta2 = exportSince(doc2, v1Before)
|
|
544
|
+
importDelta(doc1, delta2!)
|
|
545
|
+
|
|
546
|
+
expect(doc1.tasks.length).toBe(3)
|
|
547
|
+
expect((doc1.tasks.at(2) as any).name()).toBe("Read book")
|
|
548
|
+
expect(version(doc1).compare(version(doc2))).toBe("equal")
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it("create → mutate → snapshot → reconstruct → continue", () => {
|
|
552
|
+
// 1. Create and mutate
|
|
553
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
554
|
+
change(doc1, (d: any) => {
|
|
555
|
+
d.title.insert(0, "Start")
|
|
556
|
+
})
|
|
557
|
+
change(doc1, (d: any) => {
|
|
558
|
+
d.title.insert(5, " Middle")
|
|
559
|
+
d.count.set(10)
|
|
560
|
+
d.items.push("first")
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
// 2. Snapshot
|
|
564
|
+
const snapshot = exportSnapshot(doc1)
|
|
565
|
+
|
|
566
|
+
// 3. Reconstruct
|
|
567
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, snapshot)
|
|
568
|
+
expect(doc2.title()).toBe("Start Middle")
|
|
569
|
+
expect(doc2.count()).toBe(10)
|
|
570
|
+
expect(doc2.items()).toEqual(["first"])
|
|
571
|
+
|
|
572
|
+
// 4. Continue mutating the reconstructed doc
|
|
573
|
+
change(doc2, (d: any) => {
|
|
574
|
+
d.title.insert(12, " End")
|
|
575
|
+
d.count.set(20)
|
|
576
|
+
d.items.push("second")
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
expect(doc2.title()).toBe("Start Middle End")
|
|
580
|
+
expect(doc2.count()).toBe(20)
|
|
581
|
+
expect(doc2.items()).toEqual(["first", "second"])
|
|
582
|
+
|
|
583
|
+
// 5. Version should be ahead of the snapshot version
|
|
584
|
+
const snapshotVersion = version(doc1)
|
|
585
|
+
const currentVersion = version(doc2)
|
|
586
|
+
// doc2 has additional mutations beyond doc1
|
|
587
|
+
expect(snapshotVersion.compare(currentVersion)).toBe("behind")
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it("concurrent edits converge correctly", () => {
|
|
591
|
+
// 1. Create two peers from the same initial state
|
|
592
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
593
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, exportSnapshot(doc1))
|
|
594
|
+
|
|
595
|
+
const v1Before = version(doc1)
|
|
596
|
+
const v2Before = version(doc2)
|
|
597
|
+
|
|
598
|
+
// 2. Both peers edit concurrently
|
|
599
|
+
change(doc1, (d: any) => {
|
|
600
|
+
d.title.insert(0, "Peer1")
|
|
601
|
+
d.items.push("from-1")
|
|
602
|
+
})
|
|
603
|
+
change(doc2, (d: any) => {
|
|
604
|
+
d.count.set(42)
|
|
605
|
+
d.items.push("from-2")
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
// 3. Versions are concurrent
|
|
609
|
+
expect(version(doc1).compare(version(doc2))).toBe("concurrent")
|
|
610
|
+
|
|
611
|
+
// 4. Bidirectional sync
|
|
612
|
+
const d1to2 = exportSince(doc1, v2Before)
|
|
613
|
+
const d2to1 = exportSince(doc2, v1Before)
|
|
614
|
+
importDelta(doc2, d1to2!)
|
|
615
|
+
importDelta(doc1, d2to1!)
|
|
616
|
+
|
|
617
|
+
// 5. Versions converge
|
|
618
|
+
expect(version(doc1).compare(version(doc2))).toBe("equal")
|
|
619
|
+
|
|
620
|
+
// 6. Both docs have all the data
|
|
621
|
+
expect(doc1.title()).toContain("Peer1")
|
|
622
|
+
expect(doc2.title()).toContain("Peer1")
|
|
623
|
+
expect(doc1.count()).toBe(42)
|
|
624
|
+
expect(doc2.count()).toBe(42)
|
|
625
|
+
|
|
626
|
+
// Both items should be present (order determined by Yjs conflict resolution)
|
|
627
|
+
const items1 = doc1.items() as string[]
|
|
628
|
+
const items2 = doc2.items() as string[]
|
|
629
|
+
expect(items1.sort()).toEqual(["from-1", "from-2"])
|
|
630
|
+
expect(items2.sort()).toEqual(["from-1", "from-2"])
|
|
631
|
+
})
|
|
632
|
+
})
|