@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.
@@ -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/src/__tests__/position-conformance.js"
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, true)
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, true)
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, true)
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, true)
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, true)
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, true)
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, false, binding)
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, false, binding)
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, false, binding)
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, false, binding)
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, true, binding) // conditional
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, false, bindingA)
193
+ ensureContainers(docA, schemaA, bindingA)
194
194
 
195
195
  const docB = new Y.Doc()
196
- ensureContainers(docB, schemaB, false, bindingB)
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, false, v1Binding)
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, conditional containers
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, true, v2Binding) // conditional — only creates "notes"
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, true, v2Binding)
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, false, binding)
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, false, binding)
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
- // ── Conditional ensureContainers is idempotent ──
402
+ // ── ensureContainers is idempotent ──
403
403
 
404
- it("conditional ensureContainers is idempotent on hydrated doc", () => {
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, false, binding)
409
+ ensureContainers(doc, TestSchema, binding)
410
410
 
411
411
  const stateBefore = Y.encodeStateAsUpdate(doc)
412
412
 
413
- // Conditional call should not create new ops (everything already exists)
414
- ensureContainers(doc, TestSchema, true, binding)
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", async () => {
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
- // Conditional ensureContainers: skip fields that already exist
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, unconditional containers.
113
+ // Fresh doc — set identity immediately.
116
114
  const doc = new Y.Doc()
117
115
  doc.clientID = numericClientId
118
- ensureContainers(doc, schema, false, binding)
116
+ ensureContainers(doc, schema, binding)
119
117
  return createYjsSubstrate(doc, schema, binding)
120
118
  },
121
119
 
@@ -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 = resolveSchemaAtPath(rootSchema, path)
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 = resolveSchemaAtPath(rootSchema, path)
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 === "key")
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 (parent instanceof Y.Map && lastSeg.role === "key") {
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 = resolveSchemaAtPath(rootSchema, path)
324
+ const targetSchema = pathSchema(rootSchema, path)
309
325
  const yjsValue = maybeCreateSharedType(change.value, targetSchema)
310
- // Use identity hash for product-field boundaries.
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 === "key")
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 = resolveSchemaAtPath(rootSchema, path)
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 (array of string | number) to a kyneta 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
- * `event.path` from `observeDeep` is relative to the observed type.
530
- * Strings become key segments, numbers become index segments.
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
- // Reverse-map identity hash absolute schema path → leaf field name.
540
- // Yjs events emit identity-keyed strings at product-field positions;
541
- // we need to recover the original field name for kyneta schema paths.
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
- const leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath
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
- path = path.field(segment)
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 = resolveSchemaAtPath(rootSchema, kynetaPath)
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
- // Reader
54
- export { yjsReader } from "./reader.js"
53
+
55
54
  // Substrate
56
55
  export {
57
56
  createYjsSubstrate,