@loro-extended/change 0.9.0 → 1.0.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.
Files changed (44) hide show
  1. package/README.md +179 -69
  2. package/dist/index.d.ts +369 -172
  3. package/dist/index.js +691 -382
  4. package/dist/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/change.test.ts +180 -175
  7. package/src/conversion.test.ts +91 -91
  8. package/src/conversion.ts +12 -12
  9. package/src/derive-placeholder.test.ts +14 -14
  10. package/src/derive-placeholder.ts +3 -3
  11. package/src/discriminated-union-assignability.test.ts +7 -7
  12. package/src/discriminated-union-tojson.test.ts +13 -24
  13. package/src/discriminated-union.test.ts +9 -8
  14. package/src/equality.test.ts +10 -2
  15. package/src/functional-helpers.test.ts +149 -0
  16. package/src/functional-helpers.ts +61 -0
  17. package/src/grand-unified-api.test.ts +423 -0
  18. package/src/index.ts +8 -6
  19. package/src/json-patch.test.ts +64 -56
  20. package/src/overlay-recursion.test.ts +326 -0
  21. package/src/overlay.ts +54 -17
  22. package/src/readonly.test.ts +27 -26
  23. package/src/shape.ts +103 -21
  24. package/src/typed-doc.ts +227 -58
  25. package/src/typed-refs/base.ts +33 -1
  26. package/src/typed-refs/counter.test.ts +44 -13
  27. package/src/typed-refs/counter.ts +42 -5
  28. package/src/typed-refs/doc.ts +29 -30
  29. package/src/typed-refs/json-compatibility.test.ts +37 -32
  30. package/src/typed-refs/list-base.ts +49 -21
  31. package/src/typed-refs/list.test.ts +4 -3
  32. package/src/typed-refs/movable-list.test.ts +3 -2
  33. package/src/typed-refs/movable-list.ts +6 -3
  34. package/src/typed-refs/proxy-handlers.ts +14 -1
  35. package/src/typed-refs/record.test.ts +116 -51
  36. package/src/typed-refs/record.ts +86 -81
  37. package/src/typed-refs/{map.ts → struct.ts} +66 -78
  38. package/src/typed-refs/text.ts +48 -7
  39. package/src/typed-refs/tree.ts +3 -3
  40. package/src/typed-refs/utils.ts +120 -13
  41. package/src/types.test.ts +34 -39
  42. package/src/types.ts +5 -40
  43. package/src/utils/type-guards.ts +11 -6
  44. package/src/validation.ts +10 -10
