@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,604 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest"
|
|
2
|
+
import * as Y from "yjs"
|
|
3
|
+
import { Schema, change, subscribe, RawPath } from "@kyneta/schema"
|
|
4
|
+
import { createYjsSubstrate, yjsSubstrateFactory } from "../substrate.js"
|
|
5
|
+
import { YjsVersion } from "../version.js"
|
|
6
|
+
import { createYjsDoc, createYjsDocFromSnapshot } from "../create.js"
|
|
7
|
+
import {
|
|
8
|
+
version,
|
|
9
|
+
exportSnapshot,
|
|
10
|
+
exportSince,
|
|
11
|
+
importDelta,
|
|
12
|
+
} from "../sync.js"
|
|
13
|
+
import { ensureContainers } from "../populate.js"
|
|
14
|
+
|
|
15
|
+
// ===========================================================================
|
|
16
|
+
// Helpers
|
|
17
|
+
// ===========================================================================
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
// ===========================================================================
|
|
22
|
+
// Schemas used across tests
|
|
23
|
+
// ===========================================================================
|
|
24
|
+
|
|
25
|
+
const SimpleSchema = Schema.doc({
|
|
26
|
+
title: Schema.annotated("text"),
|
|
27
|
+
count: Schema.number(),
|
|
28
|
+
items: Schema.list(Schema.string()),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const StructListSchema = Schema.doc({
|
|
32
|
+
tasks: Schema.list(
|
|
33
|
+
Schema.struct({
|
|
34
|
+
name: Schema.string(),
|
|
35
|
+
done: Schema.boolean(),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const FullSchema = Schema.doc({
|
|
41
|
+
title: Schema.annotated("text"),
|
|
42
|
+
count: Schema.number(),
|
|
43
|
+
active: Schema.boolean(),
|
|
44
|
+
items: Schema.list(Schema.string()),
|
|
45
|
+
tasks: Schema.list(
|
|
46
|
+
Schema.struct({
|
|
47
|
+
name: Schema.string(),
|
|
48
|
+
done: Schema.boolean(),
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
meta: Schema.struct({
|
|
52
|
+
author: Schema.string(),
|
|
53
|
+
}),
|
|
54
|
+
labels: Schema.record(Schema.string()),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// ===========================================================================
|
|
58
|
+
// Tests
|
|
59
|
+
// ===========================================================================
|
|
60
|
+
|
|
61
|
+
describe("YjsSubstrate", () => {
|
|
62
|
+
// -------------------------------------------------------------------------
|
|
63
|
+
// Factory create
|
|
64
|
+
// -------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
describe("factory create", () => {
|
|
67
|
+
it("creates a substrate with empty containers", () => {
|
|
68
|
+
const substrate = yjsSubstrateFactory.create(SimpleSchema)
|
|
69
|
+
expect(substrate.store.read(RawPath.empty.field("title"))).toBe("")
|
|
70
|
+
// Plain scalars return structural zeros
|
|
71
|
+
expect(substrate.store.read(RawPath.empty.field("count"))).toBe(0)
|
|
72
|
+
expect(substrate.store.read(RawPath.empty.field("items"))).toEqual([])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("creates a substrate and populates via change()", () => {
|
|
76
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
77
|
+
change(doc, (d: any) => {
|
|
78
|
+
d.title.insert(0, "Hello")
|
|
79
|
+
d.count.set(42)
|
|
80
|
+
})
|
|
81
|
+
// Separate change() calls for list pushes to preserve order
|
|
82
|
+
// (Yjs reverses order within a single transaction)
|
|
83
|
+
change(doc, (d: any) => d.items.push("a"))
|
|
84
|
+
change(doc, (d: any) => d.items.push("b"))
|
|
85
|
+
expect(doc.title()).toBe("Hello")
|
|
86
|
+
expect(doc.count()).toBe(42)
|
|
87
|
+
expect(doc.items()).toEqual(["a", "b"])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("creates a substrate with partial values (unset fields stay empty)", () => {
|
|
91
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
92
|
+
change(doc, (d: any) => {
|
|
93
|
+
d.title.insert(0, "Partial")
|
|
94
|
+
})
|
|
95
|
+
expect(doc.title()).toBe("Partial")
|
|
96
|
+
expect(doc.count()).toBe(0)
|
|
97
|
+
expect(doc.items()).toEqual([])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("creates a substrate with nested struct values via change()", () => {
|
|
101
|
+
const doc = createYjsDoc(FullSchema)
|
|
102
|
+
change(doc, (d: any) => {
|
|
103
|
+
d.meta.author.set("Alice")
|
|
104
|
+
})
|
|
105
|
+
expect(doc.meta.author()).toBe("Alice")
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("creates a substrate with struct list values via change()", () => {
|
|
109
|
+
const doc = createYjsDoc(StructListSchema)
|
|
110
|
+
// Separate change() calls for list pushes to preserve order
|
|
111
|
+
change(doc, (d: any) => d.tasks.push({ name: "Task 1", done: false }))
|
|
112
|
+
change(doc, (d: any) => d.tasks.push({ name: "Task 2", done: true }))
|
|
113
|
+
expect((doc.tasks.at(0) as any).name()).toBe("Task 1")
|
|
114
|
+
expect((doc.tasks.at(1) as any).done()).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// -------------------------------------------------------------------------
|
|
119
|
+
// Write round-trip
|
|
120
|
+
// -------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
describe("write round-trip", () => {
|
|
123
|
+
it("text insert round-trips through prepare/flush", () => {
|
|
124
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
125
|
+
change(doc, (d: any) => {
|
|
126
|
+
d.title.insert(0, "Hello")
|
|
127
|
+
})
|
|
128
|
+
expect(doc.title()).toBe("Hello")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("scalar set round-trips through prepare/flush", () => {
|
|
132
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
133
|
+
change(doc, (d: any) => {
|
|
134
|
+
d.count.set(42)
|
|
135
|
+
})
|
|
136
|
+
expect(doc.count()).toBe(42)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("list push round-trips through prepare/flush", () => {
|
|
140
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
141
|
+
change(doc, (d: any) => {
|
|
142
|
+
d.items.push("a")
|
|
143
|
+
})
|
|
144
|
+
change(doc, (d: any) => {
|
|
145
|
+
d.items.push("b")
|
|
146
|
+
})
|
|
147
|
+
expect(doc.items()).toEqual(["a", "b"])
|
|
148
|
+
expect(doc.items.length).toBe(2)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// -------------------------------------------------------------------------
|
|
153
|
+
// Version tracking
|
|
154
|
+
// -------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
describe("version tracking", () => {
|
|
157
|
+
it("version advances after mutations", () => {
|
|
158
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
159
|
+
const v1 = version(doc)
|
|
160
|
+
|
|
161
|
+
change(doc, (d: any) => {
|
|
162
|
+
d.title.insert(0, "Hi")
|
|
163
|
+
})
|
|
164
|
+
const v2 = version(doc)
|
|
165
|
+
|
|
166
|
+
expect(v1.compare(v2)).toBe("behind")
|
|
167
|
+
expect(v2.compare(v1)).toBe("ahead")
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("version serialize/parse round-trips", () => {
|
|
171
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
172
|
+
change(doc, (d: any) => {
|
|
173
|
+
d.title.insert(0, "Test")
|
|
174
|
+
d.count.set(5)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const v = version(doc)
|
|
178
|
+
const serialized = v.serialize()
|
|
179
|
+
const parsed = YjsVersion.parse(serialized)
|
|
180
|
+
expect(parsed.compare(v)).toBe("equal")
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// -------------------------------------------------------------------------
|
|
185
|
+
// Export/import snapshot
|
|
186
|
+
// -------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
describe("export/import snapshot", () => {
|
|
189
|
+
it("exports a binary payload", () => {
|
|
190
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
191
|
+
change(doc, (d: any) => { d.title.insert(0, "Snapshot") })
|
|
192
|
+
const payload = exportSnapshot(doc)
|
|
193
|
+
expect(payload.encoding).toBe("binary")
|
|
194
|
+
expect(payload.data).toBeInstanceOf(Uint8Array)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("reconstructs equivalent state from snapshot", () => {
|
|
198
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
199
|
+
change(doc1, (d: any) => {
|
|
200
|
+
d.title.insert(0, "Hello")
|
|
201
|
+
d.count.set(42)
|
|
202
|
+
})
|
|
203
|
+
// Separate change() calls for list pushes to preserve order
|
|
204
|
+
change(doc1, (d: any) => d.items.push("a"))
|
|
205
|
+
change(doc1, (d: any) => d.items.push("b"))
|
|
206
|
+
change(doc1, (d: any) => {
|
|
207
|
+
d.title.insert(5, " World")
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const payload = exportSnapshot(doc1)
|
|
211
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
|
|
212
|
+
|
|
213
|
+
expect(doc2.title()).toBe("Hello World")
|
|
214
|
+
expect(doc2.count()).toBe(42)
|
|
215
|
+
expect(doc2.items()).toEqual(["a", "b"])
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// -------------------------------------------------------------------------
|
|
220
|
+
// Delta sync
|
|
221
|
+
// -------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
describe("delta sync", () => {
|
|
224
|
+
it("exportSince → importDelta syncs state", () => {
|
|
225
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
226
|
+
change(doc1, (d: any) => { d.title.insert(0, "Start") })
|
|
227
|
+
const doc2 = createYjsDocFromSnapshot(
|
|
228
|
+
SimpleSchema,
|
|
229
|
+
exportSnapshot(doc1),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
const v1Before = version(doc1)
|
|
233
|
+
|
|
234
|
+
change(doc1, (d: any) => {
|
|
235
|
+
d.title.insert(5, " Edited")
|
|
236
|
+
d.count.set(99)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const delta = exportSince(doc1, v1Before)
|
|
240
|
+
expect(delta).not.toBeNull()
|
|
241
|
+
|
|
242
|
+
importDelta(doc2, delta!)
|
|
243
|
+
expect(doc2.title()).toBe("Start Edited")
|
|
244
|
+
expect(doc2.count()).toBe(99)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("concurrent sync — two substrates converge after bidirectional sync", () => {
|
|
248
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
249
|
+
const doc2 = createYjsDocFromSnapshot(
|
|
250
|
+
SimpleSchema,
|
|
251
|
+
exportSnapshot(doc1),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
const v1Before = version(doc1)
|
|
255
|
+
const v2Before = version(doc2)
|
|
256
|
+
|
|
257
|
+
// Independent mutations
|
|
258
|
+
change(doc1, (d: any) => {
|
|
259
|
+
d.title.insert(0, "A")
|
|
260
|
+
})
|
|
261
|
+
change(doc2, (d: any) => {
|
|
262
|
+
d.count.set(7)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// Versions should be concurrent
|
|
266
|
+
const v1After = version(doc1)
|
|
267
|
+
const v2After = version(doc2)
|
|
268
|
+
expect(v1After.compare(v2After)).toBe("concurrent")
|
|
269
|
+
|
|
270
|
+
// Bidirectional sync
|
|
271
|
+
const d1to2 = exportSince(doc1, v2Before)
|
|
272
|
+
const d2to1 = exportSince(doc2, v1Before)
|
|
273
|
+
|
|
274
|
+
importDelta(doc2, d1to2!)
|
|
275
|
+
importDelta(doc1, d2to1!)
|
|
276
|
+
|
|
277
|
+
// Should now be equal
|
|
278
|
+
expect(version(doc1).compare(version(doc2))).toBe("equal")
|
|
279
|
+
|
|
280
|
+
// Both should have both mutations
|
|
281
|
+
// Note: concurrent text inserts at the same position resolve
|
|
282
|
+
// per Yjs's conflict resolution algorithm. Both will have
|
|
283
|
+
// the "A" insert. Count should be 7 on both.
|
|
284
|
+
expect(doc1.count()).toBe(7)
|
|
285
|
+
expect(doc2.count()).toBe(7)
|
|
286
|
+
expect(doc1.title()).toContain("A")
|
|
287
|
+
expect(doc2.title()).toContain("A")
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
// -------------------------------------------------------------------------
|
|
292
|
+
// Changefeed
|
|
293
|
+
// -------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
describe("changefeed", () => {
|
|
296
|
+
it("fires on importDelta", () => {
|
|
297
|
+
const doc1 = createYjsDoc(SimpleSchema)
|
|
298
|
+
change(doc1, (d: any) => { d.title.insert(0, "A") })
|
|
299
|
+
const doc2 = createYjsDocFromSnapshot(
|
|
300
|
+
SimpleSchema,
|
|
301
|
+
exportSnapshot(doc1),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
const v2Before = version(doc2)
|
|
305
|
+
|
|
306
|
+
change(doc1, (d: any) => {
|
|
307
|
+
d.count.set(42)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const received: any[] = []
|
|
311
|
+
subscribe(doc2, (changeset: any) => {
|
|
312
|
+
received.push(changeset)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const delta = exportSince(doc1, v2Before)
|
|
316
|
+
importDelta(doc2, delta!)
|
|
317
|
+
|
|
318
|
+
expect(received.length).toBeGreaterThanOrEqual(1)
|
|
319
|
+
expect(doc2.count()).toBe(42)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it("fires on external Y.Doc mutation (raw Yjs API)", () => {
|
|
323
|
+
const yjsDoc = new Y.Doc()
|
|
324
|
+
ensureContainers(yjsDoc, SimpleSchema)
|
|
325
|
+
const doc = createYjsDoc(SimpleSchema, yjsDoc)
|
|
326
|
+
|
|
327
|
+
const received: any[] = []
|
|
328
|
+
subscribe(doc, (changeset: any) => {
|
|
329
|
+
received.push(changeset)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// Mutate via raw Yjs API (not through kyneta)
|
|
333
|
+
const rootMap = yjsDoc.getMap("root")
|
|
334
|
+
rootMap.set("count", 99)
|
|
335
|
+
|
|
336
|
+
expect(received.length).toBeGreaterThanOrEqual(1)
|
|
337
|
+
expect(doc.count()).toBe(99)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it("no double-fire on kyneta local writes", () => {
|
|
341
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
342
|
+
|
|
343
|
+
const received: any[] = []
|
|
344
|
+
subscribe(doc, (changeset: any) => {
|
|
345
|
+
received.push(changeset)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
change(doc, (d: any) => {
|
|
349
|
+
d.count.set(42)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// Should fire exactly once (from the changefeed layer's flush),
|
|
353
|
+
// not twice (not also from the event bridge).
|
|
354
|
+
expect(received.length).toBe(1)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it("nested struct field changefeed fires on importDelta", () => {
|
|
358
|
+
const doc1 = createYjsDoc(StructListSchema)
|
|
359
|
+
const doc2 = createYjsDocFromSnapshot(
|
|
360
|
+
StructListSchema,
|
|
361
|
+
exportSnapshot(doc1),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
// Add a struct item on doc1, sync to doc2
|
|
365
|
+
change(doc1, (d: any) => {
|
|
366
|
+
d.tasks.push({ name: "Buy milk", done: false })
|
|
367
|
+
})
|
|
368
|
+
const snap = exportSnapshot(doc1)
|
|
369
|
+
const doc2b = createYjsDocFromSnapshot(StructListSchema, snap)
|
|
370
|
+
|
|
371
|
+
const taskB = [...doc2b.tasks][0] as any
|
|
372
|
+
expect(taskB.done()).toBe(false)
|
|
373
|
+
|
|
374
|
+
// Subscribe to the FIELD-LEVEL changefeed on doc2b's task
|
|
375
|
+
const v2 = version(doc2b)
|
|
376
|
+
const fieldChanges: unknown[] = []
|
|
377
|
+
const cf = (taskB.done as any)[Symbol.for("kyneta:changefeed")]
|
|
378
|
+
expect(cf).toBeDefined()
|
|
379
|
+
const unsub = cf.subscribe((cs: unknown) => fieldChanges.push(cs))
|
|
380
|
+
|
|
381
|
+
// Toggle done on doc1
|
|
382
|
+
change(doc1, (d: any) => {
|
|
383
|
+
d.tasks.at(0).done.set(true)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
// Sync the toggle to doc2b
|
|
387
|
+
const delta = exportSince(doc1, v2)!
|
|
388
|
+
importDelta(doc2b, delta)
|
|
389
|
+
|
|
390
|
+
// Value should be updated
|
|
391
|
+
expect(taskB.done()).toBe(true)
|
|
392
|
+
|
|
393
|
+
// The field-level changefeed should have fired
|
|
394
|
+
expect(fieldChanges.length).toBeGreaterThanOrEqual(1)
|
|
395
|
+
|
|
396
|
+
unsub()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it("multi-key struct update fires per-field changefeeds on importDelta", () => {
|
|
400
|
+
const doc1 = createYjsDoc(StructListSchema)
|
|
401
|
+
|
|
402
|
+
// Add a struct item, sync to doc2
|
|
403
|
+
change(doc1, (d: any) => {
|
|
404
|
+
d.tasks.push({ name: "Buy milk", done: false })
|
|
405
|
+
})
|
|
406
|
+
const doc2 = createYjsDocFromSnapshot(
|
|
407
|
+
StructListSchema,
|
|
408
|
+
exportSnapshot(doc1),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
const taskB = [...doc2.tasks][0] as any
|
|
412
|
+
const v2 = version(doc2)
|
|
413
|
+
|
|
414
|
+
// Subscribe to BOTH field-level changefeeds
|
|
415
|
+
const nameChanges: unknown[] = []
|
|
416
|
+
const doneChanges: unknown[] = []
|
|
417
|
+
const cfName = (taskB.name as any)[Symbol.for("kyneta:changefeed")]
|
|
418
|
+
const cfDone = (taskB.done as any)[Symbol.for("kyneta:changefeed")]
|
|
419
|
+
const unsub1 = cfName.subscribe((cs: unknown) => nameChanges.push(cs))
|
|
420
|
+
const unsub2 = cfDone.subscribe((cs: unknown) => doneChanges.push(cs))
|
|
421
|
+
|
|
422
|
+
// Update both fields in a single change() on doc1
|
|
423
|
+
change(doc1, (d: any) => {
|
|
424
|
+
const task = d.tasks.at(0)
|
|
425
|
+
task.name.set("Buy oat milk")
|
|
426
|
+
task.done.set(true)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
// Sync to doc2
|
|
430
|
+
const delta = exportSince(doc1, v2)!
|
|
431
|
+
importDelta(doc2, delta)
|
|
432
|
+
|
|
433
|
+
// Both field-level changefeeds should have fired
|
|
434
|
+
expect(nameChanges.length).toBeGreaterThanOrEqual(1)
|
|
435
|
+
expect(doneChanges.length).toBeGreaterThanOrEqual(1)
|
|
436
|
+
|
|
437
|
+
expect(taskB.name()).toBe("Buy oat milk")
|
|
438
|
+
expect(taskB.done()).toBe(true)
|
|
439
|
+
|
|
440
|
+
unsub1()
|
|
441
|
+
unsub2()
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// -------------------------------------------------------------------------
|
|
446
|
+
// Transaction support
|
|
447
|
+
// -------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
describe("transaction support", () => {
|
|
450
|
+
it("multi-op change() is atomic", () => {
|
|
451
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
452
|
+
|
|
453
|
+
const received: any[] = []
|
|
454
|
+
subscribe(doc, (changeset: any) => {
|
|
455
|
+
received.push(changeset)
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
change(doc, (d: any) => {
|
|
459
|
+
d.title.insert(0, "Hello")
|
|
460
|
+
d.count.set(42)
|
|
461
|
+
d.items.push("a")
|
|
462
|
+
d.items.push("b")
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
// Tree-level subscribe fires once per affected container in the
|
|
466
|
+
// flush cycle. Three containers changed (title + count + items) → 3 fires.
|
|
467
|
+
// This matches LoroSubstrate and PlainSubstrate behavior.
|
|
468
|
+
expect(received.length).toBe(3)
|
|
469
|
+
expect(doc.title()).toBe("Hello")
|
|
470
|
+
expect(doc.count()).toBe(42)
|
|
471
|
+
// Both items present. Order within a single transaction batch is
|
|
472
|
+
// not guaranteed because deferred-flush applies all SequenceChanges
|
|
473
|
+
// atomically — both pushes see arrayLength=0 at prepare time.
|
|
474
|
+
const items = doc.items() as string[]
|
|
475
|
+
expect(items).toHaveLength(2)
|
|
476
|
+
expect(items).toContain("a")
|
|
477
|
+
expect(items).toContain("b")
|
|
478
|
+
})
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
// -------------------------------------------------------------------------
|
|
482
|
+
// Nested structure
|
|
483
|
+
// -------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
describe("nested structure", () => {
|
|
486
|
+
it("push struct into list, read back via navigation", () => {
|
|
487
|
+
const doc = createYjsDoc(StructListSchema)
|
|
488
|
+
|
|
489
|
+
change(doc, (d: any) => {
|
|
490
|
+
d.tasks.push({ name: "Task 1", done: false })
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
expect(doc.tasks.length).toBe(1)
|
|
494
|
+
expect((doc.tasks.at(0) as any).name()).toBe("Task 1")
|
|
495
|
+
expect((doc.tasks.at(0) as any).done()).toBe(false)
|
|
496
|
+
|
|
497
|
+
change(doc, (d: any) => {
|
|
498
|
+
d.tasks.push({ name: "Task 2", done: true })
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
expect(doc.tasks.length).toBe(2)
|
|
502
|
+
expect((doc.tasks.at(1) as any).name()).toBe("Task 2")
|
|
503
|
+
expect((doc.tasks.at(1) as any).done()).toBe(true)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it("nested struct write round-trip", () => {
|
|
507
|
+
const doc = createYjsDoc(FullSchema)
|
|
508
|
+
change(doc, (d: any) => {
|
|
509
|
+
d.meta.author.set("Alice")
|
|
510
|
+
})
|
|
511
|
+
expect(doc.meta.author()).toBe("Alice")
|
|
512
|
+
|
|
513
|
+
change(doc, (d: any) => {
|
|
514
|
+
d.meta.author.set("Bob")
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
expect(doc.meta.author()).toBe("Bob")
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
// -------------------------------------------------------------------------
|
|
522
|
+
// Counter annotation throws
|
|
523
|
+
// -------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
describe("unsupported annotations", () => {
|
|
526
|
+
it("counter annotation throws clear error at construction", () => {
|
|
527
|
+
const CounterSchema = Schema.doc({
|
|
528
|
+
count: Schema.annotated("counter"),
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
expect(() =>
|
|
532
|
+
yjsSubstrateFactory.create(CounterSchema),
|
|
533
|
+
).toThrow("counter")
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it("movable annotation throws clear error at construction", () => {
|
|
537
|
+
const MovableSchema = Schema.doc({
|
|
538
|
+
items: Schema.annotated("movable", Schema.list(Schema.string())),
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
expect(() =>
|
|
542
|
+
yjsSubstrateFactory.create(MovableSchema),
|
|
543
|
+
).toThrow("movable")
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it("tree annotation throws clear error at construction", () => {
|
|
547
|
+
const TreeSchema = Schema.doc({
|
|
548
|
+
tree: Schema.annotated(
|
|
549
|
+
"tree",
|
|
550
|
+
Schema.struct({ label: Schema.string() }),
|
|
551
|
+
),
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
expect(() =>
|
|
555
|
+
yjsSubstrateFactory.create(TreeSchema),
|
|
556
|
+
).toThrow("tree")
|
|
557
|
+
})
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
// -------------------------------------------------------------------------
|
|
561
|
+
// fromSnapshot
|
|
562
|
+
// -------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
describe("fromSnapshot", () => {
|
|
565
|
+
it("rejects non-binary payloads", () => {
|
|
566
|
+
expect(() =>
|
|
567
|
+
yjsSubstrateFactory.fromSnapshot(
|
|
568
|
+
{ encoding: "json", data: "{}" },
|
|
569
|
+
SimpleSchema,
|
|
570
|
+
),
|
|
571
|
+
).toThrow("binary")
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it("reconstructs from snapshot with correct state", () => {
|
|
575
|
+
const doc = createYjsDoc(SimpleSchema)
|
|
576
|
+
change(doc, (d: any) => {
|
|
577
|
+
d.title.insert(0, "Snapshot Test")
|
|
578
|
+
d.count.set(77)
|
|
579
|
+
d.items.push("x")
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
const payload = exportSnapshot(doc)
|
|
583
|
+
const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
|
|
584
|
+
|
|
585
|
+
expect(doc2.title()).toBe("Snapshot Test")
|
|
586
|
+
expect(doc2.count()).toBe(77)
|
|
587
|
+
expect(doc2.items()).toEqual(["x"])
|
|
588
|
+
})
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
// -------------------------------------------------------------------------
|
|
592
|
+
// parseVersion
|
|
593
|
+
// -------------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
describe("parseVersion", () => {
|
|
596
|
+
it("round-trips through factory.parseVersion", () => {
|
|
597
|
+
const substrate = yjsSubstrateFactory.create(SimpleSchema)
|
|
598
|
+
const v = substrate.version()
|
|
599
|
+
const serialized = v.serialize()
|
|
600
|
+
const parsed = yjsSubstrateFactory.parseVersion(serialized)
|
|
601
|
+
expect(parsed.compare(v)).toBe("equal")
|
|
602
|
+
})
|
|
603
|
+
})
|
|
604
|
+
})
|