@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,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
+ }
package/src/populate.ts CHANGED
@@ -1,14 +1,11 @@
1
1
  // populate — Yjs container creation from schema structure.
2
2
  //
3
3
  // Ensures that the correct Yjs shared types (Y.Text, Y.Array, Y.Map)
4
- // exist in a Y.Doc's root map to match the schema structure, and that
5
- // scalar/sum fields are initialized with Zero.structural defaults.
4
+ // exist in a Y.Doc's root map to match the schema structure.
6
5
  //
7
- // This is NOT seed data it's structural completeness, matching what
8
- // PlainSubstrate does when it initializes its store with Zero.structural.
9
- // The Yjs store reader expects to find values at every schema path;
10
- // without this, unset scalars would return undefined instead of their
11
- // type-correct zero ("", 0, false).
6
+ // Only container types (text, product, sequence, map) require CRDT
7
+ // writes here. Scalar and sum fields are handled by the materializer's
8
+ // zero fallback no Yjs writes are needed for non-container types.
12
9
  //
13
10
  // Root container strategy: All schema fields are children of a single
14
11
  // root `Y.Map` obtained via `doc.getMap("root")`. This root map holds
@@ -20,7 +17,7 @@
20
17
  // functions: ensureContainers, ensureRootField, ensureMapContainers.
21
18
 
22
19
  import type { SchemaBinding, Schema as SchemaNode } from "@kyneta/schema"
