@kyneta/yjs-schema 1.6.0 → 1.7.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
@@ -3,20 +3,30 @@
3
3
  // Implements Substrate<YjsVersion> with:
4
4
  // - Imperative local writes (prepare accumulates, onFlush applies in transact)
5
5
  // - Persistent observeDeep event bridge for external changes
6
- // - Single re-entrancy guard + transaction.origin check
6
+ // - Transaction-origin filter (`KYNETA_ORIGIN`) to ignore our own writes.
7
7
  //
8
8
  // The event bridge contract: wrapping a Y.Doc in a kyneta substrate
9
9
  // means subscribing to the kyneta doc observes ALL mutations to the
10
10
  // underlying Y.Doc, regardless of source (local kyneta writes,
11
11
  // merge, external Y.applyUpdate, external raw Yjs API mutations).
12
12
  //
13
+ // `prepare` and `onFlush` accept `BatchOptions` and branch on
14
+ // `options?.replay`. The event bridge constructs the replay batch via
15
+ // `executeBatch(ctx, ops, { origin, replay: true })`; substrate-side
16
+ // work (transact, write) is skipped when `replay` is true because the
17
+ // native Y.Doc already absorbed the change. This makes `prepare` and
18
+ // `onFlush` total functions of their declared inputs — no hidden
19
+ // ambient state for the substrate-write decision. Context: jj:qpultxsw.
20
+ //
13
21
  // Identity-keying: when a SchemaBinding is provided, all Y.Map key
14
22
  // lookups and writes use the identity hash instead of the field name.
15
23
  // The binding is threaded to the reader, event bridge, and write path.
16
24
 
