@kyneta/yjs-schema 1.7.0 → 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
@@ -30,6 +47,7 @@ import type {
30
47
  PositionCapable,
31
48
  ProductSchema,
32
49
  Reader,
50
+ RecordInverseFn,
33
51
  Replica,
34
52
  ReplicaFactory,
35
53
  SchemaBinding,
@@ -45,10 +63,15 @@ import {
45
63
  applyChange,
46
64
  BACKING_DOC,
47
65
  buildWritableContext,
66
+ deepClonePreState,
48
67
  deriveSchemaBinding,
49
68
  executeBatch,
69
+ findJsonBoundary,
70
+ invert,
50
71
  KIND,
51
72
  plainReader,
73
+ RECORD_INVERSE,
74
+ syncShadow,
52
75
  } from "@kyneta/schema"
53
76
  import * as Y from "yjs"
54
77
  import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
@@ -59,10 +82,19 @@ import { YjsVersion } from "./version.js"
59
82
  import { resolveYjsType } from "./yjs-resolve.js"
60
83
 
61
84
  // ---------------------------------------------------------------------------
62
- // Origin tag — used to suppress echo from our own transactions
85
+ // Own-commit discriminator
63
86
  // ---------------------------------------------------------------------------
64
87
 
65
- 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")
66
98
 
67
99
  // ---------------------------------------------------------------------------
68
100
  // createYjsSubstrate — wrap a user-provided Y.Doc
@@ -94,8 +126,20 @@ export function createYjsSubstrate(
94
126
  ): Substrate<YjsVersion> {
95
127
  // --- Closure-scoped state ---
96
128
 
97
- // Accumulated changes from prepare(), drained by onFlush().
98
- 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
+ >()
99
143
 
100
144
  // Stashed origin from merge for the event bridge to pick up.
101
145
  let pendingMergeOrigin: string | undefined
@@ -103,14 +147,6 @@ export function createYjsSubstrate(
103
147
  // Lazy-built WritableContext (same pattern as PlainSubstrate / LoroSubstrate).
104
148
  let cachedCtx: WritableContext | undefined
105
149
 
106
- // Incremental delete set tracking.
107
- // Initialized once from the struct store (O(n)), then maintained
108
- // incrementally by merging each transaction's deleteSet (O(delta)).
109
- // Note: DeleteSet class isn't exported from yjs's public entry point,
110
- // so we infer the type from createDeleteSet's return type.
111
- let accumulatedDs: ReturnType<typeof Y.createDeleteSet> =
112
- Y.createDeleteSetFromStructStore(doc.store)
113
-
114
150
  // The root Y.Map — all schema fields are children of this single map.
115
151
  const rootMap = doc.getMap("root")
116
152
 
@@ -119,6 +155,95 @@ export function createYjsSubstrate(
119
155
  const shadow: PlainState = materializeYjsShadow(doc, schema, binding)
120
156
  const reader: Reader = plainReader(shadow)
121
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
+ }
246
+
122
247
  // --- Substrate object ---
123
248
 
124
249
  const substrate = {
@@ -127,97 +252,142 @@ export function createYjsSubstrate(
127
252
  reader: reader,
128
253
 
129
254
  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)
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)
135
274
  }
136
275
 
137
- if (options?.replay) {
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)
138
289
  return
139
290
  }
140
- 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)
141
297
  },
142
298
 
143
- onFlush(options?: BatchOptions): void {
299
+ afterBatch(options?: BatchOptions): void {
144
300
  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]
149
- }
150
- for (const key of Object.keys(shadow)) {
151
- if (!(key in fresh)) {
152
- delete shadow[key]
153
- }
154
- }
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))
155
304
  return
156
305
  }
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
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)
167
327
  },
168
328
 
