@kyneta/yjs-schema 1.0.0 → 1.2.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/substrate.ts CHANGED
@@ -8,25 +8,26 @@
8
8
  // The event bridge contract: wrapping a Y.Doc in a kyneta substrate
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
- // importDelta, external Y.applyUpdate, external raw Yjs API mutations).
11
+ // merge, external Y.applyUpdate, external raw Yjs API mutations).
12
12
 
13
13
  import type {
14
14
  ChangeBase,
15
15
  Path,
16
+ Reader,
17
+ Replica,
18
+ ReplicaFactory,
16
19
  Schema as SchemaNode,
17
- StoreReader,
18
20
  Substrate,
19
21
  SubstrateFactory,
20
22
  SubstratePayload,
21
23
  WritableContext,
22
24
  } from "@kyneta/schema"
23
- import { buildWritableContext, executeBatch } from "@kyneta/schema"
25
+ import { BACKING_DOC, buildWritableContext, executeBatch } from "@kyneta/schema"
24
26
  import * as Y from "yjs"
25
27
  import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
26
28
  import { ensureContainers } from "./populate.js"
27
- import { yjsStoreReader } from "./store-reader.js"
29
+ import { yjsReader } from "./reader.js"
28
30
  import { YjsVersion } from "./version.js"
29
- import { registerYjsSubstrate } from "./yjs-escape.js"
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Origin tag — used to suppress echo from our own transactions
@@ -44,11 +45,11 @@ const KYNETA_ORIGIN = "kyneta-prepare"
44
45
  * This is the "bring your own doc" entry point. The user creates and
45
46
  * manages the Y.Doc (possibly via a Yjs provider); this function wraps
46
47
  * it with a schema-aware overlay providing typed reads, writes,
47
- * versioning, and export/import through the standard Substrate interface.
48
+ * versioning, and export/merge through the standard Substrate interface.
48
49
  *
49
50
  * **Event bridge contract:** A persistent `observeDeep` handler is
50
51
  * registered on the root Y.Map at construction time. All non-kyneta
51
- * mutations to the Y.Doc (imports, external local writes) are bridged
52
+ * mutations to the Y.Doc (merges, external local writes) are bridged
52
53
  * to the kyneta changefeed. Subscribing to the kyneta doc observes all
53
54
  * mutations regardless of source.
54
55
  *
