@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.
- package/README.md +179 -69
- package/dist/index.d.ts +369 -172
- package/dist/index.js +691 -382
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/change.test.ts +180 -175
- package/src/conversion.test.ts +91 -91
- package/src/conversion.ts +12 -12
- package/src/derive-placeholder.test.ts +14 -14
- package/src/derive-placeholder.ts +3 -3
- package/src/discriminated-union-assignability.test.ts +7 -7
- package/src/discriminated-union-tojson.test.ts +13 -24
- package/src/discriminated-union.test.ts +9 -8
- package/src/equality.test.ts +10 -2
- package/src/functional-helpers.test.ts +149 -0
- package/src/functional-helpers.ts +61 -0
- package/src/grand-unified-api.test.ts +423 -0
- package/src/index.ts +8 -6
- package/src/json-patch.test.ts +64 -56
- package/src/overlay-recursion.test.ts +326 -0
- package/src/overlay.ts +54 -17
- package/src/readonly.test.ts +27 -26
- package/src/shape.ts +103 -21
- package/src/typed-doc.ts +227 -58
- package/src/typed-refs/base.ts +33 -1
- package/src/typed-refs/counter.test.ts +44 -13
- package/src/typed-refs/counter.ts +42 -5
- package/src/typed-refs/doc.ts +29 -30
- package/src/typed-refs/json-compatibility.test.ts +37 -32
- package/src/typed-refs/list-base.ts +49 -21
- package/src/typed-refs/list.test.ts +4 -3
- package/src/typed-refs/movable-list.test.ts +3 -2
- package/src/typed-refs/movable-list.ts +6 -3
- package/src/typed-refs/proxy-handlers.ts +14 -1
- package/src/typed-refs/record.test.ts +116 -51
- package/src/typed-refs/record.ts +86 -81
- package/src/typed-refs/{map.ts → struct.ts} +66 -78
- package/src/typed-refs/text.ts +48 -7
- package/src/typed-refs/tree.ts +3 -3
- package/src/typed-refs/utils.ts +120 -13
- package/src/types.test.ts +34 -39
- package/src/types.ts +5 -40
- package/src/utils/type-guards.ts +11 -6
- 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
|
|
59
|
+
return crdtValue !== undefined ? crdtValue : (placeholderValue ?? "")
|
|
59
60
|
case "counter":
|
|
60
|
-
return crdtValue
|
|
61
|
+
return crdtValue !== undefined ? crdtValue : (placeholderValue ?? 0)
|
|
61
62
|
case "list":
|
|
62
|
-
case "movableList":
|
|
63
|
-
|
|
64
|
-
|
|
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("
|
|
78
|
+
throw new Error("struct crdt must be object")
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
const
|
|
81
|
+
const crdtStructValue = crdtValue ?? {}
|
|
70
82
|
|
|
71
83
|
if (!isObjectValue(placeholderValue) && placeholderValue !== undefined) {
|
|
72
|
-
throw new Error("
|
|
84
|
+
throw new Error("struct placeholder must be object")
|
|
73
85
|
}
|
|
74
86
|
|
|
75
|
-
const
|
|
87
|
+
const placeholderStructValue = placeholderValue ?? {}
|
|
76
88
|
|
|
77
|
-
const result = { ...
|
|
89
|
+
const result = { ...placeholderStructValue }
|
|
78
90
|
for (const [key, nestedShape] of Object.entries(shape.shapes)) {
|
|
79
|
-
const nestedCrdtValue =
|
|
80
|
-
const nestedPlaceholderValue =
|
|
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
|
|
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 === "
|
|
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
|
|
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
|
|
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
|
|
187
|
+
return crdtValue !== undefined ? crdtValue : placeholderValue
|
|
151
188
|
}
|
|
152
189
|
|
|
153
190
|
// Merge using the variant's object shape
|
package/src/readonly.test.ts
CHANGED
|
@@ -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
|
|
5
|
+
describe("TypedDoc Mutable Mode", () => {
|
|
6
6
|
const schema = Shape.doc({
|
|
7
|
-
meta: Shape.
|
|
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
|
|
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.
|
|
32
|
+
const liveMeta = doc.meta
|
|
33
33
|
|
|
34
34
|
expect(liveMeta.count).toBe(0)
|
|
35
35
|
|
|
36
|
-
doc
|
|
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
|
|
44
|
+
it("should allow direct mutations via doc.value (auto-commit)", () => {
|
|
45
45
|
const doc = createTypedDoc(schema)
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// liveMeta.newProp = "fail"
|
|
58
|
-
// }).toThrow()
|
|
54
|
+
doc.list.push("item2")
|
|
55
|
+
expect(doc.toJSON().list).toEqual(["item1", "item2"])
|
|
56
|
+
})
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}).toThrow()
|
|
58
|
+
it("should support change() for grouped mutations", () => {
|
|
59
|
+
const doc = createTypedDoc(schema)
|
|
63
60
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
77
|
+
doc.$.change(d => {
|
|
77
78
|
d.meta.count = 1
|
|
78
79
|
d.meta.title = "json"
|
|
79
80
|
d.list.push("a")
|