@kyneta/yjs-schema 1.6.1 → 1.8.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.
@@ -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)
@@ -8,6 +8,7 @@ import {
8
8
  RawPath,
9
9
  Schema,
10
10
  subscribe,
11
+ unwrap,
11
12
  version,
12
13
  } from "@kyneta/schema"
13
14
  import { describe, expect, it } from "vitest"
@@ -618,7 +619,7 @@ describe("YjsSubstrate", () => {
618
619
  // Context: jj:qpultxsw.
619
620
 
620
621
  describe("re-entrant write during merge replay", () => {
621
- it("subscriber's local change() inside a merge-replay batch lands in Yjs", async () => {
622
+ it("subscriber's local change() inside a merge-replay batch lands in Yjs", () => {
622
623
  const docA = createDoc(yjs.bind(SimpleSchema))
623
624
  const docB = createDoc(yjs.bind(SimpleSchema))
624
625
 
@@ -647,11 +648,63 @@ describe("YjsSubstrate", () => {
647
648
  const delta = exportSince(docA, v0)!
648
649
  merge(docB, delta, { origin: "sync" })
649
650
 
650
- await new Promise<void>(r => queueMicrotask(r))
651
-
652
651
  expect(docB.title()).toBe("seedmore")
653
652
  expect(docB.count()).toBe(42)
654
653
  expect(writes).toBe(1)
655
654
  })
656
655
  })
656
+
657
+ // -------------------------------------------------------------------------
658
+ // Origin-free discriminator tests
659
+ // -------------------------------------------------------------------------
660
+
661
+ describe("origin-free discriminator", () => {
662
+ it("options.origin survives to transaction.origin", () => {
663
+ const doc = createDoc(yjs.bind(SimpleSchema))
664
+ const native = unwrap(doc) as Y.Doc
665
+
666
+ let capturedOrigin: string | undefined = "not-called"
667
+ native.on("afterTransaction", tr => {
668
+ capturedOrigin = tr.origin
669
+ })
670
+
671
+ change(doc, d => d.title.insert(0, "x"), { origin: "undo" })
672
+ expect(capturedOrigin).toBe("undo")
673
+ })
674
+
675
+ it("external wrapping kyneta is correctly classified as own", () => {
676
+ const doc = createDoc(yjs.bind(SimpleSchema))
677
+
678
+ let kynetaFires = 0
679
+ subscribe(doc, () => {
680
+ kynetaFires++
681
+ })
682
+
683
+ const native = unwrap(doc) as Y.Doc
684
+ native.transact(() => {
685
+ change(doc, d => d.title.insert(0, "x"))
686
+ }, "external")
687
+
688
+ // Should fire exactly once (captured via wrappedPrepare),
689
+ // and NOT twice (the bridge should skip the external transaction
690
+ // because the inner kyneta transact marked the transaction).
691
+ expect(kynetaFires).toBe(1)
692
+ })
693
+
694
+ it("external raw transact with any string origin is bridged", () => {
695
+ const doc = createDoc(yjs.bind(SimpleSchema))
696
+
697
+ let kynetaFires = 0
698
+ subscribe(doc, () => {
699
+ kynetaFires++
700
+ })
701
+
702
+ const native = unwrap(doc) as Y.Doc
703
+ native.transact(() => {
704
+ native.getMap("root").set("title", "ext")
705
+ }, "kyneta-prepare")
706
+
707
+ expect(kynetaFires).toBe(1)
708
+ })
709
+ })
657
710
  })
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,11 @@ import type {
33
34
  TextInstruction,
34
35
  } from "@kyneta/schema"
35
36
  import {
36
- advanceSchema,
37
37
  expandMapOpsToLeaves,
38
+ isJsonBoundary,
39
+ isPlainObject,
38
40
  KIND,
41
+ pathSchema,
39
42
  RawPath,
40
43
  richTextChange,
41
44
  } from "@kyneta/schema"
@@ -118,6 +121,17 @@ export function applyChangeToYjs(
118
121
  `Attempted TreeChange at path [${pathToString(path)}].`,
119
122
  )
120
123
 
124
+ case "set-op":
125
+ // Sets (`Schema.set`) are rejected by `yjs.bind` at compile time
126
+ // (`"add-wins-per-key"` is not in `YjsLaws`). Unreachable from any
127
+ // bound Yjs substrate today; kept against the new `SetChange`
128
+ // vocabulary so future law-set expansion has a clear extension point.
129
+ throw new Error(
130
+ `Yjs substrate does not support "${change.type}" changes. ` +
131
+ `Schema.set requires "add-wins-per-key" which is not in YjsLaws. ` +
132
+ `Attempted SetChange at path [${pathToString(path)}].`,
133
+ )
134
+
121
135
  default:
122
136
  throw new Error(
123
137
  `applyChangeToYjs: unsupported change type "${change.type}"`,
@@ -201,7 +215,7 @@ function applySequenceChange(
201
215
  }
202
216
 
203
217
  // Resolve the item schema for structured insert detection
204
- const targetSchema = resolveSchemaAtPath(rootSchema, path)
218
+ const targetSchema = pathSchema(rootSchema, path)
205
219
  const itemSchema = getItemSchema(targetSchema)
206
220
 
207
221
  let cursor = 0
@@ -241,7 +255,7 @@ function applyMapChange(
241
255
  }
242
256
 
243
257
  // Resolve the schema at this path for structured value detection
244
- const targetSchema = resolveSchemaAtPath(rootSchema, path)
258
+ const targetSchema = pathSchema(rootSchema, path)
245
259
 
246
260
  // Apply deletes first
247
261
  if (change.delete) {
@@ -259,9 +273,10 @@ function applyMapChange(
259
273
  // For map schemas (records), use the key as-is (no identity-keying).
260
274
  let mapKey = key
261
275
  if (binding && targetSchema[KIND] === "product") {
262
- // Compute absolute schema path for this field.
276
+ // Compute absolute schema path for this field — only product-field
277
+ // segments contribute (entry segments are runtime keys).
263
278
  const parentAbsPath = path.segments
264
- .filter(s => s.role === "key")
279
+ .filter(s => s.role === "field")
265
280
  .map(s => s.resolve() as string)
266
281
  .join(".")
267
282
  const absPath = parentAbsPath ? `${parentAbsPath}.${key}` : key
@@ -286,7 +301,7 @@ function applyReplaceChange(
286
301
  ): void {
287
302
  if (path.length === 0) {
288
303
  throw new Error(
289
- "applyChangeToYjs: ReplaceChange at root path is not supported",
304
+ "Cannot replace the root document struct in a CRDT backend. The root identity is fixed. Please mutate its properties individually (e.g., `doc.myField.set(value)` instead of `doc.set({ myField: value })`).",
290
305
  )
291
306
  }
292
307
 
@@ -303,15 +318,19 @@ function applyReplaceChange(
303
318
  )
304
319
 
305
320
  const resolved = lastSeg.resolve()
306
- if (parent instanceof Y.Map && lastSeg.role === "key") {
321
+ if (
322
+ parent instanceof Y.Map &&
323
+ (lastSeg.role === "field" || lastSeg.role === "entry")
324
+ ) {
307
325
  // Resolve schema for the target field for structured value detection
308
- const targetSchema = resolveSchemaAtPath(rootSchema, path)
326
+ const targetSchema = pathSchema(rootSchema, path)
309
327
  const yjsValue = maybeCreateSharedType(change.value, targetSchema)
310
- // Use identity hash for product-field boundaries.
328
+ // Identity-keying applies only at product-field boundaries; entry
329
+ // segments use the runtime key as-is.
311
330
  let mapKey = resolved as string
312
- if (binding) {
331
+ if (binding && lastSeg.role === "field") {
313
332
  const absPath = path.segments
314
- .filter(s => s.role === "key")
333
+ .filter(s => s.role === "field")
315
334
  .map(s => s.resolve() as string)
316
335
  .join(".")
317
336
  const identity = binding.forward.get(absPath) as string | undefined
@@ -319,7 +338,7 @@ function applyReplaceChange(
319
338
  }
320
339
  parent.set(mapKey, yjsValue)
321
340
  } else if (parent instanceof Y.Array && lastSeg.role === "index") {
322
- const targetSchema = resolveSchemaAtPath(rootSchema, path)
341
+ const targetSchema = pathSchema(rootSchema, path)
323
342
  const yjsValue = maybeCreateSharedType(change.value, targetSchema)
324
343
  parent.delete(resolved as number, 1)
325
344
  parent.insert(resolved as number, [yjsValue])
@@ -349,6 +368,12 @@ function maybeCreateSharedType(
349
368
  ): unknown {
350
369
  if (schema === undefined) return value
351
370
 
371
+ // JSON-boundary schemas (struct.json/list.json/record.json) store
372
+ // their entire subtree as a plain JSON value in the parent Y.Map
373
+ // entry. Skip Y.Map/Y.Array materialisation and pass the value
374
+ // through unchanged — including nested objects/arrays underneath.
375
+ if (isJsonBoundary(schema)) return value
376
+
352
377
  switch (schema[KIND]) {
353
378
  // First-class text → Y.Text
354
379
  case "text": {
@@ -383,12 +408,7 @@ function maybeCreateSharedType(
383
408
  }
384
409
 
385
410
  case "product": {
386
- if (
387
- value === null ||
388
- value === undefined ||
389
- typeof value !== "object" ||
390
- Array.isArray(value)
391
- ) {
411
+ if (!isPlainObject(value)) {
392
412
  return value
393
413
  }
394
414
  return createStructuredMap(value as Record<string, unknown>, schema)
@@ -406,12 +426,7 @@ function maybeCreateSharedType(
406
426
  }
407
427
 
408
428
  case "map": {
409
- if (
410
- value === null ||
411
- value === undefined ||
412
- typeof value !== "object" ||
413
- Array.isArray(value)
414
- ) {
429
+ if (!isPlainObject(value)) {
415
430
  return value
416
431
  }
417
432
  const map = new Y.Map()
@@ -509,7 +524,7 @@ export function eventsToOps(
509
524
  const ops: Op[] = []
510
525
 
511
526
  for (const event of events) {
512
- const kynetaPath = yjsPathToKynetaPath(event.path, binding)
527
+ const kynetaPath = yjsPathToKynetaPath(event.path, schema, binding)
513
528
  const change = eventToChange(event, schema, kynetaPath, binding)
514
529
  if (change) {
515
530
  ops.push({ path: kynetaPath, change })
@@ -524,31 +539,55 @@ export function eventsToOps(
524
539
  // ---------------------------------------------------------------------------
525
540
 
526
541
  /**
527
- * Convert a Yjs event path (array of string | number) to a kyneta Path.
542
+ * Convert a Yjs event path to a kyneta `RawPath`, walking the schema
543
+ * alongside so each segment is classified as field / entry / index by
544
+ * the current schema kind.
528
545
  *
529
- * `event.path` from `observeDeep` is relative to the observed type.
530
- * Strings become key segments, numbers become index segments.
546
+ * Why schema-aware and not "did the inverse lookup hit?": the binding's
547
+ * inverse map only covers declared product-field positions reachable
548
+ * without crossing a runtime-keyed container. A declared struct field
549
+ * nested under a `record(...)` value type is reachable via Yjs but
550
+ * absent from `binding.inverse` — without the schema walk it would be
551
+ * misclassified as an entry and then rejected by `advanceSchema`.
531
552
  */
532
553
  function yjsPathToKynetaPath(
533
554
  yjsPath: (string | number)[],
555
+ rootSchema: SchemaNode,
534
556
  binding?: SchemaBinding,
535
557
  ): RawPath {
536
558
  let path = RawPath.empty
559
+ let schema: SchemaNode | undefined = rootSchema
537
560
  for (const segment of yjsPath) {
538
561
  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.
562
+ // Inverse-lookup recovers the original declared field name when
563
+ // the segment IS an identity hash; otherwise we keep the string.
564
+ let leaf = segment
542
565
  const absPath = binding?.inverse.get(segment as any)
543
566
  if (absPath) {
544
567
  const lastDot = absPath.lastIndexOf(".")
545
- const leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath
568
+ leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath
569
+ }
570
+ const kind = schema?.[KIND]
571
+ if (kind === "product") {
546
572
  path = path.field(leaf)
573
+ schema = (schema as ProductSchema | undefined)?.fields[leaf]
574
+ } else if (kind === "map" || kind === "set" || kind === "tree") {
575
+ path = path.entry(leaf)
576
+ schema = (schema as any)?.item
547
577
  } else {
548
- path = path.field(segment)
578
+ // Unknown / sum / unrecognized — fall back to entry. Subsequent
579
+ // segments are likely walking plain JSON inside a sum variant.
580
+ path = path.entry(leaf)
581
+ schema = undefined
549
582
  }
550
583
  } else if (typeof segment === "number") {
551
584
  path = path.item(segment)
585
+ const kind = schema?.[KIND]
586
+ if (kind === "sequence" || kind === "movable") {
587
+ schema = (schema as any).item
588
+ } else {
589
+ schema = undefined
590
+ }
552
591
  }
553
592
  }
554
593
  return path
@@ -576,7 +615,7 @@ function eventToChange(
576
615
  ): ChangeBase | null {
577
616
  if (event.target instanceof Y.Text) {
578
617
  // Both text and richtext use Y.Text — resolve the schema to dispatch.
579
- const schemaAtPath = resolveSchemaAtPath(rootSchema, kynetaPath)
618
+ const schemaAtPath = pathSchema(rootSchema, kynetaPath)
580
619
  if (schemaAtPath[KIND] === "richtext") {
581
620
  return richTextEventToChange(event)
582
621
  }
@@ -739,20 +778,6 @@ function extractEventValue(value: unknown): unknown {
739
778
  // Schema helpers
740
779
  // ---------------------------------------------------------------------------
741
780
 
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
781
  /**
757
782
  * Get the item schema from a sequence schema, if available.
758
783
  */
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,
@@ -0,0 +1,109 @@
1
+ // materialize — Yjs→PlainState materialization via generic resolver.
2
+ //
3
+ // Implements `createYjsResolver`, a closure-based `MaterializeResolver`
4
+ // that navigates the Yjs shared type tree via `resolveYjsType`. The
5
+ // generic `createMaterializeInterpreter` drives the catamorphism; the
6
+ // resolver handles only the CRDT-specific value extraction.
7
+ //
8
+ // Unsupported types (counter, tree, movable) return `undefined` from
9
+ // the resolver, triggering the generic interpreter's zero fallback.
10
+ //
11
+ // Zero fallback for missing values is handled canonically by the
12
+ // generic interpreter — not inlined here.
13
+
14
+ import type {
15
+ MaterializeResolver,
16
+ Path,
17
+ PlainState,
18
+ RichTextDelta,
19
+ SchemaBinding,
20
+ Schema as SchemaNode,
21
+ } from "@kyneta/schema"
22
+ import {
23
+ createMaterializeInterpreter,
24
+ interpret,
25
+ isNonNullObject,
26
+ materializeContextFromResolver,
27
+ } from "@kyneta/schema"
28
+ import * as Y from "yjs"
29
+ import { extractValue, yTextToRichTextDelta } from "./yjs-extract.js"
30
+ import { resolveYjsType } from "./yjs-resolve.js"
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Yjs resolver
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function createYjsResolver(
37
+ rootMap: Y.Map<any>,
38
+ rootSchema: SchemaNode,
39
+ binding?: SchemaBinding,
40
+ ): MaterializeResolver {
41
+ return {
42
+ resolveValue(path: Path): unknown {
43
+ const result = resolveYjsType(rootMap, rootSchema, path, binding)
44
+ return extractValue(result.resolved)
45
+ },
46
+
47
+ resolveText(path: Path): string | undefined {
48
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
49
+ if (resolved instanceof Y.Text) {
50
+ return resolved.toJSON()
51
+ }
52
+ const value = extractValue(resolved)
53
+ return typeof value === "string" ? value : undefined
54
+ },
55
+
56
+ // Yjs does not support counters — schemas with counter types are
57
+ // rejected at bind time. Return undefined to trigger zero fallback.
58
+ resolveCounter(_path: Path): number | undefined {
59
+ return undefined
60
+ },
61
+
62
+ resolveRichText(path: Path): RichTextDelta | undefined {
63
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
64
+ if (resolved instanceof Y.Text) {
65
+ return yTextToRichTextDelta(resolved)
66
+ }
67
+ return undefined
68
+ },
69
+
70
+ resolveLength(path: Path): number {
71
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
72
+ if (resolved instanceof Y.Array) {
73
+ return resolved.length
74
+ }
75
+ return Array.isArray(resolved) ? resolved.length : 0
76
+ },
77
+
78
+ resolveKeys(path: Path): string[] {
79
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
80
+ if (resolved instanceof Y.Map) {
81
+ return Array.from(resolved.keys())
82
+ }
83
+ return isNonNullObject(resolved) ? Object.keys(resolved) : []
84
+ },
85
+
86
+ // Yjs has no tree primitive — schemas with `Schema.tree` are rejected
87
+ // at bind time. Defensive [] for any caller that reaches here.
88
+ resolveForest(_path: Path): readonly never[] {
89
+ return []
90
+ },
91
+ }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Public API
96
+ // ---------------------------------------------------------------------------
97
+
98
+ export function materializeYjsShadow(
99
+ doc: Y.Doc,
100
+ schema: SchemaNode,
101
+ binding?: SchemaBinding,
102
+ ): PlainState {
103
+ const rootMap = doc.getMap("root")
104
+ const resolver = createYjsResolver(rootMap, schema, binding)
105
+ const interp = createMaterializeInterpreter(resolver)
106
+ const ctx = materializeContextFromResolver(resolver)
107
+ const result = interpret(schema, interp, ctx)
108
+ return result as PlainState
109
+ }