@@ -0,0 +1,326 @@
1
+ import { LoroDoc, LoroList, LoroMap } from "loro-crdt"
2
+ import { describe, expect, it } from "vitest"
3
+ import { change } from "./functional-helpers.js"
4
+ import { mergeValue } from "./overlay.js"
5
+ import { Shape } from "./shape.js"
6
+ import { createTypedDoc } from "./typed-doc.js"
7
+
8
+ /**
9
+ * Regression tests for overlay/mergeValue functionality.
10
+ *
11
+ * Tests placeholder recursion in nested structures and null value preservation.
12
+ */
13
+ describe("Overlay and Placeholder Handling", () => {
14
+ describe("TypedDoc.toJSON() - uses overlayPlaceholder/mergeValue", () => {
15
+ it("should apply placeholders in nested Maps within a Map", () => {
16
+ const schema = Shape.doc({
17
+ user: Shape.struct({
18
+ profile: Shape.struct({
19
+ name: Shape.plain.string(),
20
+ role: Shape.plain.string().placeholder("guest"), // Default value
21
+ }),
22
+ }),
23
+ })
24
+
25
+ const typedDoc = createTypedDoc(schema)
26
+
27
+ // Set only the name, 'role' should default to 'guest'
28
+ change(typedDoc, draft => {
29
+ draft.user.profile.set("name", "Alice")
30
+ })
31
+
32
+ const json = typedDoc.toJSON()
33
+
34
+ expect(json.user.profile.name).toBe("Alice")
35
+ expect(json.user.profile.role).toBe("guest")
36
+ })
37
+
38
+ it("should apply placeholders to nested map properties inside list items", () => {
39
+ const schema = Shape.doc({
40
+ users: Shape.list(
41
+ Shape.struct({
42
+ name: Shape.plain.string(),
43
+ role: Shape.plain.string().placeholder("guest"),
44
+ }),
45
+ ),
46
+ })
47
+
48
+ // Create a LoroDoc with partial data (missing 'role')
49
+ const loroDoc = new LoroDoc()
50
+ const usersList = loroDoc.getList("users")
51
+ const userMap = usersList.insertContainer(0, new LoroMap())
52
+ userMap.set("name", "Alice")
53
+ // Note: 'role' is NOT set - should default to "guest"
54
+
55
+ const typedDoc = createTypedDoc(schema, loroDoc)
56
+ const json = typedDoc.toJSON()
57
+
58
+ expect(json.users[0].name).toBe("Alice")
59
+ expect(json.users[0].role).toBe("guest")
60
+ })
61
+
62
+ it("should apply placeholders in deeply nested structures: list → map → list → map", () => {
63
+ const schema = Shape.doc({
64
+ departments: Shape.list(
65
+ Shape.struct({
66
+ name: Shape.plain.string(),
67
+ employees: Shape.list(
68
+ Shape.struct({
69
+ name: Shape.plain.string(),
70
+ level: Shape.plain.number().placeholder(1),
71
+ status: Shape.plain.string().placeholder("active"),
72
+ }),
73
+ ),
74
+ }),
75
+ ),
76
+ })
77
+
78
+ // Create a LoroDoc with deeply nested partial data
79
+ const loroDoc = new LoroDoc()
80
+ const deptList = loroDoc.getList("departments")
81
+
82
+ // Add a department
83
+ const deptMap = deptList.insertContainer(0, new LoroMap())
84
+ deptMap.set("name", "Engineering")
85
+
86
+ // Add employees list to department
87
+ const empList = deptMap.setContainer("employees", new LoroList())
88
+
89
+ // Add an employee with partial data
90
+ const empMap = empList.insertContainer(0, new LoroMap())
91
+ empMap.set("name", "Bob")
92
+ // Note: 'level' and 'status' are NOT set
93
+
94
+ const typedDoc = createTypedDoc(schema, loroDoc)
95
+ const json = typedDoc.toJSON()
96
+
97
+ expect(json.departments[0].name).toBe("Engineering")
98
+ expect(json.departments[0].employees[0].name).toBe("Bob")
99
+ expect(json.departments[0].employees[0].level).toBe(1)
100
+ expect(json.departments[0].employees[0].status).toBe("active")
101
+ })
102
+
103
+ it("should apply placeholders to counter values inside list items", () => {
104
+ const schema = Shape.doc({
105
+ articles: Shape.list(
106
+ Shape.struct({
107
+ title: Shape.text(),
108
+ views: Shape.counter().placeholder(100),
109
+ }),
110
+ ),
111
+ })
112
+
113
+ // Create a LoroDoc with partial data (counter not incremented)
114
+ const loroDoc = new LoroDoc()
115
+ const articlesList = loroDoc.getList("articles")
116
+ const articleMap = articlesList.insertContainer(0, new LoroMap())
117
+ // Only set title, don't touch the counter
118
+ const _titleText = articleMap.setContainer(
119
+ "title",
120
+ loroDoc.getText("temp"),
121
+ )
122
+ // Actually we need to create a text container properly
123
+
124
+ const typedDoc = createTypedDoc(schema, loroDoc)
125
+ const json = typedDoc.toJSON()
126
+
127
+ // The counter should default to 100 if not set
128
+ // Note: This test may need adjustment based on how counters are created
129
+ expect(json.articles[0].views).toBe(100)
130
+ })
131
+
132
+ it("should apply placeholders to movableList items", () => {
133
+ const schema = Shape.doc({
134
+ tasks: Shape.movableList(
135
+ Shape.struct({
136
+ title: Shape.plain.string(),
137
+ priority: Shape.plain.number().placeholder(5),
138
+ completed: Shape.plain.boolean().placeholder(false),
139
+ }),
140
+ ),
141
+ })
142
+
143
+ // Create a LoroDoc with partial data
144
+ const loroDoc = new LoroDoc()
145
+ const tasksList = loroDoc.getMovableList("tasks")
146
+ const taskMap = tasksList.insertContainer(0, new LoroMap())
147
+ taskMap.set("title", "Important Task")
148
+ // Note: 'priority' and 'completed' are NOT set
149
+
150
+ const typedDoc = createTypedDoc(schema, loroDoc)
151
+ const json = typedDoc.toJSON()
152
+
153
+ expect(json.tasks[0].title).toBe("Important Task")
154
+ expect(json.tasks[0].priority).toBe(5)
155
+ expect(json.tasks[0].completed).toBe(false)
156
+ })
157
+ })
158
+
159
+ describe("TypedRef.toJSON() - individual ref serialization", () => {
160
+ it("should apply placeholders when calling toJSON() on a list ref directly", () => {
161
+ const schema = Shape.doc({
162
+ items: Shape.list(
163
+ Shape.struct({
164
+ name: Shape.plain.string(),
165
+ count: Shape.plain.number().placeholder(0),
166
+ }),
167
+ ),
168
+ })
169
+
170
+ // Create a LoroDoc with partial data
171
+ const loroDoc = new LoroDoc()
172
+ const itemsList = loroDoc.getList("items")
173
+ const itemMap = itemsList.insertContainer(0, new LoroMap())
174
+ itemMap.set("name", "Widget")
175
+ // Note: 'count' is NOT set
176
+
177
+ const typedDoc = createTypedDoc(schema, loroDoc)
178
+
179
+ // Access the list ref directly and call toJSON()
180
+ const listJson = typedDoc.items.toJSON()
181
+
182
+ expect(listJson[0].name).toBe("Widget")
183
+ expect(listJson[0].count).toBe(0)
184
+ })
185
+ })
186
+
187
+ describe("Edge cases", () => {
188
+ it("should handle empty lists correctly", () => {
189
+ const schema = Shape.doc({
190
+ items: Shape.list(
191
+ Shape.struct({
192
+ name: Shape.plain.string(),
193
+ value: Shape.plain.number().placeholder(42),
194
+ }),
195
+ ),
196
+ })
197
+
198
+ const typedDoc = createTypedDoc(schema)
199
+ const json = typedDoc.toJSON()
200
+
201
+ expect(json.items).toEqual([])
202
+ })
203
+
204
+ it("should handle lists with plain value items (no nested placeholders)", () => {
205
+ const schema = Shape.doc({
206
+ numbers: Shape.list(Shape.plain.number()),
207
+ strings: Shape.list(Shape.plain.string()),
208
+ })
209
+
210
+ const loroDoc = new LoroDoc()
211
+ const numbersList = loroDoc.getList("numbers")
212
+ numbersList.insert(0, 1)
213
+ numbersList.insert(1, 2)
214
+ numbersList.insert(2, 3)
215
+
216
+ const typedDoc = createTypedDoc(schema, loroDoc)
217
+ const json = typedDoc.toJSON()
218
+
219
+ expect(json.numbers).toEqual([1, 2, 3])
220
+ expect(json.strings).toEqual([])
221
+ })
222
+
223
+ it("should handle record containing list with nested placeholders", () => {
224
+ const schema = Shape.doc({
225
+ usersByDept: Shape.record(
226
+ Shape.list(
227
+ Shape.struct({
228
+ name: Shape.plain.string(),
229
+ salary: Shape.plain.number().placeholder(50000),
230
+ }),
231
+ ),
232
+ ),
233
+ })
234
+
235
+ const loroDoc = new LoroDoc()
236
+ const recordMap = loroDoc.getMap("usersByDept")
237
+ const engList = recordMap.setContainer("engineering", new LoroList())
238
+ const userMap = engList.insertContainer(0, new LoroMap())
239
+ userMap.set("name", "Charlie")
240
+ // Note: 'salary' is NOT set
241
+
242
+ const typedDoc = createTypedDoc(schema, loroDoc)
243
+ const json = typedDoc.toJSON()
244
+
245
+ expect(json.usersByDept.engineering[0].name).toBe("Charlie")
246
+ expect(json.usersByDept.engineering[0].salary).toBe(50000)
247
+ })
248
+ })
249
+
250
+ /**
251
+ * Regression tests for null value preservation in mergeValue().
252
+ *
253
+ * The nullish coalescing operator (??) treats null as a nullish value,
254
+ * but in CRDT systems null is a valid intentional value that should be
255
+ * preserved. Only undefined should trigger fallback to placeholder.
256
+ *
257
+ * @see https://github.com/loro-dev/loro-extended/issues/XXX
258
+ */
259
+ describe("Null value preservation", () => {
260
+ it("should preserve null when crdtValue is null and placeholder is empty string", () => {
261
+ const shape = Shape.plain.union([
262
+ Shape.plain.string(),
263
+ Shape.plain.null(),
264
+ ])
265
+ const crdtValue = null
266
+ const placeholderValue = ""
267
+
268
+ const result = mergeValue(shape, crdtValue, placeholderValue)
269
+
270
+ expect(result).toBeNull()
271
+ })
272
+
273
+ it("should return placeholder when crdtValue is undefined", () => {
274
+ const shape = Shape.plain.union([
275
+ Shape.plain.string(),
276
+ Shape.plain.null(),
277
+ ])
278
+ const crdtValue = undefined
279
+ const placeholderValue = ""
280
+
281
+ const result = mergeValue(shape, crdtValue as any, placeholderValue)
282
+
283
+ expect(result).toBe("")
284
+ })
285
+
286
+ it("should preserve null in nested map properties", () => {
287
+ const schema = Shape.doc({
288
+ data: Shape.struct({
289
+ value: Shape.plain.union([Shape.plain.string(), Shape.plain.null()]),
290
+ }),
291
+ })
292
+
293
+ const loroDoc = new LoroDoc()
294
+ const dataMap = loroDoc.getMap("data")
295
+ dataMap.set("value", null)
296
+
297
+ const typedDoc = createTypedDoc(schema, loroDoc)
298
+ const json = typedDoc.toJSON()
299
+
300
+ expect(json.data.value).toBeNull()
301
+ })
302
+
303
+ it("should preserve null values in LoroMap toJSON", () => {
304
+ const doc = new LoroDoc()
305
+ const map = doc.getMap("test")
306
+ map.set("key", null)
307
+
308
+ const json = map.toJSON()
309
+
310
+ expect(json).toHaveProperty("key")
311
+ expect(json.key).toBeNull()
312
+ })
313
+
314
+ it("should preserve null values in nested maps via list toJSON", () => {
315
+ const doc = new LoroDoc()
316
+ const list = doc.getList("list")
317
+ const map = list.insertContainer(0, new LoroMap())
318
+ map.set("key", null)
319
+
320
+ const json = list.toJSON()
321
+
322
+ expect(json[0]).toHaveProperty("key")
323
+ expect(json[0].key).toBeNull()
324
+ })
325
+ })
326
+ })
package/src/overlay.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Value } from "loro-crdt"
2
+ import { deriveShapePlaceholder } from "./derive-placeholder.js"
2
3
  import type {
3
4
  ContainerShape,
4
5
  DiscriminatedUnionValueShape,
@@ -55,29 +56,40 @@ export function mergeValue<Shape extends ContainerShape | ValueShape>(
55
56
 
56
57
  switch (shape._type) {
57
58
  case "text":
58
- return crdtValue ?? placeholderValue ?? ""
59
+ return crdtValue !== undefined ? crdtValue : (placeholderValue ?? "")
59
60
  case "counter":
60
- return crdtValue ?? placeholderValue ?? 0
61
+ return crdtValue !== undefined ? crdtValue : (placeholderValue ?? 0)
61
62
  case "list":
62
- case "movableList":
63
- return crdtValue ?? placeholderValue ?? []
64
- case "map": {
63
+ case "movableList": {
64
+ if (crdtValue === undefined) {
65
+ return placeholderValue ?? []
66
+ }
67
+
68
+ const crdtArray = crdtValue as Value[]
69
+ const itemShape = shape.shape
70
+ const itemPlaceholder = deriveShapePlaceholder(itemShape)
71
+
72
+ return crdtArray.map(item =>
73
+ mergeValue(itemShape, item, itemPlaceholder as Value),
74
+ )
75
+ }
76
+ case "struct": {
65
77
  if (!isObjectValue(crdtValue) && crdtValue !== undefined) {
66
- throw new Error("map crdt must be object")
78
+ throw new Error("struct crdt must be object")
67
79
  }
68
80
 
69
- const crdtMapValue = crdtValue ?? {}
81
+ const crdtStructValue = crdtValue ?? {}
70
82
 
71
83
  if (!isObjectValue(placeholderValue) && placeholderValue !== undefined) {
72
- throw new Error("map placeholder must be object")
84
+ throw new Error("struct placeholder must be object")
73
85
  }
74
86
 
75
- const placeholderMapValue = placeholderValue ?? {}
87
+ const placeholderStructValue = placeholderValue ?? {}
76
88
 
77
- const result = { ...placeholderMapValue }
89
+ const result = { ...placeholderStructValue }
78
90
  for (const [key, nestedShape] of Object.entries(shape.shapes)) {
79
- const nestedCrdtValue = crdtMapValue[key]
80
- const nestedPlaceholderValue = placeholderMapValue[key]
91
+ const nestedCrdtValue = crdtStructValue[key]
92
+ const nestedPlaceholderValue = placeholderStructValue[key]
81
93
 
82
94
  result[key as keyof typeof result] = mergeValue(
83
95
  nestedShape,
@@ -89,15 +101,40 @@ export function mergeValue<Shape extends ContainerShape | ValueShape>(
89
101
  return result
90
102
  }
91
103
  case "tree":
92
- return crdtValue ?? placeholderValue ?? []
104
+ return crdtValue !== undefined ? crdtValue : (placeholderValue ?? [])
105
+ case "record": {
106
+ if (!isObjectValue(crdtValue) && crdtValue !== undefined) {
107
+ throw new Error("record crdt must be object")
108
+ }
109
+
110
+ const crdtRecordValue = (crdtValue as Record<string, Value>) ?? {}
111
+ const result: Record<string, Value> = {}
112
+
113
+ // For records, we iterate over the keys present in the CRDT value
114
+ // and apply the nested shape's placeholder logic to each value
115
+ for (const key of Object.keys(crdtRecordValue)) {
116
+ const nestedCrdtValue = crdtRecordValue[key]
117
+ // For records, the placeholder is always {}, so we need to derive
118
+ // the placeholder for the nested shape on the fly
119
+ const nestedPlaceholderValue = deriveShapePlaceholder(shape.shape)
120
+
121
+ result[key] = mergeValue(
122
+ shape.shape,
123
+ nestedCrdtValue,
124
+ nestedPlaceholderValue as Value,
125
+ )
126
+ }
127
+
128
+ return result
129
+ }
93
130
  default:
94
- if (shape._type === "value" && shape.valueType === "object") {
131
+ if (shape._type === "value" && shape.valueType === "struct") {
95
132
  const crdtObj = (crdtValue as any) ?? {}
96
133
  const placeholderObj = (placeholderValue as any) ?? {}
97
134
  const result = { ...placeholderObj }
98
135
 
99
136
  if (typeof crdtObj !== "object" || crdtObj === null) {
100
- return crdtValue ?? placeholderValue
137
+ return crdtValue !== undefined ? crdtValue : placeholderValue
101
138
  }
102
139
 
103
140
  for (const [key, propShape] of Object.entries(shape.shape)) {
@@ -117,7 +154,7 @@ export function mergeValue<Shape extends ContainerShape | ValueShape>(
117
154
  )
118
155
  }
119
156
 
120
- return crdtValue ?? placeholderValue
157
+ return crdtValue !== undefined ? crdtValue : placeholderValue
121
158
  }
122
159
  }
123
160
 
@@ -147,7 +184,7 @@ function mergeDiscriminatedUnion(
147
184
 
148
185
  if (!variantShape) {
149
186
  // Unknown variant - return CRDT value or placeholder
150
- return crdtValue ?? placeholderValue
187
+ return crdtValue !== undefined ? crdtValue : placeholderValue
151
188
  }
152
189
 
153
190
  // Merge using the variant's object shape
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"
2
2
  import { Shape } from "./shape.js"
3
3
  import { createTypedDoc } from "./typed-doc.js"
4
4
 
5
- describe("TypedDoc Readonly Mode", () => {
5
+ describe("TypedDoc Mutable Mode", () => {
6
6
  const schema = Shape.doc({
7
- meta: Shape.map({
7
+ meta: Shape.struct({
8
8
  count: Shape.plain.number(),
9
9
  title: Shape.plain.string(),
10
10
  }),
@@ -14,7 +14,7 @@ describe("TypedDoc Readonly Mode", () => {
14
14
  it("should read values correctly", () => {
15
15
  const doc = createTypedDoc(schema)
16
16
 
17
- doc.change(d => {
17
+ doc.$.change(d => {
18
18
  d.meta.count = 1
19
19
  d.meta.title = "updated"
20
20
  d.list.push("item1")
@@ -29,11 +29,11 @@ describe("TypedDoc Readonly Mode", () => {
29
29
  const doc = createTypedDoc(schema)
30
30
 
31
31
  // Get a reference to the live view
32
- const liveMeta = doc.value.meta
32
+ const liveMeta = doc.meta
33
33
 
34
34
  expect(liveMeta.count).toBe(0)
35
35
 
36
- doc.change(d => {
36
+ doc.$.change(d => {
37
37
  d.meta.count = 5
38
38
  })
39
39
 
@@ -41,39 +41,40 @@ describe("TypedDoc Readonly Mode", () => {
41
41
  expect(liveMeta.count).toBe(5)
42
42
  })
43
43
 
44
- it("should throw on mutation attempts", () => {
44
+ it("should allow direct mutations via doc.value (auto-commit)", () => {
45
45
  const doc = createTypedDoc(schema)
46
46
 
47
- const liveMeta = doc.value.meta as any
48
- const liveList = doc.value.list as any
47
+ // Direct mutations on doc.value should work and auto-commit
48
+ doc.meta.count = 10
49
+ expect(doc.toJSON().meta.count).toBe(10)
49
50
 
50
- expect(() => {
51
- liveMeta.count = 10
52
- }).toThrow() // Proxy might not throw on set, but the underlying setter should
51
+ doc.list.push("item1")
52
+ expect(doc.toJSON().list[0]).toBe("item1")
53
53
 
54
- // We don't strictly prevent adding new properties to the JS object if it's not a Proxy,
55
- // but we ensure defined properties are protected.
56
- // expect(() => {
57
- // liveMeta.newProp = "fail"
58
- // }).toThrow()
54
+ doc.list.push("item2")
55
+ expect(doc.toJSON().list).toEqual(["item1", "item2"])
56
+ })
59
57
 
60
- expect(() => {
61
- delete liveMeta.count
62
- }).toThrow()
58
+ it("should support change() for grouped mutations", () => {
59
+ const doc = createTypedDoc(schema)
63
60
 
64
- expect(() => {
65
- liveList.push("fail")
66
- }).toThrow()
61
+ doc.$.change(d => {
62
+ d.meta.count = 1
63
+ d.meta.title = "batched"
64
+ d.list.push("a")
65
+ d.list.push("b")
66
+ })
67
67
 
68
- expect(() => {
69
- liveList[0] = "fail"
70
- }).toThrow()
68
+ expect(doc.toJSON()).toEqual({
69
+ meta: { count: 1, title: "batched" },
70
+ list: ["a", "b"],
71
+ })
71
72
  })
72
73
 
73
74
  it("should support toJSON for full serialization", () => {
74
75
  const doc = createTypedDoc(schema)
75
76
 
76
- doc.change(d => {
77
+ doc.$.change(d => {
77
78
  d.meta.count = 1
78
79
  d.meta.title = "json"
79
80
  d.list.push("a")