@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/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 { Path, Reader, Schema as SchemaNode } from "@kyneta/schema"
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(doc: Y.Doc, schema: SchemaNode): Reader {
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(rootMap, schema, path)
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 new YjsVersion(Y.encodeStateVector(doc))
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: YjsVersion): SubstratePayload | null {
225
+ exportSince(since: Version): SubstratePayload | null {
173
226
  try {
174
- const bytes = Y.encodeStateAsUpdate(doc, since.sv)
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 new YjsVersion(Y.encodeStateVector(currentDoc))
368
+ return YjsVersion.fromDoc(currentDoc)
281
369
  },
282
370
 
283
371
  baseVersion(): YjsVersion {
284
372
  return currentBase
285
373
  },
286
374
 
287
- advance(to: YjsVersion): void {
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 = new YjsVersion(Y.encodeStateVector(currentDoc))
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: YjsVersion): SubstratePayload | null {
405
+ exportSince(since: Version): SubstratePayload | null {
318
406
  try {
319
- const bytes = Y.encodeStateAsUpdate(currentDoc, since.sv)
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
- ensureContainers(doc, schema)
395
- return createYjsSubstrate(doc, schema)
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 implementation wrapping Yjs state vectors.
1
+ // YjsVersion — Version wrapping a Yjs snapshot (state vector + delete set).
2
2
  //
3
- // Yjs state vectors (`Y.encodeStateVector(doc)`) are the complete peer
4
- // state used for sync diffing matching the semantics of kyneta's
5
- // Version interface.
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
- // Serialization uses base64-encoded bytes for text-safe embedding in
8
- // HTML meta tags, script tags, etc.
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
- // Yjs does not export a state vector comparison function, so we
11
- // implement standard version-vector partial-order comparison over
12
- // decoded `Map<number, number>` (clientID clock) maps ourselves.
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 { decodeStateVector } from "yjs"
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
- * State vectors track the complete peer state which operations from
64
- * each client have been observed. This is the right abstraction for sync
65
- * diffing: `exportSince(version)` uses the state vector to compute the
66
- * minimal update payload via `Y.encodeStateAsUpdate(doc, sv)`.
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
- * `serialize()` encodes to base64 for text-safe embedding.
69
- * `compare()` decodes both state vectors and performs standard
70
- * version-vector partial-order comparison over the client-clock maps.
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
- constructor(sv: Uint8Array) {
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
- * Serialize the state vector to a base64 string.
121
+ * Construct a version from a live `Y.Doc` by snapshotting its full state.
81
122
  *
82
- * The encoding is: raw state vector bytes base64.
83
- * This is text-safe for embedding in HTML meta tags, URL parameters, etc.
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
- return uint8ArrayToBase64(this.sv)
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
- * Delegates to the shared `versionVectorCompare` utility after decoding
93
- * both state vectors via `Y.decodeStateVector()`.
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
- return versionVectorCompare(
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 the shared `versionVectorMeet` utility, and encodes the result
112
- * back to a Yjs state vector.
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
- return new YjsVersion(encodeStateVector(result))
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
- * The inverse of `serialize()`: base64 `Uint8Array`.
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 bytes = base64ToUint8Array(serialized)
136
- return new YjsVersion(bytes)
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
  }