@loro-extended/change 5.3.0 → 5.4.1

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.
Files changed (39) hide show
  1. package/README.md +85 -28
  2. package/dist/index.d.ts +291 -107
  3. package/dist/index.js +587 -36
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/src/change.test.ts +1 -1
  7. package/src/conversion.ts +40 -4
  8. package/src/diff-overlay.test.ts +95 -0
  9. package/src/diff-overlay.ts +10 -0
  10. package/src/discriminated-union-tojson.test.ts +2 -2
  11. package/src/fork-at.test.ts +1 -1
  12. package/src/functional-helpers.test.ts +50 -1
  13. package/src/functional-helpers.ts +152 -8
  14. package/src/index.ts +46 -18
  15. package/src/loro.ts +2 -1
  16. package/src/nested-container-materialization.test.ts +336 -0
  17. package/src/overlay-recursion.test.ts +8 -8
  18. package/src/replay-diff.test.ts +389 -0
  19. package/src/replay-diff.ts +229 -0
  20. package/src/shallow-fork.test.ts +302 -0
  21. package/src/shape.ts +7 -7
  22. package/src/typed-doc-ownkeys.test.ts +116 -0
  23. package/src/typed-doc.ts +33 -10
  24. package/src/typed-refs/base.ts +40 -4
  25. package/src/typed-refs/counter-ref-internals.ts +16 -2
  26. package/src/typed-refs/doc-ref-internals.ts +1 -0
  27. package/src/typed-refs/doc-ref-ownkeys.test.ts +78 -0
  28. package/src/typed-refs/index.ts +17 -0
  29. package/src/typed-refs/json-compatibility.test.ts +1 -1
  30. package/src/typed-refs/list-ref-base-internals.ts +2 -1
  31. package/src/typed-refs/list-ref-base.ts +79 -3
  32. package/src/typed-refs/record-ref-internals.ts +116 -2
  33. package/src/typed-refs/record-ref.test.ts +522 -1
  34. package/src/typed-refs/record-ref.ts +72 -3
  35. package/src/typed-refs/struct-ref-internals.ts +40 -3
  36. package/src/typed-refs/text-ref-internals.ts +70 -4
  37. package/src/typed-refs/tree-node-ref-internals.ts +14 -2
  38. package/src/typed-refs/tree-ref-internals.ts +2 -1
  39. package/src/typed-refs/utils.ts +65 -8
@@ -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
+ })
@@ -52,7 +52,7 @@ describe("Overlay and Placeholder Handling", () => {
52
52
  userMap.set("name", "Alice")
53
53
  // Note: 'role' is NOT set - should default to "guest"
54
54
 
55
- const typedDoc = createTypedDoc(schema, loroDoc)
55
+ const typedDoc = createTypedDoc(schema, { doc: loroDoc })
56
56
  const json = typedDoc.toJSON()
57
57
 
58
58
  expect(json.users[0].name).toBe("Alice")
@@ -91,7 +91,7 @@ describe("Overlay and Placeholder Handling", () => {
91
91
  empMap.set("name", "Bob")
92
92
  // Note: 'level' and 'status' are NOT set
93
93
 
94
- const typedDoc = createTypedDoc(schema, loroDoc)
94
+ const typedDoc = createTypedDoc(schema, { doc: loroDoc })
95
95
  const json = typedDoc.toJSON()
96
96
 
97
97
  expect(json.departments[0].name).toBe("Engineering")
@@ -121,7 +121,7 @@ describe("Overlay and Placeholder Handling", () => {
121
121
  )
122
122
  // Actually we need to create a text container properly
123
123
 
124
- const typedDoc = createTypedDoc(schema, loroDoc)
124
+ const typedDoc = createTypedDoc(schema, { doc: loroDoc })
125
125
  const json = typedDoc.toJSON()
126
126
 
127
127
  // The counter should default to 100 if not set
@@ -147,7 +147,7 @@ describe("Overlay and Placeholder Handling", () => {
147
147
  taskMap.set("title", "Important Task")
148
148
  // Note: 'priority' and 'completed' are NOT set
149
149
 
150
- const typedDoc = createTypedDoc(schema, loroDoc)
150
+ const typedDoc = createTypedDoc(schema, { doc: loroDoc })
151
151
  const json = typedDoc.toJSON()
152
152
 
153
153
  expect(json.tasks[0].title).toBe("Important Task")
@@ -174,7 +174,7 @@ describe("Overlay and Placeholder Handling", () => {
174
174
  itemMap.set("name", "Widget")
175
175
  // Note: 'count' is NOT set
176
176
 
177
- const typedDoc = createTypedDoc(schema, loroDoc)
177
+ const typedDoc = createTypedDoc(schema, { doc: loroDoc })
178
178
 
179
179
  // Access the list ref directly and call toJSON()
180
180
  const listJson = typedDoc.items.toJSON()
@@ -213,7 +213,7 @@ describe("Overlay and Placeholder Handling", () => {
213
213
  numbersList.insert(1, 2)
214
214
  numbersList.insert(2, 3)
215
215
 
216
- const typedDoc = createTypedDoc(schema, loroDoc)
216
+ const typedDoc = createTypedDoc(schema, { doc: loroDoc })
217
217
  const json = typedDoc.toJSON()
218
218
 
219
219
  expect(json.numbers).toEqual([1, 2, 3])
@@ -239,7 +239,7 @@ describe("Overlay and Placeholder Handling", () => {
239
239
  userMap.set("name", "Charlie")
240
240
  // Note: 'salary' is NOT set
241
241
 
242
- const typedDoc = createTypedDoc(schema, loroDoc)
242
+ const typedDoc = createTypedDoc(schema, { doc: loroDoc })
243
243
  const json = typedDoc.toJSON()
244
244
 
245
245
  expect(json.usersByDept.engineering[0].name).toBe("Charlie")
@@ -294,7 +294,7 @@ describe("Overlay and Placeholder Handling", () => {
294
294
  const dataMap = loroDoc.getMap("data")
295
295
  dataMap.set("value", null)
296
296
 
297
- const typedDoc = createTypedDoc(schema, loroDoc)
297
+ const typedDoc = createTypedDoc(schema, { doc: loroDoc })
298
298
  const json = typedDoc.toJSON()
299
299
 
300
300
  expect(json.data.value).toBeNull()