@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.
- package/README.md +6 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +121 -37
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/bind-yjs.test.ts +7 -7
- package/src/__tests__/create.test.ts +60 -49
- package/src/__tests__/eager-write-coherence.test.ts +321 -0
- package/src/__tests__/materialize.test.ts +13 -13
- package/src/__tests__/position.test.ts +18 -18
- package/src/__tests__/record-text-spike.test.ts +34 -34
- package/src/__tests__/substrate.test.ts +106 -51
- package/src/bind-yjs.ts +1 -1
- package/src/change-mapping.ts +11 -13
- package/src/index.ts +1 -1
- package/src/populate.ts +13 -1
- package/src/substrate.ts +298 -113
package/src/change-mapping.ts
CHANGED
|
@@ -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
|
-
"
|
|
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
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 `
|
|
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
|
-
// `
|
|
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
|
-
//
|
|
88
|
+
// Own-commit discriminator
|
|
63
89
|
// ---------------------------------------------------------------------------
|
|
64
90
|
|
|
65
|
-
|
|
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
|
-
//
|
|
98
|
-
|
|
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
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
+
afterBatch(options?: BatchOptions): void {
|
|
144
304
|
if (options?.replay) {
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
279
|
-
|
|
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/
|
|
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 `
|
|
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)
|