@loro-extended/change 0.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/LICENSE +21 -0
- package/README.md +565 -0
- package/dist/index.d.ts +339 -0
- package/dist/index.js +1491 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/change.test.ts +2006 -0
- package/src/change.ts +105 -0
- package/src/conversion.test.ts +728 -0
- package/src/conversion.ts +220 -0
- package/src/draft-nodes/base.ts +34 -0
- package/src/draft-nodes/counter.ts +21 -0
- package/src/draft-nodes/doc.ts +81 -0
- package/src/draft-nodes/list-base.ts +326 -0
- package/src/draft-nodes/list.ts +18 -0
- package/src/draft-nodes/map.ts +156 -0
- package/src/draft-nodes/movable-list.ts +26 -0
- package/src/draft-nodes/record.ts +215 -0
- package/src/draft-nodes/text.ts +48 -0
- package/src/draft-nodes/tree.ts +31 -0
- package/src/draft-nodes/utils.ts +55 -0
- package/src/index.ts +33 -0
- package/src/json-patch.test.ts +697 -0
- package/src/json-patch.ts +391 -0
- package/src/overlay.ts +90 -0
- package/src/record.test.ts +188 -0
- package/src/schema.fixtures.ts +138 -0
- package/src/shape.ts +348 -0
- package/src/types.ts +15 -0
- package/src/utils/type-guards.ts +210 -0
- package/src/validation.ts +261 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { createTypedDoc } from "./change.js"
|
|
3
|
+
import type { JsonPatch } from "./json-patch.js"
|
|
4
|
+
import { Shape } from "./shape.js"
|
|
5
|
+
|
|
6
|
+
describe("JSON Patch Integration", () => {
|
|
7
|
+
describe("Basic Operations", () => {
|
|
8
|
+
it("should handle add operations on map properties", () => {
|
|
9
|
+
const schema = Shape.doc({
|
|
10
|
+
metadata: Shape.map({
|
|
11
|
+
title: Shape.plain.string(),
|
|
12
|
+
count: Shape.plain.number(),
|
|
13
|
+
}),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const emptyState = {
|
|
17
|
+
metadata: {
|
|
18
|
+
title: "",
|
|
19
|
+
count: 0,
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
24
|
+
|
|
25
|
+
const patch: JsonPatch = [
|
|
26
|
+
{ op: "add", path: "/metadata/title", value: "Hello World" },
|
|
27
|
+
{ op: "add", path: "/metadata/count", value: 42 },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const result = typedDoc.applyPatch(patch)
|
|
31
|
+
|
|
32
|
+
expect(result.metadata.title).toBe("Hello World")
|
|
33
|
+
expect(result.metadata.count).toBe(42)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("should handle remove operations on map properties", () => {
|
|
37
|
+
const schema = Shape.doc({
|
|
38
|
+
config: Shape.map({
|
|
39
|
+
theme: Shape.plain.string(),
|
|
40
|
+
debug: Shape.plain.boolean(),
|
|
41
|
+
}),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const emptyState = {
|
|
45
|
+
config: {
|
|
46
|
+
theme: "light",
|
|
47
|
+
debug: true,
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
52
|
+
|
|
53
|
+
// First set some values
|
|
54
|
+
typedDoc.change(draft => {
|
|
55
|
+
draft.config.set("theme", "dark")
|
|
56
|
+
draft.config.set("debug", false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const patch: JsonPatch = [{ op: "remove", path: "/config/debug" }]
|
|
60
|
+
|
|
61
|
+
const result = typedDoc.applyPatch(patch)
|
|
62
|
+
|
|
63
|
+
expect(result.config.theme).toBe("dark")
|
|
64
|
+
expect(result.config.debug).toBe(true) // Should fall back to empty state
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("should handle replace operations on map properties", () => {
|
|
68
|
+
const schema = Shape.doc({
|
|
69
|
+
settings: Shape.map({
|
|
70
|
+
language: Shape.plain.string(),
|
|
71
|
+
volume: Shape.plain.number(),
|
|
72
|
+
}),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const emptyState = {
|
|
76
|
+
settings: {
|
|
77
|
+
language: "en",
|
|
78
|
+
volume: 50,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
83
|
+
|
|
84
|
+
// Set initial values
|
|
85
|
+
typedDoc.change(draft => {
|
|
86
|
+
draft.settings.set("language", "fr")
|
|
87
|
+
draft.settings.set("volume", 75)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const patch: JsonPatch = [
|
|
91
|
+
{ op: "replace", path: "/settings/language", value: "es" },
|
|
92
|
+
{ op: "replace", path: "/settings/volume", value: 100 },
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
const result = typedDoc.applyPatch(patch)
|
|
96
|
+
|
|
97
|
+
expect(result.settings.language).toBe("es")
|
|
98
|
+
expect(result.settings.volume).toBe(100)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe("List Operations", () => {
|
|
103
|
+
it("should handle add operations on lists", () => {
|
|
104
|
+
const schema = Shape.doc({
|
|
105
|
+
items: Shape.list(Shape.plain.string()),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const emptyState = {
|
|
109
|
+
items: [],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
113
|
+
|
|
114
|
+
const patch: JsonPatch = [
|
|
115
|
+
{ op: "add", path: "/items/0", value: "first" },
|
|
116
|
+
{ op: "add", path: "/items/1", value: "second" },
|
|
117
|
+
{ op: "add", path: "/items/1", value: "middle" }, // Insert in middle
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
const result = typedDoc.applyPatch(patch)
|
|
121
|
+
|
|
122
|
+
expect(result.items).toEqual(["first", "middle", "second"])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("should handle remove operations on lists", () => {
|
|
126
|
+
const schema = Shape.doc({
|
|
127
|
+
tasks: Shape.list(Shape.plain.string()),
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const emptyState = {
|
|
131
|
+
tasks: [],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
135
|
+
|
|
136
|
+
// Add initial items
|
|
137
|
+
typedDoc.change(draft => {
|
|
138
|
+
draft.tasks.push("task1")
|
|
139
|
+
draft.tasks.push("task2")
|
|
140
|
+
draft.tasks.push("task3")
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const patch: JsonPatch = [
|
|
144
|
+
{ op: "remove", path: "/tasks/1" }, // Remove "task2"
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
const result = typedDoc.applyPatch(patch)
|
|
148
|
+
|
|
149
|
+
expect(result.tasks).toEqual(["task1", "task3"])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("should handle replace operations on lists", () => {
|
|
153
|
+
const schema = Shape.doc({
|
|
154
|
+
numbers: Shape.list(Shape.plain.number()),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const emptyState = {
|
|
158
|
+
numbers: [],
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
162
|
+
|
|
163
|
+
// Add initial items
|
|
164
|
+
typedDoc.change(draft => {
|
|
165
|
+
draft.numbers.push(1)
|
|
166
|
+
draft.numbers.push(2)
|
|
167
|
+
draft.numbers.push(3)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const patch: JsonPatch = [
|
|
171
|
+
{ op: "replace", path: "/numbers/1", value: 20 },
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
const result = typedDoc.applyPatch(patch)
|
|
175
|
+
|
|
176
|
+
expect(result.numbers).toEqual([1, 20, 3])
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe("CRDT Container Operations", () => {
|
|
181
|
+
it("should work with text containers", () => {
|
|
182
|
+
const schema = Shape.doc({
|
|
183
|
+
title: Shape.text(),
|
|
184
|
+
content: Shape.text(),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const emptyState = {
|
|
188
|
+
title: "",
|
|
189
|
+
content: "",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
193
|
+
|
|
194
|
+
// Note: For text containers, we can't directly patch the text content
|
|
195
|
+
// since it's a CRDT container. This test verifies the path navigation works
|
|
196
|
+
// but the actual text manipulation should be done through text methods
|
|
197
|
+
|
|
198
|
+
// This should work for setting up the structure
|
|
199
|
+
const result = typedDoc.value
|
|
200
|
+
expect(result.title).toBe("")
|
|
201
|
+
expect(result.content).toBe("")
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("should work with counter containers", () => {
|
|
205
|
+
const schema = Shape.doc({
|
|
206
|
+
views: Shape.counter(),
|
|
207
|
+
likes: Shape.counter(),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const emptyState = {
|
|
211
|
+
views: 0,
|
|
212
|
+
likes: 0,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
216
|
+
|
|
217
|
+
// Note: Similar to text, counters are CRDT containers
|
|
218
|
+
// The path navigation should work, but actual counter operations
|
|
219
|
+
// should use increment/decrement methods
|
|
220
|
+
|
|
221
|
+
const result = typedDoc.value
|
|
222
|
+
expect(result.views).toBe(0)
|
|
223
|
+
expect(result.likes).toBe(0)
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe("Complex Nested Operations", () => {
|
|
228
|
+
it("should handle deeply nested map structures", () => {
|
|
229
|
+
const schema = Shape.doc({
|
|
230
|
+
user: Shape.map({
|
|
231
|
+
profile: Shape.map({
|
|
232
|
+
name: Shape.plain.string(),
|
|
233
|
+
settings: Shape.map({
|
|
234
|
+
theme: Shape.plain.string(),
|
|
235
|
+
notifications: Shape.plain.boolean(),
|
|
236
|
+
}),
|
|
237
|
+
}),
|
|
238
|
+
}),
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
const emptyState = {
|
|
242
|
+
user: {
|
|
243
|
+
profile: {
|
|
244
|
+
name: "",
|
|
245
|
+
settings: {
|
|
246
|
+
theme: "light",
|
|
247
|
+
notifications: true,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
254
|
+
|
|
255
|
+
const patch: JsonPatch = [
|
|
256
|
+
{ op: "add", path: "/user/profile/name", value: "Alice" },
|
|
257
|
+
{ op: "replace", path: "/user/profile/settings/theme", value: "dark" },
|
|
258
|
+
{
|
|
259
|
+
op: "replace",
|
|
260
|
+
path: "/user/profile/settings/notifications",
|
|
261
|
+
value: false,
|
|
262
|
+
},
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
const result = typedDoc.applyPatch(patch)
|
|
266
|
+
|
|
267
|
+
expect(result.user.profile.name).toBe("Alice")
|
|
268
|
+
expect(result.user.profile.settings.theme).toBe("dark")
|
|
269
|
+
expect(result.user.profile.settings.notifications).toBe(false)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("should handle lists of objects", () => {
|
|
273
|
+
const schema = Shape.doc({
|
|
274
|
+
todos: Shape.list(
|
|
275
|
+
Shape.plain.object({
|
|
276
|
+
id: Shape.plain.string(),
|
|
277
|
+
text: Shape.plain.string(),
|
|
278
|
+
completed: Shape.plain.boolean(),
|
|
279
|
+
}),
|
|
280
|
+
),
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const emptyState = {
|
|
284
|
+
todos: [],
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
288
|
+
|
|
289
|
+
const patch: JsonPatch = [
|
|
290
|
+
{
|
|
291
|
+
op: "add",
|
|
292
|
+
path: "/todos/0",
|
|
293
|
+
value: { id: "1", text: "Buy milk", completed: false },
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
op: "add",
|
|
297
|
+
path: "/todos/1",
|
|
298
|
+
value: { id: "2", text: "Walk dog", completed: false },
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
op: "replace",
|
|
302
|
+
path: "/todos/1/completed",
|
|
303
|
+
value: true,
|
|
304
|
+
},
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
const result = typedDoc.applyPatch(patch)
|
|
308
|
+
|
|
309
|
+
expect(result.todos).toHaveLength(2)
|
|
310
|
+
expect(result.todos[0]).toEqual({
|
|
311
|
+
id: "1",
|
|
312
|
+
text: "Buy milk",
|
|
313
|
+
completed: false,
|
|
314
|
+
})
|
|
315
|
+
expect(result.todos[1]).toEqual({
|
|
316
|
+
id: "2",
|
|
317
|
+
text: "Walk dog",
|
|
318
|
+
completed: true,
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
describe("Move and Copy Operations", () => {
|
|
324
|
+
it("should handle move operations", () => {
|
|
325
|
+
const schema = Shape.doc({
|
|
326
|
+
items: Shape.list(Shape.plain.string()),
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
const emptyState = {
|
|
330
|
+
items: [],
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
334
|
+
|
|
335
|
+
// Add initial items
|
|
336
|
+
typedDoc.change(draft => {
|
|
337
|
+
draft.items.push("first")
|
|
338
|
+
draft.items.push("second")
|
|
339
|
+
draft.items.push("third")
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const patch: JsonPatch = [
|
|
343
|
+
{ op: "move", from: "/items/0", path: "/items/2" }, // Move "first" to end
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
const result = typedDoc.applyPatch(patch)
|
|
347
|
+
|
|
348
|
+
expect(result.items).toEqual(["second", "third", "first"])
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it("should handle various move scenarios to prevent regressions", () => {
|
|
352
|
+
const schema = Shape.doc({
|
|
353
|
+
items: Shape.list(Shape.plain.string()),
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const emptyState = {
|
|
357
|
+
items: [],
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
361
|
+
|
|
362
|
+
// Test move from 0 to 3 (move first item to end of 4-item list)
|
|
363
|
+
typedDoc.change(draft => {
|
|
364
|
+
draft.items.push("A")
|
|
365
|
+
draft.items.push("B")
|
|
366
|
+
draft.items.push("C")
|
|
367
|
+
draft.items.push("D")
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
const patch1: JsonPatch = [
|
|
371
|
+
{ op: "move", from: "/items/0", path: "/items/3" },
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
const result1 = typedDoc.applyPatch(patch1)
|
|
375
|
+
expect(result1.items).toEqual(["B", "C", "D", "A"])
|
|
376
|
+
|
|
377
|
+
// Reset for next test
|
|
378
|
+
typedDoc.change(draft => {
|
|
379
|
+
draft.items.delete(0, draft.items.length)
|
|
380
|
+
draft.items.push("A")
|
|
381
|
+
draft.items.push("B")
|
|
382
|
+
draft.items.push("C")
|
|
383
|
+
draft.items.push("D")
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
// Test move from 1 to 3 (move middle item to end)
|
|
387
|
+
const patch2: JsonPatch = [
|
|
388
|
+
{ op: "move", from: "/items/1", path: "/items/3" },
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
const result2 = typedDoc.applyPatch(patch2)
|
|
392
|
+
expect(result2.items).toEqual(["A", "C", "D", "B"])
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it("should handle copy operations", () => {
|
|
396
|
+
const schema = Shape.doc({
|
|
397
|
+
source: Shape.list(Shape.plain.string()),
|
|
398
|
+
target: Shape.list(Shape.plain.string()),
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
const emptyState = {
|
|
402
|
+
source: [],
|
|
403
|
+
target: [],
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
407
|
+
|
|
408
|
+
// Add initial items
|
|
409
|
+
typedDoc.change(draft => {
|
|
410
|
+
draft.source.push("item1")
|
|
411
|
+
draft.source.push("item2")
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
const patch: JsonPatch = [
|
|
415
|
+
{ op: "copy", from: "/source/0", path: "/target/0" },
|
|
416
|
+
{ op: "copy", from: "/source/1", path: "/target/1" },
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
const result = typedDoc.applyPatch(patch)
|
|
420
|
+
|
|
421
|
+
expect(result.source).toEqual(["item1", "item2"])
|
|
422
|
+
expect(result.target).toEqual(["item1", "item2"])
|
|
423
|
+
})
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
describe("Test Operations", () => {
|
|
427
|
+
it("should handle test operations that pass", () => {
|
|
428
|
+
const schema = Shape.doc({
|
|
429
|
+
config: Shape.map({
|
|
430
|
+
version: Shape.plain.string(),
|
|
431
|
+
}),
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
const emptyState = {
|
|
435
|
+
config: {
|
|
436
|
+
version: "1.0.0",
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
441
|
+
|
|
442
|
+
typedDoc.change(draft => {
|
|
443
|
+
draft.config.set("version", "2.0.0")
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
const patch: JsonPatch = [
|
|
447
|
+
{ op: "test", path: "/config/version", value: "2.0.0" },
|
|
448
|
+
{ op: "replace", path: "/config/version", value: "2.1.0" },
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
const result = typedDoc.applyPatch(patch)
|
|
452
|
+
|
|
453
|
+
expect(result.config.version).toBe("2.1.0")
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it("should throw on test operations that fail", () => {
|
|
457
|
+
const schema = Shape.doc({
|
|
458
|
+
config: Shape.map({
|
|
459
|
+
version: Shape.plain.string(),
|
|
460
|
+
}),
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
const emptyState = {
|
|
464
|
+
config: {
|
|
465
|
+
version: "1.0.0",
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
470
|
+
|
|
471
|
+
const patch: JsonPatch = [
|
|
472
|
+
{ op: "test", path: "/config/version", value: "2.0.0" }, // This should fail
|
|
473
|
+
]
|
|
474
|
+
|
|
475
|
+
expect(() => {
|
|
476
|
+
typedDoc.applyPatch(patch)
|
|
477
|
+
}).toThrow("JSON Patch test failed at path: /config/version")
|
|
478
|
+
})
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
describe("Path Prefix Support", () => {
|
|
482
|
+
it("should support path prefixes for scoped operations", () => {
|
|
483
|
+
const schema = Shape.doc({
|
|
484
|
+
users: Shape.map({
|
|
485
|
+
alice: Shape.map({
|
|
486
|
+
name: Shape.plain.string(),
|
|
487
|
+
email: Shape.plain.string(),
|
|
488
|
+
}),
|
|
489
|
+
bob: Shape.map({
|
|
490
|
+
name: Shape.plain.string(),
|
|
491
|
+
email: Shape.plain.string(),
|
|
492
|
+
}),
|
|
493
|
+
}),
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
const emptyState = {
|
|
497
|
+
users: {
|
|
498
|
+
alice: {
|
|
499
|
+
name: "",
|
|
500
|
+
email: "",
|
|
501
|
+
},
|
|
502
|
+
bob: {
|
|
503
|
+
name: "",
|
|
504
|
+
email: "",
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
510
|
+
|
|
511
|
+
// Apply patch with path prefix to scope operations to alice
|
|
512
|
+
const patch: JsonPatch = [
|
|
513
|
+
{ op: "add", path: "/name", value: "Alice Smith" },
|
|
514
|
+
{ op: "add", path: "/email", value: "alice@example.com" },
|
|
515
|
+
]
|
|
516
|
+
|
|
517
|
+
const result = typedDoc.applyPatch(patch, ["users", "alice"])
|
|
518
|
+
|
|
519
|
+
expect(result.users.alice.name).toBe("Alice Smith")
|
|
520
|
+
expect(result.users.alice.email).toBe("alice@example.com")
|
|
521
|
+
expect(result.users.bob.name).toBe("") // Should be unchanged
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
describe("Path Formats", () => {
|
|
526
|
+
it("should handle JSON Pointer format paths", () => {
|
|
527
|
+
const schema = Shape.doc({
|
|
528
|
+
data: Shape.map({
|
|
529
|
+
items: Shape.list(Shape.plain.string()),
|
|
530
|
+
}),
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
const emptyState = {
|
|
534
|
+
data: {
|
|
535
|
+
items: [],
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
540
|
+
|
|
541
|
+
const patch: JsonPatch = [
|
|
542
|
+
{ op: "add", path: "/data/items/0", value: "first" },
|
|
543
|
+
{ op: "add", path: "/data/items/1", value: "second" },
|
|
544
|
+
]
|
|
545
|
+
|
|
546
|
+
const result = typedDoc.applyPatch(patch)
|
|
547
|
+
|
|
548
|
+
expect(result.data.items).toEqual(["first", "second"])
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it("should handle array format paths", () => {
|
|
552
|
+
const schema = Shape.doc({
|
|
553
|
+
data: Shape.map({
|
|
554
|
+
items: Shape.list(Shape.plain.string()),
|
|
555
|
+
}),
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
const emptyState = {
|
|
559
|
+
data: {
|
|
560
|
+
items: [],
|
|
561
|
+
},
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
565
|
+
|
|
566
|
+
const patch: JsonPatch = [
|
|
567
|
+
{ op: "add", path: ["data", "items", 0], value: "first" },
|
|
568
|
+
{ op: "add", path: ["data", "items", 1], value: "second" },
|
|
569
|
+
]
|
|
570
|
+
|
|
571
|
+
const result = typedDoc.applyPatch(patch)
|
|
572
|
+
|
|
573
|
+
expect(result.data.items).toEqual(["first", "second"])
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
describe("Error Handling", () => {
|
|
578
|
+
it("should throw on invalid paths", () => {
|
|
579
|
+
const schema = Shape.doc({
|
|
580
|
+
data: Shape.map({
|
|
581
|
+
value: Shape.plain.string(),
|
|
582
|
+
}),
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
const emptyState = {
|
|
586
|
+
data: {
|
|
587
|
+
value: "",
|
|
588
|
+
},
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
592
|
+
|
|
593
|
+
const patch: JsonPatch = [
|
|
594
|
+
{ op: "add", path: "/nonexistent/path", value: "test" },
|
|
595
|
+
]
|
|
596
|
+
|
|
597
|
+
expect(() => {
|
|
598
|
+
typedDoc.applyPatch(patch)
|
|
599
|
+
}).toThrow("Cannot navigate to path segment: nonexistent")
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it("should throw on invalid list indices", () => {
|
|
603
|
+
const schema = Shape.doc({
|
|
604
|
+
items: Shape.list(Shape.plain.string()),
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
const emptyState = {
|
|
608
|
+
items: [],
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
612
|
+
|
|
613
|
+
const patch: JsonPatch = [
|
|
614
|
+
{ op: "remove", path: "/items/5" }, // Index out of bounds
|
|
615
|
+
]
|
|
616
|
+
|
|
617
|
+
expect(() => {
|
|
618
|
+
typedDoc.applyPatch(patch)
|
|
619
|
+
}).toThrow("Index out of bound")
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
describe("Integration with Existing Change System", () => {
|
|
624
|
+
it("should work alongside regular change operations", () => {
|
|
625
|
+
const schema = Shape.doc({
|
|
626
|
+
counter: Shape.counter(),
|
|
627
|
+
text: Shape.text(),
|
|
628
|
+
data: Shape.map({
|
|
629
|
+
items: Shape.list(Shape.plain.string()),
|
|
630
|
+
}),
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
const emptyState = {
|
|
634
|
+
counter: 0,
|
|
635
|
+
text: "",
|
|
636
|
+
data: {
|
|
637
|
+
items: [],
|
|
638
|
+
},
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
642
|
+
|
|
643
|
+
// Use regular change operations
|
|
644
|
+
typedDoc.change(draft => {
|
|
645
|
+
draft.counter.increment(5)
|
|
646
|
+
draft.text.insert(0, "Hello")
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
// Then use JSON Patch
|
|
650
|
+
const patch: JsonPatch = [
|
|
651
|
+
{ op: "add", path: "/data/items/0", value: "item1" },
|
|
652
|
+
{ op: "add", path: "/data/items/1", value: "item2" },
|
|
653
|
+
]
|
|
654
|
+
|
|
655
|
+
const result = typedDoc.applyPatch(patch)
|
|
656
|
+
|
|
657
|
+
expect(result.counter).toBe(5)
|
|
658
|
+
expect(result.text).toBe("Hello")
|
|
659
|
+
expect(result.data.items).toEqual(["item1", "item2"])
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it("should maintain state across multiple patch applications", () => {
|
|
663
|
+
const schema = Shape.doc({
|
|
664
|
+
settings: Shape.map({
|
|
665
|
+
theme: Shape.plain.string(),
|
|
666
|
+
language: Shape.plain.string(),
|
|
667
|
+
}),
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
const emptyState = {
|
|
671
|
+
settings: {
|
|
672
|
+
theme: "light",
|
|
673
|
+
language: "en",
|
|
674
|
+
},
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
678
|
+
|
|
679
|
+
// First patch
|
|
680
|
+
const patch1: JsonPatch = [
|
|
681
|
+
{ op: "replace", path: "/settings/theme", value: "dark" },
|
|
682
|
+
]
|
|
683
|
+
|
|
684
|
+
typedDoc.applyPatch(patch1)
|
|
685
|
+
|
|
686
|
+
// Second patch
|
|
687
|
+
const patch2: JsonPatch = [
|
|
688
|
+
{ op: "replace", path: "/settings/language", value: "fr" },
|
|
689
|
+
]
|
|
690
|
+
|
|
691
|
+
const result = typedDoc.applyPatch(patch2)
|
|
692
|
+
|
|
693
|
+
expect(result.settings.theme).toBe("dark") // Should persist from first patch
|
|
694
|
+
expect(result.settings.language).toBe("fr")
|
|
695
|
+
})
|
|
696
|
+
})
|
|
697
|
+
})
|