@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.
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/dist/index.d.ts +351 -0
- package/dist/index.js +865 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/__tests__/bind-yjs.test.ts +266 -0
- package/src/__tests__/create.test.ts +632 -0
- package/src/__tests__/record-text-spike.test.ts +429 -0
- package/src/__tests__/store-reader.test.ts +722 -0
- package/src/__tests__/substrate.test.ts +604 -0
- package/src/__tests__/version.test.ts +227 -0
- package/src/bind-yjs.ts +147 -0
- package/src/change-mapping.ts +612 -0
- package/src/create.ts +172 -0
- package/src/index.ts +83 -0
- package/src/populate.ts +208 -0
- package/src/store-reader.ts +123 -0
- package/src/substrate.ts +252 -0
- package/src/sync.ts +107 -0
- package/src/version.ts +138 -0
- package/src/yjs-escape.ts +100 -0
- package/src/yjs-resolve.ts +108 -0
|
@@ -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
|
+
}
|