@kyneta/yjs-schema 1.6.1 → 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 +135 -178
- package/dist/index.js.map +1 -1
- package/package.json +6 -8
- package/src/__tests__/materialize.test.ts +227 -0
- package/src/__tests__/position.test.ts +7 -7
- package/src/__tests__/structural-merge.test.ts +18 -18
- package/src/__tests__/substrate.test.ts +1 -3
- 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 +27 -14
- 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
|
|
|
@@ -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.
|
|
@@ -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)
|
|
@@ -618,7 +618,7 @@ describe("YjsSubstrate", () => {
|
|
|
618
618
|
// Context: jj:qpultxsw.
|
|
619
619
|
|
|
620
620
|
describe("re-entrant write during merge replay", () => {
|
|
621
|
-
it("subscriber's local change() inside a merge-replay batch lands in Yjs",
|
|
621
|
+
it("subscriber's local change() inside a merge-replay batch lands in Yjs", () => {
|
|
622
622
|
const docA = createDoc(yjs.bind(SimpleSchema))
|
|
623
623
|
const docB = createDoc(yjs.bind(SimpleSchema))
|
|
624
624
|
|
|
@@ -647,8 +647,6 @@ describe("YjsSubstrate", () => {
|
|
|
647
647
|
const delta = exportSince(docA, v0)!
|
|
648
648
|
merge(docB, delta, { origin: "sync" })
|
|
649
649
|
|
|
650
|
-
await new Promise<void>(r => queueMicrotask(r))
|
|
651
|
-
|
|
652
650
|
expect(docB.title()).toBe("seedmore")
|
|
653
651
|
expect(docB.count()).toBe(42)
|
|
654
652
|
expect(writes).toBe(1)
|
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
|
|
package/src/change-mapping.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
MapChange,
|
|
23
23
|
Op,
|
|
24
24
|
Path,
|
|
25
|
+
ProductSchema,
|
|
25
26
|
ReplaceChange,
|
|
26
27
|
RichTextChange,
|
|
27
28
|
RichTextInstruction,
|
|
@@ -33,9 +34,9 @@ import type {
|
|
|
33
34
|
TextInstruction,
|
|
34
35
|
} from "@kyneta/schema"
|
|
35
36
|
import {
|
|
36
|
-
advanceSchema,
|
|
37
37
|
expandMapOpsToLeaves,
|
|
38
38
|
KIND,
|
|
39
|
+
pathSchema,
|
|
39
40
|
RawPath,
|
|
40
41
|
richTextChange,
|
|
41
42
|
} from "@kyneta/schema"
|
|
@@ -118,6 +119,17 @@ export function applyChangeToYjs(
|
|
|
118
119
|
`Attempted TreeChange at path [${pathToString(path)}].`,
|
|
119
120
|
)
|
|
120
121
|
|
|
122
|
+
case "set-op":
|
|
123
|
+
// Sets (`Schema.set`) are rejected by `yjs.bind` at compile time
|
|
124
|
+
// (`"add-wins-per-key"` is not in `YjsLaws`). Unreachable from any
|
|
125
|
+
// bound Yjs substrate today; kept against the new `SetChange`
|
|
126
|
+
// vocabulary so future law-set expansion has a clear extension point.
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Yjs substrate does not support "${change.type}" changes. ` +
|
|
129
|
+
`Schema.set requires "add-wins-per-key" which is not in YjsLaws. ` +
|
|
130
|
+
`Attempted SetChange at path [${pathToString(path)}].`,
|
|
131
|
+
)
|
|
132
|
+
|
|
121
133
|
default:
|
|
122
134
|
throw new Error(
|
|
123
135
|
`applyChangeToYjs: unsupported change type "${change.type}"`,
|
|
@@ -201,7 +213,7 @@ function applySequenceChange(
|
|
|
201
213
|
}
|
|
202
214
|
|
|
203
215
|
// Resolve the item schema for structured insert detection
|
|
204
|
-
const targetSchema =
|
|
216
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
205
217
|
const itemSchema = getItemSchema(targetSchema)
|
|
206
218
|
|
|
207
219
|
let cursor = 0
|
|
@@ -241,7 +253,7 @@ function applyMapChange(
|
|
|
241
253
|
}
|
|
242
254
|
|
|
243
255
|
// Resolve the schema at this path for structured value detection
|
|
244
|
-
const targetSchema =
|
|
256
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
245
257
|
|
|
246
258
|
// Apply deletes first
|
|
247
259
|
if (change.delete) {
|
|
@@ -259,9 +271,10 @@ function applyMapChange(
|
|
|
259
271
|
// For map schemas (records), use the key as-is (no identity-keying).
|
|
260
272
|
let mapKey = key
|
|
261
273
|
if (binding && targetSchema[KIND] === "product") {
|
|
262
|
-
// Compute absolute schema path for this field
|
|
274
|
+
// Compute absolute schema path for this field — only product-field
|
|
275
|
+
// segments contribute (entry segments are runtime keys).
|
|
263
276
|
const parentAbsPath = path.segments
|
|
264
|
-
.filter(s => s.role === "
|
|
277
|
+
.filter(s => s.role === "field")
|
|
265
278
|
.map(s => s.resolve() as string)
|
|
266
279
|
.join(".")
|
|
267
280
|
const absPath = parentAbsPath ? `${parentAbsPath}.${key}` : key
|
|
@@ -303,15 +316,19 @@ function applyReplaceChange(
|
|
|
303
316
|
)
|
|
304
317
|
|
|
305
318
|
const resolved = lastSeg.resolve()
|
|
306
|
-
if (
|
|
319
|
+
if (
|
|
320
|
+
parent instanceof Y.Map &&
|
|
321
|
+
(lastSeg.role === "field" || lastSeg.role === "entry")
|
|
322
|
+
) {
|
|
307
323
|
// Resolve schema for the target field for structured value detection
|
|
308
|
-
const targetSchema =
|
|
324
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
309
325
|
const yjsValue = maybeCreateSharedType(change.value, targetSchema)
|
|
310
|
-
//
|
|
326
|
+
// Identity-keying applies only at product-field boundaries; entry
|
|
327
|
+
// segments use the runtime key as-is.
|
|
311
328
|
let mapKey = resolved as string
|
|
312
|
-
if (binding) {
|
|
329
|
+
if (binding && lastSeg.role === "field") {
|
|
313
330
|
const absPath = path.segments
|
|
314
|
-
.filter(s => s.role === "
|
|
331
|
+
.filter(s => s.role === "field")
|
|
315
332
|
.map(s => s.resolve() as string)
|
|
316
333
|
.join(".")
|
|
317
334
|
const identity = binding.forward.get(absPath) as string | undefined
|
|
@@ -319,7 +336,7 @@ function applyReplaceChange(
|
|
|
319
336
|
}
|
|
320
337
|
parent.set(mapKey, yjsValue)
|
|
321
338
|
} else if (parent instanceof Y.Array && lastSeg.role === "index") {
|
|
322
|
-
const targetSchema =
|
|
339
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
323
340
|
const yjsValue = maybeCreateSharedType(change.value, targetSchema)
|
|
324
341
|
parent.delete(resolved as number, 1)
|
|
325
342
|
parent.insert(resolved as number, [yjsValue])
|
|
@@ -509,7 +526,7 @@ export function eventsToOps(
|
|
|
509
526
|
const ops: Op[] = []
|
|
510
527
|
|
|
511
528
|
for (const event of events) {
|
|
512
|
-
const kynetaPath = yjsPathToKynetaPath(event.path, binding)
|
|
529
|
+
const kynetaPath = yjsPathToKynetaPath(event.path, schema, binding)
|
|
513
530
|
const change = eventToChange(event, schema, kynetaPath, binding)
|
|
514
531
|
if (change) {
|
|
515
532
|
ops.push({ path: kynetaPath, change })
|
|
@@ -524,31 +541,55 @@ export function eventsToOps(
|
|
|
524
541
|
// ---------------------------------------------------------------------------
|
|
525
542
|
|
|
526
543
|
/**
|
|
527
|
-
* Convert a Yjs event path
|
|
544
|
+
* Convert a Yjs event path to a kyneta `RawPath`, walking the schema
|
|
545
|
+
* alongside so each segment is classified as field / entry / index by
|
|
546
|
+
* the current schema kind.
|
|
528
547
|
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
548
|
+
* Why schema-aware and not "did the inverse lookup hit?": the binding's
|
|
549
|
+
* inverse map only covers declared product-field positions reachable
|
|
550
|
+
* without crossing a runtime-keyed container. A declared struct field
|
|
551
|
+
* nested under a `record(...)` value type is reachable via Yjs but
|
|
552
|
+
* absent from `binding.inverse` — without the schema walk it would be
|
|
553
|
+
* misclassified as an entry and then rejected by `advanceSchema`.
|
|
531
554
|
*/
|
|
532
555
|
function yjsPathToKynetaPath(
|
|
533
556
|
yjsPath: (string | number)[],
|
|
557
|
+
rootSchema: SchemaNode,
|
|
534
558
|
binding?: SchemaBinding,
|
|
535
559
|
): RawPath {
|
|
536
560
|
let path = RawPath.empty
|
|
561
|
+
let schema: SchemaNode | undefined = rootSchema
|
|
537
562
|
for (const segment of yjsPath) {
|
|
538
563
|
if (typeof segment === "string") {
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
564
|
+
// Inverse-lookup recovers the original declared field name when
|
|
565
|
+
// the segment IS an identity hash; otherwise we keep the string.
|
|
566
|
+
let leaf = segment
|
|
542
567
|
const absPath = binding?.inverse.get(segment as any)
|
|
543
568
|
if (absPath) {
|
|
544
569
|
const lastDot = absPath.lastIndexOf(".")
|
|
545
|
-
|
|
570
|
+
leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath
|
|
571
|
+
}
|
|
572
|
+
const kind = schema?.[KIND]
|
|
573
|
+
if (kind === "product") {
|
|
546
574
|
path = path.field(leaf)
|
|
575
|
+
schema = (schema as ProductSchema | undefined)?.fields[leaf]
|
|
576
|
+
} else if (kind === "map" || kind === "set" || kind === "tree") {
|
|
577
|
+
path = path.entry(leaf)
|
|
578
|
+
schema = (schema as any)?.item
|
|
547
579
|
} else {
|
|
548
|
-
|
|
580
|
+
// Unknown / sum / unrecognized — fall back to entry. Subsequent
|
|
581
|
+
// segments are likely walking plain JSON inside a sum variant.
|
|
582
|
+
path = path.entry(leaf)
|
|
583
|
+
schema = undefined
|
|
549
584
|
}
|
|
550
585
|
} else if (typeof segment === "number") {
|
|
551
586
|
path = path.item(segment)
|
|
587
|
+
const kind = schema?.[KIND]
|
|
588
|
+
if (kind === "sequence" || kind === "movable") {
|
|
589
|
+
schema = (schema as any).item
|
|
590
|
+
} else {
|
|
591
|
+
schema = undefined
|
|
592
|
+
}
|
|
552
593
|
}
|
|
553
594
|
}
|
|
554
595
|
return path
|
|
@@ -576,7 +617,7 @@ function eventToChange(
|
|
|
576
617
|
): ChangeBase | null {
|
|
577
618
|
if (event.target instanceof Y.Text) {
|
|
578
619
|
// Both text and richtext use Y.Text — resolve the schema to dispatch.
|
|
579
|
-
const schemaAtPath =
|
|
620
|
+
const schemaAtPath = pathSchema(rootSchema, kynetaPath)
|
|
580
621
|
if (schemaAtPath[KIND] === "richtext") {
|
|
581
622
|
return richTextEventToChange(event)
|
|
582
623
|
}
|
|
@@ -739,20 +780,6 @@ function extractEventValue(value: unknown): unknown {
|
|
|
739
780
|
// Schema helpers
|
|
740
781
|
// ---------------------------------------------------------------------------
|
|
741
782
|
|
|
742
|
-
/**
|
|
743
|
-
* Resolve the schema at a given path by walking through advanceSchema.
|
|
744
|
-
*/
|
|
745
|
-
function resolveSchemaAtPath(rootSchema: SchemaNode, path: Path): SchemaNode {
|
|
746
|
-
let schema = rootSchema
|
|
747
|
-
for (const seg of path.segments) {
|
|
748
|
-
schema = advanceSchema(schema, seg)
|
|
749
|
-
// Sum variants are always PlainSchema — cannot advance further.
|
|
750
|
-
// Return early; remaining segments address plain JSON values.
|
|
751
|
-
if (schema[KIND] === "sum") return schema
|
|
752
|
-
}
|
|
753
|
-
return schema
|
|
754
|
-
}
|
|
755
|
-
|
|
756
783
|
/**
|
|
757
784
|
* Get the item schema from a sequence schema, if available.
|
|
758
785
|
*/
|
package/src/index.ts
CHANGED
|
@@ -50,8 +50,7 @@ export type { YjsNativeMap } from "./native-map.js"
|
|
|
50
50
|
export { ensureContainers } from "./populate.js"
|
|
51
51
|
// Position conformance
|
|
52
52
|
export { fromYjsAssoc, toYjsAssoc, YjsPosition } from "./position.js"
|
|
53
|
-
|
|
54
|
-
export { yjsReader } from "./reader.js"
|
|
53
|
+
|
|
55
54
|
// Substrate
|
|
56
55
|
export {
|
|
57
56
|
createYjsSubstrate,
|