@kyneta/yjs-schema 1.3.1 → 1.5.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 +28 -25
- package/dist/index.d.ts +185 -86
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1159 -860
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/bind-constraints.test.ts +39 -30
- package/src/__tests__/bind-yjs.test.ts +53 -16
- package/src/__tests__/position.test.ts +376 -0
- package/src/__tests__/structural-merge.test.ts +111 -54
- package/src/__tests__/substrate.test.ts +18 -0
- package/src/__tests__/version.test.ts +87 -0
- package/src/bind-yjs.ts +44 -37
- package/src/change-mapping.ts +219 -25
- package/src/index.ts +3 -1
- package/src/populate.ts +59 -12
- package/src/position.ts +45 -0
- package/src/reader.ts +62 -6
- package/src/substrate.ts +112 -16
- package/src/version.ts +135 -33
- package/src/yjs-resolve.ts +59 -12
package/src/reader.ts
CHANGED
|
@@ -7,8 +7,23 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Y.Text → .toJSON() (string), Y.Map → .toJSON() (plain object),
|
|
9
9
|
// Y.Array → .toJSON() (plain array), plain values → as-is.
|
|
10
|
+
//
|
|
11
|
+
// Richtext: when the schema at the resolved path is "richtext" and
|
|
12
|
+
// the resolved value is a Y.Text, we call .toDelta() and convert to
|
|
13
|
+
// RichTextDelta (array of { text, marks? } spans) instead of .toJSON().
|
|
14
|
+
//
|
|
15
|
+
// Identity-keying: when a SchemaBinding is provided, resolveYjsType
|
|
16
|
+
// navigates Y.Map children using identity hashes instead of field names.
|
|
10
17
|
|
|
11
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
Path,
|
|
20
|
+
Reader,
|
|
21
|
+
RichTextDelta,
|
|
22
|
+
RichTextSpan,
|
|
23
|
+
SchemaBinding,
|
|
24
|
+
Schema as SchemaNode,
|
|
25
|
+
} from "@kyneta/schema"
|
|
26
|
+
import { KIND } from "@kyneta/schema"
|
|
12
27
|
import * as Y from "yjs"
|
|
13
28
|
import { resolveYjsType } from "./yjs-resolve.js"
|
|
14
29
|
|
|
@@ -38,6 +53,33 @@ function extractValue(resolved: unknown): unknown {
|
|
|
38
53
|
return resolved
|
|
39
54
|
}
|
|
40
55
|
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Rich text delta conversion
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Convert a Y.Text's delta (Quill format) to a kyneta RichTextDelta.
|
|
62
|
+
*
|
|
63
|
+
* Yjs `.toDelta()` returns `{ insert: string, attributes?: Record<string, any> }[]`.
|
|
64
|
+
* Kyneta RichTextDelta is `{ text: string, marks?: MarkMap }[]`.
|
|
65
|
+
*/
|
|
66
|
+
function yTextToRichTextDelta(ytext: Y.Text): RichTextDelta {
|
|
67
|
+
const delta = ytext.toDelta() as Array<{
|
|
68
|
+
insert: string
|
|
69
|
+
attributes?: Record<string, unknown>
|
|
70
|
+
}>
|
|
71
|
+
const spans: RichTextSpan[] = []
|
|
72
|
+
for (const d of delta) {
|
|
73
|
+
if (typeof d.insert !== "string") continue
|
|
74
|
+
const span: RichTextSpan =
|
|
75
|
+
d.attributes && Object.keys(d.attributes).length > 0
|
|
76
|
+
? { text: d.insert, marks: d.attributes }
|
|
77
|
+
: { text: d.insert }
|
|
78
|
+
spans.push(span)
|
|
79
|
+
}
|
|
80
|
+
return spans
|
|
81
|
+
}
|
|
82
|
+
|
|
41
83
|
// ---------------------------------------------------------------------------
|
|
42
84
|
// yjsReader
|
|
43
85
|
// ---------------------------------------------------------------------------
|
|
@@ -55,8 +97,13 @@ function extractValue(resolved: unknown): unknown {
|
|
|
55
97
|
*
|
|
56
98
|
* @param doc - The Y.Doc to read from.
|
|
57
99
|
* @param schema - The root schema for the document.
|
|
100
|
+
* @param binding - Optional SchemaBinding for identity-keyed navigation.
|
|
58
101
|
*/
|
|
59
|
-
export function yjsReader(
|
|
102
|
+
export function yjsReader(
|
|
103
|
+
doc: Y.Doc,
|
|
104
|
+
schema: SchemaNode,
|
|
105
|
+
binding?: SchemaBinding,
|
|
106
|
+
): Reader {
|
|
60
107
|
const rootMap = doc.getMap("root")
|
|
61
108
|
|
|
62
109
|
return {
|
|
@@ -65,12 +112,21 @@ export function yjsReader(doc: Y.Doc, schema: SchemaNode): Reader {
|
|
|
65
112
|
// Root read — return the full root map as JSON
|
|
66
113
|
return rootMap.toJSON()
|
|
67
114
|
}
|
|
68
|
-
const resolved = resolveYjsType(
|
|
115
|
+
const { resolved, schema: nodeSchema } = resolveYjsType(
|
|
116
|
+
rootMap,
|
|
117
|
+
schema,
|
|
118
|
+
path,
|
|
119
|
+
binding,
|
|
120
|
+
)
|
|
121
|
+
// Richtext: Y.Text at a richtext schema position → RichTextDelta
|
|
122
|
+
if (nodeSchema[KIND] === "richtext" && resolved instanceof Y.Text) {
|
|
123
|
+
return yTextToRichTextDelta(resolved)
|
|
124
|
+
}
|
|
69
125
|
return extractValue(resolved)
|
|
70
126
|
},
|
|
71
127
|
|
|
72
128
|
arrayLength(path: Path): number {
|
|
73
|
-
const resolved = resolveYjsType(rootMap, schema, path)
|
|
129
|
+
const { resolved } = resolveYjsType(rootMap, schema, path, binding)
|
|
74
130
|
if (resolved instanceof Y.Array) {
|
|
75
131
|
return resolved.length
|
|
76
132
|
}
|
|
@@ -82,7 +138,7 @@ export function yjsReader(doc: Y.Doc, schema: SchemaNode): Reader {
|
|
|
82
138
|
},
|
|
83
139
|
|
|
84
140
|
keys(path: Path): string[] {
|
|
85
|
-
const resolved = resolveYjsType(rootMap, schema, path)
|
|
141
|
+
const { resolved } = resolveYjsType(rootMap, schema, path, binding)
|
|
86
142
|
if (resolved instanceof Y.Map) {
|
|
87
143
|
return Array.from(resolved.keys())
|
|
88
144
|
}
|
|
@@ -99,7 +155,7 @@ export function yjsReader(doc: Y.Doc, schema: SchemaNode): Reader {
|
|
|
99
155
|
},
|
|
100
156
|
|
|
101
157
|
hasKey(path: Path, key: string): boolean {
|
|
102
|
-
const resolved = resolveYjsType(rootMap, schema, path)
|
|
158
|
+
const { resolved } = resolveYjsType(rootMap, schema, path, binding)
|
|
103
159
|
if (resolved instanceof Y.Map) {
|
|
104
160
|
return resolved.has(key)
|
|
105
161
|
}
|
package/src/substrate.ts
CHANGED
|
@@ -9,28 +9,39 @@
|
|
|
9
9
|
// means subscribing to the kyneta doc observes ALL mutations to the
|
|
10
10
|
// underlying Y.Doc, regardless of source (local kyneta writes,
|
|
11
11
|
// merge, external Y.applyUpdate, external raw Yjs API mutations).
|
|
12
|
+
//
|
|
13
|
+
// Identity-keying: when a SchemaBinding is provided, all Y.Map key
|
|
14
|
+
// lookups and writes use the identity hash instead of the field name.
|
|
15
|
+
// The binding is threaded to the reader, event bridge, and write path.
|
|
12
16
|
|
|
13
17
|
import type {
|
|
14
18
|
ChangeBase,
|
|
15
19
|
Path,
|
|
20
|
+
PositionCapable,
|
|
21
|
+
ProductSchema,
|
|
16
22
|
Reader,
|
|
17
23
|
Replica,
|
|
18
24
|
ReplicaFactory,
|
|
25
|
+
SchemaBinding,
|
|
19
26
|
Schema as SchemaNode,
|
|
27
|
+
Side,
|
|
20
28
|
Substrate,
|
|
21
29
|
SubstrateFactory,
|
|
22
30
|
SubstratePayload,
|
|
31
|
+
Version,
|
|
23
32
|
WritableContext,
|
|
24
33
|
} from "@kyneta/schema"
|
|
25
34
|
import {
|
|
26
35
|
BACKING_DOC,
|
|
27
36
|
buildWritableContext,
|
|
37
|
+
deriveSchemaBinding,
|
|
28
38
|
executeBatch,
|
|
29
39
|
KIND,
|
|
30
40
|
} from "@kyneta/schema"
|
|
31
41
|
import * as Y from "yjs"
|
|
32
42
|
import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
|
|
33
43
|
import { ensureContainers } from "./populate.js"
|
|
44
|
+
import { toYjsAssoc, YjsPosition } from "./position.js"
|
|
34
45
|
import { yjsReader } from "./reader.js"
|
|
35
46
|
import { YjsVersion } from "./version.js"
|
|
36
47
|
import { resolveYjsType } from "./yjs-resolve.js"
|
|
@@ -62,10 +73,12 @@ const KYNETA_ORIGIN = "kyneta-prepare"
|
|
|
62
73
|
* @param doc - The Y.Doc to wrap. The substrate does NOT own the doc;
|
|
63
74
|
* the caller is responsible for its lifecycle.
|
|
64
75
|
* @param schema - The root schema for the document.
|
|
76
|
+
* @param binding - Optional SchemaBinding for identity-keyed containers.
|
|
65
77
|
*/
|
|
66
78
|
export function createYjsSubstrate(
|
|
67
79
|
doc: Y.Doc,
|
|
68
80
|
schema: SchemaNode,
|
|
81
|
+
binding?: SchemaBinding,
|
|
69
82
|
): Substrate<YjsVersion> {
|
|
70
83
|
// --- Closure-scoped state ---
|
|
71
84
|
|
|
@@ -84,11 +97,19 @@ export function createYjsSubstrate(
|
|
|
84
97
|
// Lazy-built WritableContext (same pattern as PlainSubstrate / LoroSubstrate).
|
|
85
98
|
let cachedCtx: WritableContext | undefined
|
|
86
99
|
|
|
100
|
+
// Incremental delete set tracking.
|
|
101
|
+
// Initialized once from the struct store (O(n)), then maintained
|
|
102
|
+
// incrementally by merging each transaction's deleteSet (O(delta)).
|
|
103
|
+
// Note: DeleteSet class isn't exported from yjs's public entry point,
|
|
104
|
+
// so we infer the type from createDeleteSet's return type.
|
|
105
|
+
let accumulatedDs: ReturnType<typeof Y.createDeleteSet> =
|
|
106
|
+
Y.createDeleteSetFromStructStore(doc.store)
|
|
107
|
+
|
|
87
108
|
// The root Y.Map — all schema fields are children of this single map.
|
|
88
109
|
const rootMap = doc.getMap("root")
|
|
89
110
|
|
|
90
111
|
// The Reader — live view over the Yjs shared type tree.
|
|
91
|
-
const reader: Reader = yjsReader(doc, schema)
|
|
112
|
+
const reader: Reader = yjsReader(doc, schema, binding)
|
|
92
113
|
|
|
93
114
|
// --- Substrate object ---
|
|
94
115
|
|
|
@@ -115,7 +136,7 @@ export function createYjsSubstrate(
|
|
|
115
136
|
try {
|
|
116
137
|
doc.transact(() => {
|
|
117
138
|
for (const { path, change } of pendingChanges) {
|
|
118
|
-
applyChangeToYjs(rootMap, schema, path, change)
|
|
139
|
+
applyChangeToYjs(rootMap, schema, path, change, binding)
|
|
119
140
|
}
|
|
120
141
|
}, KYNETA_ORIGIN)
|
|
121
142
|
pendingChanges.length = 0
|
|
@@ -139,14 +160,46 @@ export function createYjsSubstrate(
|
|
|
139
160
|
if (path.segments.length === 0) return doc
|
|
140
161
|
if (nodeSchema[KIND] === "scalar" || nodeSchema[KIND] === "sum")
|
|
141
162
|
return undefined
|
|
142
|
-
return resolveYjsType(rootMap, schema, path as any)
|
|
163
|
+
return resolveYjsType(rootMap, schema, path as any, binding).resolved
|
|
164
|
+
}
|
|
165
|
+
;(cachedCtx as any).positionResolver = (
|
|
166
|
+
_nodeSchema: unknown,
|
|
167
|
+
path: { segments: readonly unknown[] },
|
|
168
|
+
) => {
|
|
169
|
+
return {
|
|
170
|
+
createPosition(index: number, side: Side) {
|
|
171
|
+
// Resolve path to the Y.Text shared type
|
|
172
|
+
const { resolved: ytype } = resolveYjsType(
|
|
173
|
+
rootMap,
|
|
174
|
+
schema,
|
|
175
|
+
path as any,
|
|
176
|
+
binding,
|
|
177
|
+
)
|
|
178
|
+
if (!(ytype instanceof Y.Text)) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`positionResolver: path does not resolve to a Y.Text`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
const assoc = toYjsAssoc(side)
|
|
184
|
+
const rpos = Y.createRelativePositionFromTypeIndex(
|
|
185
|
+
ytype,
|
|
186
|
+
index,
|
|
187
|
+
assoc,
|
|
188
|
+
)
|
|
189
|
+
return new YjsPosition(rpos, doc)
|
|
190
|
+
},
|
|
191
|
+
decodePosition(bytes: Uint8Array) {
|
|
192
|
+
const rpos = Y.decodeRelativePosition(bytes)
|
|
193
|
+
return new YjsPosition(rpos, doc)
|
|
194
|
+
},
|
|
195
|
+
} satisfies PositionCapable
|
|
143
196
|
}
|
|
144
197
|
}
|
|
145
198
|
return cachedCtx
|
|
146
199
|
},
|
|
147
200
|
|
|
148
201
|
version(): YjsVersion {
|
|
149
|
-
return
|
|
202
|
+
return YjsVersion.fromDeleteSet(doc, accumulatedDs)
|
|
150
203
|
},
|
|
151
204
|
|
|
152
205
|
baseVersion(): YjsVersion {
|
|
@@ -169,9 +222,10 @@ export function createYjsSubstrate(
|
|
|
169
222
|
}
|
|
170
223
|
},
|
|
171
224
|
|
|
172
|
-
exportSince(since:
|
|
225
|
+
exportSince(since: Version): SubstratePayload | null {
|
|
173
226
|
try {
|
|
174
|
-
|
|
227
|
+
// ReplicaLike variance: signature uses Version, runtime type is always YjsVersion.
|
|
228
|
+
const bytes = Y.encodeStateAsUpdate(doc, (since as YjsVersion).sv)
|
|
175
229
|
return { kind: "since", encoding: "binary", data: bytes }
|
|
176
230
|
} catch {
|
|
177
231
|
return null
|
|
@@ -209,11 +263,17 @@ export function createYjsSubstrate(
|
|
|
209
263
|
}
|
|
210
264
|
|
|
211
265
|
// Convert Yjs events → kyneta Ops
|
|
212
|
-
const ops = eventsToOps(events, schema)
|
|
266
|
+
const ops = eventsToOps(events, schema, binding)
|
|
213
267
|
if (ops.length === 0) {
|
|
214
268
|
return
|
|
215
269
|
}
|
|
216
270
|
|
|
271
|
+
// Update accumulated delete set BEFORE executeBatch, so version()
|
|
272
|
+
// reflects deletes when notifyLocalChange fires from the changefeed.
|
|
273
|
+
if (transaction.deleteSet.clients.size > 0) {
|
|
274
|
+
accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet])
|
|
275
|
+
}
|
|
276
|
+
|
|
217
277
|
// Determine origin: prefer stashed kyneta origin (from merge),
|
|
218
278
|
// fall back to the transaction's origin if it's a string.
|
|
219
279
|
const origin =
|
|
@@ -234,6 +294,20 @@ export function createYjsSubstrate(
|
|
|
234
294
|
}
|
|
235
295
|
})
|
|
236
296
|
|
|
297
|
+
// For local mutations (KYNETA_ORIGIN): the observeDeep handler returns
|
|
298
|
+
// early, so we merge the delete set via afterTransaction instead.
|
|
299
|
+
// afterTransaction fires inside doc.transact() — before onFlush returns
|
|
300
|
+
// — so accumulatedDs is up to date when the changefeed's
|
|
301
|
+
// deliverNotifications → notifyLocalChange → version() fires.
|
|
302
|
+
doc.on("afterTransaction", (transaction: Y.Transaction) => {
|
|
303
|
+
if (
|
|
304
|
+
transaction.origin === KYNETA_ORIGIN &&
|
|
305
|
+
transaction.deleteSet.clients.size > 0
|
|
306
|
+
) {
|
|
307
|
+
accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet])
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
237
311
|
return substrate as Substrate<YjsVersion>
|
|
238
312
|
}
|
|
239
313
|
|
|
@@ -250,7 +324,21 @@ export function createYjsSubstrate(
|
|
|
250
324
|
* - `fromEntirety(payload, schema)` — creates a Y.Doc from an entirety
|
|
251
325
|
* payload, returns a substrate.
|
|
252
326
|
* - `parseVersion(serialized)` — deserializes a YjsVersion.
|
|
327
|
+
*
|
|
328
|
+
* Uses trivialBinding for identity-keying: every path maps to
|
|
329
|
+
* `deriveIdentity(path, 1)` (generation 1, no renames).
|
|
253
330
|
*/
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Compute a trivial SchemaBinding for a schema with no migration history.
|
|
334
|
+
* Every product field maps to `deriveIdentity(path, 1)`.
|
|
335
|
+
*/
|
|
336
|
+
function trivialBinding(schema: SchemaNode): SchemaBinding {
|
|
337
|
+
if (schema[KIND] === "product") {
|
|
338
|
+
return deriveSchemaBinding(schema as ProductSchema, {})
|
|
339
|
+
}
|
|
340
|
+
return { forward: new Map(), inverse: new Map() }
|
|
341
|
+
}
|
|
254
342
|
// ---------------------------------------------------------------------------
|
|
255
343
|
// yjsReplicaFactory — ReplicaFactory<YjsVersion>
|
|
256
344
|
// ---------------------------------------------------------------------------
|
|
@@ -277,14 +365,14 @@ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
|
|
|
277
365
|
},
|
|
278
366
|
|
|
279
367
|
version(): YjsVersion {
|
|
280
|
-
return
|
|
368
|
+
return YjsVersion.fromDoc(currentDoc)
|
|
281
369
|
},
|
|
282
370
|
|
|
283
371
|
baseVersion(): YjsVersion {
|
|
284
372
|
return currentBase
|
|
285
373
|
},
|
|
286
374
|
|
|
287
|
-
advance(to:
|
|
375
|
+
advance(to: Version): void {
|
|
288
376
|
const baseCmp = currentBase.compare(to)
|
|
289
377
|
if (baseCmp === "ahead") {
|
|
290
378
|
throw new Error("advance(): target is behind base version")
|
|
@@ -303,7 +391,7 @@ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
|
|
|
303
391
|
const newDoc = new Y.Doc()
|
|
304
392
|
Y.applyUpdate(newDoc, update)
|
|
305
393
|
currentDoc = newDoc
|
|
306
|
-
currentBase =
|
|
394
|
+
currentBase = YjsVersion.fromDoc(currentDoc)
|
|
307
395
|
},
|
|
308
396
|
|
|
309
397
|
exportEntirety(): SubstratePayload {
|
|
@@ -314,9 +402,15 @@ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
|
|
|
314
402
|
}
|
|
315
403
|
},
|
|
316
404
|
|
|
317
|
-
exportSince(since:
|
|
405
|
+
exportSince(since: Version): SubstratePayload | null {
|
|
318
406
|
try {
|
|
319
|
-
|
|
407
|
+
// The ReplicaLike contract uses the base `Version` type for variance
|
|
408
|
+
// safety. At runtime the synchronizer always passes a YjsVersion from
|
|
409
|
+
// this replica's own factory — the cast is sound.
|
|
410
|
+
const bytes = Y.encodeStateAsUpdate(
|
|
411
|
+
currentDoc,
|
|
412
|
+
(since as YjsVersion).sv,
|
|
413
|
+
)
|
|
320
414
|
return { kind: "since", encoding: "binary", data: bytes }
|
|
321
415
|
} catch {
|
|
322
416
|
return null
|
|
@@ -381,18 +475,20 @@ export const yjsSubstrateFactory: SubstrateFactory<YjsVersion> = {
|
|
|
381
475
|
schema: SchemaNode,
|
|
382
476
|
): Substrate<YjsVersion> {
|
|
383
477
|
const doc = (replica as any)[BACKING_DOC] as Y.Doc
|
|
478
|
+
const binding = trivialBinding(schema)
|
|
384
479
|
// No identity injection for the standalone factory (no peerId).
|
|
385
480
|
// Conditional ensureContainers: skip fields that already exist
|
|
386
481
|
// from hydrated state.
|
|
387
|
-
ensureContainers(doc, schema, true)
|
|
388
|
-
return createYjsSubstrate(doc, schema)
|
|
482
|
+
ensureContainers(doc, schema, true, binding)
|
|
483
|
+
return createYjsSubstrate(doc, schema, binding)
|
|
389
484
|
},
|
|
390
485
|
|
|
391
486
|
create(schema: SchemaNode): Substrate<YjsVersion> {
|
|
392
487
|
// Fresh doc — unconditional ensureContainers (nothing to conflict with).
|
|
393
488
|
const doc = new Y.Doc()
|
|
394
|
-
|
|
395
|
-
|
|
489
|
+
const binding = trivialBinding(schema)
|
|
490
|
+
ensureContainers(doc, schema, false, binding)
|
|
491
|
+
return createYjsSubstrate(doc, schema, binding)
|
|
396
492
|
},
|
|
397
493
|
|
|
398
494
|
fromEntirety(
|
package/src/version.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
// YjsVersion — Version
|
|
1
|
+
// YjsVersion — Version wrapping a Yjs snapshot (state vector + delete set).
|
|
2
2
|
//
|
|
3
|
-
// Yjs state
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// The Yjs state vector only tracks inserted items — it does NOT advance
|
|
4
|
+
// when items are deleted (tombstoned). A version based on the state vector
|
|
5
|
+
// alone cannot detect delete-only changes, causing the sync protocol to
|
|
6
|
+
// skip pushing deletes to peers.
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
8
|
+
// YjsVersion wraps the full Yjs Snapshot (state vector + delete set) so
|
|
9
|
+
// that compare() faithfully distinguishes "same state" from "divergent
|
|
10
|
+
// deletes." The state vector component is kept separately for exportSince(),
|
|
11
|
+
// which uses it to compute the minimal update payload.
|
|
9
12
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
+
// Serialization format: base64(sv) + "." + base64(snapshotBytes).
|
|
14
|
+
// Legacy format (no "."): base64(sv) only — decoded as SV-only version
|
|
15
|
+
// for backward compatibility. When a legacy version is compared against
|
|
16
|
+
// a new-format version with matching SVs, the differing snapshot bytes
|
|
17
|
+
// yield "concurrent", triggering a (redundant but safe) sync push.
|
|
13
18
|
|
|
14
19
|
import type { Version } from "@kyneta/schema"
|
|
15
20
|
import {
|
|
@@ -18,7 +23,14 @@ import {
|
|
|
18
23
|
versionVectorCompare,
|
|
19
24
|
versionVectorMeet,
|
|
20
25
|
} from "@kyneta/schema"
|
|
21
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
createSnapshot,
|
|
28
|
+
type Doc,
|
|
29
|
+
decodeStateVector,
|
|
30
|
+
encodeSnapshot,
|
|
31
|
+
encodeStateVector as yjsEncodeStateVector,
|
|
32
|
+
snapshot as yjsSnapshot,
|
|
33
|
+
} from "yjs"
|
|
22
34
|
|
|
23
35
|
// ---------------------------------------------------------------------------
|
|
24
36
|
// State vector encoding — manual varint (unsigned LEB128)
|
|
@@ -53,44 +65,112 @@ function encodeStateVector(map: Map<number, number>): Uint8Array {
|
|
|
53
65
|
return new Uint8Array(bytes)
|
|
54
66
|
}
|
|
55
67
|
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Byte-level equality
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
73
|
+
if (a.length !== b.length) return false
|
|
74
|
+
for (let i = 0; i < a.length; i++) {
|
|
75
|
+
if (a[i] !== b[i]) return false
|
|
76
|
+
}
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
// ---------------------------------------------------------------------------
|
|
57
81
|
// YjsVersion
|
|
58
82
|
// ---------------------------------------------------------------------------
|
|
59
83
|
|
|
60
84
|
/**
|
|
61
|
-
* A Version wrapping a Yjs state vector.
|
|
85
|
+
* A Version wrapping a Yjs snapshot (state vector + delete set).
|
|
86
|
+
*
|
|
87
|
+
* The state vector tracks which insertions from each client have been
|
|
88
|
+
* observed. The delete set tracks which of those items have been
|
|
89
|
+
* tombstoned. Together they fully describe a Yjs document's state.
|
|
62
90
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
91
|
+
* - `sv` is used by `exportSince()` to compute the minimal update payload
|
|
92
|
+
* via `Y.encodeStateAsUpdate(doc, sv)`.
|
|
93
|
+
* - `snapshotBytes` is the encoded Yjs `Snapshot` (SV + delete set),
|
|
94
|
+
* used for equality comparison: two documents are "equal" only when
|
|
95
|
+
* both their inserts and deletes match.
|
|
67
96
|
*
|
|
68
|
-
* `
|
|
69
|
-
*
|
|
70
|
-
*
|
|
97
|
+
* `compare()` first performs standard version-vector partial-order
|
|
98
|
+
* comparison on the state vectors. If the SVs are equal, it falls
|
|
99
|
+
* through to a byte-level comparison of the snapshot bytes — if they
|
|
100
|
+
* differ (same inserts, different deletes), the result is "concurrent",
|
|
101
|
+
* ensuring the sync protocol pushes the divergent deletes.
|
|
71
102
|
*/
|
|
72
103
|
export class YjsVersion implements Version {
|
|
104
|
+
/** Encoded state vector — used by exportSince(). */
|
|
73
105
|
readonly sv: Uint8Array
|
|
74
106
|
|
|
75
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Encoded Yjs snapshot (state vector + delete set) — used for equality
|
|
109
|
+
* comparison. Two documents are "equal" only if both their inserts
|
|
110
|
+
* (state vector) and deletes (delete set) match.
|
|
111
|
+
*/
|
|
112
|
+
readonly snapshotBytes: Uint8Array
|
|
113
|
+
|
|
114
|
+
constructor(sv: Uint8Array, snapshotBytes?: Uint8Array) {
|
|
76
115
|
this.sv = sv
|
|
116
|
+
// If no snapshot provided, use sv as snapshot (backward compat / SV-only).
|
|
117
|
+
this.snapshotBytes = snapshotBytes ?? sv
|
|
77
118
|
}
|
|
78
119
|
|
|
79
120
|
/**
|
|
80
|
-
*
|
|
121
|
+
* Construct a version from a live `Y.Doc` by snapshotting its full state.
|
|
81
122
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
123
|
+
* Walks the struct store to derive the delete set — O(n) in the number
|
|
124
|
+
* of items. Use {@link fromDeleteSet} for the incremental path.
|
|
125
|
+
*/
|
|
126
|
+
static fromDoc(doc: Doc): YjsVersion {
|
|
127
|
+
const sv = yjsEncodeStateVector(doc)
|
|
128
|
+
const snap = encodeSnapshot(yjsSnapshot(doc))
|
|
129
|
+
return new YjsVersion(sv, snap)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Construct a version from a `Y.Doc`'s state vector and an externally
|
|
134
|
+
* maintained delete set — the incremental path that avoids a struct
|
|
135
|
+
* store walk.
|
|
136
|
+
*
|
|
137
|
+
* @param doc The live Y.Doc (for the state vector).
|
|
138
|
+
* @param ds An accumulated delete set, kept in sync by merging
|
|
139
|
+
* `transaction.deleteSet` on each transaction.
|
|
140
|
+
*/
|
|
141
|
+
static fromDeleteSet(
|
|
142
|
+
doc: Doc,
|
|
143
|
+
ds: ReturnType<typeof import("yjs").createDeleteSet>,
|
|
144
|
+
): YjsVersion {
|
|
145
|
+
const sv = yjsEncodeStateVector(doc)
|
|
146
|
+
const svMap = decodeStateVector(sv)
|
|
147
|
+
const snap = encodeSnapshot(createSnapshot(ds, svMap))
|
|
148
|
+
return new YjsVersion(sv, snap)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Serialize to a text-safe string.
|
|
153
|
+
*
|
|
154
|
+
* Format: `base64(sv) + "." + base64(snapshotBytes)`.
|
|
155
|
+
* The "." separator is unambiguous since base64 never contains ".".
|
|
84
156
|
*/
|
|
85
157
|
serialize(): string {
|
|
86
|
-
|
|
158
|
+
const svB64 = uint8ArrayToBase64(this.sv)
|
|
159
|
+
const snapB64 = uint8ArrayToBase64(this.snapshotBytes)
|
|
160
|
+
return svB64 + "." + snapB64
|
|
87
161
|
}
|
|
88
162
|
|
|
89
163
|
/**
|
|
90
|
-
* Compare with another version using version-vector partial order
|
|
164
|
+
* Compare with another version using version-vector partial order,
|
|
165
|
+
* extended with delete-set equality checking.
|
|
91
166
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
167
|
+
* 1. Decode both state vectors and compare via `versionVectorCompare`.
|
|
168
|
+
* 2. If the SV comparison yields anything other than "equal", return it.
|
|
169
|
+
* 3. If the SVs are equal, compare snapshot bytes for byte equality.
|
|
170
|
+
* If they differ (same inserts, different deletes), return "concurrent"
|
|
171
|
+
* — both sides may have tombstones the other lacks.
|
|
172
|
+
* 4. "equal" is returned only when BOTH the state vector AND the
|
|
173
|
+
* delete set match.
|
|
94
174
|
*
|
|
95
175
|
* @throws If `other` is not a `YjsVersion`.
|
|
96
176
|
*/
|
|
@@ -98,18 +178,27 @@ export class YjsVersion implements Version {
|
|
|
98
178
|
if (!(other instanceof YjsVersion)) {
|
|
99
179
|
throw new Error("YjsVersion can only be compared with another YjsVersion")
|
|
100
180
|
}
|
|
101
|
-
|
|
181
|
+
const svResult = versionVectorCompare(
|
|
102
182
|
decodeStateVector(this.sv),
|
|
103
183
|
decodeStateVector(other.sv),
|
|
104
184
|
)
|
|
185
|
+
if (svResult !== "equal") return svResult
|
|
186
|
+
// State vectors are equal — check if delete sets match via snapshot bytes.
|
|
187
|
+
return arraysEqual(this.snapshotBytes, other.snapshotBytes)
|
|
188
|
+
? "equal"
|
|
189
|
+
: "concurrent"
|
|
105
190
|
}
|
|
106
191
|
|
|
107
192
|
/**
|
|
108
193
|
* Greatest lower bound (lattice meet) of two Yjs versions.
|
|
109
194
|
*
|
|
110
195
|
* Decodes both state vectors, computes the component-wise minimum
|
|
111
|
-
* via
|
|
112
|
-
*
|
|
196
|
+
* via `versionVectorMeet`, and encodes the result back to a Yjs
|
|
197
|
+
* state vector.
|
|
198
|
+
*
|
|
199
|
+
* The meet snapshot uses the meet SV with no delete-set information
|
|
200
|
+
* (conservative lower bound). meet() feeds into advance(), which Yjs
|
|
201
|
+
* does not support incrementally, so this is safe.
|
|
113
202
|
*
|
|
114
203
|
* @throws If `other` is not a `YjsVersion`.
|
|
115
204
|
*/
|
|
@@ -120,19 +209,32 @@ export class YjsVersion implements Version {
|
|
|
120
209
|
const thisMap = decodeStateVector(this.sv)
|
|
121
210
|
const otherMap = decodeStateVector(other.sv)
|
|
122
211
|
const result = versionVectorMeet(thisMap, otherMap)
|
|
123
|
-
|
|
212
|
+
const meetSv = encodeStateVector(result)
|
|
213
|
+
// Conservative: meet snapshot uses only the meet SV (no delete set).
|
|
214
|
+
return new YjsVersion(meetSv)
|
|
124
215
|
}
|
|
125
216
|
|
|
126
217
|
/**
|
|
127
218
|
* Parse a serialized YjsVersion string back into a YjsVersion.
|
|
128
219
|
*
|
|
129
|
-
*
|
|
220
|
+
* New format: `base64(sv) + "." + base64(snapshotBytes)`.
|
|
221
|
+
* Legacy format (no "."): `base64(sv)` only — constructed with
|
|
222
|
+
* `snapshotBytes` equal to the SV bytes. When compared against a
|
|
223
|
+
* new-format version with matching SVs, the differing snapshot bytes
|
|
224
|
+
* yield "concurrent", triggering a safe redundant sync push.
|
|
130
225
|
*/
|
|
131
226
|
static parse(serialized: string): YjsVersion {
|
|
132
227
|
if (serialized === "") {
|
|
133
228
|
throw new Error("Invalid YjsVersion value: (empty string)")
|
|
134
229
|
}
|
|
135
|
-
const
|
|
136
|
-
|
|
230
|
+
const dotIndex = serialized.indexOf(".")
|
|
231
|
+
if (dotIndex === -1) {
|
|
232
|
+
// Legacy format: SV-only (no delete set).
|
|
233
|
+
const bytes = base64ToUint8Array(serialized)
|
|
234
|
+
return new YjsVersion(bytes)
|
|
235
|
+
}
|
|
236
|
+
const sv = base64ToUint8Array(serialized.slice(0, dotIndex))
|
|
237
|
+
const snapshotBytes = base64ToUint8Array(serialized.slice(dotIndex + 1))
|
|
238
|
+
return new YjsVersion(sv, snapshotBytes)
|
|
137
239
|
}
|
|
138
240
|
}
|