@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.
@@ -23,6 +23,9 @@ import type {
23
23
  Op,
24
24
  Path,
25
25
  ReplaceChange,
26
+ RichTextChange,
27
+ RichTextInstruction,
28
+ SchemaBinding,
26
29
  Schema as SchemaNode,
27
30
  SequenceChange,
28
31
  SequenceInstruction,
@@ -34,6 +37,7 @@ import {
34
37
  expandMapOpsToLeaves,
35
38
  KIND,
36
39
  RawPath,
40
+ richTextChange,
37
41
  } from "@kyneta/schema"
38
42
  import * as Y from "yjs"
39
43
  import { resolveYjsType } from "./yjs-resolve.js"
@@ -59,22 +63,45 @@ export function applyChangeToYjs(
59
63
  rootSchema: SchemaNode,
60
64
  path: Path,
61
65
  change: ChangeBase,
66
+ binding?: SchemaBinding,
62
67
  ): void {
63
68
  switch (change.type) {
64
69
  case "text":
65
- applyTextChange(rootMap, rootSchema, path, change as TextChange)
70
+ applyTextChange(rootMap, rootSchema, path, change as TextChange, binding)
71
+ return
72
+
73
+ case "richtext":
74
+ applyRichTextChange(
75
+ rootMap,
76
+ rootSchema,
77
+ path,
78
+ change as RichTextChange,
79
+ binding,
80
+ )
66
81
  return
67
82
 
68
83
  case "sequence":
69
- applySequenceChange(rootMap, rootSchema, path, change as SequenceChange)
84
+ applySequenceChange(
85
+ rootMap,
86
+ rootSchema,
87
+ path,
88
+ change as SequenceChange,
89
+ binding,
90
+ )
70
91
  return
71
92
 
72
93
  case "map":
73
- applyMapChange(rootMap, rootSchema, path, change as MapChange)
94
+ applyMapChange(rootMap, rootSchema, path, change as MapChange, binding)
74
95
  return
75
96
 
76
97
  case "replace":
77
- applyReplaceChange(rootMap, rootSchema, path, change as ReplaceChange)
98
+ applyReplaceChange(
99
+ rootMap,
100
+ rootSchema,
101
+ path,
102
+ change as ReplaceChange,
103
+ binding,
104
+ )
78
105
  return
79
106
 
80
107
  case "increment":
@@ -107,8 +134,9 @@ function applyTextChange(
107
134
  rootSchema: SchemaNode,
108
135
  path: Path,
109
136
  change: TextChange,
137
+ binding?: SchemaBinding,
110
138
  ): void {
111
- const resolved = resolveYjsType(rootMap, rootSchema, path)
139
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
112
140
  if (!(resolved instanceof Y.Text)) {
113
141
  throw new Error(
114
142
  `applyChangeToYjs: TextChange target at path [${pathToString(path)}] is not a Y.Text`,
@@ -120,6 +148,40 @@ function applyTextChange(
120
148
  resolved.applyDelta(change.instructions as any)
121
149
  }
122
150
 
151
+ // ---------------------------------------------------------------------------
152
+ // Rich text change
153
+ // ---------------------------------------------------------------------------
154
+
155
+ function applyRichTextChange(
156
+ rootMap: Y.Map<any>,
157
+ rootSchema: SchemaNode,
158
+ path: Path,
159
+ change: RichTextChange,
160
+ binding?: SchemaBinding,
161
+ ): void {
162
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
163
+ if (!(resolved instanceof Y.Text)) {
164
+ throw new Error(
165
+ `applyChangeToYjs: RichTextChange target at path [${pathToString(path)}] is not a Y.Text`,
166
+ )
167
+ }
168
+ // Map RichTextInstruction → Yjs delta format
169
+ const delta = change.instructions.map((inst: RichTextInstruction) => {
170
+ if ("retain" in inst) return { retain: inst.retain }
171
+ if ("format" in inst) return { retain: inst.format, attributes: inst.marks }
172
+ if ("insert" in inst) {
173
+ const d: any = { insert: inst.insert }
174
+ if (inst.marks && Object.keys(inst.marks).length > 0) {
175
+ d.attributes = inst.marks
176
+ }
177
+ return d
178
+ }
179
+ if ("delete" in inst) return { delete: inst.delete }
180
+ throw new Error("applyRichTextChange: unknown instruction type")
181
+ })
182
+ resolved.applyDelta(delta as any)
183
+ }
184
+
123
185
  // ---------------------------------------------------------------------------
124
186
  // Sequence change
125
187
  // ---------------------------------------------------------------------------
@@ -129,8 +191,9 @@ function applySequenceChange(
129
191
  rootSchema: SchemaNode,
130
192
  path: Path,
131
193
  change: SequenceChange,
194
+ binding?: SchemaBinding,
132
195
  ): void {
133
- const resolved = resolveYjsType(rootMap, rootSchema, path)
196
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
134
197
  if (!(resolved instanceof Y.Array)) {
135
198
  throw new Error(
136
199
  `applyChangeToYjs: SequenceChange target at path [${pathToString(path)}] is not a Y.Array`,
@@ -168,8 +231,9 @@ function applyMapChange(
168
231
  rootSchema: SchemaNode,
169
232
  path: Path,
170
233
  change: MapChange,
234
+ binding?: SchemaBinding,
171
235
  ): void {
172
- const resolved = resolveYjsType(rootMap, rootSchema, path)
236
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
173
237
  if (!(resolved instanceof Y.Map)) {
174
238
  throw new Error(
175
239
  `applyChangeToYjs: MapChange target at path [${pathToString(path)}] is not a Y.Map`,
@@ -191,7 +255,20 @@ function applyMapChange(
191
255
  for (const [key, value] of Object.entries(change.set)) {
192
256
  const fieldSchema = getFieldSchema(targetSchema, key)
193
257
  const yjsValue = maybeCreateSharedType(value, fieldSchema)
194
- resolved.set(key, yjsValue)
258
+ // For product schemas (structs), use the identity hash as the map key.
259
+ // For map schemas (records), use the key as-is (no identity-keying).
260
+ let mapKey = key
261
+ if (binding && targetSchema[KIND] === "product") {
262
+ // Compute absolute schema path for this field.
263
+ const parentAbsPath = path.segments
264
+ .filter(s => s.role === "key")
265
+ .map(s => s.resolve() as string)
266
+ .join(".")
267
+ const absPath = parentAbsPath ? `${parentAbsPath}.${key}` : key
268
+ const identity = binding.forward.get(absPath) as string | undefined
269
+ if (identity) mapKey = identity
270
+ }
271
+ resolved.set(mapKey, yjsValue)
195
272
  }
196
273
  }
197
274
  }
@@ -205,6 +282,7 @@ function applyReplaceChange(
205
282
  rootSchema: SchemaNode,
206
283
  path: Path,
207
284
  change: ReplaceChange,
285
+ binding?: SchemaBinding,
208
286
  ): void {
209
287
  if (path.length === 0) {
210
288
  throw new Error(
@@ -214,16 +292,32 @@ function applyReplaceChange(
214
292
 
215
293
  // Target the parent container, using the last segment to identify
216
294
  // which child to replace.
217
- const lastSeg = path.segments[path.segments.length - 1]!
295
+ const lastSeg = path.segments.at(-1)
296
+ if (!lastSeg) throw new Error("replaceChangeToDiff: empty path")
218
297
  const parentPath = path.slice(0, -1)
219
- const parent = resolveYjsType(rootMap, rootSchema, parentPath)
298
+ const { resolved: parent } = resolveYjsType(
299
+ rootMap,
300
+ rootSchema,
301
+ parentPath,
302
+ binding,
303
+ )
220
304
 
221
305
  const resolved = lastSeg.resolve()
222
306
  if (parent instanceof Y.Map && lastSeg.role === "key") {
223
307
  // Resolve schema for the target field for structured value detection
224
308
  const targetSchema = resolveSchemaAtPath(rootSchema, path)
225
309
  const yjsValue = maybeCreateSharedType(change.value, targetSchema)
226
- parent.set(resolved as string, yjsValue)
310
+ // Use identity hash for product-field boundaries.
311
+ let mapKey = resolved as string
312
+ if (binding) {
313
+ const absPath = path.segments
314
+ .filter(s => s.role === "key")
315
+ .map(s => s.resolve() as string)
316
+ .join(".")
317
+ const identity = binding.forward.get(absPath) as string | undefined
318
+ if (identity) mapKey = identity
319
+ }
320
+ parent.set(mapKey, yjsValue)
227
321
  } else if (parent instanceof Y.Array && lastSeg.role === "index") {
228
322
  const targetSchema = resolveSchemaAtPath(rootSchema, path)
229
323
  const yjsValue = maybeCreateSharedType(change.value, targetSchema)
@@ -243,8 +337,8 @@ function applyReplaceChange(
243
337
 
244
338
  /**
245
339
  * If the schema says the value should be a shared type (product → Y.Map,
246
- * sequence → Y.Array, text → Y.Text), create and populate it.
247
- * Otherwise return the plain value as-is.
340
+ * sequence → Y.Array, text → Y.Text, richtext → Y.Text), create and
341
+ * populate it. Otherwise return the plain value as-is.
248
342
  *
249
343
  * Uses populate-then-attach: the new shared type is fully populated
250
344
  * before being returned for insertion into its parent.
@@ -265,6 +359,29 @@ function maybeCreateSharedType(
265
359
  return text
266
360
  }
267
361
 
362
+ // Rich text → Y.Text (Yjs uses Y.Text for both plain and rich text)
363
+ case "richtext": {
364
+ const text = new Y.Text()
365
+ if (typeof value === "string" && value.length > 0) {
366
+ text.insert(0, value)
367
+ } else if (Array.isArray(value)) {
368
+ // RichTextDelta: array of { text, marks? } spans → Yjs delta
369
+ const delta = (
370
+ value as Array<{ text: string; marks?: Record<string, unknown> }>
371
+ ).map(span => {
372
+ const d: any = { insert: span.text }
373
+ if (span.marks && Object.keys(span.marks).length > 0) {
374
+ d.attributes = span.marks
375
+ }
376
+ return d
377
+ })
378
+ if (delta.length > 0) {
379
+ text.applyDelta(delta)
380
+ }
381
+ }
382
+ return text
383
+ }
384
+
268
385
  case "product": {
269
386
  if (
270
387
  value === null ||
@@ -359,7 +476,7 @@ function createStructuredMap(
359
476
  productSchema.fields as Record<string, SchemaNode>,
360
477
  )) {
361
478
  if (key in obj) continue // already processed above
362
- if (fieldSchema[KIND] === "text") {
479
+ if (fieldSchema[KIND] === "text" || fieldSchema[KIND] === "richtext") {
363
480
  map.set(key, new Y.Text())
364
481
  }
365
482
  }
@@ -384,12 +501,16 @@ function createStructuredMap(
384
501
  *
385
502
  * @param events - The events from the `observeDeep` callback
386
503
  */
387
- export function eventsToOps(events: Y.YEvent<any>[], schema: SchemaNode): Op[] {
504
+ export function eventsToOps(
505
+ events: Y.YEvent<any>[],
506
+ schema: SchemaNode,
507
+ binding?: SchemaBinding,
508
+ ): Op[] {
388
509
  const ops: Op[] = []
389
510
 
390
511
  for (const event of events) {
391
- const kynetaPath = yjsPathToKynetaPath(event.path)
392
- const change = eventToChange(event)
512
+ const kynetaPath = yjsPathToKynetaPath(event.path, binding)
513
+ const change = eventToChange(event, schema, kynetaPath, binding)
393
514
  if (change) {
394
515
  ops.push({ path: kynetaPath, change })
395
516
  }
@@ -408,11 +529,24 @@ export function eventsToOps(events: Y.YEvent<any>[], schema: SchemaNode): Op[] {
408
529
  * `event.path` from `observeDeep` is relative to the observed type.
409
530
  * Strings become key segments, numbers become index segments.
410
531
  */
411
- function yjsPathToKynetaPath(yjsPath: (string | number)[]): RawPath {
532
+ function yjsPathToKynetaPath(
533
+ yjsPath: (string | number)[],
534
+ binding?: SchemaBinding,
535
+ ): RawPath {
412
536
  let path = RawPath.empty
413
537
  for (const segment of yjsPath) {
414
538
  if (typeof segment === "string") {
415
- path = path.field(segment)
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.
542
+ const absPath = binding?.inverse.get(segment as any)
543
+ if (absPath) {
544
+ const lastDot = absPath.lastIndexOf(".")
545
+ const leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath
546
+ path = path.field(leaf)
547
+ } else {
548
+ path = path.field(segment)
549
+ }
416
550
  } else if (typeof segment === "number") {
417
551
  path = path.item(segment)
418
552
  }
@@ -426,17 +560,33 @@ function yjsPathToKynetaPath(yjsPath: (string | number)[]): RawPath {
426
560
 
427
561
  /**
428
562
  * Convert a single Yjs event into a kyneta Change.
563
+ *
564
+ * For Y.Text events, dispatches to either `textEventToChange` or
565
+ * `richTextEventToChange` based on the schema at the event's path.
566
+ * Both text and richtext produce `Y.YTextEvent`, so schema awareness
567
+ * is required for correct dispatch.
568
+ *
429
569
  * Returns null for event types we can't map.
430
570
  */
431
- function eventToChange(event: Y.YEvent<any>): ChangeBase | null {
571
+ function eventToChange(
572
+ event: Y.YEvent<any>,
573
+ rootSchema: SchemaNode,
574
+ kynetaPath: RawPath,
575
+ binding?: SchemaBinding,
576
+ ): ChangeBase | null {
432
577
  if (event.target instanceof Y.Text) {
578
+ // Both text and richtext use Y.Text — resolve the schema to dispatch.
579
+ const schemaAtPath = resolveSchemaAtPath(rootSchema, kynetaPath)
580
+ if (schemaAtPath[KIND] === "richtext") {
581
+ return richTextEventToChange(event)
582
+ }
433
583
  return textEventToChange(event)
434
584
  }
435
585
  if (event.target instanceof Y.Array) {
436
586
  return arrayEventToChange(event)
437
587
  }
438
588
  if (event.target instanceof Y.Map) {
439
- return mapEventToChange(event)
589
+ return mapEventToChange(event, binding)
440
590
  }
441
591
  return null
442
592
  }
@@ -446,7 +596,7 @@ function eventToChange(event: Y.YEvent<any>): ChangeBase | null {
446
596
  *
447
597
  * `event.delta` uses the Quill Delta format, structurally identical to
448
598
  * kyneta `TextInstruction[]`. We strip the `attributes` field (rich text
449
- * formatting not surfaced by kyneta).
599
+ * formatting not surfaced by kyneta plain text).
450
600
  */
451
601
  function textEventToChange(event: Y.YEvent<any>): TextChange {
452
602
  const instructions: TextInstruction[] = []
@@ -464,6 +614,39 @@ function textEventToChange(event: Y.YEvent<any>): TextChange {
464
614
  return { type: "text", instructions }
465
615
  }
466
616
 
617
+ /**
618
+ * Y.Text event → RichTextChange.
619
+ *
620
+ * `event.delta` uses the Quill Delta format. We map each delta op to a
621
+ * `RichTextInstruction`, preserving `attributes` as `marks` for format
622
+ * and insert instructions.
623
+ */
624
+ function richTextEventToChange(event: Y.YEvent<any>): RichTextChange {
625
+ const instructions: RichTextInstruction[] = []
626
+
627
+ for (const delta of event.delta) {
628
+ if (delta.retain !== undefined) {
629
+ const attrs = (delta as any).attributes
630
+ if (attrs && Object.keys(attrs).length > 0) {
631
+ instructions.push({ format: delta.retain as number, marks: attrs })
632
+ } else {
633
+ instructions.push({ retain: delta.retain as number })
634
+ }
635
+ } else if (delta.insert !== undefined) {
636
+ const attrs = (delta as any).attributes
637
+ if (attrs && Object.keys(attrs).length > 0) {
638
+ instructions.push({ insert: delta.insert as string, marks: attrs })
639
+ } else {
640
+ instructions.push({ insert: delta.insert as string })
641
+ }
642
+ } else if (delta.delete !== undefined) {
643
+ instructions.push({ delete: delta.delete as number })
644
+ }
645
+ }
646
+
647
+ return richTextChange(instructions)
648
+ }
649
+
467
650
  /**
468
651
  * Y.Array event → SequenceChange.
469
652
  *
@@ -497,7 +680,10 @@ function arrayEventToChange(event: Y.YEvent<any>): SequenceChange {
497
680
  * - `action: 'add'|'update'` → `set[key] = map.get(key)`
498
681
  * - `action: 'delete'` → `delete.push(key)`
499
682
  */
500
- function mapEventToChange(event: Y.YEvent<any>): MapChange | null {
683
+ function mapEventToChange(
684
+ event: Y.YEvent<any>,
685
+ binding?: SchemaBinding,
686
+ ): MapChange | null {
501
687
  const set: Record<string, unknown> = {}
502
688
  const deleteKeys: string[] = []
503
689
  let hasSet = false
@@ -506,12 +692,20 @@ function mapEventToChange(event: Y.YEvent<any>): MapChange | null {
506
692
  const target = event.target as Y.Map<any>
507
693
 
508
694
  event.changes.keys.forEach((change: { action: string }, key: string) => {
695
+ // Reverse-map identity hash → absolute schema path → leaf field name.
696
+ const absPath = binding?.inverse.get(key as any)
697
+ const fieldName = absPath
698
+ ? absPath.lastIndexOf(".") >= 0
699
+ ? absPath.slice(absPath.lastIndexOf(".") + 1)
700
+ : absPath
701
+ : key
702
+
509
703
  if (change.action === "add" || change.action === "update") {
510
704
  const value = target.get(key)
511
- set[key] = extractEventValue(value)
705
+ set[fieldName] = extractEventValue(value)
512
706
  hasSet = true
513
707
  } else if (change.action === "delete") {
514
- deleteKeys.push(key)
708
+ deleteKeys.push(fieldName)
515
709
  hasDelete = true
516
710
  }
517
711
  })
package/src/index.ts CHANGED
@@ -39,7 +39,7 @@ export {
39
39
  // Yjs-specific exports
40
40
  // ---------------------------------------------------------------------------
41
41
 
42
- export type { YjsCaps } from "./bind-yjs.js"
42
+ export type { YjsLaws } from "./bind-yjs.js"
43
43
  // Namespace
44
44
  export { yjs } from "./bind-yjs.js"
45
45
  // Change mapping
@@ -48,6 +48,8 @@ export { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
48
48
  export type { YjsNativeMap } from "./native-map.js"
49
49
  // Container creation
50
50
  export { ensureContainers } from "./populate.js"
51
+ // Position conformance
52
+ export { fromYjsAssoc, toYjsAssoc, YjsPosition } from "./position.js"
51
53
  // Reader
52
54
  export { yjsReader } from "./reader.js"
53
55
  // Substrate
package/src/populate.ts CHANGED
@@ -13,8 +13,13 @@
13
13
  // Root container strategy: All schema fields are children of a single
14
14
  // root `Y.Map` obtained via `doc.getMap("root")`. This root map holds
15
15
  // shared types (Y.Text, Y.Array, Y.Map) and plain value slots uniformly.
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 all three
20
+ // functions: ensureContainers, ensureRootField, ensureMapContainers.
16
21
 
17
- import type { Schema as SchemaNode } from "@kyneta/schema"
22
+ import type { SchemaBinding, Schema as SchemaNode } from "@kyneta/schema"
18
23
  import { KIND, STRUCTURAL_YJS_CLIENT_ID, Zero } from "@kyneta/schema"
19
24
  import * as Y from "yjs"
20
25
 
@@ -44,15 +49,22 @@ import * as Y from "yjs"
44
49
  * then restores the caller's clientID. This produces byte-identical
45
50
  * structural ops across all peers, enabling Yjs deduplication on merge.
46
51
  *
52
+ * **Identity-keying:** When a `binding` is provided, each root field's
53
+ * key in the root Y.Map is the identity hash from `binding.forward`
54
+ * instead of the field name. Nested product fields are similarly keyed
55
+ * via `ensureMapContainers`.
56
+ *
47
57
  * @param doc - The Y.Doc to prepare
48
58
  * @param schema - The root document schema (a ProductSchema)
49
59
  * @param conditional - If true, skip fields that already exist in the root map.
50
60
  * Context: jj:smmulzkm (two-phase substrate construction)
61
+ * @param binding - Optional SchemaBinding for identity-keyed containers.
51
62
  */
52
63
  export function ensureContainers(
53
64
  doc: Y.Doc,
54
65
  schema: SchemaNode,
55
66
  conditional = false,
67
+ binding?: SchemaBinding,
56
68
  ): void {
57
69
  const rootMap = doc.getMap("root")
58
70
 
@@ -70,8 +82,16 @@ export function ensureContainers(
70
82
  for (const [key, fieldSchema] of Object.entries(schema.fields).sort(
71
83
  ([a], [b]) => a.localeCompare(b),
72
84
  )) {
73
- if (conditional && rootMap.has(key)) continue
74
- ensureRootField(rootMap, key, fieldSchema as SchemaNode)
85
+ const identity = binding?.forward.get(key) as string | undefined
86
+ const mapKey = identity ?? key
87
+ if (conditional && rootMap.has(mapKey)) continue
88
+ ensureRootField(
89
+ rootMap,
90
+ mapKey,
91
+ fieldSchema as SchemaNode,
92
+ binding,
93
+ key,
94
+ )
75
95
  }
76
96
  })
77
97
  } finally {
@@ -94,19 +114,28 @@ export function ensureContainers(
94
114
  * - `"map"` → empty Y.Map
95
115
  * - `"scalar"` / `"sum"` → Zero.structural default
96
116
  * - `"counter"` / `"set"` / `"tree"` / `"movable"` → throw (not supported by Yjs)
117
+ *
118
+ * @param rootMap - The root Y.Map to set the field on.
119
+ * @param key - The key to use in the root map (identity hash or field name).
120
+ * @param fieldSchema - The schema for this field.
121
+ * @param binding - Optional SchemaBinding for nested identity-keying.
122
+ * @param prefix - The absolute schema path prefix for this field (used for nested lookups).
97
123
  */
98
124
  function ensureRootField(
99
125
  rootMap: Y.Map<unknown>,
100
126
  key: string,
101
127
  fieldSchema: SchemaNode,
128
+ binding?: SchemaBinding,
129
+ prefix?: string,
102
130
  ): void {
103
131
  switch (fieldSchema[KIND]) {
104
132
  case "text":
133
+ case "richtext":
105
134
  rootMap.set(key, new Y.Text())
106
135
  return
107
136
 
108
137
  case "product":
109
- rootMap.set(key, ensureMapContainers(fieldSchema))
138
+ rootMap.set(key, ensureMapContainers(fieldSchema, binding, prefix))
110
139
  return
111
140
 
112
141
  case "sequence":
@@ -135,7 +164,7 @@ function ensureRootField(
135
164
  case "movable":
136
165
  throw new Error(
137
166
  `Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". ` +
138
- `Supported kinds: text, product, sequence, map, scalar, sum. ` +
167
+ `Supported kinds: text, richtext, product, sequence, map, scalar, sum. ` +
139
168
  `Encountered unsupported kind at root field "${key}".`,
140
169
  )
141
170
  }
@@ -152,8 +181,21 @@ function ensureRootField(
152
181
  * Only creates containers for fields that require Yjs shared types
153
182
  * (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
154
183
  * Scalar and sum fields are set to their structural zero defaults.
184
+ *
185
+ * **Identity-keying:** When a `binding` is provided, computes the
186
+ * absolute schema path for each nested field (`prefix.fieldName`) and
187
+ * looks up the identity hash from `binding.forward`. The identity hash
188
+ * is used as the Y.Map entry key instead of the field name.
189
+ *
190
+ * @param schema - The product schema for this nested map.
191
+ * @param binding - Optional SchemaBinding for identity-keyed containers.
192
+ * @param prefix - The absolute schema path prefix (e.g. "meta" for fields under meta).
155
193
  */
156
- function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
194
+ function ensureMapContainers(
195
+ schema: SchemaNode,
196
+ binding?: SchemaBinding,
197
+ prefix?: string,
198
+ ): Y.Map<unknown> {
157
199
  const map = new Y.Map()
158
200
 
159
201
  if (schema[KIND] !== "product") return map
@@ -161,28 +203,33 @@ function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
161
203
  for (const [key, fieldSchema] of Object.entries(
162
204
  schema.fields as Record<string, SchemaNode>,
163
205
  ).sort(([a], [b]) => a.localeCompare(b))) {
206
+ const absPath = prefix ? `${prefix}.${key}` : key
207
+ const identity = binding?.forward.get(absPath) as string | undefined
208
+ const mapKey = identity ?? key
209
+
164
210
  switch (fieldSchema[KIND]) {
165
211
  case "text":
166
- map.set(key, new Y.Text())
212
+ case "richtext":
213
+ map.set(mapKey, new Y.Text())
167
214
  break
168
215
 
169
216
  case "product":
170
- map.set(key, ensureMapContainers(fieldSchema))
217
+ map.set(mapKey, ensureMapContainers(fieldSchema, binding, absPath))
171
218
  break
172
219
 
173
220
  case "sequence":
174
- map.set(key, new Y.Array())
221
+ map.set(mapKey, new Y.Array())
175
222
  break
176
223
 
177
224
  case "map":
178
- map.set(key, new Y.Map())
225
+ map.set(mapKey, new Y.Map())
179
226
  break
180
227
 
181
228
  case "scalar":
182
229
  case "sum": {
183
230
  const zero = Zero.structural(fieldSchema)
184
231
  if (zero !== undefined) {
185
- map.set(key, zero)
232
+ map.set(mapKey, zero)
186
233
  }
187
234
  break
188
235
  }
@@ -193,7 +240,7 @@ function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
193
240
  case "movable":
194
241
  throw new Error(
195
242
  `Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". ` +
196
- `Supported kinds: text, product, sequence, map, scalar, sum. ` +
243
+ `Supported kinds: text, richtext, product, sequence, map, scalar, sum. ` +
197
244
  `Encountered unsupported kind at nested field "${key}".`,
198
245
  )
199
246
  }
@@ -0,0 +1,45 @@
1
+ // position — YjsPosition implementation.
2
+ //
3
+ // Wraps Yjs's RelativePosition to implement @kyneta/schema's Position interface.
4
+ // Relative positions bind to specific item IDs in the Yjs document, making
5
+ // resolve() a stateless query — transform() is a no-op.
6
+
7
+ import type { Instruction, Position, Side } from "@kyneta/schema"
8
+ import * as Y from "yjs"
9
+
10
+ /** Map kyneta Side to Yjs assoc. Left → -1 (left-sticky), Right → 0 (right-sticky). */
11
+ export function toYjsAssoc(side: Side): number {
12
+ return side === "left" ? -1 : 0
13
+ }
14
+
15
+ /** Map Yjs assoc to kyneta Side. Negative → left, non-negative → right. */
16
+ export function fromYjsAssoc(assoc: number): Side {
17
+ return assoc < 0 ? "left" : "right"
18
+ }
19
+
20
+ export class YjsPosition implements Position {
21
+ readonly side: Side
22
+
23
+ constructor(
24
+ private readonly rpos: Y.RelativePosition,
25
+ private readonly doc: Y.Doc,
26
+ ) {
27
+ this.side = fromYjsAssoc(rpos.assoc)
28
+ }
29
+
30
+ resolve(): number | null {
31
+ const abs = Y.createAbsolutePositionFromRelativePosition(
32
+ this.rpos,
33
+ this.doc,
34
+ )
35
+ return abs ? abs.index : null
36
+ }
37
+
38
+ encode(): Uint8Array {
39
+ return Y.encodeRelativePosition(this.rpos)
40
+ }
41
+
42
+ transform(_instructions: readonly Instruction[]): void {
43
+ // No-op — Yjs relative positions resolve statelessly against the document.
44
+ }
45
+ }