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