23
- import { KIND, STRUCTURAL_YJS_CLIENT_ID, Zero } from "@kyneta/schema"
20
+ import { KIND, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
24
21
  import * as Y from "yjs"
25
22
 
26
23
  // ---------------------------------------------------------------------------
@@ -35,14 +32,10 @@ import * as Y from "yjs"
35
32
  * schema's fields, and creates empty containers for each field within a
36
33
  * single `doc.transact()` call for atomicity.
37
34
  *
38
- * When `conditional` is true, fields that already exist in the root map
39
- * are skipped. This is the correct mode after hydration — containers
40
- * present from stored state must not be overwritten (each `rootMap.set()`
41
- * is a CRDT write that advances the version vector and may conflict
42
- * with stored operations).
43
- *
44
- * When `conditional` is false (default), all fields are created
45
- * unconditionally. This is the correct mode for fresh documents.
35
+ * Container fields (text, product, sequence, map) are created if absent;
36
+ * existing containers are preserved (calling `rootMap.set()` on a field
37
+ * that already exists would be a destructive CRDT write). Scalar and sum
38
+ * fields are no-ops the materializer handles zeros.
46
39
  *
47
40
  * **Structural identity:** This function temporarily sets `doc.clientID`
48
41
  * to `STRUCTURAL_YJS_CLIENT_ID` (0) for the duration of container creation,
@@ -56,14 +49,11 @@ import * as Y from "yjs"
56
49
  *
57
50
  * @param doc - The Y.Doc to prepare
58
51
  * @param schema - The root document schema (a ProductSchema)
59
- * @param conditional - If true, skip fields that already exist in the root map.
60
- * Context: jj:smmulzkm (two-phase substrate construction)
61
52
  * @param binding - Optional SchemaBinding for identity-keyed containers.
62
53
  */
63
54
  export function ensureContainers(
64
55
  doc: Y.Doc,
65
56
  schema: SchemaNode,
66
- conditional = false,
67
57
  binding?: SchemaBinding,
68
58
  ): void {
69
59
  const rootMap = doc.getMap("root")
@@ -84,7 +74,6 @@ export function ensureContainers(
84
74
  )) {
85
75
  const identity = binding?.forward.get(key) as string | undefined
86
76
  const mapKey = identity ?? key
87
- if (conditional && rootMap.has(mapKey)) continue
88
77
  ensureRootField(
89
78
  rootMap,
90
79
  mapKey,
@@ -112,7 +101,7 @@ export function ensureContainers(
112
101
  * - `"product"` → empty Y.Map (recursive for nested products)
113
102
  * - `"sequence"` → empty Y.Array
114
103
  * - `"map"` → empty Y.Map
115
- * - `"scalar"` / `"sum"` → Zero.structural default
104
+ * - `"scalar"` / `"sum"` → no-op (materializer zero fallback)
116
105
  * - `"counter"` / `"set"` / `"tree"` / `"movable"` → throw (not supported by Yjs)
117
106
  *
118
107
  * @param rootMap - The root Y.Map to set the field on.
@@ -128,6 +117,12 @@ function ensureRootField(
128
117
  binding?: SchemaBinding,
129
118
  prefix?: string,
130
119
  ): void {
120
+ // Skip fields that already exist — calling rootMap.set() on an existing
121
+ // shared type would replace it (a destructive CRDT write), and scalars
122
+ // are no-ops regardless. This is safe on fresh docs (nothing to skip)
123
+ // and necessary on hydrated docs (preserves existing data).
124
+ if (rootMap.has(key)) return
125
+
131
126
  switch (fieldSchema[KIND]) {
132
127
  case "text":
133
128
  case "richtext":
@@ -147,16 +142,10 @@ function ensureRootField(
147
142
  return
148
143
 
149
144
  case "scalar":
150
- case "sum": {
151
- // Plain values don't need shared type containers, but they DO
152
- // need structural zero defaults so the store reader returns
153
- // type-correct values (e.g. "" not undefined for strings).
154
- const zero = Zero.structural(fieldSchema)
155
- if (zero !== undefined) {
156
- rootMap.set(key, zero)
157
- }
145
+ case "sum":
146
+ // Value concerns are handled by the materializer's zero fallback.
147
+ // No CRDT writes needed for non-container types.
158
148
  return
159
- }
160
149
 
161
150
  case "counter":
162
151
  case "set":
@@ -180,7 +169,7 @@ function ensureRootField(
180
169
  *
181
170
  * Only creates containers for fields that require Yjs shared types
182
171
  * (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
183
- * Scalar and sum fields are set to their structural zero defaults.
172
+ * Scalar and sum fields are skipped (materializer zero fallback).
184
173
  *
185
174
  * **Identity-keying:** When a `binding` is provided, computes the
186
175
  * absolute schema path for each nested field (`prefix.fieldName`) and
@@ -226,13 +215,10 @@ function ensureMapContainers(
226
215
  break
227
216
 
228
217
  case "scalar":
229
- case "sum": {
230
- const zero = Zero.structural(fieldSchema)
231
- if (zero !== undefined) {
232
- map.set(mapKey, zero)
233
- }
218
+ case "sum":
219
+ // Value concerns are handled by the materializer's zero fallback.
220
+ // No CRDT writes needed for non-container types.
234
221
  break
235
- }
236
222
 
237
223
  case "counter":
238
224
  case "set":
package/src/substrate.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  BatchOptions,
27
27
  ChangeBase,
28
28
  Path,
29
+ PlainState,
29
30
  PositionCapable,
30
31
  ProductSchema,
31
32
  Reader,
@@ -41,17 +42,19 @@ import type {
41
42
  WritableContext,
42
43
  } from "@kyneta/schema"
43
44
  import {
45
+ applyChange,
44
46
  BACKING_DOC,
45
47
  buildWritableContext,
46
48
  deriveSchemaBinding,
47
49
  executeBatch,
48
50
  KIND,
51
+ plainReader,
49
52
  } from "@kyneta/schema"
50
53
  import * as Y from "yjs"
51
54
  import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
55
+ import { materializeYjsShadow } from "./materialize.js"
52
56
  import { ensureContainers } from "./populate.js"
53
57
  import { toYjsAssoc, YjsPosition } from "./position.js"
54
- import { yjsReader } from "./reader.js"
55
58
  import { YjsVersion } from "./version.js"
56
59
  import { resolveYjsType } from "./yjs-resolve.js"
57
60
 
@@ -111,8 +114,10 @@ export function createYjsSubstrate(
111
114
  // The root Y.Map — all schema fields are children of this single map.
112
115
  const rootMap = doc.getMap("root")
113
116
 
114
- // The Readerlive view over the Yjs shared type tree.
115
- const reader: Reader = yjsReader(doc, schema, binding)
117
+ // The shadowa plain JS object materialized from the Y.Doc.
118
+ // Kept in sync by applyChange() in prepare().
119
+ const shadow: PlainState = materializeYjsShadow(doc, schema, binding)
120
+ const reader: Reader = plainReader(shadow)
116
121
 
117
122
  // --- Substrate object ---
118
123
 
@@ -122,20 +127,31 @@ export function createYjsSubstrate(
122
127
  reader: reader,
123
128
 
124
129
  prepare(path: Path, change: ChangeBase, options?: BatchOptions): void {
130
+ // Local writes: apply eagerly to the shadow so reads are
131
+ // immediately consistent. Replay writes: skip — the shadow
132
+ // will be re-materialized from the Y.Doc in onFlush(replay).
133
+ if (!options?.replay) {
134
+ applyChange(shadow, path, change)
135
+ }
136
+
125
137
  if (options?.replay) {
126
- // Yjs already has these ops (the bridge is replaying them
127
- // through kyneta solely so the changefeed layer can deliver
128
- // notifications — wrappedPrepare buffered the op upstream).
129
138
  return
130
139
  }
131
- // Mutations happen in onFlush inside a single Yjs transaction.
132
140
  pendingChanges.push({ path, change })
133
141
  },
134
142
 
135
143
  onFlush(options?: BatchOptions): void {
136
144
  if (options?.replay) {
137
- // Yjs already committed; wrappedFlush still delivers
138
- // notifications upstream.
145
+ // Re-materialize shadow from the Y.Doc (already committed).
146
+ const fresh = materializeYjsShadow(doc, schema, binding)
147
+ for (const key of Object.keys(fresh)) {
148
+ shadow[key] = fresh[key]
149
+ }
150
+ for (const key of Object.keys(shadow)) {
151
+ if (!(key in fresh)) {
152
+ delete shadow[key]
153
+ }
154
+ }
139
155
  return
140
156
  }
141
157
  if (pendingChanges.length === 0) return
@@ -474,17 +490,14 @@ export const yjsSubstrateFactory: SubstrateFactory<YjsVersion> = {
474
490
  const doc = (replica as any)[BACKING_DOC] as Y.Doc
475
491
  const binding = trivialBinding(schema)
476
492
  // No identity injection for the standalone factory (no peerId).
477
- // Conditional ensureContainers: skip fields that already exist
478
- // from hydrated state.
479
- ensureContainers(doc, schema, true, binding)
493
+ ensureContainers(doc, schema, binding)
480
494
  return createYjsSubstrate(doc, schema, binding)
481
495
  },
482
496
 
483
497
  create(schema: SchemaNode): Substrate<YjsVersion> {
484
- // Fresh doc — unconditional ensureContainers (nothing to conflict with).
485
498
  const doc = new Y.Doc()
486
499
  const binding = trivialBinding(schema)
487
- ensureContainers(doc, schema, false, binding)
500
+ ensureContainers(doc, schema, binding)
488
501
  return createYjsSubstrate(doc, schema, binding)
489
502
  },
490
503
 
@@ -0,0 +1,52 @@
1
+ // yjs-extract — shared value-extraction helpers for Yjs shared types.
2
+ //
3
+ // These functions are used by both the reader (yjsReader) and the
4
+ // materialize interpreter to convert Yjs shared types into plain values.
5
+
6
+ import type { RichTextDelta, RichTextSpan } from "@kyneta/schema"
7
+ import * as Y from "yjs"
8
+
9
+ /**
10
+ * Extract a plain value from a Yjs shared type or return a plain value as-is.
11
+ *
12
+ * - Y.Text → `.toJSON()` (string)
13
+ * - Y.Map → `.toJSON()` (plain object snapshot — for product/map reads)
14
+ * - Y.Array → `.toJSON()` (plain array snapshot)
15
+ * - Plain values (string, number, boolean, null) → returned as-is
16
+ */
17
+ export function extractValue(resolved: unknown): unknown {
18
+ if (resolved instanceof Y.Text) {
19
+ return resolved.toJSON()
20
+ }
21
+ if (resolved instanceof Y.Map) {
22
+ return resolved.toJSON()
23
+ }
24
+ if (resolved instanceof Y.Array) {
25
+ return resolved.toJSON()
26
+ }
27
+ // Plain scalar value (string, number, boolean, null, etc.)
28
+ return resolved
29
+ }
30
+
31
+ /**
32
+ * Convert a Y.Text's delta (Quill format) to a kyneta RichTextDelta.
33
+ *
34
+ * Yjs `.toDelta()` returns `{ insert: string, attributes?: Record<string, any> }[]`.
35
+ * Kyneta RichTextDelta is `{ text: string, marks?: MarkMap }[]`.
36
+ */
37
+ export function yTextToRichTextDelta(ytext: Y.Text): RichTextDelta {
38
+ const delta = ytext.toDelta() as Array<{
39
+ insert: string
40
+ attributes?: Record<string, unknown>
41
+ }>
42
+ const spans: RichTextSpan[] = []
43
+ for (const d of delta) {
44
+ if (typeof d.insert !== "string") continue
45
+ const span: RichTextSpan =
46
+ d.attributes && Object.keys(d.attributes).length > 0
47
+ ? { text: d.insert, marks: d.attributes }
48
+ : { text: d.insert }
49
+ spans.push(span)
50
+ }
51
+ return spans
52
+ }
@@ -1,35 +1,29 @@
1
1
  // yjs-resolve — Yjs-specific path resolution.
2
2
  //
3
- // Implements stepIntoYjs and resolveYjsType for schema-guided
4
- // navigation of the Yjs shared type tree.
5
- //
6
- // resolveYjsType is a left-fold over path segments, accumulating
7
- // (currentType, currentSchema) at each step. This mirrors how
8
- // resolveContainer works for Loro — but uses `instanceof` for
9
- // runtime type discrimination instead of Loro's `.kind()` method.
3
+ // `stepIntoYjs` is the per-step substrate dispatch; `resolveYjsType`
4
+ // applies the core `foldPath` primitive (from `@kyneta/schema`) around
5
+ // it. The semantic invariants of the fold — identity-keying at
6
+ // product-field boundaries, sum-boundary short-circuit live in
7
+ // `fold-path.ts`, not here.
10
8
  //
11
9
  // Root container strategy: All schema fields are children of a single
12
10
  // root `Y.Map` obtained via `doc.getMap("root")`. This root map holds
13
11
  // shared types (Y.Text, Y.Array, Y.Map) and plain values uniformly.
14
12
  // Using a single root Y.Map enables one `observeDeep` call that
15
13
  // captures all mutations with correct relative paths.
16
- //
17
- // Identity-keying: when a SchemaBinding is provided, every product-field
18
- // boundary uses the identity hash (from binding.forward) instead of the
19
- // field name as the Y.Map key. The binding is threaded through
20
- // resolveYjsType and stepIntoYjs.
21
14
 
22
- import type {
23
- Path,
24
- SchemaBinding,
25
- Schema as SchemaNode,
26
- Segment,
15
+ import {
16
+ foldPath,
17
+ type Path,
18
+ type PathFoldResult,
19
+ type PathStepper,
20
+ type SchemaBinding,
21
+ type Schema as SchemaNode,
27
22
  } from "@kyneta/schema"
28
- import { advanceSchema, KIND } from "@kyneta/schema"
29
23
  import * as Y from "yjs"
30
24
 
31
25
  // ---------------------------------------------------------------------------
32
- // stepIntoYjs — single step of the fold
26
+ // stepIntoYjs — per-step substrate dispatch (PathStepper for Yjs)
33
27
  // ---------------------------------------------------------------------------
34
28
 
35
29
  /**
@@ -41,15 +35,16 @@ import * as Y from "yjs"
41
35
  * - `Y.Text` → terminal (cannot step further)
42
36
  * - Plain value → terminal (return `undefined`)
43
37
  *
44
- * @param current - The current position (a Yjs shared type or plain value)
45
- * @param segment - The path segment to follow
46
- * @param identity - Optional identity hash to use instead of the segment's resolved value
38
+ * `_nextSchema` is part of the `PathStepper` contract for Loro's root
39
+ * dispatch but is unused here Yjs's `instanceof` dispatch doesn't
40
+ * need to look ahead at the next schema kind.
47
41
  */
48
- export function stepIntoYjs(
49
- current: unknown,
50
- segment: Segment,
51
- identity?: string,
52
- ): unknown {
42
+ export const stepIntoYjs: PathStepper = (
43
+ current,
44
+ _nextSchema,
45
+ segment,
46
+ identity,
47
+ ) => {
53
48
  const resolved = segment.resolve()
54
49
 
55
50
  if (current instanceof Y.Map) {
@@ -69,85 +64,25 @@ export function stepIntoYjs(
69
64
  }
70
65
 
71
66
  // ---------------------------------------------------------------------------
72
- // resolveYjsType — full path resolution via left-fold
67
+ // resolveYjsType — full path resolution via foldPath
73
68
  // ---------------------------------------------------------------------------
74
69
 
75
- /**
76
- * Result of resolving a Yjs shared type at a path.
77
- *
78
- * Includes both the resolved Yjs value and the schema at that position,
79
- * enabling callers to distinguish between schema kinds that map to the
80
- * same Yjs type (e.g. "text" vs "richtext" both use Y.Text).
81
- */
82
- export interface ResolvedYjs {
83
- readonly resolved: unknown
84
- readonly schema: SchemaNode
85
- }
86
-
87
70
  /**
88
71
  * Resolve a Yjs shared type (or plain value) at the given path.
89
72
  *
90
- * Left-folds over path segments using `advanceSchema` for pure schema
91
- * descent and `stepIntoYjs` for Yjs-specific navigation.
73
+ * Thin wrapper around `foldPath(stepIntoYjs, ...)`. Returns the
74
+ * `PathFoldResult` shape from core — `{ resolved, schema }`.
92
75
  *
93
- * When a `binding` is provided, each step computes the absolute schema
94
- * path and looks up the identity hash from `binding.forward`. This
95
- * identity hash is used instead of the field name at every product-field
96
- * boundary (root and nested).
76
+ * When a `binding` is provided, every product-field boundary uses the
77
+ * identity hash from `binding.forward` instead of the field name.
97
78
  *
98
- * Returns both the Yjs shared type (or plain value) and the schema at
99
- * the terminal position. For an empty path, returns the root map and
100
- * root schema.
101
- *
102
- * @param rootMap - The root `Y.Map` obtained via `doc.getMap("root")`
103
- * @param rootSchema - The root document schema
104
- * @param path - The path to resolve
105
- * @param binding - Optional SchemaBinding for identity-keyed navigation.
79
+ * For an empty path, returns the root map and root schema.
106
80
  */
107
81
  export function resolveYjsType(
108
82
  rootMap: Y.Map<any>,
109
83
  rootSchema: SchemaNode,
110
84
  path: Path,
111
85
  binding?: SchemaBinding,
112
- ): ResolvedYjs {
113
- let current: unknown = rootMap
114
- let schema = rootSchema
115
- // Track the accumulated absolute schema path for identity lookup.
116
- // Only string (key) segments contribute — index segments are structural
117
- // and don't participate in identity-keying.
118
- let absPath = ""
119
-
120
- for (let i = 0; i < path.length; i++) {
121
- const seg = path.segments[i]
122
- if (!seg) throw new Error(`Missing segment at index ${i}`)
123
- const nextSchema = advanceSchema(schema, seg)
124
-
125
- // Compute identity for this step if binding is provided and the
126
- // segment is a key (field name at a product boundary).
127
- let identity: string | undefined
128
- if (binding && seg.role === "key") {
129
- const segStr = seg.resolve() as string
130
- absPath = absPath ? `${absPath}.${segStr}` : segStr
131
- identity = binding.forward.get(absPath) as string | undefined
132
- }
133
-
134
- current = stepIntoYjs(current, seg, identity)
135
- schema = nextSchema
136
-
137
- // Sum variants are always PlainSchema — no CRDT containers inside.
138
- // Once we land on a sum, resolve remaining segments via plain JS
139
- // property access on the (JSON) value.
140
- if (schema[KIND] === "sum" && i + 1 < path.length) {
141
- for (let j = i + 1; j < path.length; j++) {
142
- const remaining = path.segments[j]
143
- if (!remaining) throw new Error(`Missing segment at index ${j}`)
144
- current = (current as Record<string, unknown>)?.[
145
- remaining.resolve() as string
146
- ]
147
- }
148
- return { resolved: current, schema }
149
- }
150
- }
151
-
152
- return { resolved: current, schema }
86
+ ): PathFoldResult {
87
+ return foldPath(rootMap, rootSchema, path, stepIntoYjs, binding)
153
88
  }