@kyneta/yjs-schema 1.6.1 → 1.8.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/src/substrate.ts CHANGED
@@ -1,21 +1,38 @@
1
1
  // substrate — YjsSubstrate implementation.
2
2
  //
3
3
  // Implements Substrate<YjsVersion> with:
4
- // - Imperative local writes (prepare accumulates, onFlush applies in transact)
5
- // - Persistent observeDeep event bridge for external changes
6
- // - Transaction-origin filter (`KYNETA_ORIGIN`) to ignore our own writes.
4
+ // - Imperative-eager local writes: `prepare` advances both the shadow σ
5
+ // AND the native Y.Doc tree λ inside the ambient `Y.transact` opened
6
+ // by `runBatch`. The projection law `σ ≡ Π(λ)` holds at every prepare
7
+ // boundary — re-entrant subscribers reading either σ (via the Reader)
8
+ // or λ (via `unwrap`) see a coherent state.
9
+ // - `runBatch(body)` opens one `Y.transact(doc, body, options?.origin)` per
10
+ // outermost logical action. Yjs's native transact nesting collapses
11
+ // inner re-entrant transacts into the outer one for free — no depth
12
+ // counter needed (unlike Loro). External `observeDeep` consumers see
13
+ // exactly one batched event per outermost `change(doc, fn)`.
14
+ // - JSON-boundary writes (struct.json/list.json/record.json subtrees)
15
+ // are buffered in a per-target-key coalescer and flushed in
16
+ // `afterBatch`. Non-boundary writes are applied directly to λ via
17
+ // `applyChangeToYjs`.
18
+ // - `afterBatch` flushes the json-boundary coalescer on local writes
19
+ // and re-materialises σ from λ on replay.
20
+ // - Persistent observeDeep event bridge for external changes.
21
+ // - Per-transaction meta mark (`KYNETA_MARK`) inscribed from inside the
22
+ // transact body to ignore our own writes; survives Yjs's nested-transact
23
+ // collapse so external wrapping is handled correctly.
7
24
  //
8
25
  // The event bridge contract: wrapping a Y.Doc in a kyneta substrate
9
26
  // means subscribing to the kyneta doc observes ALL mutations to the
10
27
  // underlying Y.Doc, regardless of source (local kyneta writes,
11
28
  // merge, external Y.applyUpdate, external raw Yjs API mutations).
12
29
  //
13
- // `prepare` and `onFlush` accept `BatchOptions` and branch on
30
+ // `prepare` and `afterBatch` accept `BatchOptions` and branch on
14
31
  // `options?.replay`. The event bridge constructs the replay batch via
15
32
  // `executeBatch(ctx, ops, { origin, replay: true })`; substrate-side
16
33
  // work (transact, write) is skipped when `replay` is true because the
17
34
  // native Y.Doc already absorbed the change. This makes `prepare` and
18
- // `onFlush` total functions of their declared inputs — no hidden
35
+ // `afterBatch` total functions of their declared inputs — no hidden
19
36
  // ambient state for the substrate-write decision. Context: jj:qpultxsw.
20
37
  //
21
38
  // Identity-keying: when a SchemaBinding is provided, all Y.Map key
