@loro-extended/change 5.3.0 → 5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loro-extended/change",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "A schema-driven, type-safe wrapper for Loro CRDT that provides natural JavaScript syntax for collaborative data mutations",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
@@ -25,13 +25,14 @@
25
25
  "tsup": "^8.5.0",
26
26
  "tsx": "^4.20.3",
27
27
  "typescript": "^5.9.2",
28
- "vitest": "^3.2.4"
28
+ "vitest": "^4.0.17"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "loro-crdt": "^1.10.3"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsup",
35
+ "test": "verify logic",
35
36
  "verify": "verify"
36
37
  }
37
38
  }
package/src/conversion.ts CHANGED
@@ -108,17 +108,53 @@ function convertStructInput(
108
108
  }
109
109
 
110
110
  const map = new LoroMap()
111
- for (const [k, v] of Object.entries(value)) {
111
+
112
+ // Iterate over schema keys to ensure all nested containers are materialized
113
+ for (const k of Object.keys(shape.shapes)) {
112
114
  const nestedSchema = shape.shapes[k]
113
- if (nestedSchema) {
115
+ const v = value[k]
116
+
117
+ if (v !== undefined) {
114
118
  const convertedValue = convertInputToRef(v, nestedSchema)
115
119
  if (isContainer(convertedValue)) {
116
120
  map.setContainer(k, convertedValue)
117
121
  } else {
118
122
  map.set(k, convertedValue)
119
123
  }
120
- } else {
121
- map.set(k, value)
124
+ } else if (isContainerShape(nestedSchema)) {
125
+ // If value is missing but it's a container shape, create an empty container
126
+ // This ensures deterministic container IDs across peers
127
+ let emptyValue: any
128
+ if (nestedSchema._type === "struct" || nestedSchema._type === "record") {
129
+ emptyValue = {}
130
+ } else if (
131
+ nestedSchema._type === "list" ||
132
+ nestedSchema._type === "movableList"
133
+ ) {
134
+ emptyValue = []
135
+ } else if (nestedSchema._type === "text") {
136
+ emptyValue = ""
137
+ } else if (nestedSchema._type === "counter") {
138
+ emptyValue = 0
139
+ }
140
+
141
+ if (emptyValue !== undefined) {
142
+ const convertedValue = convertInputToRef(emptyValue, nestedSchema)
143
+ if (isContainer(convertedValue)) {
144
+ map.setContainer(k, convertedValue)
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ // Also handle keys present in value but not in schema (if any, though for structs this shouldn't happen ideally)
151
+ // But for backward compatibility or loose typing, we might want to preserve them?
152
+ // The original code did:
153
+ // if (nestedSchema) { ... } else { map.set(k, value) }
154
+ // So it allowed extra keys.
155
+ for (const [k, v] of Object.entries(value)) {
156
+ if (!shape.shapes[k]) {
157
+ map.set(k, v)
122
158
  }
123
159
  }
124
160
 
@@ -1,11 +1,11 @@
1
- import type {
2
- LoroCounter,
1
+ import {
2
+ type LoroCounter,
3
3
  LoroDoc,
4
- LoroList,
5
- LoroMap,
6
- LoroMovableList,
7
- LoroText,
8
- LoroTree,
4
+ type LoroList,
5
+ type LoroMap,
6
+ type LoroMovableList,
7
+ type LoroText,
8
+ type LoroTree,
9
9
  } from "loro-crdt"
10
10
  import { loro } from "./loro.js"
11
11
  import type {
@@ -274,6 +274,52 @@ export function getLoroContainer(
274
274
  return loro(ref as any).container
275
275
  }
276
276
 
277
+ /**
278
+ * Creates a new TypedDoc as a fork of the current document.
279
+ * The forked doc contains all history up to the current version.
280
+ * The forked doc has a different PeerID from the original by default.
281
+ *
282
+ * For raw LoroDoc access, use: `loro(doc).doc.fork()`
283
+ *
284
+ * @param doc - The TypedDoc to fork
285
+ * @param options - Optional settings
286
+ * @param options.preservePeerId - If true, copies the original doc's peer ID to the fork
287
+ * @returns A new TypedDoc with the same schema at the current version
288
+ *
289
+ * @example
290
+ * ```typescript
291
+ * import { fork, loro } from "@loro-extended/change"
292
+ *
293
+ * const doc = createTypedDoc(schema);
294
+ * doc.title.update("Hello");
295
+ *
296
+ * // Fork the document
297
+ * const forkedDoc = fork(doc);
298
+ * forkedDoc.title.update("World");
299
+ *
300
+ * console.log(doc.title.toString()); // "Hello"
301
+ * console.log(forkedDoc.title.toString()); // "World"
302
+ *
303
+ * // Fork with same peer ID (for World/Worldview pattern)
304
+ * const worldview = fork(world, { preservePeerId: true });
305
+ * ```
306
+ */
307
+ export function fork<Shape extends DocShape>(
308
+ doc: TypedDoc<Shape>,
309
+ options?: { preservePeerId?: boolean },
310
+ ): TypedDoc<Shape> {
311
+ const loroDoc = loro(doc).doc
312
+ const forkedLoroDoc = loroDoc.fork()
313
+ const shape = loro(doc).docShape as Shape
314
+
315
+ // Optionally preserve the peer ID (useful for World/Worldview pattern)
316
+ if (options?.preservePeerId) {
317
+ forkedLoroDoc.setPeerId(loroDoc.peerId)
318
+ }
319
+
320
+ return createTypedDoc(shape, forkedLoroDoc)
321
+ }
322
+
277
323
  /**
278
324
  * Creates a new TypedDoc at a specified version (frontiers).
279
325
  * The forked doc will only contain history before the specified frontiers.
@@ -309,3 +355,71 @@ export function forkAt<Shape extends DocShape>(
309
355
  const shape = loro(doc).docShape as Shape
310
356
  return createTypedDoc(shape, forkedLoroDoc)
311
357
  }
358
+
359
+ /**
360
+ * Creates a new TypedDoc at a specified version using a shallow snapshot.
361
+ * Unlike `forkAt`, this creates a "garbage-collected" snapshot that only
362
+ * contains the current state and history since the specified frontiers.
363
+ *
364
+ * This is more memory-efficient than `forkAt` for documents with long history,
365
+ * especially useful for the fork-and-merge pattern in LEA where we only need:
366
+ * 1. Read current state
367
+ * 2. Apply changes
368
+ * 3. Export delta and merge back
369
+ *
370
+ * The shallow fork has a different PeerID from the original by default.
371
+ * Use `preservePeerId: true` to copy the original's peer ID (useful for
372
+ * fork-and-merge patterns where you want consistent frontier progression).
373
+ *
374
+ * @param doc - The TypedDoc to fork
375
+ * @param frontiers - The version to fork at (obtained from `loro(doc).doc.frontiers()`)
376
+ * @param options - Optional settings
377
+ * @param options.preservePeerId - If true, copies the original doc's peer ID to the fork
378
+ * @returns A new TypedDoc with the same schema at the specified version (shallow)
379
+ *
380
+ * @example
381
+ * ```typescript
382
+ * import { shallowForkAt, loro } from "@loro-extended/change"
383
+ *
384
+ * const doc = createTypedDoc(schema);
385
+ * doc.title.update("Hello");
386
+ * const frontiers = loro(doc).doc.frontiers();
387
+ *
388
+ * // Create a shallow fork (memory-efficient)
389
+ * const shallowDoc = shallowForkAt(doc, frontiers, { preservePeerId: true });
390
+ *
391
+ * // Modify the shallow doc
392
+ * shallowDoc.title.update("World");
393
+ *
394
+ * // Merge changes back
395
+ * const update = loro(shallowDoc).doc.export({
396
+ * mode: "update",
397
+ * from: loro(doc).doc.version()
398
+ * });
399
+ * loro(doc).doc.import(update);
400
+ * ```
401
+ */
402
+ export function shallowForkAt<Shape extends DocShape>(
403
+ doc: TypedDoc<Shape>,
404
+ frontiers: Frontiers,
405
+ options?: { preservePeerId?: boolean },
406
+ ): TypedDoc<Shape> {
407
+ const loroDoc = loro(doc).doc
408
+ const shape = loro(doc).docShape as Shape
409
+
410
+ // Export a shallow snapshot at the specified frontiers
411
+ const shallowBytes = loroDoc.export({
412
+ mode: "shallow-snapshot",
413
+ frontiers,
414
+ })
415
+
416
+ // Create a new LoroDoc from the shallow snapshot
417
+ const shallowLoroDoc = LoroDoc.fromSnapshot(shallowBytes)
418
+
419
+ // Optionally preserve the peer ID for consistent frontier progression
420
+ if (options?.preservePeerId) {
421
+ shallowLoroDoc.setPeerId(loroDoc.peerId)
422
+ }
423
+
424
+ return createTypedDoc(shape, shallowLoroDoc)
425
+ }
package/src/index.ts CHANGED
@@ -4,13 +4,17 @@ export {
4
4
  derivePlaceholder,
5
5
  deriveShapePlaceholder,
6
6
  } from "./derive-placeholder.js"
7
+
7
8
  // Functional helpers (recommended API)
8
9
  export {
9
10
  change,
11
+ fork,
10
12
  forkAt,
11
13
  getLoroContainer,
12
14
  getLoroDoc,
15
+ shallowForkAt,
13
16
  } from "./functional-helpers.js"
17
+
14
18
  // The loro() escape hatch for CRDT internals
15
19
  export {
16
20
  LORO_SYMBOL,
@@ -23,6 +27,7 @@ export {
23
27
  type LoroTypedDocRef,
24
28
  loro,
25
29
  } from "./loro.js"
30
+
26
31
  export { mergeValue, overlayPlaceholder } from "./overlay.js"
27
32
  // Path selector DSL exports
28
33
  export { createPathBuilder } from "./path-builder.js"
@@ -35,43 +40,42 @@ export type {
35
40
  PathSelector,
36
41
  } from "./path-selector.js"
37
42
  export { createPlaceholderProxy } from "./placeholder-proxy.js"
43
+ export { replayDiff } from "./replay-diff.js"
44
+ // Shape utilities
45
+ // Container shapes
46
+ // Value shapes
38
47
  export type {
39
- // Escape hatch shapes for untyped integration
40
48
  AnyContainerShape,
41
49
  AnyValueShape,
42
50
  ArrayValueShape,
43
51
  ContainerOrValueShape,
44
52
  ContainerShape,
45
53
  ContainerType as RootContainerType,
46
- // Container shapes
47
54
  CounterContainerShape,
48
- // Discriminated union for tagged unions
55
+ // Tagged union
49
56
  DiscriminatedUnionValueShape,
50
- // Schema node types
51
57
  DocShape,
52
58
  ListContainerShape,
53
- /** @deprecated Use StructContainerShape instead */
54
- MapContainerShape,
55
59
  MovableListContainerShape,
56
- /** @deprecated Use StructValueShape instead */
57
- ObjectValueShape,
60
+ NumberValueShape,
58
61
  RecordContainerShape,
59
62
  RecordValueShape,
63
+ StringValueShape,
60
64
  StructContainerShape,
61
65
  StructValueShape,
62
66
  TextContainerShape,
63
67
  TreeContainerShape,
64
- // Tree-related types
65
68
  TreeNodeJSON,
66
69
  TreeRefInterface,
70
+ // Union of two or more plain value types
67
71
  UnionValueShape,
68
- // Value shapes
69
72
  ValueShape,
70
73
  // WithNullable type for shapes that support .nullable()
71
74
  WithNullable,
72
75
  // WithPlaceholder type for shapes that support .placeholder()
73
76
  WithPlaceholder,
74
77
  } from "./shape.js"
78
+
75
79
  // Schema and type exports
76
80
  export { Shape } from "./shape.js"
77
81
  export type { Frontiers, TypedDoc } from "./typed-doc.js"
package/src/loro.ts CHANGED
@@ -28,6 +28,7 @@ import type {
28
28
  Container,
29
29
  LoroCounter,
30
30
  LoroDoc,
31
+ LoroEventBatch,
31
32
  LoroList,
32
33
  LoroMap,
33
34
  LoroMovableList,
@@ -85,7 +86,7 @@ export interface LoroRefBase {
85
86
  * @param callback - Function called when the container changes
86
87
  * @returns Subscription that can be used to unsubscribe
87
88
  */
88
- subscribe(callback: (event: unknown) => void): Subscription
89
+ subscribe(callback: (event: LoroEventBatch) => void): Subscription
89
90
  }
90
91
 
91
92
  /**
@@ -0,0 +1,336 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { convertInputToRef } from "./conversion.js"
3
+ import { createTypedDoc, loro, Shape } from "./index.js"
4
+
5
+ describe("Nested Container Materialization", () => {
6
+ it("syncs correctly between peers when struct has nested empty record", () => {
7
+ // Define a schema with a nested map
8
+ const schema = Shape.doc({
9
+ items: Shape.record(
10
+ Shape.struct({
11
+ id: Shape.plain.string(),
12
+ metadata: Shape.record(Shape.struct({ key: Shape.plain.string() })), // Nested map of structs
13
+ }),
14
+ ),
15
+ })
16
+
17
+ // Create two separate documents (simulating two peers)
18
+ const clientDoc = createTypedDoc(schema)
19
+ const serverDoc = createTypedDoc(schema)
20
+
21
+ // Client creates an item with empty nested map
22
+ clientDoc.items.set("item-1", {
23
+ id: "item-1",
24
+ metadata: {}, // Empty nested map - BUG: may not materialize properly
25
+ })
26
+
27
+ // Sync client -> server
28
+ const clientSnapshot = loro(clientDoc).doc.export({ mode: "snapshot" })
29
+ loro(serverDoc).doc.import(clientSnapshot)
30
+
31
+ // Server writes to the nested map
32
+ const serverEntry = serverDoc.items.get("item-1")
33
+ expect(serverEntry).toBeDefined()
34
+ serverEntry?.metadata.set("entry-1", { key: "value" })
35
+
36
+ // Sync server -> client
37
+ const serverUpdate = loro(serverDoc).doc.export({
38
+ mode: "update",
39
+ from: loro(clientDoc).doc.version(),
40
+ })
41
+ loro(clientDoc).doc.import(serverUpdate)
42
+
43
+ // BUG: Client's metadata is EMPTY!
44
+ const clientEntry = clientDoc.items.get("item-1")
45
+ expect(clientEntry?.metadata.toJSON()).toEqual({
46
+ "entry-1": { key: "value" },
47
+ })
48
+ })
49
+
50
+ it("handles concurrent creation of nested containers", () => {
51
+ const schema = Shape.doc({
52
+ items: Shape.record(
53
+ Shape.struct({
54
+ id: Shape.plain.string(),
55
+ metadata: Shape.record(Shape.plain.string()),
56
+ }),
57
+ ),
58
+ })
59
+
60
+ const doc1 = createTypedDoc(schema)
61
+ const doc2 = createTypedDoc(schema)
62
+
63
+ // Peer 1 creates item with empty metadata
64
+ doc1.items.set("item-1", {
65
+ id: "item-1",
66
+ metadata: {},
67
+ })
68
+
69
+ // Sync 1 -> 2
70
+ loro(doc2).doc.import(loro(doc1).doc.export({ mode: "snapshot" }))
71
+
72
+ // Peer 1 writes to metadata
73
+ doc1.items.get("item-1")?.metadata.set("p1", "v1")
74
+
75
+ // Peer 2 writes to metadata (concurrently)
76
+ doc2.items.get("item-1")?.metadata.set("p2", "v2")
77
+
78
+ // Sync 1 -> 2
79
+ loro(doc2).doc.import(
80
+ loro(doc1).doc.export({ mode: "update", from: loro(doc2).doc.version() }),
81
+ )
82
+
83
+ // Sync 2 -> 1
84
+ loro(doc1).doc.import(
85
+ loro(doc2).doc.export({ mode: "update", from: loro(doc1).doc.version() }),
86
+ )
87
+
88
+ // Both should have both values
89
+ const json1 = doc1.items.get("item-1")?.metadata.toJSON()
90
+ const json2 = doc2.items.get("item-1")?.metadata.toJSON()
91
+
92
+ console.log("JSON1", json1)
93
+ console.log("JSON2", json2)
94
+
95
+ expect(json1).toEqual({ p1: "v1", p2: "v2" })
96
+ expect(json2).toEqual({ p1: "v1", p2: "v2" })
97
+ })
98
+
99
+ it("materializes nested containers in Tree nodes", () => {
100
+ const schema = Shape.doc({
101
+ tree: Shape.tree(
102
+ Shape.struct({
103
+ id: Shape.plain.string(),
104
+ tags: Shape.record(Shape.plain.boolean()),
105
+ }),
106
+ ),
107
+ })
108
+
109
+ const doc1 = createTypedDoc(schema)
110
+ const doc2 = createTypedDoc(schema)
111
+
112
+ // Create a node with empty tags
113
+ const node = doc1.tree.createNode({
114
+ id: "node-1",
115
+ tags: {},
116
+ })
117
+
118
+ // Get the node ID for lookup
119
+ const nodeId = loro(node).container.id
120
+
121
+ // Sync 1 -> 2
122
+ loro(doc2).doc.import(loro(doc1).doc.export({ mode: "snapshot" }))
123
+
124
+ // Verify the container exists in doc1
125
+ const tags = node.data.tags
126
+ expect(tags).toBeDefined()
127
+
128
+ // Verify it's materialized by checking if we can get its container ID
129
+ const tagsContainer = loro(tags).container
130
+ expect(tagsContainer).toBeDefined()
131
+
132
+ // Now check doc2 using getNodeByID
133
+ const tree2 = loro(doc2.tree).container
134
+ const node2 = tree2.getNodeByID(nodeId)
135
+
136
+ expect(node2).toBeDefined()
137
+ if (node2) {
138
+ const data = node2.data // LoroMap
139
+ const tagsMap = data.get("tags") // Should be LoroMap
140
+ expect(tagsMap).toBeDefined()
141
+ // @ts-expect-error - kind() exists at runtime
142
+ expect(tagsMap.kind()).toBe("Map")
143
+ }
144
+ })
145
+
146
+ it("handles missing containers gracefully (schema evolution)", async () => {
147
+ // Simulate an old document that doesn't have a nested container
148
+ const { LoroDoc } = await import("loro-crdt")
149
+ const oldDoc = new LoroDoc()
150
+ const map = oldDoc.getMap("root")
151
+ map.set("existing", "value")
152
+ // "newField" is missing
153
+ oldDoc.commit()
154
+
155
+ // Load with new schema that expects "newField" as a container
156
+ const schema = Shape.doc({
157
+ root: Shape.struct({
158
+ existing: Shape.plain.string(),
159
+ newField: Shape.record(Shape.plain.string()),
160
+ }),
161
+ })
162
+
163
+ const doc = createTypedDoc(schema)
164
+ loro(doc).doc.import(oldDoc.export({ mode: "snapshot" }))
165
+
166
+ // Access missing container
167
+ const root = doc.root
168
+ // Should not crash
169
+ expect(root.existing).toBe("value")
170
+
171
+ // Accessing the missing container ref should work (it creates the wrapper)
172
+ const newField = root.newField
173
+ expect(newField).toBeDefined()
174
+
175
+ // But the underlying container doesn't exist yet?
176
+ // getOrCreateRef creates the wrapper.
177
+ // The wrapper's getContainer() calls getOrCreateContainer().
178
+
179
+ // If we read from it:
180
+ expect(newField.keys()).toEqual([])
181
+
182
+ // If we write to it:
183
+ newField.set("k", "v")
184
+
185
+ // It should create the container lazily (or eagerly on set)
186
+ expect(newField.get("k")).toBe("v")
187
+
188
+ // Verify in raw doc
189
+ const rawMap = loro(doc).doc.getMap("root")
190
+ const rawNewField = rawMap.get("newField")
191
+ expect(rawNewField).toBeDefined()
192
+ })
193
+
194
+ // Task 1.2: Test struct with deeply nested empty containers
195
+ it("materializes all levels of deeply nested structs", () => {
196
+ const schema = Shape.doc({
197
+ root: Shape.struct({
198
+ level1: Shape.struct({
199
+ level2: Shape.struct({
200
+ level3: Shape.record(Shape.plain.string()),
201
+ }),
202
+ }),
203
+ }),
204
+ })
205
+
206
+ const doc1 = createTypedDoc(schema)
207
+ const doc2 = createTypedDoc(schema)
208
+
209
+ // Initialize with empty nested structure - all levels should materialize
210
+ // Note: For structs, we need to access them to trigger materialization
211
+ // since the doc root is created lazily
212
+ const _root = doc1.root
213
+ const _level1 = doc1.root.level1
214
+ const _level2 = doc1.root.level1.level2
215
+ const _level3 = doc1.root.level1.level2.level3
216
+
217
+ // Sync doc1 -> doc2
218
+ loro(doc2).doc.import(loro(doc1).doc.export({ mode: "snapshot" }))
219
+
220
+ // Peer 2 writes to the deeply nested container
221
+ doc2.root.level1.level2.level3.set("deep-key", "deep-value")
222
+
223
+ // Sync doc2 -> doc1
224
+ loro(doc1).doc.import(
225
+ loro(doc2).doc.export({ mode: "update", from: loro(doc1).doc.version() }),
226
+ )
227
+
228
+ // Verify the deeply nested value is visible in doc1
229
+ expect(doc1.root.level1.level2.level3.get("deep-key")).toBe("deep-value")
230
+ })
231
+
232
+ // Task 1.3: Test list push with nested empty container
233
+ it("materializes nested containers when pushing to list", () => {
234
+ const schema = Shape.doc({
235
+ items: Shape.list(
236
+ Shape.struct({
237
+ id: Shape.plain.string(),
238
+ metadata: Shape.record(Shape.plain.string()),
239
+ }),
240
+ ),
241
+ })
242
+
243
+ const doc1 = createTypedDoc(schema)
244
+ const doc2 = createTypedDoc(schema)
245
+
246
+ // Push an item with empty nested metadata
247
+ doc1.items.push({
248
+ id: "item-1",
249
+ metadata: {},
250
+ })
251
+
252
+ // Sync doc1 -> doc2
253
+ loro(doc2).doc.import(loro(doc1).doc.export({ mode: "snapshot" }))
254
+
255
+ // Peer 2 writes to the nested metadata
256
+ const item = doc2.items[0]
257
+ expect(item).toBeDefined()
258
+ item?.metadata.set("key", "value")
259
+
260
+ // Sync doc2 -> doc1
261
+ loro(doc1).doc.import(
262
+ loro(doc2).doc.export({ mode: "update", from: loro(doc1).doc.version() }),
263
+ )
264
+
265
+ // Verify the nested value is visible in doc1
266
+ expect(doc1.items[0]?.metadata.get("key")).toBe("value")
267
+ })
268
+
269
+ // Task 1.5: Test conversion API with nested empty container
270
+ it("convertInputToRef creates containers for empty nested values", async () => {
271
+ const loroCrdt = await import("loro-crdt")
272
+
273
+ const structShape = Shape.struct({
274
+ id: Shape.plain.string(),
275
+ nested: Shape.record(Shape.plain.string()),
276
+ })
277
+
278
+ // Convert a plain object with empty nested record
279
+ const result = convertInputToRef({ id: "test", nested: {} }, structShape)
280
+
281
+ // Result should be a LoroMap
282
+ expect(result).toBeInstanceOf(loroCrdt.LoroMap)
283
+
284
+ // The nested field should also be a LoroMap (not undefined or plain object)
285
+ const nestedContainer = (result as typeof loroCrdt.LoroMap.prototype).get(
286
+ "nested",
287
+ )
288
+ expect(nestedContainer).toBeDefined()
289
+ expect(nestedContainer).toBeInstanceOf(loroCrdt.LoroMap)
290
+ })
291
+
292
+ // Tree Node Full Sync Test (from test-plan)
293
+ it("syncs nested containers in Tree nodes between peers", () => {
294
+ const schema = Shape.doc({
295
+ tree: Shape.tree(
296
+ Shape.struct({
297
+ id: Shape.plain.string(),
298
+ tags: Shape.record(Shape.plain.boolean()),
299
+ }),
300
+ ),
301
+ })
302
+
303
+ const doc1 = createTypedDoc(schema)
304
+ const doc2 = createTypedDoc(schema)
305
+
306
+ // Create a node with empty tags in doc1
307
+ const node = doc1.tree.createNode({
308
+ id: "node-1",
309
+ tags: {},
310
+ })
311
+
312
+ // Get the node ID for later lookup
313
+ const nodeId = loro(node).container.id
314
+
315
+ // Sync doc1 -> doc2
316
+ loro(doc2).doc.import(loro(doc1).doc.export({ mode: "snapshot" }))
317
+
318
+ // Peer 2 writes to tags using raw Loro API (since TreeRef doesn't expose get by ID)
319
+ const tree2 = loro(doc2.tree).container
320
+ const node2Raw = tree2.getNodeByID(nodeId)
321
+ expect(node2Raw).toBeDefined()
322
+ if (node2Raw) {
323
+ const data2 = node2Raw.data as any
324
+ const tags2 = data2.get("tags") as any
325
+ tags2.set("important", true)
326
+ }
327
+
328
+ // Sync doc2 -> doc1
329
+ loro(doc1).doc.import(
330
+ loro(doc2).doc.export({ mode: "update", from: loro(doc1).doc.version() }),
331
+ )
332
+
333
+ // Verify the tag is visible in doc1's node
334
+ expect(node.data.tags.get("important")).toBe(true)
335
+ })
336
+ })