@@ -71,8 +72,8 @@ export function createYjsSubstrate(
71
72
  // to be), and onFlush() skips transact/commit.
72
73
  let inOurTransaction = false
73
74
 
74
- // Stashed origin from importDelta for the event bridge to pick up.
75
- let pendingImportOrigin: string | undefined
75
+ // Stashed origin from merge for the event bridge to pick up.
76
+ let pendingMergeOrigin: string | undefined
76
77
 
77
78
  // Lazy-built WritableContext (same pattern as PlainSubstrate / LoroSubstrate).
78
79
  let cachedCtx: WritableContext | undefined
@@ -80,13 +81,15 @@ export function createYjsSubstrate(
80
81
  // The root Y.Map — all schema fields are children of this single map.
81
82
  const rootMap = doc.getMap("root")
82
83
 
83
- // The StoreReader — live view over the Yjs shared type tree.
84
- const reader: StoreReader = yjsStoreReader(doc, schema)
84
+ // The Reader — live view over the Yjs shared type tree.
85
+ const reader: Reader = yjsReader(doc, schema)
85
86
 
86
87
  // --- Substrate object ---
87
88
 
88
- const substrate: Substrate<YjsVersion> = {
89
- store: reader,
89
+ const substrate = {
90
+ [BACKING_DOC]: doc,
91
+
92
+ reader: reader,
90
93
 
91
94
  prepare(path: Path, change: ChangeBase): void {
92
95
  if (!inOurTransaction) {
@@ -98,7 +101,7 @@ export function createYjsSubstrate(
98
101
  // wrappedPrepare (changefeed layer) still buffers the op.
99
102
  },
100
103
 
101
- onFlush(origin?: string): void {
104
+ onFlush(_origin?: string): void {
102
105
  if (!inOurTransaction && pendingChanges.length > 0) {
103
106
  // Local write: apply accumulated changes within a single
104
107
  // Yjs transaction tagged with our origin for echo suppression.
@@ -129,8 +132,21 @@ export function createYjsSubstrate(
129
132
  return new YjsVersion(Y.encodeStateVector(doc))
130
133
  },
131
134
 
132
- exportSnapshot(): SubstratePayload {
135
+ baseVersion(): YjsVersion {
136
+ // Yjs substrate: base is always the initial state (no advance supported).
137
+ return new YjsVersion(new Uint8Array([0]))
138
+ },
139
+
140
+ advance(_to: YjsVersion): void {
141
+ throw new Error(
142
+ "advance() on a live Yjs substrate is not yet supported. " +
143
+ "Use advance() on a YjsReplica instead.",
144
+ )
145
+ },
146
+
147
+ exportEntirety(): SubstratePayload {
133
148
  return {
149
+ kind: "entirety",
134
150
  encoding: "binary",
135
151
  data: Y.encodeStateAsUpdate(doc),
136
152
  }
@@ -139,27 +155,28 @@ export function createYjsSubstrate(
139
155
  exportSince(since: YjsVersion): SubstratePayload | null {
140
156
  try {
141
157
  const bytes = Y.encodeStateAsUpdate(doc, since.sv)
142
- return { encoding: "binary", data: bytes }
158
+ return { kind: "since", encoding: "binary", data: bytes }
143
159
  } catch {
144
160
  return null
145
161
  }
146
162
  },
147
163
 
148
- importDelta(payload: SubstratePayload, origin?: string): void {
164
+ merge(payload: SubstratePayload, origin?: string): void {
149
165
  if (
150
166
  payload.encoding !== "binary" ||
151
167
  !(payload.data instanceof Uint8Array)
152
168
  ) {
153
169
  throw new Error(
154
- "YjsSubstrate.importDelta only supports binary-encoded payloads",
170
+ "YjsSubstrate.merge expects binary-encoded payloads. " +
171
+ "If you recently switched CRDT backends, stale clients may be sending incompatible data.",
155
172
  )
156
173
  }
157
174
  // Stash origin for the event bridge to pick up
158
- pendingImportOrigin = origin
175
+ pendingMergeOrigin = origin
159
176
  try {
160
177
  Y.applyUpdate(doc, payload.data, origin ?? "remote")
161
178
  } finally {
162
- pendingImportOrigin = undefined
179
+ pendingMergeOrigin = undefined
163
180
  }
164
181
  // That's it — the observeDeep handler bridges events to the
165
182
  // changefeed via executeBatch.
@@ -175,18 +192,16 @@ export function createYjsSubstrate(
175
192
  }
176
193
 
177
194
  // Convert Yjs events → kyneta Ops
178
- const ops = eventsToOps(events)
195
+ const ops = eventsToOps(events, schema)
179
196
  if (ops.length === 0) {
180
197
  return
181
198
  }
182
199
 
183
- // Determine origin: prefer stashed kyneta origin (from importDelta),
200
+ // Determine origin: prefer stashed kyneta origin (from merge),
184
201
  // fall back to the transaction's origin if it's a string.
185
202
  const origin =
186
- pendingImportOrigin ??
187
- (typeof transaction.origin === "string"
188
- ? transaction.origin
189
- : undefined)
203
+ pendingMergeOrigin ??
204
+ (typeof transaction.origin === "string" ? transaction.origin : undefined)
190
205
 
191
206
  // Lazily ensure the context is built
192
207
  const ctx = substrate.context()
@@ -202,10 +217,7 @@ export function createYjsSubstrate(
202
217
  }
203
218
  })
204
219
 
205
- // Register for the yjs() escape hatch
206
- registerYjsSubstrate(substrate, doc)
207
-
208
- return substrate
220
+ return substrate as Substrate<YjsVersion>
209
221
  }
210
222
 
211
223
  // ---------------------------------------------------------------------------
@@ -218,35 +230,167 @@ export function createYjsSubstrate(
218
230
  * - `create(schema)` — creates a fresh Y.Doc with empty containers
219
231
  * matching the schema structure. No seed data — initial content
220
232
  * should be applied via `change()` after construction.
221
- * - `fromSnapshot(payload, schema)` — creates a Y.Doc from a snapshot
233
+ * - `fromEntirety(payload, schema)` — creates a Y.Doc from an entirety
222
234
  * payload, returns a substrate.
223
235
  * - `parseVersion(serialized)` — deserializes a YjsVersion.
224
236
  */
225
- export const yjsSubstrateFactory: SubstrateFactory<YjsVersion> = {
226
- create(schema: SchemaNode): Substrate<YjsVersion> {
227
- const doc = new Y.Doc()
228
- ensureContainers(doc, schema)
229
- return createYjsSubstrate(doc, schema)
237
+ // ---------------------------------------------------------------------------
238
+ // yjsReplicaFactory — ReplicaFactory<YjsVersion>
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Schema-free replica factory for Yjs substrates.
243
+ *
244
+ * Constructs headless `Replica<YjsVersion>` instances backed by bare
245
+ * `Y.Doc`s — no schema walking, no container initialization, no
246
+ * Reader, no event bridge, no changefeed. Just the CRDT runtime
247
+ * with version tracking and export/merge.
248
+ *
249
+ * Used by conduit participants (stores, routing servers)
250
+ * that need to accumulate state, compute per-peer deltas, and compact
251
+ * storage without ever interpreting document fields.
252
+ */
253
+ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
254
+ let currentDoc = doc
255
+ let currentBase: YjsVersion = new YjsVersion(
256
+ Y.encodeStateVector(new Y.Doc()),
257
+ )
258
+
259
+ return {
260
+ get [BACKING_DOC]() {
261
+ return currentDoc
262
+ },
263
+
264
+ version(): YjsVersion {
265
+ return new YjsVersion(Y.encodeStateVector(currentDoc))
266
+ },
267
+
268
+ baseVersion(): YjsVersion {
269
+ return currentBase
270
+ },
271
+
272
+ advance(to: YjsVersion): void {
273
+ const baseCmp = currentBase.compare(to)
274
+ if (baseCmp === "ahead") {
275
+ throw new Error("advance(): target is behind base version")
276
+ }
277
+ const currentCmp = to.compare(this.version())
278
+ if (currentCmp === "ahead") {
279
+ throw new Error("advance(): target is ahead of current version")
280
+ }
281
+
282
+ // Yjs can only do full projection (to = version).
283
+ // For any to < version, it's a no-op — undershoot contract.
284
+ if (currentCmp !== "equal") return
285
+
286
+ // Full projection: create a new doc with current state, no history.
287
+ const update = Y.encodeStateAsUpdate(currentDoc)
288
+ const newDoc = new Y.Doc()
289
+ Y.applyUpdate(newDoc, update)
290
+ currentDoc = newDoc
291
+ currentBase = new YjsVersion(Y.encodeStateVector(currentDoc))
292
+ },
293
+
294
+ exportEntirety(): SubstratePayload {
295
+ return {
296
+ kind: "entirety",
297
+ encoding: "binary",
298
+ data: Y.encodeStateAsUpdate(currentDoc),
299
+ }
300
+ },
301
+
302
+ exportSince(since: YjsVersion): SubstratePayload | null {
303
+ try {
304
+ const bytes = Y.encodeStateAsUpdate(currentDoc, since.sv)
305
+ return { kind: "since", encoding: "binary", data: bytes }
306
+ } catch {
307
+ return null
308
+ }
309
+ },
310
+
311
+ merge(payload: SubstratePayload, _origin?: string): void {
312
+ if (
313
+ payload.encoding !== "binary" ||
314
+ !(payload.data instanceof Uint8Array)
315
+ ) {
316
+ throw new Error(
317
+ "YjsReplica.merge expects binary-encoded payloads. " +
318
+ "If you recently switched CRDT backends, stale clients may be sending incompatible data.",
319
+ )
320
+ }
321
+ Y.applyUpdate(currentDoc, payload.data)
322
+ },
323
+ } as Replica<YjsVersion>
324
+ }
325
+
326
+ export const yjsReplicaFactory: ReplicaFactory<YjsVersion> = {
327
+ replicaType: ["yjs", 1, 0] as const,
328
+
329
+ createEmpty(): Replica<YjsVersion> {
330
+ return createYjsReplica(new Y.Doc())
230
331
  },
231
332
 
232
- fromSnapshot(
233
- payload: SubstratePayload,
234
- schema: SchemaNode,
235
- ): Substrate<YjsVersion> {
333
+ fromEntirety(payload: SubstratePayload): Replica<YjsVersion> {
236
334
  if (
237
335
  payload.encoding !== "binary" ||
238
336
  !(payload.data instanceof Uint8Array)
239
337
  ) {
240
338
  throw new Error(
241
- "YjsSubstrateFactory.fromSnapshot only supports binary-encoded payloads",
339
+ "YjsReplicaFactory.fromEntirety only supports binary-encoded payloads",
242
340
  )
243
341
  }
244
342
  const doc = new Y.Doc()
245
343
  Y.applyUpdate(doc, payload.data)
344
+ return createYjsReplica(doc)
345
+ },
346
+
347
+ parseVersion(serialized: string): YjsVersion {
348
+ return YjsVersion.parse(serialized)
349
+ },
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // yjsSubstrateFactory — SubstrateFactory<YjsVersion>
354
+ // ---------------------------------------------------------------------------
355
+
356
+ export const yjsSubstrateFactory: SubstrateFactory<YjsVersion> = {
357
+ replica: yjsReplicaFactory,
358
+
359
+ createReplica(): Replica<YjsVersion> {
360
+ // Default random clientID — safe for hydration (no local writes).
361
+ return createYjsReplica(new Y.Doc())
362
+ },
363
+
364
+ upgrade(
365
+ replica: Replica<YjsVersion>,
366
+ schema: SchemaNode,
367
+ ): Substrate<YjsVersion> {
368
+ const doc = (replica as any)[BACKING_DOC] as Y.Doc
369
+ // No identity injection for the standalone factory (no peerId).
370
+ // Conditional ensureContainers: skip fields that already exist
371
+ // from hydrated state.
372
+ ensureContainers(doc, schema, true)
373
+ return createYjsSubstrate(doc, schema)
374
+ },
375
+
376
+ create(schema: SchemaNode): Substrate<YjsVersion> {
377
+ // Fresh doc — unconditional ensureContainers (nothing to conflict with).
378
+ const doc = new Y.Doc()
379
+ ensureContainers(doc, schema)
246
380
  return createYjsSubstrate(doc, schema)
247
381
  },
248
382
 
383
+ fromEntirety(
384
+ payload: SubstratePayload,
385
+ schema: SchemaNode,
386
+ ): Substrate<YjsVersion> {
387
+ // Two-phase path: createReplica → merge → upgrade
388
+ const replica = this.createReplica()
389
+ replica.merge(payload)
390
+ return this.upgrade(replica, schema)
391
+ },
392
+
249
393
  parseVersion(serialized: string): YjsVersion {
250
394
  return YjsVersion.parse(serialized)
251
395
  },
252
- }
396
+ }
package/src/sync.ts CHANGED
@@ -1,17 +1,17 @@
1
1
  // sync — sync primitives for YjsSubstrate-backed documents.
2
2
  //
3
- // These functions provide version tracking, snapshot export, and delta
4
- // import for documents created via `createYjsDoc` or
5
- // `createYjsDocFromSnapshot`. They discover the substrate via the
3
+ // These functions provide version tracking, entirety export, and merge
4
+ // for documents created via `createYjsDoc` or
5
+ // `createYjsDocFromEntirety`. They discover the substrate via the
6
6
  // module-scoped WeakMap in `create.ts`.
7
7
  //
8
8
  // Unlike PlainSubstrate's sync (which returns Op[] for deltas),
9
- // YjsSubstrate's sync uses binary SubstratePayload for both snapshots
9
+ // YjsSubstrate's sync uses binary SubstratePayload for both entireties
10
10
  // and deltas — these are Yjs's native state-as-update bytes.
11
11
 
12
12
  import type { SubstratePayload } from "@kyneta/schema"
13
- import { YjsVersion } from "./version.js"
14
13
  import { getSubstrate } from "./create.js"
14
+ import type { YjsVersion } from "./version.js"
15
15
 
16
16
  // ---------------------------------------------------------------------------
17
17
  // version — current YjsVersion
@@ -23,28 +23,28 @@ import { getSubstrate } from "./create.js"
23
23
  * Use `.serialize()` to get a text-safe string for embedding in HTML
24
24
  * meta tags, URL parameters, etc.
25
25
  *
26
- * @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
27
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
26
+ * @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
27
+ * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
28
28
  */
29
29
  export function version(doc: object): YjsVersion {
30
30
  return getSubstrate(doc).version()
31
31
  }
32
32
 
33
33
  // ---------------------------------------------------------------------------
34
- // exportSnapshot — full state for reconstruction
34
+ // exportEntirety — full state for reconstruction
35
35
  // ---------------------------------------------------------------------------
36
36
 
37
37
  /**
38
- * Export the full substrate snapshot — sufficient for a new peer to
39
- * reconstruct an equivalent document via `createYjsDocFromSnapshot()`.
38
+ * Export the full substrate entirety — sufficient for a new peer to
39
+ * reconstruct an equivalent document via `createYjsDocFromEntirety()`.
40
40
  *
41
41
  * Returns a binary `SubstratePayload` (Yjs state-as-update bytes).
42
42
  *
43
- * @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
44
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
43
+ * @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
44
+ * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
45
45
  */
46
- export function exportSnapshot(doc: object): SubstratePayload {
47
- return getSubstrate(doc).exportSnapshot()
46
+ export function exportEntirety(doc: object): SubstratePayload {
47
+ return getSubstrate(doc).exportEntirety()
48
48
  }
49
49
 
50
50
  // ---------------------------------------------------------------------------
@@ -61,12 +61,12 @@ export function exportSnapshot(doc: object): SubstratePayload {
61
61
  * const v0 = version(docA)
62
62
  * change(docA, d => d.title.insert(0, "Hi"))
63
63
  * const delta = exportSince(docA, v0)
64
- * importDelta(docB, delta!)
64
+ * merge(docB, delta!)
65
65
  * ```
66
66
  *
67
- * @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
67
+ * @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
68
68
  * @param since - The version to diff from.
69
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
69
+ * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
70
70
  */
71
71
  export function exportSince(
72
72
  doc: object,
@@ -76,32 +76,32 @@ export function exportSince(
76
76
  }
77
77
 
78
78
  // ---------------------------------------------------------------------------
79
- // importDelta — apply a delta from another peer
79
+ // merge — apply a delta from another peer
80
80
  // ---------------------------------------------------------------------------
81
81
 
82
82
  /**
83
83
  * Import a delta payload into a live document.
84
84
  *
85
85
  * The payload must have been produced by `exportSince()` or
86
- * `exportSnapshot()` on a compatible document.
86
+ * `exportEntirety()` on a compatible document.
87
87
  *
88
88
  * After import, the changefeed fires for all subscribers — the event
89
89
  * bridge handles this automatically.
90
90
  *
91
91
  * ```ts
92
92
  * const delta = exportSince(docA, sinceVersion)
93
- * importDelta(docB, delta!, "sync")
93
+ * merge(docB, delta!, "sync")
94
94
  * ```
95
95
  *
96
- * @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
97
- * @param payload - The delta or snapshot payload to import.
96
+ * @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
97
+ * @param payload - The delta or entirety payload to merge.
98
98
  * @param origin - Optional provenance tag for the changeset.
99
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
99
+ * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
100
100
  */
101
- export function importDelta(
101
+ export function merge(
102
102
  doc: object,
103
103
  payload: SubstratePayload,
104
104
  origin?: string,
105
105
  ): void {
106
- getSubstrate(doc).importDelta(payload, origin)
107
- }
106
+ getSubstrate(doc).merge(payload, origin)
107
+ }
package/src/version.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // decoded `Map<number, number>` (clientID → clock) maps ourselves.
13
13
 
14
14
  import type { Version } from "@kyneta/schema"
15
+ import { versionVectorMeet } from "@kyneta/schema"
15
16
  import { decodeStateVector } from "yjs"
16
17
 
17
18
  // ---------------------------------------------------------------------------
@@ -35,6 +36,39 @@ function base64ToUint8Array(base64: string): Uint8Array {
35
36
  return bytes
36
37
  }
37
38
 
39
+ // ---------------------------------------------------------------------------
40
+ // State vector encoding — manual varint (unsigned LEB128)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Encode a state vector map to Yjs's binary state vector format.
45
+ *
46
+ * Yjs does not export `encodeStateVector(map)` — only `Y.encodeStateVector(doc)`
47
+ * which requires a full doc. This implements the same binary format directly:
48
+ * `[entryCount: varint, (clientId: varint, clock: varint)*]`
49
+ *
50
+ * Each value is encoded as an unsigned LEB128 varint.
51
+ */
52
+ function encodeStateVector(map: Map<number, number>): Uint8Array {
53
+ const bytes: number[] = []
54
+
55
+ function writeVarUint(value: number): void {
56
+ while (value > 0x7f) {
57
+ bytes.push((value & 0x7f) | 0x80)
58
+ value >>>= 7
59
+ }
60
+ bytes.push(value & 0x7f)
61
+ }
62
+
63
+ writeVarUint(map.size)
64
+ for (const [clientId, clock] of map) {
65
+ writeVarUint(clientId)
66
+ writeVarUint(clock)
67
+ }
68
+
69
+ return new Uint8Array(bytes)
70
+ }
71
+
38
72
  // ---------------------------------------------------------------------------
39
73
  // YjsVersion
40
74
  // ---------------------------------------------------------------------------
@@ -85,9 +119,7 @@ export class YjsVersion implements Version {
85
119
  */
86
120
  compare(other: Version): "behind" | "equal" | "ahead" | "concurrent" {
87
121
  if (!(other instanceof YjsVersion)) {
88
- throw new Error(
89
- "YjsVersion can only be compared with another YjsVersion",
90
- )
122
+ throw new Error("YjsVersion can only be compared with another YjsVersion")
91
123
  }
92
124
 
93
125
  const thisMap = decodeStateVector(this.sv)
@@ -123,6 +155,27 @@ export class YjsVersion implements Version {
123
155
  return "equal"
124
156
  }
125
157
 
158
+ /**
159
+ * Greatest lower bound (lattice meet) of two Yjs versions.
160
+ *
161
+ * Decodes both state vectors, computes the component-wise minimum
162
+ * via the shared `versionVectorMeet` utility, and encodes the result
163
+ * back to a Yjs state vector.
164
+ *
165
+ * @throws If `other` is not a `YjsVersion`.
166
+ */
167
+ meet(other: Version): YjsVersion {
168
+ if (!(other instanceof YjsVersion)) {
169
+ throw new Error(
170
+ "YjsVersion can only be meet'd with another YjsVersion",
171
+ )
172
+ }
173
+ const thisMap = decodeStateVector(this.sv)
174
+ const otherMap = decodeStateVector(other.sv)
175
+ const result = versionVectorMeet(thisMap, otherMap)
176
+ return new YjsVersion(encodeStateVector(result))
177
+ }
178
+
126
179
  /**
127
180
  * Parse a serialized YjsVersion string back into a YjsVersion.
128
181
  *
@@ -135,4 +188,4 @@ export class YjsVersion implements Version {
135
188
  const bytes = base64ToUint8Array(serialized)
136
189
  return new YjsVersion(bytes)
137
190
  }
138
- }
191
+ }
@@ -14,9 +14,8 @@
14
14
  // Using a single root Y.Map enables one `observeDeep` call that
15
15
  // captures all mutations with correct relative paths.
16
16
 
17
- import { advanceSchema } from "@kyneta/schema"
18
- import type { Path, Segment } from "@kyneta/schema"
19
- import type { Schema as SchemaNode } from "@kyneta/schema"
17
+ import type { Path, Schema as SchemaNode, Segment } from "@kyneta/schema"
18
+ import { advanceSchema, KIND } from "@kyneta/schema"
20
19
  import * as Y from "yjs"
21
20
 
22
21
  // ---------------------------------------------------------------------------
@@ -35,10 +34,7 @@ import * as Y from "yjs"
35
34
  * @param current - The current position (a Yjs shared type or plain value)
36
35
  * @param segment - The path segment to follow
37
36
  */
38
- export function stepIntoYjs(
39
- current: unknown,
40
- segment: Segment,
41
- ): unknown {
37
+ export function stepIntoYjs(current: unknown, segment: Segment): unknown {
42
38
  const resolved = segment.resolve()
43
39
 
44
40
  if (current instanceof Y.Map) {
@@ -50,9 +46,7 @@ export function stepIntoYjs(
50
46
  }
51
47
 
52
48
  if (current instanceof Y.Text) {
53
- throw new Error(
54
- `yjs-resolve: cannot step into Y.Text`,
55
- )
49
+ throw new Error(`yjs-resolve: cannot step into Y.Text`)
56
50
  }
57
51
 
58
52
  // Plain value — terminal, cannot step further
@@ -84,16 +78,6 @@ export function resolveYjsType(
84
78
  let current: unknown = rootMap
85
79
  let schema = rootSchema
86
80
 
87
- // Unwrap the root annotation (e.g. annotated("doc", product))
88
- // to reach the product schema whose fields are the root map's children.
89
- let rootProduct = rootSchema
90
- while (
91
- rootProduct._kind === "annotated" &&
92
- rootProduct.schema !== undefined
93
- ) {
94
- rootProduct = rootProduct.schema
95
- }
96
-
97
81
  for (let i = 0; i < path.length; i++) {
98
82
  const seg = path.segments[i]!
99
83
  const nextSchema = advanceSchema(schema, seg)
@@ -105,4 +89,4 @@ export function resolveYjsType(
105
89
  }
106
90
 
107
91
  return current
108
- }
92
+ }