@kyneta/yjs-schema 1.3.0 → 1.4.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 +99 -11
- 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,14 +9,22 @@
|
|
|
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,
|
|
@@ -25,12 +33,14 @@ import type {
|
|
|
25
33
|
import {
|
|
26
34
|
BACKING_DOC,
|
|
27
35
|
buildWritableContext,
|
|
36
|
+
deriveSchemaBinding,
|
|
28
37
|
executeBatch,
|
|
29
38
|
KIND,
|
|
30
39
|
} from "@kyneta/schema"
|
|
31
40
|
import * as Y from "yjs"
|
|
32
41
|
import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
|
|
33
42
|
import { ensureContainers } from "./populate.js"
|
|
43
|
+
import { toYjsAssoc, YjsPosition } from "./position.js"
|
|
34
44
|
import { yjsReader } from "./reader.js"
|
|
35
45
|
import { YjsVersion } from "./version.js"
|
|
36
46
|
import { resolveYjsType } from "./yjs-resolve.js"
|
|
@@ -62,10 +72,12 @@ const KYNETA_ORIGIN = "kyneta-prepare"
|
|
|
62
72
|
* @param doc - The Y.Doc to wrap. The substrate does NOT own the doc;
|
|
63
73
|
* the caller is responsible for its lifecycle.
|
|
64
74
|
* @param schema - The root schema for the document.
|
|
75
|
+
* @param binding - Optional SchemaBinding for identity-keyed containers.
|
|
65
76
|
*/
|
|
66
77
|
export function createYjsSubstrate(
|
|
67
78
|
doc: Y.Doc,
|
|
68
79
|
schema: SchemaNode,
|
|
80
|
+
binding?: SchemaBinding,
|
|
69
81
|
): Substrate<YjsVersion> {
|
|
70
82
|
// --- Closure-scoped state ---
|
|
71
83
|
|
|
@@ -84,11 +96,19 @@ export function createYjsSubstrate(
|
|
|
84
96
|
// Lazy-built WritableContext (same pattern as PlainSubstrate / LoroSubstrate).
|
|
85
97
|
let cachedCtx: WritableContext | undefined
|
|
86
98
|
|
|
99
|
+
// Incremental delete set tracking.
|
|
100
|
+
// Initialized once from the struct store (O(n)), then maintained
|
|
101
|
+
// incrementally by merging each transaction's deleteSet (O(delta)).
|
|
102
|
+
// Note: DeleteSet class isn't exported from yjs's public entry point,
|
|
103
|
+
// so we infer the type from createDeleteSet's return type.
|
|
104
|
+
let accumulatedDs: ReturnType<typeof Y.createDeleteSet> =
|
|
105
|
+
Y.createDeleteSetFromStructStore(doc.store)
|
|
106
|
+
|
|
87
107
|
// The root Y.Map — all schema fields are children of this single map.
|
|
88
108
|
const rootMap = doc.getMap("root")
|
|
89
109
|
|
|
90
110
|
// The Reader — live view over the Yjs shared type tree.
|
|
91
|
-
const reader: Reader = yjsReader(doc, schema)
|
|
111
|
+
const reader: Reader = yjsReader(doc, schema, binding)
|
|
92
112
|
|
|
93
113
|
// --- Substrate object ---
|
|
94
114
|
|
|
@@ -115,7 +135,7 @@ export function createYjsSubstrate(
|
|
|
115
135
|
try {
|
|
116
136
|
doc.transact(() => {
|
|
117
137
|
for (const { path, change } of pendingChanges) {
|
|
118
|
-
applyChangeToYjs(rootMap, schema, path, change)
|
|
138
|
+
applyChangeToYjs(rootMap, schema, path, change, binding)
|
|
119
139
|
}
|
|
120
140
|
}, KYNETA_ORIGIN)
|
|
121
141
|
pendingChanges.length = 0
|
|
@@ -139,14 +159,46 @@ export function createYjsSubstrate(
|
|
|
139
159
|
if (path.segments.length === 0) return doc
|
|
140
160
|
if (nodeSchema[KIND] === "scalar" || nodeSchema[KIND] === "sum")
|
|
141
161
|
return undefined
|
|
142
|
-
return resolveYjsType(rootMap, schema, path as any)
|
|
162
|
+
return resolveYjsType(rootMap, schema, path as any, binding).resolved
|
|
163
|
+
}
|
|
164
|
+
;(cachedCtx as any).positionResolver = (
|
|
165
|
+
_nodeSchema: unknown,
|
|
166
|
+
path: { segments: readonly unknown[] },
|
|
167
|
+
) => {
|
|
168
|
+
return {
|
|
169
|
+
createPosition(index: number, side: Side) {
|
|
170
|
+
// Resolve path to the Y.Text shared type
|
|
171
|
+
const { resolved: ytype } = resolveYjsType(
|
|
172
|
+
rootMap,
|
|
173
|
+
schema,
|
|
174
|
+
path as any,
|
|
175
|
+
binding,
|
|
176
|
+
)
|
|
177
|
+
if (!(ytype instanceof Y.Text)) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`positionResolver: path does not resolve to a Y.Text`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
const assoc = toYjsAssoc(side)
|
|
183
|
+
const rpos = Y.createRelativePositionFromTypeIndex(
|
|
184
|
+
ytype,
|
|
185
|
+
index,
|
|
186
|
+
assoc,
|
|
187
|
+
)
|
|
188
|
+
return new YjsPosition(rpos, doc)
|
|
189
|
+
},
|
|
190
|
+
decodePosition(bytes: Uint8Array) {
|
|
191
|
+
const rpos = Y.decodeRelativePosition(bytes)
|
|
192
|
+
return new YjsPosition(rpos, doc)
|
|
193
|
+
},
|
|
194
|
+
} satisfies PositionCapable
|
|
143
195
|
}
|
|
144
196
|
}
|
|
145
197
|
return cachedCtx
|
|
146
198
|
},
|
|
147
199
|
|
|
148
200
|
version(): YjsVersion {
|
|
149
|
-
return
|
|
201
|
+
return YjsVersion.fromDeleteSet(doc, accumulatedDs)
|
|
150
202
|
},
|
|
151
203
|
|
|
152
204
|
baseVersion(): YjsVersion {
|
|
@@ -209,11 +261,17 @@ export function createYjsSubstrate(
|
|
|
209
261
|
}
|
|
210
262
|
|
|
211
263
|
// Convert Yjs events → kyneta Ops
|
|
212
|
-
const ops = eventsToOps(events, schema)
|
|
264
|
+
const ops = eventsToOps(events, schema, binding)
|
|
213
265
|
if (ops.length === 0) {
|
|
214
266
|
return
|
|
215
267
|
}
|
|
216
268
|
|
|
269
|
+
// Update accumulated delete set BEFORE executeBatch, so version()
|
|
270
|
+
// reflects deletes when notifyLocalChange fires from the changefeed.
|
|
271
|
+
if (transaction.deleteSet.clients.size > 0) {
|
|
272
|
+
accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet])
|
|
273
|
+
}
|
|
274
|
+
|
|
217
275
|
// Determine origin: prefer stashed kyneta origin (from merge),
|
|
218
276
|
// fall back to the transaction's origin if it's a string.
|
|
219
277
|
const origin =
|
|
@@ -234,6 +292,20 @@ export function createYjsSubstrate(
|
|
|
234
292
|
}
|
|
235
293
|
})
|
|
236
294
|
|
|
295
|
+
// For local mutations (KYNETA_ORIGIN): the observeDeep handler returns
|
|
296
|
+
// early, so we merge the delete set via afterTransaction instead.
|
|
297
|
+
// afterTransaction fires inside doc.transact() — before onFlush returns
|
|
298
|
+
// — so accumulatedDs is up to date when the changefeed's
|
|
299
|
+
// deliverNotifications → notifyLocalChange → version() fires.
|
|
300
|
+
doc.on("afterTransaction", (transaction: Y.Transaction) => {
|
|
301
|
+
if (
|
|
302
|
+
transaction.origin === KYNETA_ORIGIN &&
|
|
303
|
+
transaction.deleteSet.clients.size > 0
|
|
304
|
+
) {
|
|
305
|
+
accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet])
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
|
|
237
309
|
return substrate as Substrate<YjsVersion>
|
|
238
310
|
}
|
|
239
311
|
|
|
@@ -250,7 +322,21 @@ export function createYjsSubstrate(
|
|
|
250
322
|
* - `fromEntirety(payload, schema)` — creates a Y.Doc from an entirety
|
|
251
323
|
* payload, returns a substrate.
|
|
252
324
|
* - `parseVersion(serialized)` — deserializes a YjsVersion.
|
|
325
|
+
*
|
|
326
|
+
* Uses trivialBinding for identity-keying: every path maps to
|
|
327
|
+
* `deriveIdentity(path, 1)` (generation 1, no renames).
|
|
253
328
|
*/
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Compute a trivial SchemaBinding for a schema with no migration history.
|
|
332
|
+
* Every product field maps to `deriveIdentity(path, 1)`.
|
|
333
|
+
*/
|
|
334
|
+
function trivialBinding(schema: SchemaNode): SchemaBinding {
|
|
335
|
+
if (schema[KIND] === "product") {
|
|
336
|
+
return deriveSchemaBinding(schema as ProductSchema, {})
|
|
337
|
+
}
|
|
338
|
+
return { forward: new Map(), inverse: new Map() }
|
|
339
|
+
}
|
|
254
340
|
// ---------------------------------------------------------------------------
|
|
255
341
|
// yjsReplicaFactory — ReplicaFactory<YjsVersion>
|
|
256
342
|
// ---------------------------------------------------------------------------
|
|
@@ -277,7 +363,7 @@ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
|
|
|
277
363
|
},
|
|
278
364
|
|
|
279
365
|
version(): YjsVersion {
|
|
280
|
-
return
|
|
366
|
+
return YjsVersion.fromDoc(currentDoc)
|
|
281
367
|
},
|
|
282
368
|
|
|
283
369
|
baseVersion(): YjsVersion {
|
|
@@ -303,7 +389,7 @@ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
|
|
|
303
389
|
const newDoc = new Y.Doc()
|
|
304
390
|
Y.applyUpdate(newDoc, update)
|
|
305
391
|
currentDoc = newDoc
|
|
306
|
-
currentBase =
|
|
392
|
+
currentBase = YjsVersion.fromDoc(currentDoc)
|
|
307
393
|
},
|
|
308
394
|
|
|
309
395
|
exportEntirety(): SubstratePayload {
|
|
@@ -381,18 +467,20 @@ export const yjsSubstrateFactory: SubstrateFactory<YjsVersion> = {
|
|
|
381
467
|
schema: SchemaNode,
|
|
382
468
|
): Substrate<YjsVersion> {
|
|
383
469
|
const doc = (replica as any)[BACKING_DOC] as Y.Doc
|
|
470
|
+
const binding = trivialBinding(schema)
|
|
384
471
|
// No identity injection for the standalone factory (no peerId).
|
|
385
472
|
// Conditional ensureContainers: skip fields that already exist
|
|
386
473
|
// from hydrated state.
|
|
387
|
-
ensureContainers(doc, schema, true)
|
|
388
|
-
return createYjsSubstrate(doc, schema)
|
|
474
|
+
ensureContainers(doc, schema, true, binding)
|
|
475
|
+
return createYjsSubstrate(doc, schema, binding)
|
|
389
476
|
},
|
|
390
477
|
|
|
391
478
|
create(schema: SchemaNode): Substrate<YjsVersion> {
|
|
392
479
|
// Fresh doc — unconditional ensureContainers (nothing to conflict with).
|
|
393
480
|
const doc = new Y.Doc()
|
|
394
|
-
|
|
395
|
-
|
|
481
|
+
const binding = trivialBinding(schema)
|
|
482
|
+
ensureContainers(doc, schema, false, binding)
|
|
483
|
+
return createYjsSubstrate(doc, schema, binding)
|
|
396
484
|
},
|
|
397
485
|
|
|
398
486
|
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
|
}
|