@kyneta/yjs-schema 1.6.0 → 1.7.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 +17 -61
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +151 -202
- package/dist/index.js.map +1 -1
- package/package.json +6 -8
- package/src/__tests__/create.test.ts +1 -1
- package/src/__tests__/materialize.test.ts +227 -0
- package/src/__tests__/position.test.ts +7 -7
- package/src/__tests__/record-text-spike.test.ts +5 -5
- package/src/__tests__/structural-merge.test.ts +20 -20
- package/src/__tests__/substrate.test.ts +45 -0
- package/src/bind-yjs.ts +3 -5
- package/src/change-mapping.ts +62 -35
- package/src/index.ts +1 -2
- package/src/materialize.ts +109 -0
- package/src/populate.ts +23 -37
- package/src/substrate.ts +62 -52
- package/src/yjs-extract.ts +52 -0
- package/src/yjs-resolve.ts +30 -95
- package/src/__tests__/reader.test.ts +0 -685
- package/src/reader.ts +0 -174
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// materialize — Tests for materializeYjsShadow.
|
|
2
|
+
//
|
|
3
|
+
// Validates that the Yjs→PlainState materialization produces correct
|
|
4
|
+
// plain JS objects for all supported schema types: text, scalar,
|
|
5
|
+
// sequence, nested struct, and empty documents.
|
|
6
|
+
//
|
|
7
|
+
// Yjs does not support counter, tree, or movableList — those are skipped.
|
|
8
|
+
|
|
9
|
+
import type { ProductSchema, SchemaBinding } from "@kyneta/schema"
|
|
10
|
+
import {
|
|
11
|
+
BACKING_DOC,
|
|
12
|
+
change,
|
|
13
|
+
createDoc,
|
|
14
|
+
deriveSchemaBinding,
|
|
15
|
+
KIND,
|
|
16
|
+
Schema,
|
|
17
|
+
type SchemaNode,
|
|
18
|
+
SUBSTRATE,
|
|
19
|
+
} from "@kyneta/schema"
|
|
20
|
+
import { describe, expect, it } from "vitest"
|
|
21
|
+
import type * as Y from "yjs"
|
|
22
|
+
import { yjs } from "../bind-yjs.js"
|
|
23
|
+
import { materializeYjsShadow } from "../materialize.js"
|
|
24
|
+
|
|
25
|
+
// ===========================================================================
|
|
26
|
+
// Helpers
|
|
27
|
+
// ===========================================================================
|
|
28
|
+
|
|
29
|
+
function trivialBinding(schema: SchemaNode): 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 getYDoc(docRef: unknown): Y.Doc {
|
|
37
|
+
const substrate = (docRef as any)[SUBSTRATE]
|
|
38
|
+
return (substrate as any)[BACKING_DOC] as Y.Doc
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ===========================================================================
|
|
42
|
+
// Test schemas
|
|
43
|
+
// ===========================================================================
|
|
44
|
+
|
|
45
|
+
const TextSchema = Schema.struct({
|
|
46
|
+
title: Schema.text(),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const ScalarSchema = Schema.struct({
|
|
50
|
+
name: Schema.string(),
|
|
51
|
+
age: Schema.number(),
|
|
52
|
+
active: Schema.boolean(),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const SequenceSchema = Schema.struct({
|
|
56
|
+
items: Schema.list(Schema.string()),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const NestedSchema = Schema.struct({
|
|
60
|
+
meta: Schema.struct({
|
|
61
|
+
author: Schema.string(),
|
|
62
|
+
version: Schema.number(),
|
|
63
|
+
}),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const FullSchema = Schema.struct({
|
|
67
|
+
title: Schema.text(),
|
|
68
|
+
theme: Schema.string(),
|
|
69
|
+
tags: Schema.list(Schema.string()),
|
|
70
|
+
settings: Schema.struct({
|
|
71
|
+
darkMode: Schema.boolean(),
|
|
72
|
+
fontSize: Schema.number(),
|
|
73
|
+
}),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ===========================================================================
|
|
77
|
+
// Tests
|
|
78
|
+
// ===========================================================================
|
|
79
|
+
|
|
80
|
+
describe("materializeYjsShadow", () => {
|
|
81
|
+
it("materializes text fields", () => {
|
|
82
|
+
const doc = createDoc(yjs.bind(TextSchema))
|
|
83
|
+
change(doc, (d: any) => {
|
|
84
|
+
d.title.insert(0, "hello")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const yDoc = getYDoc(doc)
|
|
88
|
+
const binding = trivialBinding(TextSchema)
|
|
89
|
+
const result = materializeYjsShadow(yDoc, TextSchema, binding)
|
|
90
|
+
|
|
91
|
+
expect(result).toEqual({ title: "hello" })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("materializes scalar fields", () => {
|
|
95
|
+
const doc = createDoc(yjs.bind(ScalarSchema))
|
|
96
|
+
change(doc, (d: any) => {
|
|
97
|
+
d.name.set("Alice")
|
|
98
|
+
d.age.set(30)
|
|
99
|
+
d.active.set(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const yDoc = getYDoc(doc)
|
|
103
|
+
const binding = trivialBinding(ScalarSchema)
|
|
104
|
+
const result = materializeYjsShadow(yDoc, ScalarSchema, binding)
|
|
105
|
+
|
|
106
|
+
expect(result).toEqual({ name: "Alice", age: 30, active: true })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("materializes sequence fields", () => {
|
|
110
|
+
const doc = createDoc(yjs.bind(SequenceSchema))
|
|
111
|
+
// Separate change() calls for list pushes to preserve order
|
|
112
|
+
// (Yjs reverses order within a single transaction)
|
|
113
|
+
change(doc, (d: any) => d.items.push("alpha"))
|
|
114
|
+
change(doc, (d: any) => d.items.push("beta"))
|
|
115
|
+
change(doc, (d: any) => d.items.push("gamma"))
|
|
116
|
+
|
|
117
|
+
const yDoc = getYDoc(doc)
|
|
118
|
+
const binding = trivialBinding(SequenceSchema)
|
|
119
|
+
const result = materializeYjsShadow(yDoc, SequenceSchema, binding)
|
|
120
|
+
|
|
121
|
+
expect(result).toEqual({ items: ["alpha", "beta", "gamma"] })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("materializes nested struct fields", () => {
|
|
125
|
+
const doc = createDoc(yjs.bind(NestedSchema))
|
|
126
|
+
change(doc, (d: any) => {
|
|
127
|
+
d.meta.author.set("Bob")
|
|
128
|
+
d.meta.version.set(42)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const yDoc = getYDoc(doc)
|
|
132
|
+
const binding = trivialBinding(NestedSchema)
|
|
133
|
+
const result = materializeYjsShadow(yDoc, NestedSchema, binding)
|
|
134
|
+
|
|
135
|
+
expect(result).toEqual({
|
|
136
|
+
meta: { author: "Bob", version: 42 },
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("materializes empty document to structural zeros", () => {
|
|
141
|
+
const doc = createDoc(yjs.bind(FullSchema))
|
|
142
|
+
|
|
143
|
+
const yDoc = getYDoc(doc)
|
|
144
|
+
const binding = trivialBinding(FullSchema)
|
|
145
|
+
const result = materializeYjsShadow(yDoc, FullSchema, binding)
|
|
146
|
+
|
|
147
|
+
expect(result).toEqual({
|
|
148
|
+
title: "",
|
|
149
|
+
theme: "",
|
|
150
|
+
tags: [],
|
|
151
|
+
settings: { darkMode: false, fontSize: 0 },
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("uses raw field names, not identity hashes", () => {
|
|
156
|
+
const doc = createDoc(yjs.bind(TextSchema))
|
|
157
|
+
change(doc, (d: any) => {
|
|
158
|
+
d.title.insert(0, "test")
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const yDoc = getYDoc(doc)
|
|
162
|
+
const binding = trivialBinding(TextSchema)
|
|
163
|
+
const result = materializeYjsShadow(yDoc, TextSchema, binding)
|
|
164
|
+
|
|
165
|
+
// Keys should be the raw field name "title", not an identity hash
|
|
166
|
+
const keys = Object.keys(result as Record<string, unknown>)
|
|
167
|
+
expect(keys).toContain("title")
|
|
168
|
+
expect(keys).toHaveLength(1)
|
|
169
|
+
// No key should look like a hash (long alphanumeric string)
|
|
170
|
+
for (const key of keys) {
|
|
171
|
+
expect(key).toBe("title")
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("materializes a complex document with multiple field types", () => {
|
|
176
|
+
const doc = createDoc(yjs.bind(FullSchema))
|
|
177
|
+
|
|
178
|
+
change(doc, (d: any) => {
|
|
179
|
+
d.title.insert(0, "My Doc")
|
|
180
|
+
d.theme.set("dark")
|
|
181
|
+
d.settings.darkMode.set(true)
|
|
182
|
+
d.settings.fontSize.set(16)
|
|
183
|
+
})
|
|
184
|
+
change(doc, (d: any) => d.tags.push("important"))
|
|
185
|
+
change(doc, (d: any) => d.tags.push("urgent"))
|
|
186
|
+
|
|
187
|
+
const yDoc = getYDoc(doc)
|
|
188
|
+
const binding = trivialBinding(FullSchema)
|
|
189
|
+
const result = materializeYjsShadow(yDoc, FullSchema, binding)
|
|
190
|
+
|
|
191
|
+
expect(result).toEqual({
|
|
192
|
+
title: "My Doc",
|
|
193
|
+
theme: "dark",
|
|
194
|
+
tags: ["important", "urgent"],
|
|
195
|
+
settings: { darkMode: true, fontSize: 16 },
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it("materializes without binding (undefined binding)", () => {
|
|
200
|
+
const doc = createDoc(yjs.bind(TextSchema))
|
|
201
|
+
change(doc, (d: any) => {
|
|
202
|
+
d.title.insert(0, "no binding")
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const yDoc = getYDoc(doc)
|
|
206
|
+
// Pass no binding — materialize should still work
|
|
207
|
+
const result = materializeYjsShadow(yDoc, TextSchema)
|
|
208
|
+
|
|
209
|
+
// Without binding, keys may be identity hashes — but the values
|
|
210
|
+
// should still be correct and the result should be an object.
|
|
211
|
+
expect(result).toBeDefined()
|
|
212
|
+
expect(typeof result).toBe("object")
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("nested nullable materializes to null on fresh doc", () => {
|
|
216
|
+
const schema = Schema.struct({
|
|
217
|
+
settings: Schema.struct({
|
|
218
|
+
theme: Schema.string().nullable(),
|
|
219
|
+
}),
|
|
220
|
+
})
|
|
221
|
+
const doc = createDoc(yjs.bind(schema))
|
|
222
|
+
const yDoc = getYDoc(doc)
|
|
223
|
+
const binding = trivialBinding(schema)
|
|
224
|
+
const result = materializeYjsShadow(yDoc, schema, binding)
|
|
225
|
+
expect(result).toEqual({ settings: { theme: null } })
|
|
226
|
+
})
|
|
227
|
+
})
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
type PositionTestEnv,
|
|
22
22
|
positionConformance,
|
|
23
|
-
} from "@kyneta/schema/
|
|
23
|
+
} from "@kyneta/schema/testing"
|
|
24
24
|
import { describe, expect, it } from "vitest"
|
|
25
25
|
import * as Y from "yjs"
|
|
26
26
|
import { ensureContainers } from "../populate.js"
|
|
@@ -118,7 +118,7 @@ describe("YjsPosition: concurrent edits", () => {
|
|
|
118
118
|
// --- Doc 2: fork from doc1's state ---
|
|
119
119
|
const doc2 = new Y.Doc()
|
|
120
120
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
|
121
|
-
ensureContainers(doc2, TextSchema
|
|
121
|
+
ensureContainers(doc2, TextSchema)
|
|
122
122
|
const substrate2 = createYjsSubstrate(doc2, TextSchema)
|
|
123
123
|
const ref2 = createRef(TextSchema, substrate2) as any
|
|
124
124
|
|
|
@@ -175,7 +175,7 @@ describe("YjsPosition: concurrent edits", () => {
|
|
|
175
175
|
// --- Doc 2: fork ---
|
|
176
176
|
const doc2 = new Y.Doc()
|
|
177
177
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
|
178
|
-
ensureContainers(doc2, TextSchema
|
|
178
|
+
ensureContainers(doc2, TextSchema)
|
|
179
179
|
const substrate2 = createYjsSubstrate(doc2, TextSchema)
|
|
180
180
|
const ref2 = createRef(TextSchema, substrate2) as any
|
|
181
181
|
|
|
@@ -229,7 +229,7 @@ describe("YjsPosition: concurrent edits", () => {
|
|
|
229
229
|
// --- Doc 2: fork ---
|
|
230
230
|
const doc2 = new Y.Doc()
|
|
231
231
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
|
232
|
-
ensureContainers(doc2, TextSchema
|
|
232
|
+
ensureContainers(doc2, TextSchema)
|
|
233
233
|
const substrate2 = createYjsSubstrate(doc2, TextSchema)
|
|
234
234
|
const ref2 = createRef(TextSchema, substrate2) as any
|
|
235
235
|
|
|
@@ -285,7 +285,7 @@ describe("YjsPosition: concurrent edits", () => {
|
|
|
285
285
|
// Decode on a different doc that has the same state
|
|
286
286
|
const doc2 = new Y.Doc()
|
|
287
287
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
|
288
|
-
ensureContainers(doc2, TextSchema
|
|
288
|
+
ensureContainers(doc2, TextSchema)
|
|
289
289
|
const substrate2 = createYjsSubstrate(doc2, TextSchema)
|
|
290
290
|
const ref2 = createRef(TextSchema, substrate2) as any
|
|
291
291
|
|
|
@@ -310,13 +310,13 @@ describe("YjsPosition: concurrent edits", () => {
|
|
|
310
310
|
|
|
311
311
|
const doc2 = new Y.Doc()
|
|
312
312
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
|
313
|
-
ensureContainers(doc2, TextSchema
|
|
313
|
+
ensureContainers(doc2, TextSchema)
|
|
314
314
|
const substrate2 = createYjsSubstrate(doc2, TextSchema)
|
|
315
315
|
const ref2 = createRef(TextSchema, substrate2) as any
|
|
316
316
|
|
|
317
317
|
const doc3 = new Y.Doc()
|
|
318
318
|
Y.applyUpdate(doc3, Y.encodeStateAsUpdate(doc1))
|
|
319
|
-
ensureContainers(doc3, TextSchema
|
|
319
|
+
ensureContainers(doc3, TextSchema)
|
|
320
320
|
const substrate3 = createYjsSubstrate(doc3, TextSchema)
|
|
321
321
|
const ref3 = createRef(TextSchema, substrate3) as any
|
|
322
322
|
|
|
@@ -147,7 +147,7 @@ describe("record-of-struct (plain baseline)", () => {
|
|
|
147
147
|
|
|
148
148
|
const delta = exportSince(docA, v0)
|
|
149
149
|
expect(delta).not.toBeNull()
|
|
150
|
-
merge(docB, delta!, "sync")
|
|
150
|
+
merge(docB, delta!, { origin: "sync" })
|
|
151
151
|
|
|
152
152
|
expect(docB.profiles()).toEqual({
|
|
153
153
|
alice: { displayName: "Alice", age: 30 },
|
|
@@ -304,7 +304,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
304
304
|
|
|
305
305
|
const delta = exportSince(docA, v0)
|
|
306
306
|
expect(delta).not.toBeNull()
|
|
307
|
-
merge(docB, delta!, "sync")
|
|
307
|
+
merge(docB, delta!, { origin: "sync" })
|
|
308
308
|
|
|
309
309
|
expect(docB.profiles()).toEqual({
|
|
310
310
|
alice: { displayName: "Alice", bio: "Hello from A" },
|
|
@@ -342,8 +342,8 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
342
342
|
const deltaBA = exportSince(docB, vA)
|
|
343
343
|
expect(deltaAB).not.toBeNull()
|
|
344
344
|
expect(deltaBA).not.toBeNull()
|
|
345
|
-
merge(docB, deltaAB!, "sync")
|
|
346
|
-
merge(docA, deltaBA!, "sync")
|
|
345
|
+
merge(docB, deltaAB!, { origin: "sync" })
|
|
346
|
+
merge(docA, deltaBA!, { origin: "sync" })
|
|
347
347
|
|
|
348
348
|
// Both converge to the same value (order depends on client IDs)
|
|
349
349
|
expect((docA as any).profiles.at("alice").bio()).toBe(
|
|
@@ -429,7 +429,7 @@ describe("text-inside-struct-inside-list", () => {
|
|
|
429
429
|
|
|
430
430
|
const delta = exportSince(docA, v0)
|
|
431
431
|
expect(delta).not.toBeNull()
|
|
432
|
-
merge(docB, delta!, "sync")
|
|
432
|
+
merge(docB, delta!, { origin: "sync" })
|
|
433
433
|
|
|
434
434
|
expect(docB.players.length).toBe(1)
|
|
435
435
|
expect((docB as any).players.at(0).name()).toBe("Alice")
|
|
@@ -60,7 +60,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
60
60
|
// Peer A creates doc and writes text
|
|
61
61
|
const docA = new Y.Doc()
|
|
62
62
|
docA.clientID = 100
|
|
63
|
-
ensureContainers(docA, TestSchema,
|
|
63
|
+
ensureContainers(docA, TestSchema, binding)
|
|
64
64
|
docA.transact(() => {
|
|
65
65
|
const root = docA.getMap("root")
|
|
66
66
|
;(root.get(id("title")) as Y.Text).insert(0, "Hello from A")
|
|
@@ -70,7 +70,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
70
70
|
// Peer B independently creates same doc and writes different text
|
|
71
71
|
const docB = new Y.Doc()
|
|
72
72
|
docB.clientID = 200
|
|
73
|
-
ensureContainers(docB, TestSchema,
|
|
73
|
+
ensureContainers(docB, TestSchema, binding)
|
|
74
74
|
docB.transact(() => {
|
|
75
75
|
const root = docB.getMap("root")
|
|
76
76
|
;(root.get(id("title")) as Y.Text).insert(0, "Hello from B")
|
|
@@ -105,7 +105,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
105
105
|
const docs = [100, 200, 300].map(cid => {
|
|
106
106
|
const doc = new Y.Doc()
|
|
107
107
|
doc.clientID = cid
|
|
108
|
-
ensureContainers(doc, TestSchema,
|
|
108
|
+
ensureContainers(doc, TestSchema, binding)
|
|
109
109
|
doc.transact(() => {
|
|
110
110
|
const root = doc.getMap("root")
|
|
111
111
|
;(root.get(id("title")) as Y.Text).insert(0, `Peer${cid}`)
|
|
@@ -145,7 +145,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
145
145
|
// Create and write
|
|
146
146
|
const doc1 = new Y.Doc()
|
|
147
147
|
doc1.clientID = 42
|
|
148
|
-
ensureContainers(doc1, TestSchema,
|
|
148
|
+
ensureContainers(doc1, TestSchema, binding)
|
|
149
149
|
doc1.transact(() => {
|
|
150
150
|
const root = doc1.getMap("root")
|
|
151
151
|
;(root.get(id("title")) as Y.Text).insert(0, "Persistent")
|
|
@@ -159,7 +159,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
159
159
|
const doc2 = new Y.Doc()
|
|
160
160
|
doc2.clientID = 42
|
|
161
161
|
Y.applyUpdate(doc2, snapshot)
|
|
162
|
-
ensureContainers(doc2, TestSchema,
|
|
162
|
+
ensureContainers(doc2, TestSchema, binding)
|
|
163
163
|
|
|
164
164
|
// Data preserved
|
|
165
165
|
const root2 = doc2.getMap("root")
|
|
@@ -190,10 +190,10 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
190
190
|
const bindingB = trivialBinding(schemaB)
|
|
191
191
|
|
|
192
192
|
const docA = new Y.Doc()
|
|
193
|
-
ensureContainers(docA, schemaA,
|
|
193
|
+
ensureContainers(docA, schemaA, bindingA)
|
|
194
194
|
|
|
195
195
|
const docB = new Y.Doc()
|
|
196
|
-
ensureContainers(docB, schemaB,
|
|
196
|
+
ensureContainers(docB, schemaB, bindingB)
|
|
197
197
|
|
|
198
198
|
// Both should produce byte-identical structural state
|
|
199
199
|
const stateA = Y.encodeStateAsUpdate(docA)
|
|
@@ -221,7 +221,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
221
221
|
// Peer A: create v1, write data, export
|
|
222
222
|
const docA = new Y.Doc()
|
|
223
223
|
docA.clientID = 100
|
|
224
|
-
ensureContainers(docA, v1Schema,
|
|
224
|
+
ensureContainers(docA, v1Schema, v1Binding)
|
|
225
225
|
docA.transact(() => {
|
|
226
226
|
const root = docA.getMap("root")
|
|
227
227
|
;(root.get(id("title")) as Y.Text).insert(0, "Title")
|
|
@@ -229,17 +229,17 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
229
229
|
})
|
|
230
230
|
const v1State = Y.encodeStateAsUpdate(docA)
|
|
231
231
|
|
|
232
|
-
// Peer B: independently create v2, hydrate v1 data,
|
|
232
|
+
// Peer B: independently create v2, hydrate v1 data, ensure containers
|
|
233
233
|
const docB = new Y.Doc()
|
|
234
234
|
docB.clientID = 200
|
|
235
235
|
Y.applyUpdate(docB, v1State)
|
|
236
|
-
ensureContainers(docB, v2Schema,
|
|
236
|
+
ensureContainers(docB, v2Schema, v2Binding)
|
|
237
237
|
|
|
238
238
|
// Another peer C: same thing independently
|
|
239
239
|
const docC = new Y.Doc()
|
|
240
240
|
docC.clientID = 300
|
|
241
241
|
Y.applyUpdate(docC, v1State)
|
|
242
|
-
ensureContainers(docC, v2Schema,
|
|
242
|
+
ensureContainers(docC, v2Schema, v2Binding)
|
|
243
243
|
|
|
244
244
|
// B and C's structural ops for "notes" should be identical (both at clientID 0)
|
|
245
245
|
const stateB = Y.encodeStateAsUpdate(docB)
|
|
@@ -270,7 +270,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
270
270
|
|
|
271
271
|
const doc = new Y.Doc()
|
|
272
272
|
doc.clientID = 999
|
|
273
|
-
ensureContainers(doc, TestSchema,
|
|
273
|
+
ensureContainers(doc, TestSchema, binding)
|
|
274
274
|
|
|
275
275
|
// clientID should be restored
|
|
276
276
|
expect(doc.clientID).toBe(999)
|
|
@@ -285,7 +285,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
285
285
|
|
|
286
286
|
const doc = new Y.Doc()
|
|
287
287
|
doc.clientID = 777
|
|
288
|
-
ensureContainers(doc, TestSchema,
|
|
288
|
+
ensureContainers(doc, TestSchema, binding)
|
|
289
289
|
|
|
290
290
|
// The state vector should NOT contain the caller's clientID —
|
|
291
291
|
// only STRUCTURAL_YJS_CLIENT_ID (0) should have produced ops.
|
|
@@ -383,8 +383,8 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
383
383
|
// Bidirectional merge — should not throw
|
|
384
384
|
const payloadA = subA.exportEntirety()
|
|
385
385
|
const payloadB = subB.exportEntirety()
|
|
386
|
-
subA.merge(payloadB, "sync")
|
|
387
|
-
subB.merge(payloadA, "sync")
|
|
386
|
+
subA.merge(payloadB, { origin: "sync" })
|
|
387
|
+
subB.merge(payloadA, { origin: "sync" })
|
|
388
388
|
|
|
389
389
|
// Both converge
|
|
390
390
|
const rootA = docA.getMap("root")
|
|
@@ -399,19 +399,19 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
399
399
|
expect(rootA.get(id("count"))).toBe(rootB.get(id("count")))
|
|
400
400
|
})
|
|
401
401
|
|
|
402
|
-
// ──
|
|
402
|
+
// ── ensureContainers is idempotent ──
|
|
403
403
|
|
|
404
|
-
it("
|
|
404
|
+
it("ensureContainers after hydration preserves existing data", () => {
|
|
405
405
|
const binding = trivialBinding(TestSchema)
|
|
406
406
|
|
|
407
407
|
const doc = new Y.Doc()
|
|
408
408
|
doc.clientID = 50
|
|
409
|
-
ensureContainers(doc, TestSchema,
|
|
409
|
+
ensureContainers(doc, TestSchema, binding)
|
|
410
410
|
|
|
411
411
|
const stateBefore = Y.encodeStateAsUpdate(doc)
|
|
412
412
|
|
|
413
|
-
//
|
|
414
|
-
ensureContainers(doc, TestSchema,
|
|
413
|
+
// Repeated call should not create new ops (everything already exists)
|
|
414
|
+
ensureContainers(doc, TestSchema, binding)
|
|
415
415
|
|
|
416
416
|
const stateAfter = Y.encodeStateAsUpdate(doc)
|
|
417
417
|
expect(stateAfter).toEqual(stateBefore)
|
|
@@ -607,4 +607,49 @@ describe("YjsSubstrate", () => {
|
|
|
607
607
|
expect(parsed.compare(v)).toBe("equal")
|
|
608
608
|
})
|
|
609
609
|
})
|
|
610
|
+
|
|
611
|
+
// -------------------------------------------------------------------------
|
|
612
|
+
// Re-entrant write during merge replay
|
|
613
|
+
// -------------------------------------------------------------------------
|
|
614
|
+
//
|
|
615
|
+
// A subscriber that calls `change(doc, ...)` while delivering a sync
|
|
616
|
+
// merge must reach Yjs — otherwise the substrate stalls and the
|
|
617
|
+
// subscriber loops on stale state until the lease budget trips.
|
|
618
|
+
// Context: jj:qpultxsw.
|
|
619
|
+
|
|
620
|
+
describe("re-entrant write during merge replay", () => {
|
|
621
|
+
it("subscriber's local change() inside a merge-replay batch lands in Yjs", () => {
|
|
622
|
+
const docA = createDoc(yjs.bind(SimpleSchema))
|
|
623
|
+
const docB = createDoc(yjs.bind(SimpleSchema))
|
|
624
|
+
|
|
625
|
+
change(docA, (d: any) => {
|
|
626
|
+
d.title.insert(0, "seed")
|
|
627
|
+
})
|
|
628
|
+
merge(docB, exportEntirety(docA), { origin: "sync" })
|
|
629
|
+
|
|
630
|
+
// On the first replay-driven update, the subscriber writes once
|
|
631
|
+
// to an unrelated field. The write must hit Yjs; the guard
|
|
632
|
+
// ensures we don't re-enter on subsequent flushes.
|
|
633
|
+
let writes = 0
|
|
634
|
+
subscribe(docB.title, () => {
|
|
635
|
+
if (writes === 0 && (docB.title() as string) === "seedmore") {
|
|
636
|
+
writes++
|
|
637
|
+
change(docB, (d: any) => {
|
|
638
|
+
d.count.set(42)
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
const v0 = version(docB)
|
|
644
|
+
change(docA, (d: any) => {
|
|
645
|
+
d.title.insert((d.title() as string).length, "more")
|
|
646
|
+
})
|
|
647
|
+
const delta = exportSince(docA, v0)!
|
|
648
|
+
merge(docB, delta, { origin: "sync" })
|
|
649
|
+
|
|
650
|
+
expect(docB.title()).toBe("seedmore")
|
|
651
|
+
expect(docB.count()).toBe(42)
|
|
652
|
+
expect(writes).toBe(1)
|
|
653
|
+
})
|
|
654
|
+
})
|
|
610
655
|
})
|
package/src/bind-yjs.ts
CHANGED
|
@@ -105,17 +105,15 @@ function createYjsFactory(
|
|
|
105
105
|
// Set stable identity AFTER hydration — avoids Yjs clientID
|
|
106
106
|
// conflict detection that would reassign to a random value.
|
|
107
107
|
doc.clientID = numericClientId
|
|
108
|
-
|
|
109
|
-
// from hydrated state (each set() is a CRDT write).
|
|
110
|
-
ensureContainers(doc, schema, true, binding)
|
|
108
|
+
ensureContainers(doc, schema, binding)
|
|
111
109
|
return createYjsSubstrate(doc, schema, binding)
|
|
112
110
|
},
|
|
113
111
|
|
|
114
112
|
create(schema: SchemaNode): Substrate<YjsVersion> {
|
|
115
|
-
// Fresh doc — set identity immediately
|
|
113
|
+
// Fresh doc — set identity immediately.
|
|
116
114
|
const doc = new Y.Doc()
|
|
117
115
|
doc.clientID = numericClientId
|
|
118
|
-
ensureContainers(doc, schema,
|
|
116
|
+
ensureContainers(doc, schema, binding)
|
|
119
117
|
return createYjsSubstrate(doc, schema, binding)
|
|
120
118
|
},
|
|
121
119
|
|