@@ -26,9 +43,11 @@ import type {
26
43
  BatchOptions,
27
44
  ChangeBase,
28
45
  Path,
46
+ PlainState,
29
47
  PositionCapable,
30
48
  ProductSchema,
31
49
  Reader,
50
+ RecordInverseFn,
32
51
  Replica,
33
52
  ReplicaFactory,
34
53
  SchemaBinding,
@@ -41,25 +60,41 @@ import type {
41
60
  WritableContext,
42
61
  } from "@kyneta/schema"
43
62
  import {
63
+ applyChange,
44
64
  BACKING_DOC,
45
65
  buildWritableContext,
66
+ deepClonePreState,
46
67
  deriveSchemaBinding,
47
68
  executeBatch,
69
+ findJsonBoundary,
70
+ invert,
48
71
  KIND,
72
+ plainReader,
73
+ RECORD_INVERSE,
74
+ syncShadow,
49
75
  } from "@kyneta/schema"
50
76
  import * as Y from "yjs"
51
77
  import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
78
+ import { materializeYjsShadow } from "./materialize.js"
52
79
  import { ensureContainers } from "./populate.js"
53
80
  import { toYjsAssoc, YjsPosition } from "./position.js"
54
- import { yjsReader } from "./reader.js"
55
81
  import { YjsVersion } from "./version.js"
56
82
  import { resolveYjsType } from "./yjs-resolve.js"
57
83
 
58
84
  // ---------------------------------------------------------------------------
59
- // Origin tag — used to suppress echo from our own transactions
85
+ // Own-commit discriminator
60
86
  // ---------------------------------------------------------------------------
61
87
 
62
- const KYNETA_ORIGIN = "kyneta-prepare"
88
+ // Own-commit discriminator. We mark `transaction.meta` from inside
89
+ // the transact body — the mark travels with the Transaction object
90
+ // that Yjs hands to observeDeep, regardless of `transaction.origin`.
91
+ // This frees the user-facing `origin` slot for `options.origin`
92
+ // round-trip and correctly handles the case where external code
93
+ // wraps `change(doc, fn)` in its own `Y.transact` (Yjs's nested-
94
+ // transact collapse delivers the SAME Transaction object to both
95
+ // outer and inner callbacks; verified by probe — see TECHNICAL.md
96
+ // "Why transaction.meta mark").
97
+ const KYNETA_MARK = Symbol("kyneta:own-commit")
63
98
 
64
99
  // ---------------------------------------------------------------------------
65
100
  // createYjsSubstrate — wrap a user-provided Y.Doc
@@ -91,8 +126,20 @@ export function createYjsSubstrate(
91
126
  ): Substrate<YjsVersion> {
92
127
  // --- Closure-scoped state ---
93
128
 
94
- // Accumulated changes from prepare(), drained by onFlush().
95
- const pendingChanges: Array<{ path: Path; change: ChangeBase }> = []
129
+ // JSON-boundary coalescing buffer. Keyed by the target Y.Map and the
130
+ // boundary key repeated writes inside the same struct.json /
131
+ // record.json subtree overwrite the entry with the latest σ value
132
+ // before `afterBatch` flushes it back into λ as a single
133
+ // `target.set(key, value)`. Non-boundary writes bypass the buffer
134
+ // entirely — they go straight to `applyChangeToYjs` in prepare.
135
+ const jsonBoundaryBuffer = new Map<
136
+ string,
137
+ {
138
+ target: Y.Map<unknown> | Y.Array<unknown>
139
+ key: string | number
140
+ value: unknown
141
+ }
142
+ >()
96
143
 
97
144
  // Stashed origin from merge for the event bridge to pick up.
98
145
  let pendingMergeOrigin: string | undefined
@@ -100,19 +147,102 @@ export function createYjsSubstrate(
100
147
  // Lazy-built WritableContext (same pattern as PlainSubstrate / LoroSubstrate).
101
148
  let cachedCtx: WritableContext | undefined
102
149
 
103
- // Incremental delete set tracking.
104
- // Initialized once from the struct store (O(n)), then maintained
105
- // incrementally by merging each transaction's deleteSet (O(delta)).
106
- // Note: DeleteSet class isn't exported from yjs's public entry point,
107
- // so we infer the type from createDeleteSet's return type.
108
- let accumulatedDs: ReturnType<typeof Y.createDeleteSet> =
109
- Y.createDeleteSetFromStructStore(doc.store)
110
-
111
150
  // The root Y.Map — all schema fields are children of this single map.
112
151
  const rootMap = doc.getMap("root")
113
152
 
114
- // The Readerlive view over the Yjs shared type tree.
115
- const reader: Reader = yjsReader(doc, schema, binding)
153
+ // The shadowa plain JS object materialized from the Y.Doc.
154
+ // Kept in sync by applyChange() in prepare().
155
+ const shadow: PlainState = materializeYjsShadow(doc, schema, binding)
156
+ const reader: Reader = plainReader(shadow)
157
+
158
+ // --- Coalescer helpers ---
159
+
160
+ /**
161
+ * Compute the identity-aware boundary key (or numeric index) for a
162
+ * json-boundary write at `prefixLength`. Mirrors the Loro substrate's
163
+ * `boundaryKey`; field segments inside a bound product get the
164
+ * identity hash, others pass through raw.
165
+ */
166
+ function boundaryKey(path: Path, prefixLength: number): string | number {
167
+ const seg = path.segments[prefixLength]!
168
+ if (seg.role === "field" && binding) {
169
+ const absPath = path.segments
170
+ .slice(0, prefixLength + 1)
171
+ .filter(s => s.role === "field")
172
+ .map(s => s.resolve() as string)
173
+ .join(".")
174
+ const identity = binding.forward.get(absPath) as string | undefined
175
+ if (identity) return identity
176
+ }
177
+ return seg.resolve() as string | number
178
+ }
179
+
180
+ /**
181
+ * Buffer a json-boundary write. The boundary value is the entire σ
182
+ * subtree at the boundary path — already updated by the preceding
183
+ * `applyChange(shadow, ...)`. Subsequent writes inside the same
184
+ * subtree overwrite this entry (last-write-wins by σ snapshot).
185
+ *
186
+ * Returns silently when the parent container can't be resolved
187
+ * (root-level json fields land in `rootMap` directly — Yjs's
188
+ * root is the rootMap, so the parentResolved is `rootMap`).
189
+ */
190
+ function stageJsonBoundaryWrite(path: Path, prefixLength: number): void {
191
+ const parentPath = path.slice(0, prefixLength)
192
+ const { resolved: parent } = resolveYjsType(
193
+ rootMap,
194
+ schema,
195
+ parentPath,
196
+ binding,
197
+ )
198
+ const boundaryPath = path.slice(0, prefixLength + 1)
199
+ const value = boundaryPath.read(shadow)
200
+ const key = boundaryKey(path, prefixLength)
201
+
202
+ // The target can be either a Y.Map (struct field, record entry,
203
+ // or rootMap) or a Y.Array (list/movable item). Both expose a
204
+ // shape we can stash and flush in `afterBatch`.
205
+ let target: Y.Map<unknown> | Y.Array<unknown>
206
+ if (parent instanceof Y.Map) {
207
+ target = parent
208
+ } else if (parent instanceof Y.Array) {
209
+ target = parent
210
+ } else {
211
+ throw new Error(
212
+ `yjs substrate: json-boundary write to unsupported parent type at path ${path.format()}`,
213
+ )
214
+ }
215
+
216
+ // Use the Yjs shared-type's stable identity for the buffer key
217
+ // when available; fall back to a unique sentinel for the
218
+ // ultra-rare case where `_item` is undefined (freshly-created
219
+ // shared types before they're attached). Combine with key/index
220
+ // for a unique slot — repeat writes to the same slot overwrite.
221
+ const targetId = `${(target as any)._item?.id?.client ?? "root"}:${(target as any)._item?.id?.clock ?? "root"}`
222
+ const slot = `${targetId}/${String(key)}`
223
+ jsonBoundaryBuffer.set(slot, { target, key, value })
224
+ }
225
+
226
+ /**
227
+ * Drain the json-boundary buffer into λ. Called from `afterBatch`
228
+ * inside the ambient `Y.transact` opened by `runBatch`. Each entry
229
+ * is applied as `target.set(key, value)` for Y.Map parents or as a
230
+ * delete+insert for Y.Array parents (Yjs Arrays don't have a
231
+ * `set(index, value)` primitive — replace = delete one + insert one).
232
+ */
233
+ function flushJsonBoundaryBuffer(): void {
234
+ if (jsonBoundaryBuffer.size === 0) return
235
+ for (const { target, key, value } of jsonBoundaryBuffer.values()) {
236
+ if (target instanceof Y.Map) {
237
+ target.set(String(key), value)
238
+ } else {
239
+ const index = key as number
240
+ target.delete(index, 1)
241
+ target.insert(index, [value])
242
+ }
243
+ }
244
+ jsonBoundaryBuffer.clear()
245
+ }
116
246
 
117
247
  // --- Substrate object ---
118
248
 
@@ -122,86 +252,142 @@ export function createYjsSubstrate(
122
252
  reader: reader,
123
253
 
124
254
  prepare(path: Path, change: ChangeBase, options?: BatchOptions): void {
125
- if (options?.replay) {
126
- // Yjs already has these ops (the bridge is replaying them
127
- // through kyneta solely so the changefeed layer can deliver
128
- // notifications — wrappedPrepare buffered the op upstream).
255
+ // Replay writes: λ has already absorbed these ops via
256
+ // Y.applyUpdate at the event-bridge call site; skip σ/λ
257
+ // advance afterBatch(replay) rebuilds σ from λ in one
258
+ // Π pass.
259
+ if (options?.replay) return
260
+
261
+ // Inverse recording under the normal handler. Capture σ at the
262
+ // target path before applyChange mutates the shadow; the
263
+ // recording closure pushes onto the active runBatch frame's
264
+ // stack. Skipped under the undo-replay handler (compensating).
265
+ const record = (
266
+ options as
267
+ | (BatchOptions & { [RECORD_INVERSE]?: RecordInverseFn })
268
+ | undefined
269
+ )?.[RECORD_INVERSE]
270
+ if (record && !options?.compensating) {
271
+ const pre = deepClonePreState(path.read(shadow))
272
+ const inverse = invert(pre, change)
273
+ record(path, inverse)
274
+ }
275
+
276
+ // Local write — σ advances eagerly. CRDT-side writes happen
277
+ // inside the ambient Y.transact opened by runBatch (the
278
+ // substrate's `runBatch` wraps `executeBatch`'s prepare-loop +
279
+ // flush).
280
+ applyChange(shadow, path, change)
281
+
282
+ // JSON-boundary write: stage a full-value write at the
283
+ // boundary segment of the parent container. Coalesces with
284
+ // repeated writes inside the same subtree (last σ snapshot
285
+ // wins) and lands in λ on `afterBatch` flush.
286
+ const boundary = findJsonBoundary(schema, path, binding)
287
+ if (boundary !== null) {
288
+ stageJsonBoundaryWrite(path, boundary.prefixLength)
129
289
  return
130
290
  }
131
- // Mutations happen in onFlush inside a single Yjs transaction.
132
- pendingChanges.push({ path, change })
291
+
292
+ // Non-boundary write: imperatively apply to λ inside the
293
+ // ambient Y.transact. The KYNETA_ORIGIN tag lets the
294
+ // observeDeep bridge below recognise and skip the events we
295
+ // generate here, so the changefeed isn't fired twice.
296
+ applyChangeToYjs(rootMap, schema, path, change, binding)
133
297
  },
134
298
 
135
- onFlush(options?: BatchOptions): void {
299
+ afterBatch(options?: BatchOptions): void {
136
300
  if (options?.replay) {
137
- // Yjs already committed; wrappedFlush still delivers
138
- // notifications upstream.
301
+ // CRDT merge is a lattice join — re-materialise σ from λ in
302
+ // one Π pass instead of replaying ops incrementally.
303
+ syncShadow(shadow, materializeYjsShadow(doc, schema, binding))
139
304
  return
140
305
  }
141
- if (pendingChanges.length === 0) return
142
- // The KYNETA_ORIGIN tag lets the observeDeep bridge below
143
- // recognise and skip the events we generate here, so the
144
- // changefeed isn't fired twice for the same write.
145
- doc.transact(() => {
146
- for (const { path, change } of pendingChanges) {
147
- applyChangeToYjs(rootMap, schema, path, change, binding)
148
- }
149
- }, KYNETA_ORIGIN)
150
- pendingChanges.length = 0
306
+ // Local write: drain the json-boundary coalescer. Runs inside
307
+ // the ambient Y.transact from `runBatch`; the transact closes
308
+ // when `runBatch`'s body returns, emitting one batched
309
+ // observeDeep event for the whole logical action.
310
+ flushJsonBoundaryBuffer()
311
+ },
312
+
313
+ runBatch(work: () => void, options?: BatchOptions): void {
314
+ // Yjs's native transact nesting collapses inner re-entrant
315
+ // transacts into the outermost — exactly the "one batched
316
+ // event per outermost logical action" semantic we want. No
317
+ // depth counter needed.
318
+ //
319
+ // We mark the transaction via `tr.meta.set` inside the transact body.
320
+ // The mark lives on per-transaction meta, orthogonal to origin.
321
+ // The app-level `options?.origin` flows directly to `transaction.origin`
322
+ // and round-trips to the changefeed layer.
323
+ doc.transact(tr => {
324
+ tr.meta.set(KYNETA_MARK, true)
325
+ work()
326
+ }, options?.origin)
151
327
  },
152
328
 
153
329
  context(): WritableContext {
154
330
  if (!cachedCtx) {
155
- cachedCtx = buildWritableContext(substrate)
156
- // Attach nativeResolver — used by interpretImpl to set [NATIVE]
157
- // on every ref. The resolver maps schema positions to Yjs shared types.
158
- ;(cachedCtx as any).nativeResolver = (
159
- nodeSchema: SchemaNode,
160
- path: { segments: readonly unknown[] },
161
- ) => {
162
- if (path.segments.length === 0) return doc
163
- if (nodeSchema[KIND] === "scalar" || nodeSchema[KIND] === "sum")
164
- return undefined
165
- return resolveYjsType(rootMap, schema, path as any, binding).resolved
166
- }
167
- ;(cachedCtx as any).positionResolver = (
168
- _nodeSchema: unknown,
169
- path: { segments: readonly unknown[] },
170
- ) => {
171
- return {
172
- createPosition(index: number, side: Side) {
173
- // Resolve path to the Y.Text shared type
174
- const { resolved: ytype } = resolveYjsType(
175
- rootMap,
176
- schema,
177
- path as any,
178
- binding,
179
- )
180
- if (!(ytype instanceof Y.Text)) {
181
- throw new Error(
182
- `positionResolver: path does not resolve to a Y.Text`,
331
+ cachedCtx = buildWritableContext(substrate, {
332
+ nativeResolver: (
333
+ nodeSchema: SchemaNode,
334
+ path: { segments: readonly unknown[] },
335
+ ) => {
336
+ if (path.segments.length === 0) return doc
337
+ if (nodeSchema[KIND] === "scalar" || nodeSchema[KIND] === "sum")
338
+ return undefined
339
+ return resolveYjsType(rootMap, schema, path as any, binding)
340
+ .resolved
341
+ },
342
+ positionResolver: (
343
+ _nodeSchema: unknown,
344
+ path: { segments: readonly unknown[] },
345
+ ) => {
346
+ return {
347
+ createPosition(index: number, side: Side) {
348
+ // Resolve path to the Y.Text shared type
349
+ const { resolved: ytype } = resolveYjsType(
350
+ rootMap,
351
+ schema,
352
+ path as any,
353
+ binding,
354
+ )
355
+ if (!(ytype instanceof Y.Text)) {
356
+ throw new Error(
357
+ `positionResolver: path does not resolve to a Y.Text`,
358
+ )
359
+ }
360
+ const assoc = toYjsAssoc(side)
361
+ const rpos = Y.createRelativePositionFromTypeIndex(
362
+ ytype,
363
+ index,
364
+ assoc,
183
365
  )
184
- }
185
- const assoc = toYjsAssoc(side)
186
- const rpos = Y.createRelativePositionFromTypeIndex(
187
- ytype,
188
- index,
189
- assoc,
190
- )
191
- return new YjsPosition(rpos, doc)
192
- },
193
- decodePosition(bytes: Uint8Array) {
194
- const rpos = Y.decodeRelativePosition(bytes)
195
- return new YjsPosition(rpos, doc)
196
- },
197
- } satisfies PositionCapable
198
- }
366
+ return new YjsPosition(rpos, doc)
367
+ },
368
+ decodePosition(bytes: Uint8Array) {
369
+ const rpos = Y.decodeRelativePosition(bytes)
370
+ return new YjsPosition(rpos, doc)
371
+ },
372
+ } satisfies PositionCapable
373
+ },
374
+ })
199
375
  }
200
376
  return cachedCtx
201
377
  },
202
378
 
203
379
  version(): YjsVersion {
204
- return YjsVersion.fromDeleteSet(doc, accumulatedDs)
380
+ // Derive the deleteSet from the live struct store on every read.
381
+ // Eager-prepare delivers notifications *inside* the ambient
382
+ // `Y.transact` opened by `runBatch`, so `afterTransaction` fires
383
+ // AFTER the changefeed's notify pipeline. Computing from the
384
+ // store picks up in-progress deletes too, so the exchange's
385
+ // auto-subscribe sees a version that already reflects the
386
+ // just-applied mutation.
387
+ return YjsVersion.fromDeleteSet(
388
+ doc,
389
+ Y.createDeleteSetFromStructStore(doc.store),
390
+ )
205
391
  },
206
392
 
207
393
  baseVersion(): YjsVersion {
@@ -259,8 +445,11 @@ export function createYjsSubstrate(
259
445
  // --- Event bridge (registered once at construction) ---
260
446
 
261
447
  rootMap.observeDeep((events, transaction) => {
262
- // Ignore our own transactions (changefeed already captured via wrappedPrepare)
263
- if (transaction.origin === KYNETA_ORIGIN) {
448
+ // Own-commit discriminator: kyneta's runBatch marks the transaction
449
+ // via `tr.meta.set` inside the transact body. The mark survives Yjs's
450
+ // nested-transact collapse, so external code wrapping `change()` in
451
+ // its own Y.transact is correctly classified as own.
452
+ if (transaction.meta.get(KYNETA_MARK)) {
264
453
  return
265
454
  }
266
455
 
@@ -270,12 +459,6 @@ export function createYjsSubstrate(
270
459
  return
271
460
  }
272
461
 
273
- // Update accumulated delete set BEFORE executeBatch, so version()
274
- // reflects deletes when notifyLocalChange fires from the changefeed.
275
- if (transaction.deleteSet.clients.size > 0) {
276
- accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet])
277
- }
278
-
279
462
  // Determine origin: prefer stashed kyneta origin (from merge),
280
463
  // fall back to the transaction's origin if it's a string.
281
464
  const origin =
@@ -285,26 +468,12 @@ export function createYjsSubstrate(
285
468
  // Lazily ensure the context is built
286
469
  const ctx = substrate.context()
287
470
 
288
- // `replay: true` tells substrate.prepare/onFlush to skip native-side
471
+ // `replay: true` tells substrate.prepare/afterBatch to skip native-side
289
472
  // work (Yjs has already absorbed these ops via Y.applyUpdate) and
290
473
  // surfaces on the Changeset for downstream filters (exchange echo).
291
474
  executeBatch(ctx, ops, { origin, replay: true })
292
475
  })
293
476
 
294
- // For local mutations (KYNETA_ORIGIN): the observeDeep handler returns
295
- // early, so we merge the delete set via afterTransaction instead.
296
- // afterTransaction fires inside doc.transact() — before onFlush returns
297
- // — so accumulatedDs is up to date when the changefeed's
298
- // deliverNotifications → notifyLocalChange → version() fires.
299
- doc.on("afterTransaction", (transaction: Y.Transaction) => {
300
- if (
301
- transaction.origin === KYNETA_ORIGIN &&
302
- transaction.deleteSet.clients.size > 0
303
- ) {
304
- accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet])
305
- }
306
- })
307
-
308
477
  return substrate as Substrate<YjsVersion>
309
478
  }
310
479
 
@@ -474,17 +643,14 @@ export const yjsSubstrateFactory: SubstrateFactory<YjsVersion> = {
474
643
  const doc = (replica as any)[BACKING_DOC] as Y.Doc
475
644
  const binding = trivialBinding(schema)
476
645
  // No identity injection for the standalone factory (no peerId).
477
- // Conditional ensureContainers: skip fields that already exist
478
- // from hydrated state.
479
- ensureContainers(doc, schema, true, binding)
646
+ ensureContainers(doc, schema, binding)
480
647
  return createYjsSubstrate(doc, schema, binding)
481
648
  },
482
649
 
483
650
  create(schema: SchemaNode): Substrate<YjsVersion> {
484
- // Fresh doc — unconditional ensureContainers (nothing to conflict with).
485
651
  const doc = new Y.Doc()
486
652
  const binding = trivialBinding(schema)
487
- ensureContainers(doc, schema, false, binding)
653
+ ensureContainers(doc, schema, binding)
488
654
  return createYjsSubstrate(doc, schema, binding)
489
655
  },
490
656
 
@@ -0,0 +1,52 @@
1
+ // yjs-extract — shared value-extraction helpers for Yjs shared types.
2
+ //
3
+ // These functions are used by both the reader (yjsReader) and the
4
+ // materialize interpreter to convert Yjs shared types into plain values.
5
+
6
+ import type { RichTextDelta, RichTextSpan } from "@kyneta/schema"
7
+ import * as Y from "yjs"
8
+
9
+ /**
10
+ * Extract a plain value from a Yjs shared type or return a plain value as-is.
11
+ *
12
+ * - Y.Text → `.toJSON()` (string)
13
+ * - Y.Map → `.toJSON()` (plain object snapshot — for product/map reads)
14
+ * - Y.Array → `.toJSON()` (plain array snapshot)
15
+ * - Plain values (string, number, boolean, null) → returned as-is
16
+ */
17
+ export function extractValue(resolved: unknown): unknown {
18
+ if (resolved instanceof Y.Text) {
19
+ return resolved.toJSON()
20
+ }
21
+ if (resolved instanceof Y.Map) {
22
+ return resolved.toJSON()
23
+ }
24
+ if (resolved instanceof Y.Array) {
25
+ return resolved.toJSON()
26
+ }
27
+ // Plain scalar value (string, number, boolean, null, etc.)
28
+ return resolved
29
+ }
30
+
31
+ /**
32
+ * Convert a Y.Text's delta (Quill format) to a kyneta RichTextDelta.
33
+ *
34
+ * Yjs `.toDelta()` returns `{ insert: string, attributes?: Record<string, any> }[]`.
35
+ * Kyneta RichTextDelta is `{ text: string, marks?: MarkMap }[]`.
36
+ */
37
+ export function yTextToRichTextDelta(ytext: Y.Text): RichTextDelta {
38
+ const delta = ytext.toDelta() as Array<{
39
+ insert: string
40
+ attributes?: Record<string, unknown>
41
+ }>
42
+ const spans: RichTextSpan[] = []
43
+ for (const d of delta) {
44
+ if (typeof d.insert !== "string") continue
45
+ const span: RichTextSpan =
46
+ d.attributes && Object.keys(d.attributes).length > 0
47
+ ? { text: d.insert, marks: d.attributes }
48
+ : { text: d.insert }
49
+ spans.push(span)
50
+ }
51
+ return spans
52
+ }