@kyneta/yjs-schema 1.3.1 → 1.5.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/README.md +28 -25
- package/dist/index.d.ts +185 -86
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1159 -860
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/bind-constraints.test.ts +39 -30
- package/src/__tests__/bind-yjs.test.ts +53 -16
- package/src/__tests__/position.test.ts +376 -0
- package/src/__tests__/structural-merge.test.ts +111 -54
- package/src/__tests__/substrate.test.ts +18 -0
- package/src/__tests__/version.test.ts +87 -0
- package/src/bind-yjs.ts +44 -37
- package/src/change-mapping.ts +219 -25
- package/src/index.ts +3 -1
- package/src/populate.ts +59 -12
- package/src/position.ts +45 -0
- package/src/reader.ts +62 -6
- package/src/substrate.ts +112 -16
- package/src/version.ts +135 -33
- package/src/yjs-resolve.ts +59 -12
|
@@ -6,13 +6,37 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Context: jj:ptyzqoul (structural merge protocol)
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
BACKING_DOC,
|
|
11
|
+
deriveIdentity,
|
|
12
|
+
deriveSchemaBinding,
|
|
13
|
+
KIND,
|
|
14
|
+
type ProductSchema,
|
|
15
|
+
Schema,
|
|
16
|
+
type SchemaBinding,
|
|
17
|
+
STRUCTURAL_YJS_CLIENT_ID,
|
|
18
|
+
} from "@kyneta/schema"
|
|
10
19
|
import { describe, expect, it } from "vitest"
|
|
11
20
|
import * as Y from "yjs"
|
|
12
21
|
import { yjs } from "../bind-yjs.js"
|
|
13
22
|
import { ensureContainers } from "../populate.js"
|
|
14
23
|
import { yjsSubstrateFactory } from "../substrate.js"
|
|
15
24
|
|
|
25
|
+
// ===========================================================================
|
|
26
|
+
// Identity-keying helpers
|
|
27
|
+
// ===========================================================================
|
|
28
|
+
|
|
29
|
+
function trivialBinding(schema: any): SchemaBinding {
|
|
30
|
+
if (schema[KIND] === "product") {
|
|
31
|
+
return deriveSchemaBinding(schema as ProductSchema, {})
|
|
32
|
+
}
|
|
33
|
+
return { forward: new Map(), inverse: new Map() }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function id(fieldName: string): string {
|
|
37
|
+
return deriveIdentity(fieldName, 1)
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
// ===========================================================================
|
|
17
41
|
// Schemas used across tests
|
|
18
42
|
// ===========================================================================
|
|
@@ -31,24 +55,26 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
31
55
|
// ── Core invariant: two peers independently create, sync, no data loss ──
|
|
32
56
|
|
|
33
57
|
it("two peers independently create same schema, sync, data preserved", () => {
|
|
58
|
+
const binding = trivialBinding(TestSchema)
|
|
59
|
+
|
|
34
60
|
// Peer A creates doc and writes text
|
|
35
61
|
const docA = new Y.Doc()
|
|
36
62
|
docA.clientID = 100
|
|
37
|
-
ensureContainers(docA, TestSchema)
|
|
63
|
+
ensureContainers(docA, TestSchema, false, binding)
|
|
38
64
|
docA.transact(() => {
|
|
39
65
|
const root = docA.getMap("root")
|
|
40
|
-
;(root.get("title") as Y.Text).insert(0, "Hello from A")
|
|
41
|
-
root.set("count", 42)
|
|
66
|
+
;(root.get(id("title")) as Y.Text).insert(0, "Hello from A")
|
|
67
|
+
root.set(id("count"), 42)
|
|
42
68
|
})
|
|
43
69
|
|
|
44
70
|
// Peer B independently creates same doc and writes different text
|
|
45
71
|
const docB = new Y.Doc()
|
|
46
72
|
docB.clientID = 200
|
|
47
|
-
ensureContainers(docB, TestSchema)
|
|
73
|
+
ensureContainers(docB, TestSchema, false, binding)
|
|
48
74
|
docB.transact(() => {
|
|
49
75
|
const root = docB.getMap("root")
|
|
50
|
-
;(root.get("title") as Y.Text).insert(0, "Hello from B")
|
|
51
|
-
root.set("count", 99)
|
|
76
|
+
;(root.get(id("title")) as Y.Text).insert(0, "Hello from B")
|
|
77
|
+
root.set(id("count"), 99)
|
|
52
78
|
})
|
|
53
79
|
|
|
54
80
|
// Sync bidirectionally
|
|
@@ -62,25 +88,27 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
62
88
|
const rootB = docB.getMap("root")
|
|
63
89
|
|
|
64
90
|
// 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()
|
|
91
|
+
const titleA = (rootA.get(id("title")) as Y.Text).toString()
|
|
92
|
+
const titleB = (rootB.get(id("title")) as Y.Text).toString()
|
|
67
93
|
expect(titleA).toBe(titleB)
|
|
68
94
|
// Both texts should be present (merged, not lost)
|
|
69
95
|
expect(titleA).toContain("Hello from A")
|
|
70
96
|
expect(titleA).toContain("Hello from B")
|
|
71
97
|
|
|
72
98
|
// Count: last writer wins — both converge to the same value
|
|
73
|
-
expect(rootA.get("count")).toBe(rootB.get("count"))
|
|
99
|
+
expect(rootA.get(id("count"))).toBe(rootB.get(id("count")))
|
|
74
100
|
})
|
|
75
101
|
|
|
76
102
|
it("three peers independently create, sync, all converge", () => {
|
|
77
|
-
const
|
|
103
|
+
const binding = trivialBinding(TestSchema)
|
|
104
|
+
|
|
105
|
+
const docs = [100, 200, 300].map(cid => {
|
|
78
106
|
const doc = new Y.Doc()
|
|
79
|
-
doc.clientID =
|
|
80
|
-
ensureContainers(doc, TestSchema)
|
|
107
|
+
doc.clientID = cid
|
|
108
|
+
ensureContainers(doc, TestSchema, false, binding)
|
|
81
109
|
doc.transact(() => {
|
|
82
110
|
const root = doc.getMap("root")
|
|
83
|
-
;(root.get("title") as Y.Text).insert(0, `Peer${
|
|
111
|
+
;(root.get(id("title")) as Y.Text).insert(0, `Peer${cid}`)
|
|
84
112
|
})
|
|
85
113
|
return doc
|
|
86
114
|
})
|
|
@@ -89,14 +117,17 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
89
117
|
for (let i = 0; i < docs.length; i++) {
|
|
90
118
|
for (let j = 0; j < docs.length; j++) {
|
|
91
119
|
if (i !== j) {
|
|
92
|
-
|
|
120
|
+
const target = docs[j]
|
|
121
|
+
const source = docs[i]
|
|
122
|
+
if (target && source)
|
|
123
|
+
Y.applyUpdate(target, Y.encodeStateAsUpdate(source))
|
|
93
124
|
}
|
|
94
125
|
}
|
|
95
126
|
}
|
|
96
127
|
|
|
97
128
|
// All three converge to the same text
|
|
98
129
|
const texts = docs.map(d =>
|
|
99
|
-
(d.getMap("root").get("title") as Y.Text).toString(),
|
|
130
|
+
(d.getMap("root").get(id("title")) as Y.Text).toString(),
|
|
100
131
|
)
|
|
101
132
|
expect(texts[0]).toBe(texts[1])
|
|
102
133
|
expect(texts[1]).toBe(texts[2])
|
|
@@ -109,14 +140,16 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
109
140
|
// ── Persistence round-trip ──
|
|
110
141
|
|
|
111
142
|
it("persist → hydrate → data preserved", () => {
|
|
143
|
+
const binding = trivialBinding(TestSchema)
|
|
144
|
+
|
|
112
145
|
// Create and write
|
|
113
146
|
const doc1 = new Y.Doc()
|
|
114
147
|
doc1.clientID = 42
|
|
115
|
-
ensureContainers(doc1, TestSchema)
|
|
148
|
+
ensureContainers(doc1, TestSchema, false, binding)
|
|
116
149
|
doc1.transact(() => {
|
|
117
150
|
const root = doc1.getMap("root")
|
|
118
|
-
;(root.get("title") as Y.Text).insert(0, "Persistent")
|
|
119
|
-
root.set("count", 7)
|
|
151
|
+
;(root.get(id("title")) as Y.Text).insert(0, "Persistent")
|
|
152
|
+
root.set(id("count"), 7)
|
|
120
153
|
})
|
|
121
154
|
|
|
122
155
|
// Export
|
|
@@ -126,12 +159,12 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
126
159
|
const doc2 = new Y.Doc()
|
|
127
160
|
doc2.clientID = 42
|
|
128
161
|
Y.applyUpdate(doc2, snapshot)
|
|
129
|
-
ensureContainers(doc2, TestSchema, true) // conditional
|
|
162
|
+
ensureContainers(doc2, TestSchema, true, binding) // conditional
|
|
130
163
|
|
|
131
164
|
// Data preserved
|
|
132
165
|
const root2 = doc2.getMap("root")
|
|
133
|
-
expect((root2.get("title") as Y.Text).toString()).toBe("Persistent")
|
|
134
|
-
expect(root2.get("count")).toBe(7)
|
|
166
|
+
expect((root2.get(id("title")) as Y.Text).toString()).toBe("Persistent")
|
|
167
|
+
expect(root2.get(id("count"))).toBe(7)
|
|
135
168
|
})
|
|
136
169
|
|
|
137
170
|
// ── Determinism: alphabetical sort ──
|
|
@@ -153,11 +186,14 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
153
186
|
fields.beta = Schema.number()
|
|
154
187
|
const schemaB = Schema.struct(fields)
|
|
155
188
|
|
|
189
|
+
const bindingA = trivialBinding(schemaA)
|
|
190
|
+
const bindingB = trivialBinding(schemaB)
|
|
191
|
+
|
|
156
192
|
const docA = new Y.Doc()
|
|
157
|
-
ensureContainers(docA, schemaA)
|
|
193
|
+
ensureContainers(docA, schemaA, false, bindingA)
|
|
158
194
|
|
|
159
195
|
const docB = new Y.Doc()
|
|
160
|
-
ensureContainers(docB, schemaB)
|
|
196
|
+
ensureContainers(docB, schemaB, false, bindingB)
|
|
161
197
|
|
|
162
198
|
// Both should produce byte-identical structural state
|
|
163
199
|
const stateA = Y.encodeStateAsUpdate(docA)
|
|
@@ -179,14 +215,17 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
179
215
|
title: Schema.text(),
|
|
180
216
|
})
|
|
181
217
|
|
|
218
|
+
const v1Binding = trivialBinding(v1Schema)
|
|
219
|
+
const v2Binding = trivialBinding(v2Schema)
|
|
220
|
+
|
|
182
221
|
// Peer A: create v1, write data, export
|
|
183
222
|
const docA = new Y.Doc()
|
|
184
223
|
docA.clientID = 100
|
|
185
|
-
ensureContainers(docA, v1Schema)
|
|
224
|
+
ensureContainers(docA, v1Schema, false, v1Binding)
|
|
186
225
|
docA.transact(() => {
|
|
187
226
|
const root = docA.getMap("root")
|
|
188
|
-
;(root.get("title") as Y.Text).insert(0, "Title")
|
|
189
|
-
root.set("count", 5)
|
|
227
|
+
;(root.get(id("title")) as Y.Text).insert(0, "Title")
|
|
228
|
+
root.set(id("count"), 5)
|
|
190
229
|
})
|
|
191
230
|
const v1State = Y.encodeStateAsUpdate(docA)
|
|
192
231
|
|
|
@@ -194,13 +233,13 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
194
233
|
const docB = new Y.Doc()
|
|
195
234
|
docB.clientID = 200
|
|
196
235
|
Y.applyUpdate(docB, v1State)
|
|
197
|
-
ensureContainers(docB, v2Schema, true) // conditional — only creates "notes"
|
|
236
|
+
ensureContainers(docB, v2Schema, true, v2Binding) // conditional — only creates "notes"
|
|
198
237
|
|
|
199
238
|
// Another peer C: same thing independently
|
|
200
239
|
const docC = new Y.Doc()
|
|
201
240
|
docC.clientID = 300
|
|
202
241
|
Y.applyUpdate(docC, v1State)
|
|
203
|
-
ensureContainers(docC, v2Schema, true)
|
|
242
|
+
ensureContainers(docC, v2Schema, true, v2Binding)
|
|
204
243
|
|
|
205
244
|
// B and C's structural ops for "notes" should be identical (both at clientID 0)
|
|
206
245
|
const stateB = Y.encodeStateAsUpdate(docB)
|
|
@@ -214,22 +253,24 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
214
253
|
const rootC = docC.getMap("root")
|
|
215
254
|
|
|
216
255
|
// 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)
|
|
256
|
+
expect((rootB.get(id("title")) as Y.Text).toString()).toBe("Title")
|
|
257
|
+
expect(rootB.get(id("count"))).toBe(5)
|
|
258
|
+
expect((rootC.get(id("title")) as Y.Text).toString()).toBe("Title")
|
|
259
|
+
expect(rootC.get(id("count"))).toBe(5)
|
|
221
260
|
|
|
222
261
|
// New field exists on both
|
|
223
|
-
expect(rootB.get("notes")).toBeInstanceOf(Y.Text)
|
|
224
|
-
expect(rootC.get("notes")).toBeInstanceOf(Y.Text)
|
|
262
|
+
expect(rootB.get(id("notes"))).toBeInstanceOf(Y.Text)
|
|
263
|
+
expect(rootC.get(id("notes"))).toBeInstanceOf(Y.Text)
|
|
225
264
|
})
|
|
226
265
|
|
|
227
266
|
// ── Structural identity is clientID 0 ──
|
|
228
267
|
|
|
229
268
|
it("ensureContainers uses clientID 0 for structural ops", () => {
|
|
269
|
+
const binding = trivialBinding(TestSchema)
|
|
270
|
+
|
|
230
271
|
const doc = new Y.Doc()
|
|
231
272
|
doc.clientID = 999
|
|
232
|
-
ensureContainers(doc, TestSchema)
|
|
273
|
+
ensureContainers(doc, TestSchema, false, binding)
|
|
233
274
|
|
|
234
275
|
// clientID should be restored
|
|
235
276
|
expect(doc.clientID).toBe(999)
|
|
@@ -240,9 +281,11 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
240
281
|
})
|
|
241
282
|
|
|
242
283
|
it("ensureContainers does not leak caller clientID into structural ops", () => {
|
|
284
|
+
const binding = trivialBinding(TestSchema)
|
|
285
|
+
|
|
243
286
|
const doc = new Y.Doc()
|
|
244
287
|
doc.clientID = 777
|
|
245
|
-
ensureContainers(doc, TestSchema)
|
|
288
|
+
ensureContainers(doc, TestSchema, false, binding)
|
|
246
289
|
|
|
247
290
|
// The state vector should NOT contain the caller's clientID —
|
|
248
291
|
// only STRUCTURAL_YJS_CLIENT_ID (0) should have produced ops.
|
|
@@ -269,8 +312,8 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
269
312
|
const doc1 = (sub1 as any)[BACKING_DOC] as Y.Doc
|
|
270
313
|
doc1.transact(() => {
|
|
271
314
|
const root = doc1.getMap("root")
|
|
272
|
-
;(root.get("title") as Y.Text).insert(0, "Round-trip")
|
|
273
|
-
root.set("count", 123)
|
|
315
|
+
;(root.get(id("title")) as Y.Text).insert(0, "Round-trip")
|
|
316
|
+
root.set(id("count"), 123)
|
|
274
317
|
})
|
|
275
318
|
|
|
276
319
|
const payload = sub1.exportEntirety()
|
|
@@ -278,8 +321,8 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
278
321
|
const doc2 = (sub2 as any)[BACKING_DOC] as Y.Doc
|
|
279
322
|
const root2 = doc2.getMap("root")
|
|
280
323
|
|
|
281
|
-
expect((root2.get("title") as Y.Text).toString()).toBe("Round-trip")
|
|
282
|
-
expect(root2.get("count")).toBe(123)
|
|
324
|
+
expect((root2.get(id("title")) as Y.Text).toString()).toBe("Round-trip")
|
|
325
|
+
expect(root2.get(id("count"))).toBe(123)
|
|
283
326
|
})
|
|
284
327
|
|
|
285
328
|
// ── yjs.bind integration ──
|
|
@@ -287,8 +330,14 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
287
330
|
it("yjs.bind factory produces deterministic structural ops across peers", () => {
|
|
288
331
|
const bound = yjs.bind(TestSchema)
|
|
289
332
|
|
|
290
|
-
const factoryA = bound.factory({
|
|
291
|
-
|
|
333
|
+
const factoryA = bound.factory({
|
|
334
|
+
peerId: "alice",
|
|
335
|
+
binding: bound.identityBinding,
|
|
336
|
+
})
|
|
337
|
+
const factoryB = bound.factory({
|
|
338
|
+
peerId: "bob",
|
|
339
|
+
binding: bound.identityBinding,
|
|
340
|
+
})
|
|
292
341
|
|
|
293
342
|
const subA = factoryA.create(TestSchema)
|
|
294
343
|
const subB = factoryB.create(TestSchema)
|
|
@@ -304,8 +353,14 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
304
353
|
it("yjs.bind peers merge without structural conflict", () => {
|
|
305
354
|
const bound = yjs.bind(TestSchema)
|
|
306
355
|
|
|
307
|
-
const factoryA = bound.factory({
|
|
308
|
-
|
|
356
|
+
const factoryA = bound.factory({
|
|
357
|
+
peerId: "alice",
|
|
358
|
+
binding: bound.identityBinding,
|
|
359
|
+
})
|
|
360
|
+
const factoryB = bound.factory({
|
|
361
|
+
peerId: "bob",
|
|
362
|
+
binding: bound.identityBinding,
|
|
363
|
+
})
|
|
309
364
|
|
|
310
365
|
const subA = factoryA.create(TestSchema)
|
|
311
366
|
const subB = factoryB.create(TestSchema)
|
|
@@ -314,15 +369,15 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
314
369
|
const docA = (subA as any)[BACKING_DOC] as Y.Doc
|
|
315
370
|
docA.transact(() => {
|
|
316
371
|
const root = docA.getMap("root")
|
|
317
|
-
;(root.get("title") as Y.Text).insert(0, "Alice's text")
|
|
318
|
-
root.set("count", 10)
|
|
372
|
+
;(root.get(id("title")) as Y.Text).insert(0, "Alice's text")
|
|
373
|
+
root.set(id("count"), 10)
|
|
319
374
|
})
|
|
320
375
|
|
|
321
376
|
const docB = (subB as any)[BACKING_DOC] as Y.Doc
|
|
322
377
|
docB.transact(() => {
|
|
323
378
|
const root = docB.getMap("root")
|
|
324
|
-
;(root.get("title") as Y.Text).insert(0, "Bob's text")
|
|
325
|
-
root.set("count", 20)
|
|
379
|
+
;(root.get(id("title")) as Y.Text).insert(0, "Bob's text")
|
|
380
|
+
root.set(id("count"), 20)
|
|
326
381
|
})
|
|
327
382
|
|
|
328
383
|
// Bidirectional merge — should not throw
|
|
@@ -335,26 +390,28 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
335
390
|
const rootA = docA.getMap("root")
|
|
336
391
|
const rootB = docB.getMap("root")
|
|
337
392
|
|
|
338
|
-
const titleA = (rootA.get("title") as Y.Text).toString()
|
|
339
|
-
const titleB = (rootB.get("title") as Y.Text).toString()
|
|
393
|
+
const titleA = (rootA.get(id("title")) as Y.Text).toString()
|
|
394
|
+
const titleB = (rootB.get(id("title")) as Y.Text).toString()
|
|
340
395
|
expect(titleA).toBe(titleB)
|
|
341
396
|
expect(titleA).toContain("Alice's text")
|
|
342
397
|
expect(titleA).toContain("Bob's text")
|
|
343
398
|
|
|
344
|
-
expect(rootA.get("count")).toBe(rootB.get("count"))
|
|
399
|
+
expect(rootA.get(id("count"))).toBe(rootB.get(id("count")))
|
|
345
400
|
})
|
|
346
401
|
|
|
347
402
|
// ── Conditional ensureContainers is idempotent ──
|
|
348
403
|
|
|
349
404
|
it("conditional ensureContainers is idempotent on hydrated doc", () => {
|
|
405
|
+
const binding = trivialBinding(TestSchema)
|
|
406
|
+
|
|
350
407
|
const doc = new Y.Doc()
|
|
351
408
|
doc.clientID = 50
|
|
352
|
-
ensureContainers(doc, TestSchema)
|
|
409
|
+
ensureContainers(doc, TestSchema, false, binding)
|
|
353
410
|
|
|
354
411
|
const stateBefore = Y.encodeStateAsUpdate(doc)
|
|
355
412
|
|
|
356
413
|
// Conditional call should not create new ops (everything already exists)
|
|
357
|
-
ensureContainers(doc, TestSchema, true)
|
|
414
|
+
ensureContainers(doc, TestSchema, true, binding)
|
|
358
415
|
|
|
359
416
|
const stateAfter = Y.encodeStateAsUpdate(doc)
|
|
360
417
|
expect(stateAfter).toEqual(stateBefore)
|
|
@@ -182,6 +182,24 @@ describe("YjsSubstrate", () => {
|
|
|
182
182
|
const parsed = YjsVersion.parse(serialized)
|
|
183
183
|
expect(parsed.compare(v)).toBe("equal")
|
|
184
184
|
})
|
|
185
|
+
|
|
186
|
+
it("version changes after a delete-only mutation", () => {
|
|
187
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
188
|
+
change(doc, (d: any) => {
|
|
189
|
+
d.title.insert(0, "hello")
|
|
190
|
+
})
|
|
191
|
+
const vAfterInsert = version(doc)
|
|
192
|
+
|
|
193
|
+
change(doc, (d: any) => {
|
|
194
|
+
d.title.delete(1, 1)
|
|
195
|
+
})
|
|
196
|
+
const vAfterDelete = version(doc)
|
|
197
|
+
|
|
198
|
+
// The version must change after a delete — even though Yjs's state
|
|
199
|
+
// vector does not advance on delete. The snapshot-based YjsVersion
|
|
200
|
+
// detects the delete set change and returns "concurrent" (not "equal").
|
|
201
|
+
expect(vAfterInsert.compare(vAfterDelete)).not.toBe("equal")
|
|
202
|
+
})
|
|
185
203
|
})
|
|
186
204
|
|
|
187
205
|
// -------------------------------------------------------------------------
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { uint8ArrayToBase64 } from "@kyneta/schema"
|
|
1
2
|
import { describe, expect, it } from "vitest"
|
|
2
3
|
import * as Y from "yjs"
|
|
3
4
|
import { YjsVersion } from "../version.js"
|
|
@@ -195,6 +196,92 @@ describe("YjsVersion", () => {
|
|
|
195
196
|
})
|
|
196
197
|
})
|
|
197
198
|
|
|
199
|
+
// ===========================================================================
|
|
200
|
+
// Snapshot-aware comparison (delete detection)
|
|
201
|
+
// ===========================================================================
|
|
202
|
+
|
|
203
|
+
describe("YjsVersion: snapshot-aware comparison (delete detection)", () => {
|
|
204
|
+
it("compare returns 'concurrent' when SVs match but delete sets differ", () => {
|
|
205
|
+
const doc = new Y.Doc()
|
|
206
|
+
doc.getText("body").insert(0, "hello")
|
|
207
|
+
const sv1 = Y.encodeStateVector(doc)
|
|
208
|
+
const snap1 = Y.encodeSnapshot(Y.snapshot(doc))
|
|
209
|
+
const v1 = new YjsVersion(sv1, snap1)
|
|
210
|
+
|
|
211
|
+
doc.getText("body").delete(1, 1)
|
|
212
|
+
const sv2 = Y.encodeStateVector(doc)
|
|
213
|
+
const snap2 = Y.encodeSnapshot(Y.snapshot(doc))
|
|
214
|
+
const v2 = new YjsVersion(sv2, snap2)
|
|
215
|
+
|
|
216
|
+
// SVs are identical (Yjs doesn't advance SV on delete)
|
|
217
|
+
expect(v1.compare(v2)).toBe("concurrent")
|
|
218
|
+
expect(v2.compare(v1)).toBe("concurrent")
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("compare returns 'equal' when both SV and delete set match", () => {
|
|
222
|
+
const doc = new Y.Doc()
|
|
223
|
+
doc.getText("body").insert(0, "hello")
|
|
224
|
+
doc.getText("body").delete(1, 1)
|
|
225
|
+
const sv = Y.encodeStateVector(doc)
|
|
226
|
+
const snap = Y.encodeSnapshot(Y.snapshot(doc))
|
|
227
|
+
|
|
228
|
+
const v1 = new YjsVersion(sv, snap)
|
|
229
|
+
const v2 = new YjsVersion(sv, snap)
|
|
230
|
+
expect(v1.compare(v2)).toBe("equal")
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("serialize/parse round-trips snapshot bytes", () => {
|
|
234
|
+
const doc = new Y.Doc()
|
|
235
|
+
doc.getText("body").insert(0, "hello")
|
|
236
|
+
doc.getText("body").delete(1, 1)
|
|
237
|
+
const sv = Y.encodeStateVector(doc)
|
|
238
|
+
const snap = Y.encodeSnapshot(Y.snapshot(doc))
|
|
239
|
+
const v = new YjsVersion(sv, snap)
|
|
240
|
+
|
|
241
|
+
const parsed = YjsVersion.parse(v.serialize())
|
|
242
|
+
expect(parsed.compare(v)).toBe("equal")
|
|
243
|
+
// Snapshot bytes preserved through round-trip
|
|
244
|
+
expect(parsed.snapshotBytes.length).toBe(snap.length)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("SV-ahead still returns 'ahead' even with different snapshots", () => {
|
|
248
|
+
const doc = new Y.Doc()
|
|
249
|
+
doc.getText("body").insert(0, "hello")
|
|
250
|
+
const sv1 = Y.encodeStateVector(doc)
|
|
251
|
+
const snap1 = Y.encodeSnapshot(Y.snapshot(doc))
|
|
252
|
+
const v1 = new YjsVersion(sv1, snap1)
|
|
253
|
+
|
|
254
|
+
doc.getText("body").insert(5, " world")
|
|
255
|
+
doc.getText("body").delete(0, 1)
|
|
256
|
+
const sv2 = Y.encodeStateVector(doc)
|
|
257
|
+
const snap2 = Y.encodeSnapshot(Y.snapshot(doc))
|
|
258
|
+
const v2 = new YjsVersion(sv2, snap2)
|
|
259
|
+
|
|
260
|
+
// v2 has more inserts (SV advanced), snapshot check is moot
|
|
261
|
+
expect(v2.compare(v1)).toBe("ahead")
|
|
262
|
+
expect(v1.compare(v2)).toBe("behind")
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it("legacy parse (no dot) treats SV bytes as snapshot", () => {
|
|
266
|
+
// Legacy serialized format: just base64(sv), no "."
|
|
267
|
+
const doc = new Y.Doc()
|
|
268
|
+
doc.getText("body").insert(0, "hello")
|
|
269
|
+
const sv = Y.encodeStateVector(doc)
|
|
270
|
+
|
|
271
|
+
const legacy = new YjsVersion(sv) // no snapshot arg
|
|
272
|
+
const serialized = legacy.serialize()
|
|
273
|
+
// Should have a "." separator in new format
|
|
274
|
+
expect(serialized).toContain(".")
|
|
275
|
+
|
|
276
|
+
// But parsing old-format strings without "." should work
|
|
277
|
+
const oldFormat = uint8ArrayToBase64(sv) // no dot
|
|
278
|
+
const parsed = YjsVersion.parse(oldFormat)
|
|
279
|
+
// snapshotBytes defaults to sv bytes
|
|
280
|
+
expect(parsed.sv.length).toBe(sv.length)
|
|
281
|
+
expect(parsed.snapshotBytes.length).toBe(sv.length)
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
198
285
|
// -------------------------------------------------------------------------
|
|
199
286
|
// compare after round-trip
|
|
200
287
|
// -------------------------------------------------------------------------
|
package/src/bind-yjs.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// bind-yjs — Yjs CRDT
|
|
1
|
+
// bind-yjs — Yjs CRDT binding target and factory internals.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// The `yjs` binding target provides `yjs.bind()` and `yjs.replica()` for
|
|
4
|
+
// binding schemas to the Yjs substrate with collaborative sync protocol.
|
|
5
|
+
// The factory builder accepts { peerId } and returns a SubstrateFactory
|
|
6
|
+
// that calls doc.clientID = hashPeerId(peerId) on every new Y.Doc,
|
|
7
|
+
// ensuring deterministic peer identity across all documents in an exchange.
|
|
6
8
|
//
|
|
7
9
|
// Yjs clientID is a uint32 number. We use FNV-1a hash truncated to
|
|
8
10
|
// 32 bits, mirroring the Loro binding's hashPeerId pattern but
|
|
@@ -19,18 +21,19 @@
|
|
|
19
21
|
// const doc = exchange.get("my-doc", TodoDoc)
|
|
20
22
|
|
|
21
23
|
import type {
|
|
22
|
-
|
|
24
|
+
BindingTarget,
|
|
23
25
|
Replica,
|
|
26
|
+
SchemaBinding,
|
|
24
27
|
Schema as SchemaNode,
|
|
25
28
|
Substrate,
|
|
26
29
|
SubstrateFactory,
|
|
27
|
-
SubstrateNamespace,
|
|
28
30
|
SubstratePayload,
|
|
29
31
|
} from "@kyneta/schema"
|
|
30
32
|
import {
|
|
31
33
|
BACKING_DOC,
|
|
32
|
-
|
|
34
|
+
createBindingTarget,
|
|
33
35
|
STRUCTURAL_YJS_CLIENT_ID,
|
|
36
|
+
SYNC_COLLABORATIVE,
|
|
34
37
|
} from "@kyneta/schema"
|
|
35
38
|
import * as Y from "yjs"
|
|
36
39
|
import type { YjsNativeMap } from "./native-map.js"
|
|
@@ -79,7 +82,10 @@ function hashPeerId(peerId: string): number {
|
|
|
79
82
|
* on every new Y.Doc with a deterministic uint32 clientID derived
|
|
80
83
|
* from the exchange's string peerId.
|
|
81
84
|
*/
|
|
82
|
-
function createYjsFactory(
|
|
85
|
+
function createYjsFactory(
|
|
86
|
+
peerId: string,
|
|
87
|
+
binding: SchemaBinding,
|
|
88
|
+
): SubstrateFactory<YjsVersion> {
|
|
83
89
|
const numericClientId = hashPeerId(peerId)
|
|
84
90
|
|
|
85
91
|
return {
|
|
@@ -101,16 +107,16 @@ function createYjsFactory(peerId: string): SubstrateFactory<YjsVersion> {
|
|
|
101
107
|
doc.clientID = numericClientId
|
|
102
108
|
// Conditional ensureContainers: skip fields that already exist
|
|
103
109
|
// from hydrated state (each set() is a CRDT write).
|
|
104
|
-
ensureContainers(doc, schema, true)
|
|
105
|
-
return createYjsSubstrate(doc, schema)
|
|
110
|
+
ensureContainers(doc, schema, true, binding)
|
|
111
|
+
return createYjsSubstrate(doc, schema, binding)
|
|
106
112
|
},
|
|
107
113
|
|
|
108
114
|
create(schema: SchemaNode): Substrate<YjsVersion> {
|
|
109
115
|
// Fresh doc — set identity immediately, unconditional containers.
|
|
110
116
|
const doc = new Y.Doc()
|
|
111
117
|
doc.clientID = numericClientId
|
|
112
|
-
ensureContainers(doc, schema)
|
|
113
|
-
return createYjsSubstrate(doc, schema)
|
|
118
|
+
ensureContainers(doc, schema, false, binding)
|
|
119
|
+
return createYjsSubstrate(doc, schema, binding)
|
|
114
120
|
},
|
|
115
121
|
|
|
116
122
|
fromEntirety(
|
|
@@ -132,36 +138,37 @@ function createYjsFactory(peerId: string): SubstrateFactory<YjsVersion> {
|
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
// ---------------------------------------------------------------------------
|
|
135
|
-
// yjs — the Yjs CRDT
|
|
141
|
+
// yjs — the Yjs CRDT binding target
|
|
136
142
|
// ---------------------------------------------------------------------------
|
|
137
143
|
|
|
138
144
|
/**
|
|
139
|
-
*
|
|
145
|
+
* Yjs composition-law tags — the set of concurrent composition laws
|
|
146
|
+
* that the Yjs substrate faithfully implements.
|
|
147
|
+
*/
|
|
148
|
+
export type YjsLaws =
|
|
149
|
+
| "lww"
|
|
150
|
+
| "positional-ot"
|
|
151
|
+
| "lww-per-key"
|
|
152
|
+
| "lww-tag-replaced"
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The Yjs CRDT binding target.
|
|
140
156
|
*
|
|
141
|
-
* - `yjs.bind(schema)` — collaborative sync
|
|
142
|
-
* - `yjs.
|
|
143
|
-
* - `yjs.replica()` — collaborative replication (default)
|
|
144
|
-
* - `yjs.replica("ephemeral")` — ephemeral replication
|
|
157
|
+
* - `yjs.bind(schema)` — bind a schema to Yjs with collaborative sync
|
|
158
|
+
* - `yjs.replica()` — create a collaborative replica
|
|
145
159
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
160
|
+
* Laws are constrained to `YjsLaws` — schemas requiring composition laws
|
|
161
|
+
* outside this set (e.g. `"additive"` from `Schema.counter()`,
|
|
162
|
+
* `"positional-ot-move"` from `Schema.movableList()`) are rejected at
|
|
163
|
+
* compile time.
|
|
148
164
|
*
|
|
149
165
|
* To access the underlying Y.Doc, use `unwrap(ref)` from `@kyneta/schema`.
|
|
150
166
|
*/
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
replicaFactory: yjsReplicaFactory,
|
|
160
|
-
},
|
|
161
|
-
ephemeral: {
|
|
162
|
-
factory: ctx => createYjsFactory(ctx.peerId),
|
|
163
|
-
replicaFactory: yjsReplicaFactory,
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
defaultStrategy: "collaborative",
|
|
167
|
-
})
|
|
167
|
+
export const yjs: BindingTarget<YjsLaws, YjsNativeMap> = createBindingTarget<
|
|
168
|
+
YjsLaws,
|
|
169
|
+
YjsNativeMap
|
|
170
|
+
>({
|
|
171
|
+
factory: ctx => createYjsFactory(ctx.peerId, ctx.binding),
|
|
172
|
+
replicaFactory: yjsReplicaFactory,
|
|
173
|
+
syncProtocol: SYNC_COLLABORATIVE,
|
|
174
|
+
})
|