@kyneta/yjs-schema 1.0.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,612 @@
1
+ // change-mapping — bidirectional change mapping between kyneta and Yjs.
2
+ //
3
+ // Two directions:
4
+ //
5
+ // 1. kyneta → Yjs (`applyChangeToYjs`): Resolves the target Yjs shared
6
+ // type at a path, then applies the change imperatively via Yjs API.
7
+ // No intermediate diff format — direct imperative mutations.
8
+ //
9
+ // 2. Yjs → kyneta (`eventsToOps`): Converts `observeDeep` events into
10
+ // kyneta `Op[]` for changefeed delivery. Each Y.YEvent maps to one Op
11
+ // with a path derived from `event.path` (relative to the observed root
12
+ // Y.Map) and a Change derived from the event's delta/keys.
13
+ //
14
+ // Structured inserts use populate-then-attach order: new shared types
15
+ // are fully populated before being inserted into their parent container.
16
+ // This produces a single observeDeep event with the complete struct,
17
+ // rather than a cascade of child MapChange events.
18
+
19
+ import { advanceSchema, expandMapOpsToLeaves } from "@kyneta/schema"
20
+ import type {
21
+ ChangeBase,
22
+ IncrementChange,
23
+ MapChange,
24
+ Op,
25
+ Path,
26
+ ReplaceChange,
27
+ Schema as SchemaNode,
28
+ SequenceChange,
29
+ SequenceInstruction,
30
+ TextChange,
31
+ TextInstruction,
32
+ } from "@kyneta/schema"
33
+ import { RawPath } from "@kyneta/schema"
34
+ import * as Y from "yjs"
35
+ import { resolveYjsType } from "./yjs-resolve.js"
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Direction 1: kyneta → Yjs (`applyChangeToYjs`)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Apply a kyneta Change to the Yjs shared type tree imperatively.
43
+ *
44
+ * Resolves the target shared type at `path`, then applies the change
45
+ * via the appropriate Yjs API. Must be called within a `doc.transact()`
46
+ * for atomicity and correct event batching.
47
+ *
48
+ * @param rootMap - The root `Y.Map` obtained via `doc.getMap("root")`
49
+ * @param rootSchema - The root document schema
50
+ * @param path - The path to the target
51
+ * @param change - The kyneta Change to apply
52
+ */
53
+ export function applyChangeToYjs(
54
+ rootMap: Y.Map<any>,
55
+ rootSchema: SchemaNode,
56
+ path: Path,
57
+ change: ChangeBase,
58
+ ): void {
59
+ switch (change.type) {
60
+ case "text":
61
+ applyTextChange(rootMap, rootSchema, path, change as TextChange)
62
+ return
63
+
64
+ case "sequence":
65
+ applySequenceChange(rootMap, rootSchema, path, change as SequenceChange)
66
+ return
67
+
68
+ case "map":
69
+ applyMapChange(rootMap, rootSchema, path, change as MapChange)
70
+ return
71
+
72
+ case "replace":
73
+ applyReplaceChange(rootMap, rootSchema, path, change as ReplaceChange)
74
+ return
75
+
76
+ case "increment":
77
+ throw new Error(
78
+ "Yjs substrate does not support counter annotations. " +
79
+ "Use Schema.number() with ReplaceChange instead. " +
80
+ `Attempted IncrementChange with amount=${(change as IncrementChange).amount} at path [${pathToString(path)}].`,
81
+ )
82
+
83
+ case "tree":
84
+ throw new Error(
85
+ "Yjs substrate does not support tree annotations. " +
86
+ "Yjs has no native tree type. " +
87
+ `Attempted TreeChange at path [${pathToString(path)}].`,
88
+ )
89
+
90
+ default:
91
+ throw new Error(
92
+ `applyChangeToYjs: unsupported change type "${change.type}"`,
93
+ )
94
+ }
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Text change
99
+ // ---------------------------------------------------------------------------
100
+
101
+ function applyTextChange(
102
+ rootMap: Y.Map<any>,
103
+ rootSchema: SchemaNode,
104
+ path: Path,
105
+ change: TextChange,
106
+ ): void {
107
+ const resolved = resolveYjsType(rootMap, rootSchema, path)
108
+ if (!(resolved instanceof Y.Text)) {
109
+ throw new Error(
110
+ `applyChangeToYjs: TextChange target at path [${pathToString(path)}] is not a Y.Text`,
111
+ )
112
+ }
113
+
114
+ // Yjs Y.Text.applyDelta uses the Quill Delta format, which is
115
+ // structurally identical to kyneta TextInstruction[].
116
+ resolved.applyDelta(change.instructions as any)
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Sequence change
121
+ // ---------------------------------------------------------------------------
122
+
123
+ function applySequenceChange(
124
+ rootMap: Y.Map<any>,
125
+ rootSchema: SchemaNode,
126
+ path: Path,
127
+ change: SequenceChange,
128
+ ): void {
129
+ const resolved = resolveYjsType(rootMap, rootSchema, path)
130
+ if (!(resolved instanceof Y.Array)) {
131
+ throw new Error(
132
+ `applyChangeToYjs: SequenceChange target at path [${pathToString(path)}] is not a Y.Array`,
133
+ )
134
+ }
135
+
136
+ // Resolve the item schema for structured insert detection
137
+ const targetSchema = resolveSchemaAtPath(rootSchema, path)
138
+ const itemSchema = getItemSchema(targetSchema)
139
+
140
+ let cursor = 0
141
+ for (const instruction of change.instructions) {
142
+ if ("retain" in instruction) {
143
+ cursor += instruction.retain
144
+ } else if ("delete" in instruction) {
145
+ resolved.delete(cursor, instruction.delete)
146
+ // cursor stays — deleted items shift remaining items down
147
+ } else if ("insert" in instruction) {
148
+ const items = instruction.insert as readonly unknown[]
149
+ const yjsItems = items.map((item) =>
150
+ maybeCreateSharedType(item, itemSchema),
151
+ )
152
+ resolved.insert(cursor, yjsItems)
153
+ cursor += items.length
154
+ }
155
+ }
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Map change
160
+ // ---------------------------------------------------------------------------
161
+
162
+ function applyMapChange(
163
+ rootMap: Y.Map<any>,
164
+ rootSchema: SchemaNode,
165
+ path: Path,
166
+ change: MapChange,
167
+ ): void {
168
+ const resolved = resolveYjsType(rootMap, rootSchema, path)
169
+ if (!(resolved instanceof Y.Map)) {
170
+ throw new Error(
171
+ `applyChangeToYjs: MapChange target at path [${pathToString(path)}] is not a Y.Map`,
172
+ )
173
+ }
174
+
175
+ // Resolve the schema at this path for structured value detection
176
+ const targetSchema = resolveSchemaAtPath(rootSchema, path)
177
+
178
+ // Apply deletes first
179
+ if (change.delete) {
180
+ for (const key of change.delete) {
181
+ resolved.delete(key)
182
+ }
183
+ }
184
+
185
+ // Apply sets
186
+ if (change.set) {
187
+ for (const [key, value] of Object.entries(change.set)) {
188
+ const fieldSchema = getFieldSchema(targetSchema, key)
189
+ const yjsValue = maybeCreateSharedType(value, fieldSchema)
190
+ resolved.set(key, yjsValue)
191
+ }
192
+ }
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Replace change
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function applyReplaceChange(
200
+ rootMap: Y.Map<any>,
201
+ rootSchema: SchemaNode,
202
+ path: Path,
203
+ change: ReplaceChange,
204
+ ): void {
205
+ if (path.length === 0) {
206
+ throw new Error(
207
+ "applyChangeToYjs: ReplaceChange at root path is not supported",
208
+ )
209
+ }
210
+
211
+ // Target the parent container, using the last segment to identify
212
+ // which child to replace.
213
+ const lastSeg = path.segments[path.segments.length - 1]!
214
+ const parentPath = path.slice(0, -1)
215
+ const parent = resolveYjsType(rootMap, rootSchema, parentPath)
216
+
217
+ const resolved = lastSeg.resolve()
218
+ if (parent instanceof Y.Map && lastSeg.role === "key") {
219
+ // Resolve schema for the target field for structured value detection
220
+ const targetSchema = resolveSchemaAtPath(rootSchema, path)
221
+ const yjsValue = maybeCreateSharedType(change.value, targetSchema)
222
+ parent.set(resolved as string, yjsValue)
223
+ } else if (parent instanceof Y.Array && lastSeg.role === "index") {
224
+ const targetSchema = resolveSchemaAtPath(rootSchema, path)
225
+ const yjsValue = maybeCreateSharedType(change.value, targetSchema)
226
+ parent.delete(resolved as number, 1)
227
+ parent.insert(resolved as number, [yjsValue])
228
+ } else {
229
+ throw new Error(
230
+ `applyChangeToYjs: ReplaceChange parent at path [${pathToString(parentPath)}] ` +
231
+ `is not a Y.Map or Y.Array (got ${typeof parent})`,
232
+ )
233
+ }
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Structured value creation (populate-then-attach pattern)
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /**
241
+ * If the schema says the value should be a shared type (product → Y.Map,
242
+ * sequence → Y.Array, text → Y.Text), create and populate it.
243
+ * Otherwise return the plain value as-is.
244
+ *
245
+ * Uses populate-then-attach: the new shared type is fully populated
246
+ * before being returned for insertion into its parent.
247
+ */
248
+ function maybeCreateSharedType(
249
+ value: unknown,
250
+ schema: SchemaNode | undefined,
251
+ ): unknown {
252
+ if (schema === undefined) return value
253
+
254
+ const structural = unwrapAnnotations(schema)
255
+ const tag = schema._kind === "annotated" ? schema.tag : undefined
256
+
257
+ // Annotated text → Y.Text
258
+ if (tag === "text") {
259
+ const text = new Y.Text()
260
+ if (typeof value === "string" && value.length > 0) {
261
+ text.insert(0, value)
262
+ }
263
+ return text
264
+ }
265
+
266
+ // Annotated counter/movable/tree → should not reach here (thrown earlier)
267
+ if (tag === "counter" || tag === "movable" || tag === "tree") {
268
+ throw new Error(
269
+ `Yjs substrate does not support "${tag}" annotations.`,
270
+ )
271
+ }
272
+
273
+ switch (structural._kind) {
274
+ case "product": {
275
+ if (
276
+ value === null ||
277
+ value === undefined ||
278
+ typeof value !== "object" ||
279
+ Array.isArray(value)
280
+ ) {
281
+ return value
282
+ }
283
+ return createStructuredMap(
284
+ value as Record<string, unknown>,
285
+ structural,
286
+ )
287
+ }
288
+
289
+ case "sequence": {
290
+ if (!Array.isArray(value)) return value
291
+ const arr = new Y.Array()
292
+ const itemSchema = structural.item
293
+ const items = (value as unknown[]).map((item) =>
294
+ maybeCreateSharedType(item, itemSchema),
295
+ )
296
+ arr.insert(0, items)
297
+ return arr
298
+ }
299
+
300
+ case "map": {
301
+ if (
302
+ value === null ||
303
+ value === undefined ||
304
+ typeof value !== "object" ||
305
+ Array.isArray(value)
306
+ ) {
307
+ return value
308
+ }
309
+ const map = new Y.Map()
310
+ const valueSchema = structural.item
311
+ for (const [k, v] of Object.entries(
312
+ value as Record<string, unknown>,
313
+ )) {
314
+ map.set(k, maybeCreateSharedType(v, valueSchema))
315
+ }
316
+ return map
317
+ }
318
+
319
+ default:
320
+ // Scalar, sum, or other — return as plain value
321
+ return value
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Create a Y.Map from a plain object, recursively creating nested
327
+ * shared types as guided by the product schema.
328
+ *
329
+ * Follows populate-then-attach: fully populates the map before the
330
+ * caller inserts it into a parent container.
331
+ */
332
+ function createStructuredMap(
333
+ obj: Record<string, unknown>,
334
+ productSchema: SchemaNode,
335
+ ): Y.Map<any> {
336
+ const map = new Y.Map()
337
+ const structural = unwrapAnnotations(productSchema)
338
+
339
+ if (structural._kind !== "product") {
340
+ // Fallback: set all values as plain
341
+ for (const [key, val] of Object.entries(obj)) {
342
+ map.set(key, val)
343
+ }
344
+ return map
345
+ }
346
+
347
+ // Process fields present in the value object
348
+ for (const [key, val] of Object.entries(obj)) {
349
+ if (val === undefined) continue
350
+ const fieldSchema = structural.fields[key]
351
+ const yjsVal = fieldSchema
352
+ ? maybeCreateSharedType(val, fieldSchema)
353
+ : val
354
+ map.set(key, yjsVal)
355
+ }
356
+
357
+ // Create shared types for annotated fields declared in the schema
358
+ // but missing from the value object. This ensures Yjs containers
359
+ // exist for later mutation (e.g. .insert() on a text field inside
360
+ // a struct inside a record/list).
361
+ for (const [key, fieldSchema] of Object.entries(
362
+ structural.fields as Record<string, SchemaNode>,
363
+ )) {
364
+ if (key in obj) continue // already processed above
365
+ const tag =
366
+ fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
367
+ if (tag === "text") {
368
+ map.set(key, new Y.Text())
369
+ }
370
+ // Other annotated container types (counter, movable, tree) are
371
+ // unsupported in Yjs and will throw if used elsewhere.
372
+ }
373
+
374
+ return map
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Direction 2: Yjs → kyneta (`eventsToOps`)
379
+ // ---------------------------------------------------------------------------
380
+
381
+ /**
382
+ * Convert `observeDeep` events into kyneta `Op[]` for changefeed delivery.
383
+ *
384
+ * Each `Y.YEvent` in the array maps to one Op with:
385
+ * - `path`: derived from `event.path` (relative to the observed root Y.Map)
386
+ * - `change`: derived from the event's delta/keys based on target type
387
+ *
388
+ * `event.path` in `observeDeep` is relative to the observed shared type.
389
+ * Since we observe `rootMap` (the single root Y.Map), paths map directly
390
+ * to kyneta `PathSegment[]`.
391
+ *
392
+ * @param events - The events from the `observeDeep` callback
393
+ */
394
+ export function eventsToOps(events: Y.YEvent<any>[]): Op[] {
395
+ const ops: Op[] = []
396
+
397
+ for (const event of events) {
398
+ const kynetaPath = yjsPathToKynetaPath(event.path)
399
+ const change = eventToChange(event)
400
+ if (change) {
401
+ ops.push({ path: kynetaPath, change })
402
+ }
403
+ }
404
+
405
+ return expandMapOpsToLeaves(ops)
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Yjs path → kyneta Path conversion
410
+ // ---------------------------------------------------------------------------
411
+
412
+ /**
413
+ * Convert a Yjs event path (array of string | number) to a kyneta Path.
414
+ *
415
+ * `event.path` from `observeDeep` is relative to the observed type.
416
+ * Strings become key segments, numbers become index segments.
417
+ */
418
+ function yjsPathToKynetaPath(yjsPath: (string | number)[]): RawPath {
419
+ let path = RawPath.empty
420
+ for (const segment of yjsPath) {
421
+ if (typeof segment === "string") {
422
+ path = path.field(segment)
423
+ } else if (typeof segment === "number") {
424
+ path = path.item(segment)
425
+ }
426
+ }
427
+ return path
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Per-type event → Change converters
432
+ // ---------------------------------------------------------------------------
433
+
434
+ /**
435
+ * Convert a single Yjs event into a kyneta Change.
436
+ * Returns null for event types we can't map.
437
+ */
438
+ function eventToChange(event: Y.YEvent<any>): ChangeBase | null {
439
+ if (event.target instanceof Y.Text) {
440
+ return textEventToChange(event)
441
+ }
442
+ if (event.target instanceof Y.Array) {
443
+ return arrayEventToChange(event)
444
+ }
445
+ if (event.target instanceof Y.Map) {
446
+ return mapEventToChange(event)
447
+ }
448
+ return null
449
+ }
450
+
451
+ /**
452
+ * Y.Text event → TextChange.
453
+ *
454
+ * `event.delta` uses the Quill Delta format, structurally identical to
455
+ * kyneta `TextInstruction[]`. We strip the `attributes` field (rich text
456
+ * formatting not surfaced by kyneta).
457
+ */
458
+ function textEventToChange(event: Y.YEvent<any>): TextChange {
459
+ const instructions: TextInstruction[] = []
460
+
461
+ for (const delta of event.delta) {
462
+ if (delta.retain !== undefined) {
463
+ instructions.push({ retain: delta.retain as number })
464
+ } else if (delta.insert !== undefined) {
465
+ instructions.push({ insert: delta.insert as string })
466
+ } else if (delta.delete !== undefined) {
467
+ instructions.push({ delete: delta.delete as number })
468
+ }
469
+ }
470
+
471
+ return { type: "text", instructions }
472
+ }
473
+
474
+ /**
475
+ * Y.Array event → SequenceChange.
476
+ *
477
+ * `event.changes.delta` provides the same cursor-based ops as kyneta
478
+ * SequenceInstruction[]. Container values (Y.Map, Y.Array) in insert
479
+ * arrays are converted to plain objects via `.toJSON()`.
480
+ */
481
+ function arrayEventToChange(event: Y.YEvent<any>): SequenceChange {
482
+ const instructions: SequenceInstruction[] = []
483
+
484
+ for (const delta of event.changes.delta) {
485
+ if (delta.retain !== undefined) {
486
+ instructions.push({ retain: delta.retain as number })
487
+ } else if (delta.delete !== undefined) {
488
+ instructions.push({ delete: delta.delete as number })
489
+ } else if (delta.insert !== undefined) {
490
+ const items = (delta.insert as unknown[]).map((item: unknown) =>
491
+ extractEventValue(item),
492
+ )
493
+ instructions.push({ insert: items })
494
+ }
495
+ }
496
+
497
+ return { type: "sequence", instructions }
498
+ }
499
+
500
+ /**
501
+ * Y.Map event → MapChange.
502
+ *
503
+ * `event.changes.keys` is a `Map<string, { action: 'add'|'update'|'delete', ... }>`.
504
+ * - `action: 'add'|'update'` → `set[key] = map.get(key)`
505
+ * - `action: 'delete'` → `delete.push(key)`
506
+ */
507
+ function mapEventToChange(event: Y.YEvent<any>): MapChange | null {
508
+ const set: Record<string, unknown> = {}
509
+ const deleteKeys: string[] = []
510
+ let hasSet = false
511
+ let hasDelete = false
512
+
513
+ const target = event.target as Y.Map<any>
514
+
515
+ event.changes.keys.forEach(
516
+ (change: { action: string }, key: string) => {
517
+ if (change.action === "add" || change.action === "update") {
518
+ const value = target.get(key)
519
+ set[key] = extractEventValue(value)
520
+ hasSet = true
521
+ } else if (change.action === "delete") {
522
+ deleteKeys.push(key)
523
+ hasDelete = true
524
+ }
525
+ },
526
+ )
527
+
528
+ if (!hasSet && !hasDelete) return null
529
+
530
+ return {
531
+ type: "map",
532
+ ...(hasSet ? { set } : {}),
533
+ ...(hasDelete ? { delete: deleteKeys } : {}),
534
+ }
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // Value extraction from Yjs events
539
+ // ---------------------------------------------------------------------------
540
+
541
+ /**
542
+ * Convert a Yjs value from an event into a plain value.
543
+ * Container values (Y.Map, Y.Array, Y.Text) → `.toJSON()`.
544
+ * Plain values → returned as-is.
545
+ */
546
+ function extractEventValue(value: unknown): unknown {
547
+ if (value instanceof Y.Map) return value.toJSON()
548
+ if (value instanceof Y.Array) return value.toJSON()
549
+ if (value instanceof Y.Text) return value.toJSON()
550
+ return value
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Schema helpers
555
+ // ---------------------------------------------------------------------------
556
+
557
+ /**
558
+ * Unwrap annotation wrappers to reach the structural schema node.
559
+ */
560
+ function unwrapAnnotations(schema: SchemaNode): SchemaNode {
561
+ let s = schema
562
+ while (s._kind === "annotated" && s.schema !== undefined) {
563
+ s = s.schema
564
+ }
565
+ return s
566
+ }
567
+
568
+ /**
569
+ * Resolve the schema at a given path by walking through advanceSchema.
570
+ */
571
+ function resolveSchemaAtPath(rootSchema: SchemaNode, path: Path): SchemaNode {
572
+ let schema = rootSchema
573
+ for (const seg of path.segments) {
574
+ schema = advanceSchema(schema, seg)
575
+ }
576
+ return schema
577
+ }
578
+
579
+ /**
580
+ * Get the item schema from a sequence schema, if available.
581
+ */
582
+ function getItemSchema(schema: SchemaNode): SchemaNode | undefined {
583
+ const structural = unwrapAnnotations(schema)
584
+ return structural._kind === "sequence" ? structural.item : undefined
585
+ }
586
+
587
+ /**
588
+ * Get the field schema from a product or map schema for a given key.
589
+ */
590
+ function getFieldSchema(
591
+ schema: SchemaNode,
592
+ key: string,
593
+ ): SchemaNode | undefined {
594
+ const structural = unwrapAnnotations(schema)
595
+ if (structural._kind === "product") {
596
+ return structural.fields[key]
597
+ }
598
+ if (structural._kind === "map") {
599
+ return structural.item
600
+ }
601
+ return undefined
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Path formatting
606
+ // ---------------------------------------------------------------------------
607
+
608
+ function pathToString(path: Path): string {
609
+ return path.segments
610
+ .map((seg) => String(seg.resolve()))
611
+ .join(".")
612
+ }