169
329
  context(): WritableContext {
170
330
  if (!cachedCtx) {
171
- cachedCtx = buildWritableContext(substrate)
172
- // Attach nativeResolver — used by interpretImpl to set [NATIVE]
173
- // on every ref. The resolver maps schema positions to Yjs shared types.
174
- ;(cachedCtx as any).nativeResolver = (
175
- nodeSchema: SchemaNode,
176
- path: { segments: readonly unknown[] },
177
- ) => {
178
- if (path.segments.length === 0) return doc
179
- if (nodeSchema[KIND] === "scalar" || nodeSchema[KIND] === "sum")
180
- return undefined
181
- return resolveYjsType(rootMap, schema, path as any, binding).resolved
182
- }
183
- ;(cachedCtx as any).positionResolver = (
184
- _nodeSchema: unknown,
185
- path: { segments: readonly unknown[] },
186
- ) => {
187
- return {
188
- createPosition(index: number, side: Side) {
189
- // Resolve path to the Y.Text shared type
190
- const { resolved: ytype } = resolveYjsType(
191
- rootMap,
192
- schema,
193
- path as any,
194
- binding,
195
- )
196
- if (!(ytype instanceof Y.Text)) {
197
- throw new Error(
198
- `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,
199
365
  )
200
- }
201
- const assoc = toYjsAssoc(side)
202
- const rpos = Y.createRelativePositionFromTypeIndex(
203
- ytype,
204
- index,
205
- assoc,
206
- )
207
- return new YjsPosition(rpos, doc)
208
- },
209
- decodePosition(bytes: Uint8Array) {
210
- const rpos = Y.decodeRelativePosition(bytes)
211
- return new YjsPosition(rpos, doc)
212
- },
213
- } satisfies PositionCapable
214
- }
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
+ })
215
375
  }
216
376
  return cachedCtx
217
377
  },
218
378
 
219
379
  version(): YjsVersion {
220
- 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
+ )
221
391
  },
222
392
 
223
393
  baseVersion(): YjsVersion {
@@ -275,8 +445,11 @@ export function createYjsSubstrate(
275
445
  // --- Event bridge (registered once at construction) ---
276
446
 
277
447
  rootMap.observeDeep((events, transaction) => {
278
- // Ignore our own transactions (changefeed already captured via wrappedPrepare)
279
- 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)) {
280
453
  return
281
454
  }
282
455
 
@@ -286,12 +459,6 @@ export function createYjsSubstrate(
286
459
  return
287
460
  }
288
461
 
289
- // Update accumulated delete set BEFORE executeBatch, so version()
290
- // reflects deletes when notifyLocalChange fires from the changefeed.
291
- if (transaction.deleteSet.clients.size > 0) {
292
- accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet])
293
- }
294
-
295
462
  // Determine origin: prefer stashed kyneta origin (from merge),
296
463
  // fall back to the transaction's origin if it's a string.
297
464
  const origin =
@@ -301,26 +468,12 @@ export function createYjsSubstrate(
301
468
  // Lazily ensure the context is built
302
469
  const ctx = substrate.context()
303
470
 
304
- // `replay: true` tells substrate.prepare/onFlush to skip native-side
471
+ // `replay: true` tells substrate.prepare/afterBatch to skip native-side
305
472
  // work (Yjs has already absorbed these ops via Y.applyUpdate) and
306
473
  // surfaces on the Changeset for downstream filters (exchange echo).
307
474
  executeBatch(ctx, ops, { origin, replay: true })
308
475
  })
309
476
 
310
- // For local mutations (KYNETA_ORIGIN): the observeDeep handler returns
311
- // early, so we merge the delete set via afterTransaction instead.
312
- // afterTransaction fires inside doc.transact() — before onFlush returns
313
- // — so accumulatedDs is up to date when the changefeed's
314
- // deliverNotifications → notifyLocalChange → version() fires.
315
- doc.on("afterTransaction", (transaction: Y.Transaction) => {
316
- if (
317
- transaction.origin === KYNETA_ORIGIN &&
318
- transaction.deleteSet.clients.size > 0
319
- ) {
320
- accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet])
321
- }
322
- })
323
-
324
477
  return substrate as Substrate<YjsVersion>
325
478
  }
326
479