@kyneta/yjs-schema 1.3.1 → 1.4.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.
- package/README.md +28 -25
- package/dist/index.d.ts +185 -86
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1159 -860
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/bind-constraints.test.ts +39 -30
- package/src/__tests__/bind-yjs.test.ts +53 -16
- package/src/__tests__/position.test.ts +376 -0
- package/src/__tests__/structural-merge.test.ts +111 -54
- package/src/__tests__/substrate.test.ts +18 -0
- package/src/__tests__/version.test.ts +87 -0
- package/src/bind-yjs.ts +44 -37
- package/src/change-mapping.ts +219 -25
- package/src/index.ts +3 -1
- package/src/populate.ts +59 -12
- package/src/position.ts +45 -0
- package/src/reader.ts +62 -6
- package/src/substrate.ts +99 -11
- package/src/version.ts +135 -33
- package/src/yjs-resolve.ts +59 -12
package/src/change-mapping.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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[
|
|
705
|
+
set[fieldName] = extractEventValue(value)
|
|
512
706
|
hasSet = true
|
|
513
707
|
} else if (change.action === "delete") {
|
|
514
|
-
deleteKeys.push(
|
|
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 {
|
|
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
|
-
|
|
74
|
-
|
|
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(
|
|
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
|
-
|
|
212
|
+
case "richtext":
|
|
213
|
+
map.set(mapKey, new Y.Text())
|
|
167
214
|
break
|
|
168
215
|
|
|
169
216
|
case "product":
|
|
170
|
-
map.set(
|
|
217
|
+
map.set(mapKey, ensureMapContainers(fieldSchema, binding, absPath))
|
|
171
218
|
break
|
|
172
219
|
|
|
173
220
|
case "sequence":
|
|
174
|
-
map.set(
|
|
221
|
+
map.set(mapKey, new Y.Array())
|
|
175
222
|
break
|
|
176
223
|
|
|
177
224
|
case "map":
|
|
178
|
-
map.set(
|
|
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(
|
|
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
|
}
|
package/src/position.ts
ADDED
|
@@ -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
|
+
}
|