@kyneta/yjs-schema 1.0.0 → 1.1.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/dist/index.d.ts +110 -108
- package/dist/index.js +171 -109
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/bind-yjs.test.ts +19 -19
- package/src/__tests__/create.test.ts +61 -51
- package/src/__tests__/{store-reader.test.ts → reader.test.ts} +30 -33
- package/src/__tests__/record-text-spike.test.ts +29 -21
- package/src/__tests__/structural-merge.test.ts +362 -0
- package/src/__tests__/substrate.test.ts +48 -64
- package/src/__tests__/version.test.ts +7 -16
- package/src/bind-yjs.ts +46 -25
- package/src/change-mapping.ts +20 -35
- package/src/create.ts +32 -27
- package/src/index.ts +24 -30
- package/src/populate.ts +42 -14
- package/src/{store-reader.ts → reader.ts} +7 -12
- package/src/substrate.ts +139 -40
- package/src/sync.ts +26 -26
- package/src/version.ts +2 -4
- package/src/yjs-escape.ts +19 -35
- package/src/yjs-resolve.ts +4 -10
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// structural-merge — Yjs structural merge protocol tests.
|
|
2
|
+
//
|
|
3
|
+
// Validates the core invariant: all peers using the same schema produce
|
|
4
|
+
// byte-identical structural ops via STRUCTURAL_YJS_CLIENT_ID (0),
|
|
5
|
+
// which Yjs deduplicates on merge instead of conflicting.
|
|
6
|
+
//
|
|
7
|
+
// Context: jj:ptyzqoul (structural merge protocol)
|
|
8
|
+
|
|
9
|
+
import { BACKING_DOC, Schema, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
|
|
10
|
+
import { describe, expect, it } from "vitest"
|
|
11
|
+
import * as Y from "yjs"
|
|
12
|
+
import { bindYjs } from "../bind-yjs.js"
|
|
13
|
+
import { ensureContainers } from "../populate.js"
|
|
14
|
+
import { createYjsSubstrate, yjsSubstrateFactory } from "../substrate.js"
|
|
15
|
+
|
|
16
|
+
// ===========================================================================
|
|
17
|
+
// Schemas used across tests
|
|
18
|
+
// ===========================================================================
|
|
19
|
+
|
|
20
|
+
const TestSchema = Schema.doc({
|
|
21
|
+
title: Schema.annotated("text"),
|
|
22
|
+
count: Schema.number(),
|
|
23
|
+
items: Schema.list(Schema.string()),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// ===========================================================================
|
|
27
|
+
// Tests
|
|
28
|
+
// ===========================================================================
|
|
29
|
+
|
|
30
|
+
describe("structural merge protocol (Yjs)", () => {
|
|
31
|
+
// ── Core invariant: two peers independently create, sync, no data loss ──
|
|
32
|
+
|
|
33
|
+
it("two peers independently create same schema, sync, data preserved", () => {
|
|
34
|
+
// Peer A creates doc and writes text
|
|
35
|
+
const docA = new Y.Doc()
|
|
36
|
+
docA.clientID = 100
|
|
37
|
+
ensureContainers(docA, TestSchema)
|
|
38
|
+
docA.transact(() => {
|
|
39
|
+
const root = docA.getMap("root")
|
|
40
|
+
;(root.get("title") as Y.Text).insert(0, "Hello from A")
|
|
41
|
+
root.set("count", 42)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Peer B independently creates same doc and writes different text
|
|
45
|
+
const docB = new Y.Doc()
|
|
46
|
+
docB.clientID = 200
|
|
47
|
+
ensureContainers(docB, TestSchema)
|
|
48
|
+
docB.transact(() => {
|
|
49
|
+
const root = docB.getMap("root")
|
|
50
|
+
;(root.get("title") as Y.Text).insert(0, "Hello from B")
|
|
51
|
+
root.set("count", 99)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Sync bidirectionally
|
|
55
|
+
const updateA = Y.encodeStateAsUpdate(docA)
|
|
56
|
+
const updateB = Y.encodeStateAsUpdate(docB)
|
|
57
|
+
Y.applyUpdate(docB, updateA)
|
|
58
|
+
Y.applyUpdate(docA, updateB)
|
|
59
|
+
|
|
60
|
+
// Both see the same state — no data loss
|
|
61
|
+
const rootA = docA.getMap("root")
|
|
62
|
+
const rootB = docB.getMap("root")
|
|
63
|
+
|
|
64
|
+
// Text should contain content from both peers (no orphaned containers)
|
|
65
|
+
const titleA = (rootA.get("title") as Y.Text).toString()
|
|
66
|
+
const titleB = (rootB.get("title") as Y.Text).toString()
|
|
67
|
+
expect(titleA).toBe(titleB)
|
|
68
|
+
// Both texts should be present (merged, not lost)
|
|
69
|
+
expect(titleA).toContain("Hello from A")
|
|
70
|
+
expect(titleA).toContain("Hello from B")
|
|
71
|
+
|
|
72
|
+
// Count: last writer wins — both converge to the same value
|
|
73
|
+
expect(rootA.get("count")).toBe(rootB.get("count"))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("three peers independently create, sync, all converge", () => {
|
|
77
|
+
const docs = [100, 200, 300].map(id => {
|
|
78
|
+
const doc = new Y.Doc()
|
|
79
|
+
doc.clientID = id
|
|
80
|
+
ensureContainers(doc, TestSchema)
|
|
81
|
+
doc.transact(() => {
|
|
82
|
+
const root = doc.getMap("root")
|
|
83
|
+
;(root.get("title") as Y.Text).insert(0, `Peer${id}`)
|
|
84
|
+
})
|
|
85
|
+
return doc
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Full mesh sync
|
|
89
|
+
for (let i = 0; i < docs.length; i++) {
|
|
90
|
+
for (let j = 0; j < docs.length; j++) {
|
|
91
|
+
if (i !== j) {
|
|
92
|
+
Y.applyUpdate(docs[j]!, Y.encodeStateAsUpdate(docs[i]!))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// All three converge to the same text
|
|
98
|
+
const texts = docs.map(d =>
|
|
99
|
+
(d.getMap("root").get("title") as Y.Text).toString(),
|
|
100
|
+
)
|
|
101
|
+
expect(texts[0]).toBe(texts[1])
|
|
102
|
+
expect(texts[1]).toBe(texts[2])
|
|
103
|
+
// All three peer contributions present
|
|
104
|
+
expect(texts[0]).toContain("Peer100")
|
|
105
|
+
expect(texts[0]).toContain("Peer200")
|
|
106
|
+
expect(texts[0]).toContain("Peer300")
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// ── Persistence round-trip ──
|
|
110
|
+
|
|
111
|
+
it("persist → hydrate → data preserved", () => {
|
|
112
|
+
// Create and write
|
|
113
|
+
const doc1 = new Y.Doc()
|
|
114
|
+
doc1.clientID = 42
|
|
115
|
+
ensureContainers(doc1, TestSchema)
|
|
116
|
+
doc1.transact(() => {
|
|
117
|
+
const root = doc1.getMap("root")
|
|
118
|
+
;(root.get("title") as Y.Text).insert(0, "Persistent")
|
|
119
|
+
root.set("count", 7)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Export
|
|
123
|
+
const snapshot = Y.encodeStateAsUpdate(doc1)
|
|
124
|
+
|
|
125
|
+
// Hydrate into fresh doc
|
|
126
|
+
const doc2 = new Y.Doc()
|
|
127
|
+
doc2.clientID = 42
|
|
128
|
+
Y.applyUpdate(doc2, snapshot)
|
|
129
|
+
ensureContainers(doc2, TestSchema, true) // conditional
|
|
130
|
+
|
|
131
|
+
// Data preserved
|
|
132
|
+
const root2 = doc2.getMap("root")
|
|
133
|
+
expect((root2.get("title") as Y.Text).toString()).toBe("Persistent")
|
|
134
|
+
expect(root2.get("count")).toBe(7)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ── Determinism: alphabetical sort ──
|
|
138
|
+
|
|
139
|
+
it("field reordering in source doesn't affect structural ops", () => {
|
|
140
|
+
// Schema A: fields in one order
|
|
141
|
+
const schemaA = Schema.doc({
|
|
142
|
+
alpha: Schema.string(),
|
|
143
|
+
beta: Schema.number(),
|
|
144
|
+
gamma: Schema.annotated("text"),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Schema B: same fields, different insertion order
|
|
148
|
+
// JavaScript objects preserve insertion order, so we construct
|
|
149
|
+
// with a different order to verify alphabetical sort overrides it.
|
|
150
|
+
const fields: Record<string, any> = {}
|
|
151
|
+
fields.gamma = Schema.annotated("text")
|
|
152
|
+
fields.alpha = Schema.string()
|
|
153
|
+
fields.beta = Schema.number()
|
|
154
|
+
const schemaB = Schema.doc(fields)
|
|
155
|
+
|
|
156
|
+
const docA = new Y.Doc()
|
|
157
|
+
ensureContainers(docA, schemaA)
|
|
158
|
+
|
|
159
|
+
const docB = new Y.Doc()
|
|
160
|
+
ensureContainers(docB, schemaB)
|
|
161
|
+
|
|
162
|
+
// Both should produce byte-identical structural state
|
|
163
|
+
const stateA = Y.encodeStateAsUpdate(docA)
|
|
164
|
+
const stateB = Y.encodeStateAsUpdate(docB)
|
|
165
|
+
expect(stateA).toEqual(stateB)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ── Schema evolution ──
|
|
169
|
+
|
|
170
|
+
it("add field after hydration, structural ops extend correctly", () => {
|
|
171
|
+
const v1Schema = Schema.doc({
|
|
172
|
+
title: Schema.annotated("text"),
|
|
173
|
+
count: Schema.number(),
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const v2Schema = Schema.doc({
|
|
177
|
+
count: Schema.number(),
|
|
178
|
+
notes: Schema.annotated("text"), // new field
|
|
179
|
+
title: Schema.annotated("text"),
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Peer A: create v1, write data, export
|
|
183
|
+
const docA = new Y.Doc()
|
|
184
|
+
docA.clientID = 100
|
|
185
|
+
ensureContainers(docA, v1Schema)
|
|
186
|
+
docA.transact(() => {
|
|
187
|
+
const root = docA.getMap("root")
|
|
188
|
+
;(root.get("title") as Y.Text).insert(0, "Title")
|
|
189
|
+
root.set("count", 5)
|
|
190
|
+
})
|
|
191
|
+
const v1State = Y.encodeStateAsUpdate(docA)
|
|
192
|
+
|
|
193
|
+
// Peer B: independently create v2, hydrate v1 data, conditional containers
|
|
194
|
+
const docB = new Y.Doc()
|
|
195
|
+
docB.clientID = 200
|
|
196
|
+
Y.applyUpdate(docB, v1State)
|
|
197
|
+
ensureContainers(docB, v2Schema, true) // conditional — only creates "notes"
|
|
198
|
+
|
|
199
|
+
// Another peer C: same thing independently
|
|
200
|
+
const docC = new Y.Doc()
|
|
201
|
+
docC.clientID = 300
|
|
202
|
+
Y.applyUpdate(docC, v1State)
|
|
203
|
+
ensureContainers(docC, v2Schema, true)
|
|
204
|
+
|
|
205
|
+
// B and C's structural ops for "notes" should be identical (both at clientID 0)
|
|
206
|
+
const stateB = Y.encodeStateAsUpdate(docB)
|
|
207
|
+
const stateC = Y.encodeStateAsUpdate(docC)
|
|
208
|
+
|
|
209
|
+
// Merge B into C and C into B — should converge without conflict
|
|
210
|
+
Y.applyUpdate(docC, stateB)
|
|
211
|
+
Y.applyUpdate(docB, stateC)
|
|
212
|
+
|
|
213
|
+
const rootB = docB.getMap("root")
|
|
214
|
+
const rootC = docC.getMap("root")
|
|
215
|
+
|
|
216
|
+
// Original data preserved
|
|
217
|
+
expect((rootB.get("title") as Y.Text).toString()).toBe("Title")
|
|
218
|
+
expect(rootB.get("count")).toBe(5)
|
|
219
|
+
expect((rootC.get("title") as Y.Text).toString()).toBe("Title")
|
|
220
|
+
expect(rootC.get("count")).toBe(5)
|
|
221
|
+
|
|
222
|
+
// New field exists on both
|
|
223
|
+
expect(rootB.get("notes")).toBeInstanceOf(Y.Text)
|
|
224
|
+
expect(rootC.get("notes")).toBeInstanceOf(Y.Text)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// ── Structural identity is clientID 0 ──
|
|
228
|
+
|
|
229
|
+
it("ensureContainers uses clientID 0 for structural ops", () => {
|
|
230
|
+
const doc = new Y.Doc()
|
|
231
|
+
doc.clientID = 999
|
|
232
|
+
ensureContainers(doc, TestSchema)
|
|
233
|
+
|
|
234
|
+
// clientID should be restored
|
|
235
|
+
expect(doc.clientID).toBe(999)
|
|
236
|
+
|
|
237
|
+
// Structural ops should be at clientID 0
|
|
238
|
+
const sv = Y.decodeStateVector(Y.encodeStateVector(doc))
|
|
239
|
+
expect(sv.get(STRUCTURAL_YJS_CLIENT_ID)).toBeGreaterThan(0)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("ensureContainers does not leak caller clientID into structural ops", () => {
|
|
243
|
+
const doc = new Y.Doc()
|
|
244
|
+
doc.clientID = 777
|
|
245
|
+
ensureContainers(doc, TestSchema)
|
|
246
|
+
|
|
247
|
+
// The state vector should NOT contain the caller's clientID —
|
|
248
|
+
// only STRUCTURAL_YJS_CLIENT_ID (0) should have produced ops.
|
|
249
|
+
const sv = Y.decodeStateVector(Y.encodeStateVector(doc))
|
|
250
|
+
expect(sv.has(STRUCTURAL_YJS_CLIENT_ID)).toBe(true)
|
|
251
|
+
expect(sv.has(777)).toBe(false)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// ── SubstrateFactory integration ──
|
|
255
|
+
|
|
256
|
+
it("yjsSubstrateFactory.create produces deterministic structural ops", () => {
|
|
257
|
+
const sub1 = yjsSubstrateFactory.create(TestSchema)
|
|
258
|
+
const sub2 = yjsSubstrateFactory.create(TestSchema)
|
|
259
|
+
|
|
260
|
+
const state1 = sub1.exportEntirety()
|
|
261
|
+
const state2 = sub2.exportEntirety()
|
|
262
|
+
|
|
263
|
+
// Byte-identical structural state
|
|
264
|
+
expect(state1.data).toEqual(state2.data)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it("yjsSubstrateFactory.fromEntirety preserves data through round-trip", () => {
|
|
268
|
+
const sub1 = yjsSubstrateFactory.create(TestSchema)
|
|
269
|
+
const doc1 = (sub1 as any)[BACKING_DOC] as Y.Doc
|
|
270
|
+
doc1.transact(() => {
|
|
271
|
+
const root = doc1.getMap("root")
|
|
272
|
+
;(root.get("title") as Y.Text).insert(0, "Round-trip")
|
|
273
|
+
root.set("count", 123)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
const payload = sub1.exportEntirety()
|
|
277
|
+
const sub2 = yjsSubstrateFactory.fromEntirety(payload, TestSchema)
|
|
278
|
+
const doc2 = (sub2 as any)[BACKING_DOC] as Y.Doc
|
|
279
|
+
const root2 = doc2.getMap("root")
|
|
280
|
+
|
|
281
|
+
expect((root2.get("title") as Y.Text).toString()).toBe("Round-trip")
|
|
282
|
+
expect(root2.get("count")).toBe(123)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// ── bindYjs integration ──
|
|
286
|
+
|
|
287
|
+
it("bindYjs factory produces deterministic structural ops across peers", () => {
|
|
288
|
+
const bound = bindYjs(TestSchema)
|
|
289
|
+
|
|
290
|
+
const factoryA = bound.factory({ peerId: "alice" })
|
|
291
|
+
const factoryB = bound.factory({ peerId: "bob" })
|
|
292
|
+
|
|
293
|
+
const subA = factoryA.create(TestSchema)
|
|
294
|
+
const subB = factoryB.create(TestSchema)
|
|
295
|
+
|
|
296
|
+
// Different peerIds but same structural ops
|
|
297
|
+
const stateA = subA.exportEntirety()
|
|
298
|
+
const stateB = subB.exportEntirety()
|
|
299
|
+
|
|
300
|
+
// Structural state is byte-identical (same schema → same containers at clientID 0)
|
|
301
|
+
expect(stateA.data).toEqual(stateB.data)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it("bindYjs peers merge without structural conflict", () => {
|
|
305
|
+
const bound = bindYjs(TestSchema)
|
|
306
|
+
|
|
307
|
+
const factoryA = bound.factory({ peerId: "alice" })
|
|
308
|
+
const factoryB = bound.factory({ peerId: "bob" })
|
|
309
|
+
|
|
310
|
+
const subA = factoryA.create(TestSchema)
|
|
311
|
+
const subB = factoryB.create(TestSchema)
|
|
312
|
+
|
|
313
|
+
// Write different data on each peer
|
|
314
|
+
const docA = (subA as any)[BACKING_DOC] as Y.Doc
|
|
315
|
+
docA.transact(() => {
|
|
316
|
+
const root = docA.getMap("root")
|
|
317
|
+
;(root.get("title") as Y.Text).insert(0, "Alice's text")
|
|
318
|
+
root.set("count", 10)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const docB = (subB as any)[BACKING_DOC] as Y.Doc
|
|
322
|
+
docB.transact(() => {
|
|
323
|
+
const root = docB.getMap("root")
|
|
324
|
+
;(root.get("title") as Y.Text).insert(0, "Bob's text")
|
|
325
|
+
root.set("count", 20)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Bidirectional merge — should not throw
|
|
329
|
+
const payloadA = subA.exportEntirety()
|
|
330
|
+
const payloadB = subB.exportEntirety()
|
|
331
|
+
subA.merge(payloadB, "sync")
|
|
332
|
+
subB.merge(payloadA, "sync")
|
|
333
|
+
|
|
334
|
+
// Both converge
|
|
335
|
+
const rootA = docA.getMap("root")
|
|
336
|
+
const rootB = docB.getMap("root")
|
|
337
|
+
|
|
338
|
+
const titleA = (rootA.get("title") as Y.Text).toString()
|
|
339
|
+
const titleB = (rootB.get("title") as Y.Text).toString()
|
|
340
|
+
expect(titleA).toBe(titleB)
|
|
341
|
+
expect(titleA).toContain("Alice's text")
|
|
342
|
+
expect(titleA).toContain("Bob's text")
|
|
343
|
+
|
|
344
|
+
expect(rootA.get("count")).toBe(rootB.get("count"))
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
// ── Conditional ensureContainers is idempotent ──
|
|
348
|
+
|
|
349
|
+
it("conditional ensureContainers is idempotent on hydrated doc", () => {
|
|
350
|
+
const doc = new Y.Doc()
|
|
351
|
+
doc.clientID = 50
|
|
352
|
+
ensureContainers(doc, TestSchema)
|
|
353
|
+
|
|
354
|
+
const stateBefore = Y.encodeStateAsUpdate(doc)
|
|
355
|
+
|
|
356
|
+
// Conditional call should not create new ops (everything already exists)
|
|
357
|
+
ensureContainers(doc, TestSchema, true)
|
|
358
|
+
|
|
359
|
+
const stateAfter = Y.encodeStateAsUpdate(doc)
|
|
360
|
+
expect(stateAfter).toEqual(stateBefore)
|
|
361
|
+
})
|
|
362
|
+
})
|
|
@@ -1,23 +1,16 @@
|
|
|
1
|
+
import { change, RawPath, Schema, subscribe } from "@kyneta/schema"
|
|
1
2
|
import { describe, expect, it, vi } from "vitest"
|
|
2
3
|
import * as Y from "yjs"
|
|
3
|
-
import {
|
|
4
|
+
import { createYjsDoc, createYjsDocFromEntirety } from "../create.js"
|
|
5
|
+
import { ensureContainers } from "../populate.js"
|
|
4
6
|
import { createYjsSubstrate, yjsSubstrateFactory } from "../substrate.js"
|
|
7
|
+
import { exportEntirety, exportSince, merge, version } from "../sync.js"
|
|
5
8
|
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
9
|
|
|
15
10
|
// ===========================================================================
|
|
16
11
|
// Helpers
|
|
17
12
|
// ===========================================================================
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|
|
21
14
|
// ===========================================================================
|
|
22
15
|
// Schemas used across tests
|
|
23
16
|
// ===========================================================================
|
|
@@ -66,10 +59,10 @@ describe("YjsSubstrate", () => {
|
|
|
66
59
|
describe("factory create", () => {
|
|
67
60
|
it("creates a substrate with empty containers", () => {
|
|
68
61
|
const substrate = yjsSubstrateFactory.create(SimpleSchema)
|
|
69
|
-
expect(substrate.
|
|
62
|
+
expect(substrate.reader.read(RawPath.empty.field("title"))).toBe("")
|
|
70
63
|
// Plain scalars return structural zeros
|
|
71
|
-
expect(substrate.
|
|
72
|
-
expect(substrate.
|
|
64
|
+
expect(substrate.reader.read(RawPath.empty.field("count"))).toBe(0)
|
|
65
|
+
expect(substrate.reader.read(RawPath.empty.field("items"))).toEqual([])
|
|
73
66
|
})
|
|
74
67
|
|
|
75
68
|
it("creates a substrate and populates via change()", () => {
|
|
@@ -188,8 +181,10 @@ describe("YjsSubstrate", () => {
|
|
|
188
181
|
describe("export/import snapshot", () => {
|
|
189
182
|
it("exports a binary payload", () => {
|
|
190
183
|
const doc = createYjsDoc(SimpleSchema)
|
|
191
|
-
change(doc, (d: any) => {
|
|
192
|
-
|
|
184
|
+
change(doc, (d: any) => {
|
|
185
|
+
d.title.insert(0, "Snapshot")
|
|
186
|
+
})
|
|
187
|
+
const payload = exportEntirety(doc)
|
|
193
188
|
expect(payload.encoding).toBe("binary")
|
|
194
189
|
expect(payload.data).toBeInstanceOf(Uint8Array)
|
|
195
190
|
})
|
|
@@ -207,8 +202,8 @@ describe("YjsSubstrate", () => {
|
|
|
207
202
|
d.title.insert(5, " World")
|
|
208
203
|
})
|
|
209
204
|
|
|
210
|
-
const payload =
|
|
211
|
-
const doc2 =
|
|
205
|
+
const payload = exportEntirety(doc1)
|
|
206
|
+
const doc2 = createYjsDocFromEntirety(SimpleSchema, payload)
|
|
212
207
|
|
|
213
208
|
expect(doc2.title()).toBe("Hello World")
|
|
214
209
|
expect(doc2.count()).toBe(42)
|
|
@@ -221,13 +216,12 @@ describe("YjsSubstrate", () => {
|
|
|
221
216
|
// -------------------------------------------------------------------------
|
|
222
217
|
|
|
223
218
|
describe("delta sync", () => {
|
|
224
|
-
it("exportSince →
|
|
219
|
+
it("exportSince → merge syncs state", () => {
|
|
225
220
|
const doc1 = createYjsDoc(SimpleSchema)
|
|
226
|
-
change(doc1, (d: any) => {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)
|
|
221
|
+
change(doc1, (d: any) => {
|
|
222
|
+
d.title.insert(0, "Start")
|
|
223
|
+
})
|
|
224
|
+
const doc2 = createYjsDocFromEntirety(SimpleSchema, exportEntirety(doc1))
|
|
231
225
|
|
|
232
226
|
const v1Before = version(doc1)
|
|
233
227
|
|
|
@@ -239,17 +233,14 @@ describe("YjsSubstrate", () => {
|
|
|
239
233
|
const delta = exportSince(doc1, v1Before)
|
|
240
234
|
expect(delta).not.toBeNull()
|
|
241
235
|
|
|
242
|
-
|
|
236
|
+
merge(doc2, delta!)
|
|
243
237
|
expect(doc2.title()).toBe("Start Edited")
|
|
244
238
|
expect(doc2.count()).toBe(99)
|
|
245
239
|
})
|
|
246
240
|
|
|
247
241
|
it("concurrent sync — two substrates converge after bidirectional sync", () => {
|
|
248
242
|
const doc1 = createYjsDoc(SimpleSchema)
|
|
249
|
-
const doc2 =
|
|
250
|
-
SimpleSchema,
|
|
251
|
-
exportSnapshot(doc1),
|
|
252
|
-
)
|
|
243
|
+
const doc2 = createYjsDocFromEntirety(SimpleSchema, exportEntirety(doc1))
|
|
253
244
|
|
|
254
245
|
const v1Before = version(doc1)
|
|
255
246
|
const v2Before = version(doc2)
|
|
@@ -271,8 +262,8 @@ describe("YjsSubstrate", () => {
|
|
|
271
262
|
const d1to2 = exportSince(doc1, v2Before)
|
|
272
263
|
const d2to1 = exportSince(doc2, v1Before)
|
|
273
264
|
|
|
274
|
-
|
|
275
|
-
|
|
265
|
+
merge(doc2, d1to2!)
|
|
266
|
+
merge(doc1, d2to1!)
|
|
276
267
|
|
|
277
268
|
// Should now be equal
|
|
278
269
|
expect(version(doc1).compare(version(doc2))).toBe("equal")
|
|
@@ -293,13 +284,12 @@ describe("YjsSubstrate", () => {
|
|
|
293
284
|
// -------------------------------------------------------------------------
|
|
294
285
|
|
|
295
286
|
describe("changefeed", () => {
|
|
296
|
-
it("fires on
|
|
287
|
+
it("fires on merge", () => {
|
|
297
288
|
const doc1 = createYjsDoc(SimpleSchema)
|
|
298
|
-
change(doc1, (d: any) => {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
)
|
|
289
|
+
change(doc1, (d: any) => {
|
|
290
|
+
d.title.insert(0, "A")
|
|
291
|
+
})
|
|
292
|
+
const doc2 = createYjsDocFromEntirety(SimpleSchema, exportEntirety(doc1))
|
|
303
293
|
|
|
304
294
|
const v2Before = version(doc2)
|
|
305
295
|
|
|
@@ -313,7 +303,7 @@ describe("YjsSubstrate", () => {
|
|
|
313
303
|
})
|
|
314
304
|
|
|
315
305
|
const delta = exportSince(doc1, v2Before)
|
|
316
|
-
|
|
306
|
+
merge(doc2, delta!)
|
|
317
307
|
|
|
318
308
|
expect(received.length).toBeGreaterThanOrEqual(1)
|
|
319
309
|
expect(doc2.count()).toBe(42)
|
|
@@ -354,19 +344,19 @@ describe("YjsSubstrate", () => {
|
|
|
354
344
|
expect(received.length).toBe(1)
|
|
355
345
|
})
|
|
356
346
|
|
|
357
|
-
it("nested struct field changefeed fires on
|
|
347
|
+
it("nested struct field changefeed fires on merge", () => {
|
|
358
348
|
const doc1 = createYjsDoc(StructListSchema)
|
|
359
|
-
const doc2 =
|
|
349
|
+
const doc2 = createYjsDocFromEntirety(
|
|
360
350
|
StructListSchema,
|
|
361
|
-
|
|
351
|
+
exportEntirety(doc1),
|
|
362
352
|
)
|
|
363
353
|
|
|
364
354
|
// Add a struct item on doc1, sync to doc2
|
|
365
355
|
change(doc1, (d: any) => {
|
|
366
356
|
d.tasks.push({ name: "Buy milk", done: false })
|
|
367
357
|
})
|
|
368
|
-
const snap =
|
|
369
|
-
const doc2b =
|
|
358
|
+
const snap = exportEntirety(doc1)
|
|
359
|
+
const doc2b = createYjsDocFromEntirety(StructListSchema, snap)
|
|
370
360
|
|
|
371
361
|
const taskB = [...doc2b.tasks][0] as any
|
|
372
362
|
expect(taskB.done()).toBe(false)
|
|
@@ -385,7 +375,7 @@ describe("YjsSubstrate", () => {
|
|
|
385
375
|
|
|
386
376
|
// Sync the toggle to doc2b
|
|
387
377
|
const delta = exportSince(doc1, v2)!
|
|
388
|
-
|
|
378
|
+
merge(doc2b, delta)
|
|
389
379
|
|
|
390
380
|
// Value should be updated
|
|
391
381
|
expect(taskB.done()).toBe(true)
|
|
@@ -396,16 +386,16 @@ describe("YjsSubstrate", () => {
|
|
|
396
386
|
unsub()
|
|
397
387
|
})
|
|
398
388
|
|
|
399
|
-
it("multi-key struct update fires per-field changefeeds on
|
|
389
|
+
it("multi-key struct update fires per-field changefeeds on merge", () => {
|
|
400
390
|
const doc1 = createYjsDoc(StructListSchema)
|
|
401
391
|
|
|
402
392
|
// Add a struct item, sync to doc2
|
|
403
393
|
change(doc1, (d: any) => {
|
|
404
394
|
d.tasks.push({ name: "Buy milk", done: false })
|
|
405
395
|
})
|
|
406
|
-
const doc2 =
|
|
396
|
+
const doc2 = createYjsDocFromEntirety(
|
|
407
397
|
StructListSchema,
|
|
408
|
-
|
|
398
|
+
exportEntirety(doc1),
|
|
409
399
|
)
|
|
410
400
|
|
|
411
401
|
const taskB = [...doc2.tasks][0] as any
|
|
@@ -428,7 +418,7 @@ describe("YjsSubstrate", () => {
|
|
|
428
418
|
|
|
429
419
|
// Sync to doc2
|
|
430
420
|
const delta = exportSince(doc1, v2)!
|
|
431
|
-
|
|
421
|
+
merge(doc2, delta)
|
|
432
422
|
|
|
433
423
|
// Both field-level changefeeds should have fired
|
|
434
424
|
expect(nameChanges.length).toBeGreaterThanOrEqual(1)
|
|
@@ -528,9 +518,7 @@ describe("YjsSubstrate", () => {
|
|
|
528
518
|
count: Schema.annotated("counter"),
|
|
529
519
|
})
|
|
530
520
|
|
|
531
|
-
expect(() =>
|
|
532
|
-
yjsSubstrateFactory.create(CounterSchema),
|
|
533
|
-
).toThrow("counter")
|
|
521
|
+
expect(() => yjsSubstrateFactory.create(CounterSchema)).toThrow("counter")
|
|
534
522
|
})
|
|
535
523
|
|
|
536
524
|
it("movable annotation throws clear error at construction", () => {
|
|
@@ -538,9 +526,7 @@ describe("YjsSubstrate", () => {
|
|
|
538
526
|
items: Schema.annotated("movable", Schema.list(Schema.string())),
|
|
539
527
|
})
|
|
540
528
|
|
|
541
|
-
expect(() =>
|
|
542
|
-
yjsSubstrateFactory.create(MovableSchema),
|
|
543
|
-
).toThrow("movable")
|
|
529
|
+
expect(() => yjsSubstrateFactory.create(MovableSchema)).toThrow("movable")
|
|
544
530
|
})
|
|
545
531
|
|
|
546
532
|
it("tree annotation throws clear error at construction", () => {
|
|
@@ -551,21 +537,19 @@ describe("YjsSubstrate", () => {
|
|
|
551
537
|
),
|
|
552
538
|
})
|
|
553
539
|
|
|
554
|
-
expect(() =>
|
|
555
|
-
yjsSubstrateFactory.create(TreeSchema),
|
|
556
|
-
).toThrow("tree")
|
|
540
|
+
expect(() => yjsSubstrateFactory.create(TreeSchema)).toThrow("tree")
|
|
557
541
|
})
|
|
558
542
|
})
|
|
559
543
|
|
|
560
544
|
// -------------------------------------------------------------------------
|
|
561
|
-
//
|
|
545
|
+
// fromEntirety
|
|
562
546
|
// -------------------------------------------------------------------------
|
|
563
547
|
|
|
564
|
-
describe("
|
|
548
|
+
describe("fromEntirety", () => {
|
|
565
549
|
it("rejects non-binary payloads", () => {
|
|
566
550
|
expect(() =>
|
|
567
|
-
yjsSubstrateFactory.
|
|
568
|
-
{ encoding: "json", data: "{}" },
|
|
551
|
+
yjsSubstrateFactory.fromEntirety(
|
|
552
|
+
{ kind: "entirety", encoding: "json", data: "{}" },
|
|
569
553
|
SimpleSchema,
|
|
570
554
|
),
|
|
571
555
|
).toThrow("binary")
|
|
@@ -579,8 +563,8 @@ describe("YjsSubstrate", () => {
|
|
|
579
563
|
d.items.push("x")
|
|
580
564
|
})
|
|
581
565
|
|
|
582
|
-
const payload =
|
|
583
|
-
const doc2 =
|
|
566
|
+
const payload = exportEntirety(doc)
|
|
567
|
+
const doc2 = createYjsDocFromEntirety(SimpleSchema, payload)
|
|
584
568
|
|
|
585
569
|
expect(doc2.title()).toBe("Snapshot Test")
|
|
586
570
|
expect(doc2.count()).toBe(77)
|
|
@@ -601,4 +585,4 @@ describe("YjsSubstrate", () => {
|
|
|
601
585
|
expect(parsed.compare(v)).toBe("equal")
|
|
602
586
|
})
|
|
603
587
|
})
|
|
604
|
-
})
|
|
588
|
+
})
|
|
@@ -36,7 +36,7 @@ describe("YjsVersion", () => {
|
|
|
36
36
|
})
|
|
37
37
|
|
|
38
38
|
it("round-trips a version vector with one peer", () => {
|
|
39
|
-
const v = versionAfterOps(
|
|
39
|
+
const v = versionAfterOps(doc => {
|
|
40
40
|
doc.getMap("root").set("title", "Hello")
|
|
41
41
|
})
|
|
42
42
|
const serialized = v.serialize()
|
|
@@ -66,7 +66,7 @@ describe("YjsVersion", () => {
|
|
|
66
66
|
})
|
|
67
67
|
|
|
68
68
|
it("serialized form is a non-empty string", () => {
|
|
69
|
-
const v = versionAfterOps(
|
|
69
|
+
const v = versionAfterOps(doc => {
|
|
70
70
|
doc.getMap("root").set("count", 42)
|
|
71
71
|
})
|
|
72
72
|
const s = v.serialize()
|
|
@@ -89,7 +89,7 @@ describe("YjsVersion", () => {
|
|
|
89
89
|
|
|
90
90
|
describe("compare", () => {
|
|
91
91
|
it("returns 'equal' for the same version vector", () => {
|
|
92
|
-
const v = versionAfterOps(
|
|
92
|
+
const v = versionAfterOps(doc => {
|
|
93
93
|
doc.getMap("root").set("t", "hi")
|
|
94
94
|
})
|
|
95
95
|
expect(v.compare(v)).toBe("equal")
|
|
@@ -152,10 +152,7 @@ describe("YjsVersion", () => {
|
|
|
152
152
|
doc2.getMap("root").set("t", "B")
|
|
153
153
|
|
|
154
154
|
// Sync doc1 → doc2 only (doc2 knows about both, doc1 only knows itself)
|
|
155
|
-
const update = Y.encodeStateAsUpdate(
|
|
156
|
-
doc1,
|
|
157
|
-
Y.encodeStateVector(doc2),
|
|
158
|
-
)
|
|
155
|
+
const update = Y.encodeStateAsUpdate(doc1, Y.encodeStateVector(doc2))
|
|
159
156
|
Y.applyUpdate(doc2, update)
|
|
160
157
|
|
|
161
158
|
const v1 = new YjsVersion(Y.encodeStateVector(doc1))
|
|
@@ -175,14 +172,8 @@ describe("YjsVersion", () => {
|
|
|
175
172
|
doc2.getMap("root").set("t", "B")
|
|
176
173
|
|
|
177
174
|
// Bidirectional sync
|
|
178
|
-
const u1to2 = Y.encodeStateAsUpdate(
|
|
179
|
-
|
|
180
|
-
Y.encodeStateVector(doc2),
|
|
181
|
-
)
|
|
182
|
-
const u2to1 = Y.encodeStateAsUpdate(
|
|
183
|
-
doc2,
|
|
184
|
-
Y.encodeStateVector(doc1),
|
|
185
|
-
)
|
|
175
|
+
const u1to2 = Y.encodeStateAsUpdate(doc1, Y.encodeStateVector(doc2))
|
|
176
|
+
const u2to1 = Y.encodeStateAsUpdate(doc2, Y.encodeStateVector(doc1))
|
|
186
177
|
Y.applyUpdate(doc2, u1to2)
|
|
187
178
|
Y.applyUpdate(doc1, u2to1)
|
|
188
179
|
|
|
@@ -224,4 +215,4 @@ describe("YjsVersion", () => {
|
|
|
224
215
|
expect(late.compare(earlyParsed)).toBe("ahead")
|
|
225
216
|
})
|
|
226
217
|
})
|
|
227
|
-
})
|
|
218
|
+
})
|