17
25
  import type {
26
+ BatchOptions,
18
27
  ChangeBase,
19
28
  Path,
29
+ PlainState,
20
30
  PositionCapable,
21
31
  ProductSchema,
22
32
  Reader,
@@ -32,17 +42,19 @@ import type {
32
42
  WritableContext,
33
43
  } from "@kyneta/schema"
34
44
  import {
45
+ applyChange,
35
46
  BACKING_DOC,
36
47
  buildWritableContext,
37
48
  deriveSchemaBinding,
38
49
  executeBatch,
39
50
  KIND,
51
+ plainReader,
40
52
  } from "@kyneta/schema"
41
53
  import * as Y from "yjs"
42
54
  import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
55
+ import { materializeYjsShadow } from "./materialize.js"
43
56
  import { ensureContainers } from "./populate.js"
44
57
  import { toYjsAssoc, YjsPosition } from "./position.js"
45
- import { yjsReader } from "./reader.js"
46
58
  import { YjsVersion } from "./version.js"
47
59
  import { resolveYjsType } from "./yjs-resolve.js"
48
60
 
@@ -85,12 +97,6 @@ export function createYjsSubstrate(
85
97
  // Accumulated changes from prepare(), drained by onFlush().
86
98
  const pendingChanges: Array<{ path: Path; change: ChangeBase }> = []
87
99
 
88
- // Re-entrancy guard: set true around our doc.transact() in onFlush
89
- // AND around executeBatch in the event bridge. When true, prepare()
90
- // skips Yjs-side work (changes are already applied by Yjs or about
91
- // to be), and onFlush() skips transact/commit.
92
- let inOurTransaction = false
93
-
94
100
  // Stashed origin from merge for the event bridge to pick up.
95
101
  let pendingMergeOrigin: string | undefined
96
102
 
@@ -108,8 +114,10 @@ export function createYjsSubstrate(
108
114
  // The root Y.Map — all schema fields are children of this single map.
109
115
  const rootMap = doc.getMap("root")
110
116
 
111
- // The Readerlive view over the Yjs shared type tree.
112
- const reader: Reader = yjsReader(doc, schema, binding)
117
+ // The shadowa plain JS object materialized from the Y.Doc.
118
+ // Kept in sync by applyChange() in prepare().
119
+ const shadow: PlainState = materializeYjsShadow(doc, schema, binding)
120
+ const reader: Reader = plainReader(shadow)
113
121
 
114
122
  // --- Substrate object ---
115
123
 
@@ -118,34 +126,44 @@ export function createYjsSubstrate(
118
126
 
119
127
  reader: reader,
120
128
 
121
- prepare(path: Path, change: ChangeBase): void {
122
- if (!inOurTransaction) {
123
- // Local write: accumulate for flush.
124
- // No Yjs side effects mutations happen at flush time.
125
- pendingChanges.push({ path, change })
129
+ prepare(path: Path, change: ChangeBase, options?: BatchOptions): void {
130
+ // Local writes: apply eagerly to the shadow so reads are
131
+ // immediately consistent. Replay writes: skip the shadow
132
+ // will be re-materialized from the Y.Doc in onFlush(replay).
133
+ if (!options?.replay) {
134
+ applyChange(shadow, path, change)
135
+ }
136
+
137
+ if (options?.replay) {
138
+ return
126
139
  }
127
- // During event handler replay: no-op on Yjs side.
128
- // wrappedPrepare (changefeed layer) still buffers the op.
140
+ pendingChanges.push({ path, change })
129
141
  },
130
142
 
131
- onFlush(_origin?: string): void {
132
- if (!inOurTransaction && pendingChanges.length > 0) {
133
- // Local write: apply accumulated changes within a single
134
- // Yjs transaction tagged with our origin for echo suppression.
135
- inOurTransaction = true
136
- try {
137
- doc.transact(() => {
138
- for (const { path, change } of pendingChanges) {
139
- applyChangeToYjs(rootMap, schema, path, change, binding)
140
- }
141
- }, KYNETA_ORIGIN)
142
- pendingChanges.length = 0
143
- } finally {
144
- inOurTransaction = false
143
+ onFlush(options?: BatchOptions): void {
144
+ if (options?.replay) {
145
+ // Re-materialize shadow from the Y.Doc (already committed).
146
+ const fresh = materializeYjsShadow(doc, schema, binding)
147
+ for (const key of Object.keys(fresh)) {
148
+ shadow[key] = fresh[key]
145
149
  }
150
+ for (const key of Object.keys(shadow)) {
151
+ if (!(key in fresh)) {
152
+ delete shadow[key]
153
+ }
154
+ }
155
+ return
146
156
  }
147
- // During event handler replay: no-op on Yjs side.
148
- // wrappedFlush (changefeed layer) still delivers notifications.
157
+ if (pendingChanges.length === 0) return
158
+ // The KYNETA_ORIGIN tag lets the observeDeep bridge below
159
+ // recognise and skip the events we generate here, so the
160
+ // changefeed isn't fired twice for the same write.
161
+ doc.transact(() => {
162
+ for (const { path, change } of pendingChanges) {
163
+ applyChangeToYjs(rootMap, schema, path, change, binding)
164
+ }
165
+ }, KYNETA_ORIGIN)
166
+ pendingChanges.length = 0
149
167
  },
150
168
 
151
169
  context(): WritableContext {
@@ -232,7 +250,7 @@ export function createYjsSubstrate(
232
250
  }
233
251
  },
234
252
 
235
- merge(payload: SubstratePayload, origin?: string): void {
253
+ merge(payload: SubstratePayload, options?: BatchOptions): void {
236
254
  if (
237
255
  payload.encoding !== "binary" ||
238
256
  !(payload.data instanceof Uint8Array)
@@ -243,14 +261,14 @@ export function createYjsSubstrate(
243
261
  )
244
262
  }
245
263
  // Stash origin for the event bridge to pick up
246
- pendingMergeOrigin = origin
264
+ pendingMergeOrigin = options?.origin
247
265
  try {
248
- Y.applyUpdate(doc, payload.data, origin ?? "remote")
266
+ Y.applyUpdate(doc, payload.data, options?.origin ?? "remote")
249
267
  } finally {
250
268
  pendingMergeOrigin = undefined
251
269
  }
252
270
  // That's it — the observeDeep handler bridges events to the
253
- // changefeed via executeBatch.
271
+ // changefeed via executeBatch with `replay: true`.
254
272
  },
255
273
  }
256
274
 
@@ -283,15 +301,10 @@ export function createYjsSubstrate(
283
301
  // Lazily ensure the context is built
284
302
  const ctx = substrate.context()
285
303
 
286
- // Feed through executeBatch for changefeed delivery.
287
- // The inOurTransaction guard prevents prepare/onFlush from doing
288
- // Yjs-side work the changes are already applied by Yjs.
289
- inOurTransaction = true
290
- try {
291
- executeBatch(ctx, ops, origin)
292
- } finally {
293
- inOurTransaction = false
294
- }
304
+ // `replay: true` tells substrate.prepare/onFlush to skip native-side
305
+ // work (Yjs has already absorbed these ops via Y.applyUpdate) and
306
+ // surfaces on the Changeset for downstream filters (exchange echo).
307
+ executeBatch(ctx, ops, { origin, replay: true })
295
308
  })
296
309
 
297
310
  // For local mutations (KYNETA_ORIGIN): the observeDeep handler returns
@@ -417,7 +430,7 @@ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
417
430
  }
418
431
  },
419
432
 
420
- merge(payload: SubstratePayload, _origin?: string): void {
433
+ merge(payload: SubstratePayload, _options?: BatchOptions): void {
421
434
  if (
422
435
  payload.encoding !== "binary" ||
423
436
  !(payload.data instanceof Uint8Array)
@@ -477,17 +490,14 @@ export const yjsSubstrateFactory: SubstrateFactory<YjsVersion> = {
477
490
  const doc = (replica as any)[BACKING_DOC] as Y.Doc
478
491
  const binding = trivialBinding(schema)
479
492
  // No identity injection for the standalone factory (no peerId).
480
- // Conditional ensureContainers: skip fields that already exist
481
- // from hydrated state.
482
- ensureContainers(doc, schema, true, binding)
493
+ ensureContainers(doc, schema, binding)
483
494
  return createYjsSubstrate(doc, schema, binding)
484
495
  },
485
496
 
486
497
  create(schema: SchemaNode): Substrate<YjsVersion> {
487
- // Fresh doc — unconditional ensureContainers (nothing to conflict with).
488
498
  const doc = new Y.Doc()
489
499
  const binding = trivialBinding(schema)
490
- ensureContainers(doc, schema, false, binding)
500
+ ensureContainers(doc, schema, binding)
491
501
  return createYjsSubstrate(doc, schema, binding)
492
502
  },
493
503
 
@@ -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
+ }
@@ -1,35 +1,29 @@
1
1
  // yjs-resolve — Yjs-specific path resolution.
2
2
  //
3
- // Implements stepIntoYjs and resolveYjsType for schema-guided
4
- // navigation of the Yjs shared type tree.
5
- //
6
- // resolveYjsType is a left-fold over path segments, accumulating
7
- // (currentType, currentSchema) at each step. This mirrors how
8
- // resolveContainer works for Loro — but uses `instanceof` for
9
- // runtime type discrimination instead of Loro's `.kind()` method.
3
+ // `stepIntoYjs` is the per-step substrate dispatch; `resolveYjsType`
4
+ // applies the core `foldPath` primitive (from `@kyneta/schema`) around
5
+ // it. The semantic invariants of the fold — identity-keying at
6
+ // product-field boundaries, sum-boundary short-circuit live in
7
+ // `fold-path.ts`, not here.
10
8
  //
11
9
  // Root container strategy: All schema fields are children of a single
12
10
  // root `Y.Map` obtained via `doc.getMap("root")`. This root map holds
13
11
  // shared types (Y.Text, Y.Array, Y.Map) and plain values uniformly.
14
12
  // Using a single root Y.Map enables one `observeDeep` call that
15
13
  // captures all mutations with correct relative paths.
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
20
- // resolveYjsType and stepIntoYjs.
21
14
 
22
- import type {
23
- Path,
24
- SchemaBinding,
25
- Schema as SchemaNode,
26
- Segment,
15
+ import {
16
+ foldPath,
17
+ type Path,
18
+ type PathFoldResult,
19
+ type PathStepper,
20
+ type SchemaBinding,
21
+ type Schema as SchemaNode,
27
22
  } from "@kyneta/schema"
28
- import { advanceSchema, KIND } from "@kyneta/schema"
29
23
  import * as Y from "yjs"
30
24
 
31
25
  // ---------------------------------------------------------------------------
32
- // stepIntoYjs — single step of the fold
26
+ // stepIntoYjs — per-step substrate dispatch (PathStepper for Yjs)
33
27
  // ---------------------------------------------------------------------------
34
28
 
35
29
  /**
@@ -41,15 +35,16 @@ import * as Y from "yjs"
41
35
  * - `Y.Text` → terminal (cannot step further)
42
36
  * - Plain value → terminal (return `undefined`)
43
37
  *
44
- * @param current - The current position (a Yjs shared type or plain value)
45
- * @param segment - The path segment to follow
46
- * @param identity - Optional identity hash to use instead of the segment's resolved value
38
+ * `_nextSchema` is part of the `PathStepper` contract for Loro's root
39
+ * dispatch but is unused here Yjs's `instanceof` dispatch doesn't
40
+ * need to look ahead at the next schema kind.
47
41
  */
48
- export function stepIntoYjs(
49
- current: unknown,
50
- segment: Segment,
51
- identity?: string,
52
- ): unknown {
42
+ export const stepIntoYjs: PathStepper = (
43
+ current,
44
+ _nextSchema,
45
+ segment,
46
+ identity,
47
+ ) => {
53
48
  const resolved = segment.resolve()
54
49
 
55
50
  if (current instanceof Y.Map) {
@@ -69,85 +64,25 @@ export function stepIntoYjs(
69
64
  }
70
65
 
71
66
  // ---------------------------------------------------------------------------
72
- // resolveYjsType — full path resolution via left-fold
67
+ // resolveYjsType — full path resolution via foldPath
73
68
  // ---------------------------------------------------------------------------
74
69
 
75
- /**
76
- * Result of resolving a Yjs shared type at a path.
77
- *
78
- * Includes both the resolved Yjs value and the schema at that position,
79
- * enabling callers to distinguish between schema kinds that map to the
80
- * same Yjs type (e.g. "text" vs "richtext" both use Y.Text).
81
- */
82
- export interface ResolvedYjs {
83
- readonly resolved: unknown
84
- readonly schema: SchemaNode
85
- }
86
-
87
70
  /**
88
71
  * Resolve a Yjs shared type (or plain value) at the given path.
89
72
  *
90
- * Left-folds over path segments using `advanceSchema` for pure schema
91
- * descent and `stepIntoYjs` for Yjs-specific navigation.
73
+ * Thin wrapper around `foldPath(stepIntoYjs, ...)`. Returns the
74
+ * `PathFoldResult` shape from core — `{ resolved, schema }`.
92
75
  *
93
- * When a `binding` is provided, each step computes the absolute schema
94
- * path and looks up the identity hash from `binding.forward`. This
95
- * identity hash is used instead of the field name at every product-field
96
- * boundary (root and nested).
76
+ * When a `binding` is provided, every product-field boundary uses the
77
+ * identity hash from `binding.forward` instead of the field name.
97
78
  *
98
- * Returns both the Yjs shared type (or plain value) and the schema at
99
- * the terminal position. For an empty path, returns the root map and
100
- * root schema.
101
- *
102
- * @param rootMap - The root `Y.Map` obtained via `doc.getMap("root")`
103
- * @param rootSchema - The root document schema
104
- * @param path - The path to resolve
105
- * @param binding - Optional SchemaBinding for identity-keyed navigation.
79
+ * For an empty path, returns the root map and root schema.
106
80
  */
107
81
  export function resolveYjsType(
108
82
  rootMap: Y.Map<any>,
109
83
  rootSchema: SchemaNode,
110
84
  path: Path,
111
85
  binding?: SchemaBinding,
112
- ): ResolvedYjs {
113
- let current: unknown = rootMap
114
- let schema = rootSchema
115
- // Track the accumulated absolute schema path for identity lookup.
116
- // Only string (key) segments contribute — index segments are structural
117
- // and don't participate in identity-keying.
118
- let absPath = ""
119
-
120
- for (let i = 0; i < path.length; i++) {
121
- const seg = path.segments[i]
122
- if (!seg) throw new Error(`Missing segment at index ${i}`)
123
- const nextSchema = advanceSchema(schema, seg)
124
-
125
- // Compute identity for this step if binding is provided and the
126
- // segment is a key (field name at a product boundary).
127
- let identity: string | undefined
128
- if (binding && seg.role === "key") {
129
- const segStr = seg.resolve() as string
130
- absPath = absPath ? `${absPath}.${segStr}` : segStr
131
- identity = binding.forward.get(absPath) as string | undefined
132
- }
133
-
134
- current = stepIntoYjs(current, seg, identity)
135
- schema = nextSchema
136
-
137
- // Sum variants are always PlainSchema — no CRDT containers inside.
138
- // Once we land on a sum, resolve remaining segments via plain JS
139
- // property access on the (JSON) value.
140
- if (schema[KIND] === "sum" && i + 1 < path.length) {
141
- for (let j = i + 1; j < path.length; j++) {
142
- const remaining = path.segments[j]
143
- if (!remaining) throw new Error(`Missing segment at index ${j}`)
144
- current = (current as Record<string, unknown>)?.[
145
- remaining.resolve() as string
146
- ]
147
- }
148
- return { resolved: current, schema }
149
- }
150
- }
151
-
152
- return { resolved: current, schema }
86
+ ): PathFoldResult {
87
+ return foldPath(rootMap, rootSchema, path, stepIntoYjs, binding)
153
88
  }