@kyneta/yjs-schema 1.7.0 → 2.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.
@@ -35,6 +35,8 @@ import type {
35
35
  } from "@kyneta/schema"
36
36
  import {
37
37
  expandMapOpsToLeaves,
38
+ isJsonBoundary,
39
+ isPlainObject,
38
40
  KIND,
39
41
  pathSchema,
40
42
  RawPath,
@@ -299,7 +301,7 @@ function applyReplaceChange(
299
301
  ): void {
300
302
  if (path.length === 0) {
301
303
  throw new Error(
302
- "applyChangeToYjs: ReplaceChange at root path is not supported",
304
+ "Cannot replace the root document struct in a CRDT backend. The root identity is fixed. Please mutate its properties individually (e.g., `doc.myField.set(value)` instead of `doc.set({ myField: value })`).",
303
305
  )
304
306
  }
305
307
 
@@ -366,6 +368,12 @@ function maybeCreateSharedType(
366
368
  ): unknown {
367
369
  if (schema === undefined) return value
368
370
 
371
+ // JSON-boundary schemas (struct.json/list.json/record.json) store
372
+ // their entire subtree as a plain JSON value in the parent Y.Map
373
+ // entry. Skip Y.Map/Y.Array materialisation and pass the value
374
+ // through unchanged — including nested objects/arrays underneath.
375
+ if (isJsonBoundary(schema)) return value
376
+
369
377
  switch (schema[KIND]) {
370
378
  // First-class text → Y.Text
371
379
  case "text": {
@@ -400,12 +408,7 @@ function maybeCreateSharedType(
400
408
  }
401
409
 
402
410
  case "product": {
403
- if (
404
- value === null ||
405
- value === undefined ||
406
- typeof value !== "object" ||
407
- Array.isArray(value)
408
- ) {
411
+ if (!isPlainObject(value)) {
409
412
  return value
410
413
  }
411
414
  return createStructuredMap(value as Record<string, unknown>, schema)
@@ -423,12 +426,7 @@ function maybeCreateSharedType(
423
426
  }
424
427
 
425
428
  case "map": {
426
- if (
427
- value === null ||
428
- value === undefined ||
429
- typeof value !== "object" ||
430
- Array.isArray(value)
431
- ) {
429
+ if (!isPlainObject(value)) {
432
430
  return value
433
431
  }
434
432
  const map = new Y.Map()
package/src/index.ts CHANGED
@@ -21,7 +21,7 @@ export type { DocRef, Op, Ref, SubstratePayload } from "@kyneta/schema"
21
21
  // Sync primitives (generic — work for any substrate)
22
22
  export {
23
23
  applyChanges,
24
- change,
24
+ batch,
25
25
  createDoc,
26
26
  createRef,
27
27
  exportEntirety,
package/src/populate.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  // functions: ensureContainers, ensureRootField, ensureMapContainers.
18
18
 
19
19
  import type { SchemaBinding, Schema as SchemaNode } from "@kyneta/schema"
20
- import { KIND, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
20
+ import { isJsonBoundary, KIND, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
21
21
  import * as Y from "yjs"
22
22
 
23
23
  // ---------------------------------------------------------------------------
@@ -123,6 +123,12 @@ function ensureRootField(
123
123
  // and necessary on hydrated docs (preserves existing data).
124
124
  if (rootMap.has(key)) return
125
125
 
126
+ // JSON-boundary fields (struct.json/list.json/record.json) store the
127
+ // subtree as a plain JSON value in the root Y.Map entry. We leave the
128
+ // entry absent at structural-init time; the first write materialises
129
+ // it with `rootMap.set(key, plainValue)`.
130
+ if (isJsonBoundary(fieldSchema)) return
131
+
126
132
  switch (fieldSchema[KIND]) {
127
133
  case "text":
128
134
  case "richtext":
@@ -196,6 +202,12 @@ function ensureMapContainers(
196
202
  const identity = binding?.forward.get(absPath) as string | undefined
197
203
  const mapKey = identity ?? key
198
204
 
205
+ // JSON-boundary nested field: leave the entry absent, the first
206
+ // write will set the plain JSON value at this key.
207
+ if (isJsonBoundary(fieldSchema)) {
208
+ continue
209
+ }
210
+
199
211
  switch (fieldSchema[KIND]) {
200
212
  case "text":
201
213
  case "richtext":
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 `batch(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,18 @@ import {
45
63
  applyChange,
46
64
  BACKING_DOC,
47
65
  buildWritableContext,
66
+ DEVTOOLS_HISTORY,
67
+ type DevtoolsHistory,
68
+ type DevtoolsHistorySummary,
69
+ deepClonePreState,
48
70
  deriveSchemaBinding,
49
71
  executeBatch,
72
+ findJsonBoundary,
73
+ invert,
50
74
  KIND,
51
75
  plainReader,
76
+ RECORD_INVERSE,
77
+ syncShadow,
52
78
  } from "@kyneta/schema"
53
79
  import * as Y from "yjs"
54
80
  import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
@@ -59,10 +85,19 @@ import { YjsVersion } from "./version.js"
59
85
  import { resolveYjsType } from "./yjs-resolve.js"
60
86
 
61
87
  // ---------------------------------------------------------------------------
62
- // Origin tag — used to suppress echo from our own transactions
88
+ // Own-commit discriminator
63
89
  // ---------------------------------------------------------------------------
64
90
 
65
- const KYNETA_ORIGIN = "kyneta-prepare"
91
+ // Own-commit discriminator. We mark `transaction.meta` from inside
92
+ // the transact body — the mark travels with the Transaction object
93
+ // that Yjs hands to observeDeep, regardless of `transaction.origin`.
94
+ // This frees the user-facing `origin` slot for `options.origin`
95
+ // round-trip and correctly handles the case where external code
96
+ // wraps `batch(doc, fn)` in its own `Y.transact` (Yjs's nested-
97
+ // transact collapse delivers the SAME Transaction object to both
98
+ // outer and inner callbacks; verified by probe — see TECHNICAL.md
99
+ // "Why transaction.meta mark").
100
+ const KYNETA_MARK = Symbol("kyneta:own-commit")
66
101
 
67
102
  // ---------------------------------------------------------------------------
68
103
  // createYjsSubstrate — wrap a user-provided Y.Doc
@@ -94,8 +129,20 @@ export function createYjsSubstrate(
94
129
  ): Substrate<YjsVersion> {
95
130
  // --- Closure-scoped state ---
96
131
 
97
- // Accumulated changes from prepare(), drained by onFlush().
98
- const pendingChanges: Array<{ path: Path; change: ChangeBase }> = []
132
+ // JSON-boundary coalescing buffer. Keyed by the target Y.Map and the
133
+ // boundary key repeated writes inside the same struct.json /
134
+ // record.json subtree overwrite the entry with the latest σ value
135
+ // before `afterBatch` flushes it back into λ as a single
136
+ // `target.set(key, value)`. Non-boundary writes bypass the buffer
137
+ // entirely — they go straight to `applyChangeToYjs` in prepare.
138
+ const jsonBoundaryBuffer = new Map<
139
+ string,
140
+ {
141
+ target: Y.Map<unknown> | Y.Array<unknown>
142
+ key: string | number
143
+ value: unknown
144
+ }
145
+ >()
99
146
 
100
147
  // Stashed origin from merge for the event bridge to pick up.
101
148
  let pendingMergeOrigin: string | undefined
@@ -103,14 +150,6 @@ export function createYjsSubstrate(
103
150
  // Lazy-built WritableContext (same pattern as PlainSubstrate / LoroSubstrate).
104
151
  let cachedCtx: WritableContext | undefined
105
152
 
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
153
  // The root Y.Map — all schema fields are children of this single map.
115
154
  const rootMap = doc.getMap("root")
116
155
 
@@ -119,105 +158,240 @@ export function createYjsSubstrate(
119
158
  const shadow: PlainState = materializeYjsShadow(doc, schema, binding)
120
159
  const reader: Reader = plainReader(shadow)
121
160
 
161
+ // --- Coalescer helpers ---
162
+
163
+ /**
164
+ * Compute the identity-aware boundary key (or numeric index) for a
165
+ * json-boundary write at `prefixLength`. Mirrors the Loro substrate's
166
+ * `boundaryKey`; field segments inside a bound product get the
167
+ * identity hash, others pass through raw.
168
+ */
169
+ function boundaryKey(path: Path, prefixLength: number): string | number {
170
+ const seg = path.segments[prefixLength]!
171
+ if (seg.role === "field" && binding) {
172
+ const absPath = path.segments
173
+ .slice(0, prefixLength + 1)
174
+ .filter(s => s.role === "field")
175
+ .map(s => s.resolve() as string)
176
+ .join(".")
177
+ const identity = binding.forward.get(absPath) as string | undefined
178
+ if (identity) return identity
179
+ }
180
+ return seg.resolve() as string | number
181
+ }
182
+
183
+ /**
184
+ * Buffer a json-boundary write. The boundary value is the entire σ
185
+ * subtree at the boundary path — already updated by the preceding
186
+ * `applyChange(shadow, ...)`. Subsequent writes inside the same
187
+ * subtree overwrite this entry (last-write-wins by σ snapshot).
188
+ *
189
+ * Returns silently when the parent container can't be resolved
190
+ * (root-level json fields land in `rootMap` directly — Yjs's
191
+ * root is the rootMap, so the parentResolved is `rootMap`).
192
+ */
193
+ function stageJsonBoundaryWrite(path: Path, prefixLength: number): void {
194
+ const parentPath = path.slice(0, prefixLength)
195
+ const { resolved: parent } = resolveYjsType(
196
+ rootMap,
197
+ schema,
198
+ parentPath,
199
+ binding,
200
+ )
201
+ const boundaryPath = path.slice(0, prefixLength + 1)
202
+ const value = boundaryPath.read(shadow)
203
+ const key = boundaryKey(path, prefixLength)
204
+
205
+ // The target can be either a Y.Map (struct field, record entry,
206
+ // or rootMap) or a Y.Array (list/movable item). Both expose a
207
+ // shape we can stash and flush in `afterBatch`.
208
+ let target: Y.Map<unknown> | Y.Array<unknown>
209
+ if (parent instanceof Y.Map) {
210
+ target = parent
211
+ } else if (parent instanceof Y.Array) {
212
+ target = parent
213
+ } else {
214
+ throw new Error(
215
+ `yjs substrate: json-boundary write to unsupported parent type at path ${path.format()}`,
216
+ )
217
+ }
218
+
219
+ // Use the Yjs shared-type's stable identity for the buffer key
220
+ // when available; fall back to a unique sentinel for the
221
+ // ultra-rare case where `_item` is undefined (freshly-created
222
+ // shared types before they're attached). Combine with key/index
223
+ // for a unique slot — repeat writes to the same slot overwrite.
224
+ const targetId = `${(target as any)._item?.id?.client ?? "root"}:${(target as any)._item?.id?.clock ?? "root"}`
225
+ const slot = `${targetId}/${String(key)}`
226
+ jsonBoundaryBuffer.set(slot, { target, key, value })
227
+ }
228
+
229
+ /**
230
+ * Drain the json-boundary buffer into λ. Called from `afterBatch`
231
+ * inside the ambient `Y.transact` opened by `runBatch`. Each entry
232
+ * is applied as `target.set(key, value)` for Y.Map parents or as a
233
+ * delete+insert for Y.Array parents (Yjs Arrays don't have a
234
+ * `set(index, value)` primitive — replace = delete one + insert one).
235
+ */
236
+ function flushJsonBoundaryBuffer(): void {
237
+ if (jsonBoundaryBuffer.size === 0) return
238
+ for (const { target, key, value } of jsonBoundaryBuffer.values()) {
239
+ if (target instanceof Y.Map) {
240
+ target.set(String(key), value)
241
+ } else {
242
+ const index = key as number
243
+ target.delete(index, 1)
244
+ target.insert(index, [value])
245
+ }
246
+ }
247
+ jsonBoundaryBuffer.clear()
248
+ }
249
+
122
250
  // --- Substrate object ---
123
251
 
124
252
  const substrate = {
125
253
  [BACKING_DOC]: doc,
254
+ [DEVTOOLS_HISTORY]: yjsDevtoolsHistory(() => doc),
126
255
 
127
256
  reader: reader,
128
257
 
129
258
  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)
259
+ // Replay writes: λ has already absorbed these ops via
260
+ // Y.applyUpdate at the event-bridge call site; skip σ/λ
261
+ // advance afterBatch(replay) rebuilds σ from λ in one
262
+ // Π pass.
263
+ if (options?.replay) return
264
+
265
+ // Inverse recording under the normal handler. Capture σ at the
266
+ // target path before applyChange mutates the shadow; the
267
+ // recording closure pushes onto the active runBatch frame's
268
+ // stack. Skipped under the undo-replay handler (compensating).
269
+ const record = (
270
+ options as
271
+ | (BatchOptions & { [RECORD_INVERSE]?: RecordInverseFn })
272
+ | undefined
273
+ )?.[RECORD_INVERSE]
274
+ if (record && !options?.compensating) {
275
+ const pre = deepClonePreState(path.read(shadow))
276
+ const inverse = invert(pre, change)
277
+ record(path, inverse)
135
278
  }
136
279
 
137
- if (options?.replay) {
280
+ // Local write — σ advances eagerly. CRDT-side writes happen
281
+ // inside the ambient Y.transact opened by runBatch (the
282
+ // substrate's `runBatch` wraps `executeBatch`'s prepare-loop +
283
+ // flush).
284
+ applyChange(shadow, path, change)
285
+
286
+ // JSON-boundary write: stage a full-value write at the
287
+ // boundary segment of the parent container. Coalesces with
288
+ // repeated writes inside the same subtree (last σ snapshot
289
+ // wins) and lands in λ on `afterBatch` flush.
290
+ const boundary = findJsonBoundary(schema, path, binding)
291
+ if (boundary !== null) {
292
+ stageJsonBoundaryWrite(path, boundary.prefixLength)
138
293
  return
139
294
  }
140
- pendingChanges.push({ path, change })
295
+
296
+ // Non-boundary write: imperatively apply to λ inside the
297
+ // ambient Y.transact. The KYNETA_ORIGIN tag lets the
298
+ // observeDeep bridge below recognise and skip the events we
299
+ // generate here, so the changefeed isn't fired twice.
300
+ applyChangeToYjs(rootMap, schema, path, change, binding)
141
301
  },
142
302
 
143
- onFlush(options?: BatchOptions): void {
303
+ afterBatch(options?: BatchOptions): void {
144
304
  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
- }
305
+ // CRDT merge is a lattice join — re-materialise σ from λ in
306
+ // one Π pass instead of replaying ops incrementally.
307
+ syncShadow(shadow, materializeYjsShadow(doc, schema, binding))
155
308
  return
156
309
  }
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
310
+ // Local write: drain the json-boundary coalescer. Runs inside
311
+ // the ambient Y.transact from `runBatch`; the transact closes
312
+ // when `runBatch`'s body returns, emitting one batched
313
+ // observeDeep event for the whole logical action.
314
+ flushJsonBoundaryBuffer()
315
+ },
316
+
317
+ runBatch(work: () => void, options?: BatchOptions): void {
318
+ // Yjs's native transact nesting collapses inner re-entrant
319
+ // transacts into the outermost — exactly the "one batched
320
+ // event per outermost logical action" semantic we want. No
321
+ // depth counter needed.
322
+ //
323
+ // We mark the transaction via `tr.meta.set` inside the transact body.
324
+ // The mark lives on per-transaction meta, orthogonal to origin.
325
+ // The app-level `options?.origin` flows directly to `transaction.origin`
326
+ // and round-trips to the changefeed layer.
327
+ doc.transact(tr => {
328
+ tr.meta.set(KYNETA_MARK, true)
329
+ work()
330
+ }, options?.origin)
167
331
  },
168
332
 
169
333
  context(): WritableContext {
170
334
  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`,
335
+ cachedCtx = buildWritableContext(substrate, {
336
+ nativeResolver: (
337
+ nodeSchema: SchemaNode,
338
+ path: { segments: readonly unknown[] },
339
+ ) => {
340
+ if (path.segments.length === 0) return doc
341
+ if (nodeSchema[KIND] === "scalar" || nodeSchema[KIND] === "sum")
342
+ return undefined
343
+ return resolveYjsType(rootMap, schema, path as any, binding)
344
+ .resolved
345
+ },
346
+ positionResolver: (
347
+ _nodeSchema: unknown,
348
+ path: { segments: readonly unknown[] },
349
+ ) => {
350
+ return {
351
+ createPosition(index: number, side: Side) {
352
+ // Resolve path to the Y.Text shared type
353
+ const { resolved: ytype } = resolveYjsType(
354
+ rootMap,
355
+ schema,
356
+ path as any,
357
+ binding,
358
+ )
359
+ if (!(ytype instanceof Y.Text)) {
360
+ throw new Error(
361
+ `positionResolver: path does not resolve to a Y.Text`,
362
+ )
363
+ }
364
+ const assoc = toYjsAssoc(side)
365
+ const rpos = Y.createRelativePositionFromTypeIndex(
366
+ ytype,
367
+ index,
368
+ assoc,
199
369
  )
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
- }
370
+ return new YjsPosition(rpos, doc)
371
+ },
372
+ decodePosition(bytes: Uint8Array) {
373
+ const rpos = Y.decodeRelativePosition(bytes)
374
+ return new YjsPosition(rpos, doc)
375
+ },
376
+ } satisfies PositionCapable
377
+ },
378
+ })
215
379
  }
216
380
  return cachedCtx
217
381
  },
218
382
 
219
383
  version(): YjsVersion {
220
- return YjsVersion.fromDeleteSet(doc, accumulatedDs)
384
+ // Derive the deleteSet from the live struct store on every read.
385
+ // Eager-prepare delivers notifications *inside* the ambient
386
+ // `Y.transact` opened by `runBatch`, so `afterTransaction` fires
387
+ // AFTER the changefeed's notify pipeline. Computing from the
388
+ // store picks up in-progress deletes too, so the exchange's
389
+ // auto-subscribe sees a version that already reflects the
390
+ // just-applied mutation.
391
+ return YjsVersion.fromDeleteSet(
392
+ doc,
393
+ Y.createDeleteSetFromStructStore(doc.store),
394
+ )
221
395
  },
222
396
 
223
397
  baseVersion(): YjsVersion {
@@ -275,8 +449,11 @@ export function createYjsSubstrate(
275
449
  // --- Event bridge (registered once at construction) ---
276
450
 
277
451
  rootMap.observeDeep((events, transaction) => {
278
- // Ignore our own transactions (changefeed already captured via wrappedPrepare)
279
- if (transaction.origin === KYNETA_ORIGIN) {
452
+ // Own-commit discriminator: kyneta's runBatch marks the transaction
453
+ // via `tr.meta.set` inside the transact body. The mark survives Yjs's
454
+ // nested-transact collapse, so external code wrapping `batch()` in
455
+ // its own Y.transact is correctly classified as own.
456
+ if (transaction.meta.get(KYNETA_MARK)) {
280
457
  return
281
458
  }
282
459
 
@@ -286,12 +463,6 @@ export function createYjsSubstrate(
286
463
  return
287
464
  }
288
465
 
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
466
  // Determine origin: prefer stashed kyneta origin (from merge),
296
467
  // fall back to the transaction's origin if it's a string.
297
468
  const origin =
@@ -301,26 +472,12 @@ export function createYjsSubstrate(
301
472
  // Lazily ensure the context is built
302
473
  const ctx = substrate.context()
303
474
 
304
- // `replay: true` tells substrate.prepare/onFlush to skip native-side
475
+ // `replay: true` tells substrate.prepare/afterBatch to skip native-side
305
476
  // work (Yjs has already absorbed these ops via Y.applyUpdate) and
306
477
  // surfaces on the Changeset for downstream filters (exchange echo).
307
478
  executeBatch(ctx, ops, { origin, replay: true })
308
479
  })
309
480
 
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
481
  return substrate as Substrate<YjsVersion>
325
482
  }
326
483
 
@@ -333,7 +490,7 @@ export function createYjsSubstrate(
333
490
  *
334
491
  * - `create(schema)` — creates a fresh Y.Doc with empty containers
335
492
  * matching the schema structure. No seed data — initial content
336
- * should be applied via `change()` after construction.
493
+ * should be applied via `batch()` after construction.
337
494
  * - `fromEntirety(payload, schema)` — creates a Y.Doc from an entirety
338
495
  * payload, returns a substrate.
339
496
  * - `parseVersion(serialized)` — deserializes a YjsVersion.
@@ -368,6 +525,33 @@ function trivialBinding(schema: SchemaNode): SchemaBinding {
368
525
  * that need to accumulate state, compute per-peer deltas, and compact
369
526
  * storage without ever interpreting document fields.
370
527
  */
528
+ // ---------------------------------------------------------------------------
529
+ // DevTools history capability (pull) — version/op summary.
530
+ // ---------------------------------------------------------------------------
531
+
532
+ /**
533
+ * Build the `DevtoolsHistory` capability over a Y.Doc accessor.
534
+ *
535
+ * `summary()` only: reliable Yjs time-travel (`valueAt`) requires the doc to
536
+ * be constructed with `gc: false`, which this substrate does not impose (it
537
+ * wraps a user-provided Y.Doc). So `valueAt` is intentionally omitted.
538
+ * Context: jj:qpmkoryn.
539
+ */
540
+ function yjsDevtoolsHistory(getDoc: () => Y.Doc): DevtoolsHistory {
541
+ return {
542
+ summary(): DevtoolsHistorySummary {
543
+ const sv = Y.encodeStateVector(getDoc())
544
+ const actors: Record<string, number> = {}
545
+ let opCount = 0
546
+ for (const [client, clock] of Y.decodeStateVector(sv)) {
547
+ actors[String(client)] = clock
548
+ opCount += clock
549
+ }
550
+ return { version: new YjsVersion(sv).serialize(), opCount, actors }
551
+ },
552
+ }
553
+ }
554
+
371
555
  export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
372
556
  let currentDoc = doc
373
557
  let currentBase: YjsVersion = new YjsVersion(Y.encodeStateVector(new Y.Doc()))
@@ -376,6 +560,7 @@ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
376
560
  get [BACKING_DOC]() {
377
561
  return currentDoc
378
562
  },
563
+ [DEVTOOLS_HISTORY]: yjsDevtoolsHistory(() => currentDoc),
379
564
 
380
565
  version(): YjsVersion {
381
566
  return YjsVersion.fromDoc(currentDoc)