@kyneta/yjs-schema 1.3.1 → 1.5.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.
@@ -13,8 +13,18 @@
13
13
  // shared types (Y.Text, Y.Array, Y.Map) and plain values uniformly.
14
14
  // Using a single root Y.Map enables one `observeDeep` call that
15
15
  // 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.
16
21
 
17
- import type { Path, Schema as SchemaNode, Segment } from "@kyneta/schema"
22
+ import type {
23
+ Path,
24
+ SchemaBinding,
25
+ Schema as SchemaNode,
26
+ Segment,
27
+ } from "@kyneta/schema"
18
28
  import { advanceSchema } from "@kyneta/schema"
19
29
  import * as Y from "yjs"
20
30
 
@@ -26,19 +36,24 @@ import * as Y from "yjs"
26
36
  * Navigate one step deeper into the Yjs shared type tree.
27
37
  *
28
38
  * Uses `instanceof` for runtime type discrimination:
29
- * - `Y.Map` → `.get(key)`
39
+ * - `Y.Map` → `.get(key)` — uses the identity hash when provided
30
40
  * - `Y.Array` → `.get(index)`
31
41
  * - `Y.Text` → terminal (cannot step further)
32
42
  * - Plain value → terminal (return `undefined`)
33
43
  *
34
44
  * @param current - The current position (a Yjs shared type or plain value)
35
45
  * @param segment - The path segment to follow
46
+ * @param identity - Optional identity hash to use instead of the segment's resolved value
36
47
  */
37
- export function stepIntoYjs(current: unknown, segment: Segment): unknown {
48
+ export function stepIntoYjs(
49
+ current: unknown,
50
+ segment: Segment,
51
+ identity?: string,
52
+ ): unknown {
38
53
  const resolved = segment.resolve()
39
54
 
40
55
  if (current instanceof Y.Map) {
41
- return current.get(resolved as string)
56
+ return current.get(identity ?? (resolved as string))
42
57
  }
43
58
 
44
59
  if (current instanceof Y.Array) {
@@ -57,36 +72,68 @@ export function stepIntoYjs(current: unknown, segment: Segment): unknown {
57
72
  // resolveYjsType — full path resolution via left-fold
58
73
  // ---------------------------------------------------------------------------
59
74
 
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
+
60
87
  /**
61
88
  * Resolve a Yjs shared type (or plain value) at the given path.
62
89
  *
63
90
  * Left-folds over path segments using `advanceSchema` for pure schema
64
91
  * descent and `stepIntoYjs` for Yjs-specific navigation.
65
92
  *
66
- * Returns the Yjs shared type or plain value at the terminal position.
67
- * For an empty path, returns the root map itself.
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).
97
+ *
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.
68
101
  *
69
102
  * @param rootMap - The root `Y.Map` obtained via `doc.getMap("root")`
70
103
  * @param rootSchema - The root document schema
71
104
  * @param path - The path to resolve
105
+ * @param binding - Optional SchemaBinding for identity-keyed navigation.
72
106
  */
73
107
  export function resolveYjsType(
74
108
  rootMap: Y.Map<any>,
75
109
  rootSchema: SchemaNode,
76
110
  path: Path,
77
- ): unknown {
111
+ binding?: SchemaBinding,
112
+ ): ResolvedYjs {
78
113
  let current: unknown = rootMap
79
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 = ""
80
119
 
81
120
  for (let i = 0; i < path.length; i++) {
82
- const seg = path.segments[i]!
121
+ const seg = path.segments[i]
122
+ if (!seg) throw new Error(`Missing segment at index ${i}`)
83
123
  const nextSchema = advanceSchema(schema, seg)
84
124
 
85
- // For the first segment, we step into the root map directly.
86
- // For subsequent segments, we use stepIntoYjs on the current value.
87
- current = stepIntoYjs(current, seg)
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)
88
135
  schema = nextSchema
89
136
  }
90
137
 
91
- return current
138
+ return { resolved: current, schema }
92
139
  }