1import { describe, it } from "@effect/vitest"
2import { deepStrictEqual, strictEqual, throws } from "@effect/vitest/utils"
3import * as JsonPatch from "effect/JsonPatch"
4import type * as Schema from "effect/Schema"
5
6const expectMessage = (f: () => unknown, includes: string) => {
7 throws(f, (e) => {
8 if (e instanceof Error && e.message.includes(includes)) return undefined
9 throw e
10 })
11}
12
13const expectAppliesTo = (patch: JsonPatch.JsonPatch, oldValue: Schema.Json, newValue: Schema.Json) => {
14 deepStrictEqual(JsonPatch.apply(patch, oldValue), newValue)
15}
16
17describe("JsonPatch", () => {
18 describe("get", () => {
19 describe("root values", () => {
20 it("returns [] for identical values", () => {
21 const cases: ReadonlyArray<Schema.Json> = [
22 1,
23 "hello",
24 true,
25 null,
26 [1, 2, 3],
27 { a: 1 }
28 ]
29 for (const v of cases) {
30 deepStrictEqual(JsonPatch.get(v, v), [])
31 }
32 })
33
34 it("emits a root replace for primitive changes", () => {
35 const cases: ReadonlyArray<[Schema.Json, Schema.Json]> = [
36 [1, 2],
37 ["hello", "world"],
38 [true, false],
39 [null, 42]
40 ]
41 for (const [from, to] of cases) {
42 deepStrictEqual(JsonPatch.get(from, to), [{ op: "replace", path: "", value: to }])
43 }
44 })
45
46 it("emits a root replace for type changes", () => {
47 deepStrictEqual(JsonPatch.get(1, "string"), [{ op: "replace", path: "", value: "string" }])
48 deepStrictEqual(JsonPatch.get([1, 2], { a: 1 }), [{ op: "replace", path: "", value: { a: 1 } }])
49 deepStrictEqual(JsonPatch.get({ a: 1 }, [1, 2]), [{ op: "replace", path: "", value: [1, 2] }])
50 })
51 })
52
53 describe("arrays", () => {
54 it("adds new elements (append semantics)", () => {
55 deepStrictEqual(JsonPatch.get([1, 2], [1, 2, 3]), [{ op: "add", path: "/2", value: 3 }])
56 deepStrictEqual(JsonPatch.get([], [1, 2, 3]), [
57 { op: "add", path: "/0", value: 1 },
58 { op: "add", path: "/1", value: 2 },
59 { op: "add", path: "/2", value: 3 }
60 ])
61 })
62
63 it("removes elements in descending index order (no index shifting)", () => {
64 deepStrictEqual(JsonPatch.get([1, 2, 3], [1, 2]), [{ op: "remove", path: "/2" }])
65
66 const patch = JsonPatch.get([0, 1, 2, 3, 4, 5], [0, 1, 3])
67 const removeIdx = patch
68 .filter((op) => op.op === "remove")
69 .map((op) => Number(op.path.slice(1)))
70
71 deepStrictEqual(removeIdx, [...removeIdx].sort((a, b) => b - a))
72 expectAppliesTo(patch, [0, 1, 2, 3, 4, 5], [0, 1, 3])
73 })
74
75 it("replaces modified elements", () => {
76 deepStrictEqual(JsonPatch.get([1, 2, 3], [1, 4, 3]), [{ op: "replace", path: "/1", value: 4 }])
77 })
78
79 it("handles mixed operations", () => {
80 deepStrictEqual(JsonPatch.get([1, 2, 3], [1, 4, 5, 6]), [
81 { op: "replace", path: "/1", value: 4 },
82 { op: "replace", path: "/2", value: 5 },
83 { op: "add", path: "/3", value: 6 }
84 ])
85
86 deepStrictEqual(JsonPatch.get([1, 2, 3, 4], [1, 5]), [
87 { op: "replace", path: "/1", value: 5 },
88 { op: "remove", path: "/3" },
89 { op: "remove", path: "/2" }
90 ])
91 })
92
93 it("supports nested arrays", () => {
94 deepStrictEqual(JsonPatch.get([[1, 2], [3, 4]], [[1, 2], [3, 5]]), [
95 { op: "replace", path: "/1/1", value: 5 }
96 ])
97
98 deepStrictEqual(JsonPatch.get([[1, 2]], [[1, 2], [3, 4]]), [
99 { op: "add", path: "/1", value: [3, 4] }
100 ])
101 })
102 })
103
104 describe("objects", () => {
105 it("uses stable key order for deterministic patches", () => {
106 const patch = JsonPatch.get({ b: 1, a: 1 }, { a: 2, b: 2 })
107 deepStrictEqual(
108 patch.filter((op) => op.op === "replace").map((op) => op.path),
109 ["/a", "/b"]
110 )
111 expectAppliesTo(patch, { b: 1, a: 1 }, { a: 2, b: 2 })
112 })
113
114 it("adds / removes / replaces properties", () => {
115 deepStrictEqual(JsonPatch.get({ a: 1 }, { a: 1, b: 2 }), [{ op: "add", path: "/b", value: 2 }])
116 deepStrictEqual(JsonPatch.get({ a: 1, b: 2 }, { a: 1 }), [{ op: "remove", path: "/b" }])
117 deepStrictEqual(JsonPatch.get({ a: 1, b: 2 }, { a: 1, b: 3 }), [{ op: "replace", path: "/b", value: 3 }])
118 })
119
120 it("removes properties in sorted key order", () => {
121 deepStrictEqual(JsonPatch.get({ a: 1, b: 2, c: 3 }, {}), [
122 { op: "remove", path: "/a" },
123 { op: "remove", path: "/b" },
124 { op: "remove", path: "/c" }
125 ])
126 })
127
128 it("handles mixed object operations", () => {
129 deepStrictEqual(JsonPatch.get({ a: 1, b: 2 }, { a: 1, c: 3, d: 4 }), [
130 { op: "remove", path: "/b" },
131 { op: "add", path: "/c", value: 3 },
132 { op: "add", path: "/d", value: 4 }
133 ])
134 })
135
136 it("supports nested objects", () => {
137 deepStrictEqual(JsonPatch.get({ a: { b: 1 } }, { a: { b: 2 } }), [
138 { op: "replace", path: "/a/b", value: 2 }
139 ])
140 deepStrictEqual(JsonPatch.get({ a: { b: 1 } }, { a: { b: 1, c: 2 } }), [
141 { op: "add", path: "/a/c", value: 2 }
142 ])
143 deepStrictEqual(JsonPatch.get({ a: { b: 1, c: 2 } }, { a: { b: 1 } }), [
144 { op: "remove", path: "/a/c" }
145 ])
146 })
147
148 describe("JSON Pointer escaping in keys", () => {
149 it("escapes '/' as '~1' and '~' as '~0'", () => {
150 deepStrictEqual(JsonPatch.get({ "a/b": 1 }, { "a/b": 2 }), [
151 { op: "replace", path: "/a~1b", value: 2 }
152 ])
153 deepStrictEqual(JsonPatch.get({ "a~b": 1 }, { "a~b": 2 }), [
154 { op: "replace", path: "/a~0b", value: 2 }
155 ])
156 })
157
158 it("represents a literal key '~1' as token '~01'", () => {
159 deepStrictEqual(JsonPatch.get({}, { "~1": 0 }), [
160 { op: "add", path: "/~01", value: 0 }
161 ])
162 })
163
164 it("does not confuse '~01' with '/' (unescape order)", () => {
165 deepStrictEqual(JsonPatch.get({ "a~1b": 1 }, { "a~1b": 2 }), [
166 { op: "replace", path: "/a~01b", value: 2 }
167 ])
168 })
169
170 it("treats '__proto__' as an own key", () => {
171 const oldValue = JSON.parse(`{"__proto__":{"value":1}}`)
172 const newValue = JSON.parse(`{"__proto__":{"value":2}}`)
173
174 deepStrictEqual(JsonPatch.get(oldValue, newValue), [
175 { op: "replace", path: "/__proto__/value", value: 2 }
176 ])
177 })
178 })
179 })
180
181 describe("complex nested structures", () => {
182 it("emits a minimal-ish patch for deep updates (and is applicable)", () => {
183 const oldValue: Schema.Json = {
184 users: [
185 { id: 1, name: "Alice", tags: ["admin"] },
186 { id: 2, name: "Bob", tags: [] }
187 ],
188 metadata: { version: 1 }
189 }
190
191 const newValue: Schema.Json = {
192 users: [
193 { id: 1, name: "Alice", tags: ["admin", "moderator"] },
194 { id: 2, name: "Bob", tags: [] },
195 { id: 3, name: "Charlie", tags: [] }
196 ],
197 metadata: { version: 2 }
198 }
199
200 const patch = JsonPatch.get(oldValue, newValue)
201 deepStrictEqual(patch, [
202 { op: "replace", path: "/metadata/version", value: 2 },
203 { op: "add", path: "/users/0/tags/1", value: "moderator" },
204 { op: "add", path: "/users/2", value: { id: 3, name: "Charlie", tags: [] } }
205 ])
206
207 expectAppliesTo(patch, oldValue, newValue)
208 })
209 })
210
211 describe("immutability", () => {
212 it("does not mutate input values", () => {
213 const oldArray = [1, 2, 3]
214 const newArray = [1, 2, 4]
215 const originalArray = [...oldArray]
216
217 JsonPatch.get(oldArray, newArray)
218 deepStrictEqual(oldArray, originalArray)
219
220 const oldObject = { a: 1, b: 2 }
221 const newObject = { a: 1, b: 3, c: 4 }
222 const originalObject = { ...oldObject }
223
224 JsonPatch.get(oldObject, newObject)
225 deepStrictEqual(oldObject, originalObject)
226 })
227
228 it("returns empty array for identical references", () => {
229 const value = { a: 1, b: [2, 3] }
230 const patch = JsonPatch.get(value, value)
231 deepStrictEqual(patch, [])
232 })
233 })
234
235 describe("empty structures", () => {
236 it("handles empty array to empty array", () => {
237 deepStrictEqual(JsonPatch.get([], []), [])
238 })
239
240 it("handles empty object to empty object", () => {
241 deepStrictEqual(JsonPatch.get({}, {}), [])
242 })
243
244 it("handles empty array to non-empty array", () => {
245 deepStrictEqual(JsonPatch.get([], [1, 2]), [
246 { op: "add", path: "/0", value: 1 },
247 { op: "add", path: "/1", value: 2 }
248 ])
249 })
250
251 it("handles non-empty array to empty array", () => {
252 const patch = JsonPatch.get([1, 2], [])
253 deepStrictEqual(patch, [{ op: "remove", path: "/1" }, { op: "remove", path: "/0" }])
254 expectAppliesTo(patch, [1, 2], [])
255 })
256
257 it("handles empty object to non-empty object", () => {
258 deepStrictEqual(JsonPatch.get({}, { a: 1, b: 2 }), [
259 { op: "add", path: "/a", value: 1 },
260 { op: "add", path: "/b", value: 2 }
261 ])
262 })
263
264 it("handles non-empty object to empty object", () => {
265 const patch = JsonPatch.get({ a: 1, b: 2 }, {})
266 deepStrictEqual(patch, [
267 { op: "remove", path: "/a" },
268 { op: "remove", path: "/b" }
269 ])
270 expectAppliesTo(patch, { a: 1, b: 2 }, {})
271 })
272 })
273
274 describe("reference equality optimization", () => {
275 it("returns empty patch for structurally equal objects and same references", () => {
276 const value1 = { a: 1 }
277 const value2 = { a: 1 }
278 const value3 = value1
279
280 const patch1 = JsonPatch.get(value1, value2)
281 deepStrictEqual(patch1, [])
282 expectAppliesTo(patch1, value1, value2)
283
284 deepStrictEqual(JsonPatch.get(value1, value3), [])
285 })
286
287 it("returns empty patch for structurally equal but different objects", () => {
288 const value1 = { a: 1, b: { c: 2 } }
289 const value2 = { a: 1, b: { c: 2 } }
290 deepStrictEqual(JsonPatch.get(value1, value2), [])
291 })
292
293 it("emits the property replace for structurally different objects", () => {
294 const value1 = { a: 1 }
295 const value2 = { a: 2 }
296 const patch = JsonPatch.get(value1, value2)
297 deepStrictEqual(patch.length > 0, true)
298 expectAppliesTo(patch, value1, value2)
299 })
300
301 it("returns empty patch for same array reference", () => {
302 const arr = [1, 2, 3]
303 deepStrictEqual(JsonPatch.get(arr, arr), [])
304 })
305
306 it("returns empty patch for same object reference", () => {
307 const obj = { a: 1, b: 2 }
308 deepStrictEqual(JsonPatch.get(obj, obj), [])
309 })
310 })
311
312 describe("edge cases", () => {
313 it("handles single-element arrays", () => {
314 deepStrictEqual(JsonPatch.get([1], [2]), [{ op: "replace", path: "/0", value: 2 }])
315 deepStrictEqual(JsonPatch.get([1], []), [{ op: "remove", path: "/0" }])
316 deepStrictEqual(JsonPatch.get([], [1]), [{ op: "add", path: "/0", value: 1 }])
317 })
318
319 it("handles single-property objects", () => {
320 deepStrictEqual(JsonPatch.get({ a: 1 }, { a: 2 }), [{ op: "replace", path: "/a", value: 2 }])
321 deepStrictEqual(JsonPatch.get({ a: 1 }, {}), [{ op: "remove", path: "/a" }])
322 deepStrictEqual(JsonPatch.get({}, { a: 1 }), [{ op: "add", path: "/a", value: 1 }])
323 })
324
325 it("handles array index 0 operations", () => {
326 deepStrictEqual(JsonPatch.get([1, 2, 3], [0, 2, 3]), [{ op: "replace", path: "/0", value: 0 }])
327 const patch = JsonPatch.get([1, 2, 3], [2, 3])
328 expectAppliesTo(patch, [1, 2, 3], [2, 3])
329 deepStrictEqual(patch, [
330 { op: "replace", path: "/0", value: 2 },
331 { op: "replace", path: "/1", value: 3 },
332 { op: "remove", path: "/2" }
333 ])
334 })
335
336 it("handles operations at last array index", () => {
337 deepStrictEqual(JsonPatch.get([1, 2, 3], [1, 2, 4]), [{ op: "replace", path: "/2", value: 4 }])
338 deepStrictEqual(JsonPatch.get([1, 2, 3], [1, 2]), [{ op: "remove", path: "/2" }])
339 })
340 })
341
342 describe("deep nesting", () => {
343 it("handles deeply nested structures", () => {
344 const oldValue: Schema.Json = {
345 level1: {
346 level2: {
347 level3: {
348 level4: {
349 level5: { value: "old" }
350 }
351 }
352 }
353 }
354 }
355
356 const newValue: Schema.Json = {
357 level1: {
358 level2: {
359 level3: {
360 level4: {
361 level5: { value: "new", extra: "added" }
362 }
363 }
364 }
365 }
366 }
367
368 const patch = JsonPatch.get(oldValue, newValue)
369 expectAppliesTo(patch, oldValue, newValue)
370 })
371
372 it("handles nested arrays within objects", () => {
373 const oldValue: Schema.Json = {
374 items: [
375 [1, 2],
376 [3, 4]
377 ]
378 }
379
380 const newValue: Schema.Json = {
381 items: [
382 [1, 2],
383 [3, 5],
384 [6, 7]
385 ]
386 }
387
388 const patch = JsonPatch.get(oldValue, newValue)
389 expectAppliesTo(patch, oldValue, newValue)
390 })
391 })
392 })
393
394 describe("apply", () => {
395 describe("happy paths", () => {
396 it("applies operations after a root replace", () => {
397 deepStrictEqual(
398 JsonPatch.apply(
399 [
400 { op: "replace", path: "", value: {} },
401 { op: "add", path: "/a", value: 1 }
402 ],
403 { old: true }
404 ),
405 { a: 1 }
406 )
407 })
408
409 it("replace", () => {
410 deepStrictEqual(JsonPatch.apply([{ op: "replace", path: "", value: 42 }], 1), 42)
411
412 deepStrictEqual(
413 JsonPatch.apply([{ op: "replace", path: "/a", value: 2 }], { a: 1 }),
414 { a: 2 }
415 )
416
417 deepStrictEqual(
418 JsonPatch.apply([{ op: "replace", path: "/1", value: 20 }], [1, 2, 3]),
419 [1, 20, 3]
420 )
421
422 deepStrictEqual(
423 JsonPatch.apply([{ op: "replace", path: "/a/b", value: 2 }], { a: { b: 1 } }),
424 { a: { b: 2 } }
425 )
426 })
427
428 it("add", () => {
429 deepStrictEqual(
430 JsonPatch.apply([{ op: "add", path: "/b", value: 2 }], { a: 1 }),
431 { a: 1, b: 2 }
432 )
433
434 deepStrictEqual(
435 JsonPatch.apply([{ op: "add", path: "/1", value: 10 }], [1, 2, 3]),
436 [1, 10, 2, 3]
437 )
438
439 deepStrictEqual(
440 JsonPatch.apply([{ op: "add", path: "/-", value: 4 }], [1, 2, 3]),
441 [1, 2, 3, 4]
442 )
443
444 deepStrictEqual(
445 JsonPatch.apply([{ op: "add", path: "/users/0/tags/-", value: "admin" }], {
446 users: [{ id: 1, tags: [] }]
447 }),
448 { users: [{ id: 1, tags: ["admin"] }] }
449 )
450 })
451
452 it("remove", () => {
453 deepStrictEqual(
454 JsonPatch.apply([{ op: "remove", path: "/a" }], { a: 1, b: 2 }),
455 { b: 2 }
456 )
457
458 deepStrictEqual(
459 JsonPatch.apply([{ op: "remove", path: "/1" }], [1, 2, 3]),
460 [1, 3]
461 )
462
463 deepStrictEqual(
464 JsonPatch.apply([{ op: "remove", path: "/a/b" }], { a: { b: 1, c: 2 } }),
465 { a: { c: 2 } }
466 )
467 })
468
469 it("applies multiple operations in sequence", () => {
470 deepStrictEqual(
471 JsonPatch.apply(
472 [
473 { op: "add", path: "/c", value: 3 },
474 { op: "replace", path: "/a", value: 10 },
475 { op: "remove", path: "/b" }
476 ],
477 { a: 1, b: 2 }
478 ),
479 { a: 10, c: 3 }
480 )
481 })
482 })
483
484 describe("JSON Pointer decoding", () => {
485 it("decodes '~1' as '/' and '~0' as '~'", () => {
486 deepStrictEqual(
487 JsonPatch.apply([{ op: "replace", path: "/a~1b", value: 2 }], { "a/b": 1 }),
488 { "a/b": 2 }
489 )
490
491 deepStrictEqual(
492 JsonPatch.apply([{ op: "add", path: "/a~0b", value: 1 }], {}),
493 { "a~b": 1 }
494 )
495
496 deepStrictEqual(
497 JsonPatch.apply([{ op: "add", path: "/path~1to~0key", value: "value" }], {}),
498 { "path/to~key": "value" }
499 )
500 })
501
502 it("decodes '~01' as a literal '~1' (unescape order)", () => {
503 deepStrictEqual(
504 JsonPatch.apply([{ op: "add", path: "/a~01b", value: 1 }], {}),
505 { "a~1b": 1 }
506 )
507 })
508
509 it("addresses a literal key '~1' via token '~01'", () => {
510 deepStrictEqual(
511 JsonPatch.apply([{ op: "add", path: "/~01", value: 0 }], {}),
512 { "~1": 0 }
513 )
514 })
515 })
516
517 describe("errors", () => {
518 it("rejects non-empty pointers that do not start with '/'", () => {
519 expectMessage(
520 () => JsonPatch.apply([{ op: "add", path: "invalid", value: 1 }], {}),
521 `must start with "/"`
522 )
523 })
524
525 it("rejects invalid array indices", () => {
526 expectMessage(
527 () => JsonPatch.apply([{ op: "add", path: "/abc", value: 1 }], []),
528 `Invalid array index`
529 )
530 expectMessage(
531 () => JsonPatch.apply([{ op: "replace", path: "/-1", value: 1 }], [1, 2, 3]),
532 `Invalid array index`
533 )
534 expectMessage(
535 () => JsonPatch.apply([{ op: "add", path: "/items/abc/value", value: 1 }], { items: [] }),
536 `Invalid array index: "abc"`
537 )
538 })
539
540 it("rejects out-of-bounds array access", () => {
541 expectMessage(
542 () => JsonPatch.apply([{ op: "replace", path: "/10", value: 1 }], [1, 2, 3]),
543 "Array index out of bounds"
544 )
545 expectMessage(
546 () => JsonPatch.apply([{ op: "add", path: "/10", value: 1 }], [1, 2, 3]),
547 "Array index out of bounds"
548 )
549 expectMessage(
550 () => JsonPatch.apply([{ op: "remove", path: "/10" }], [1, 2, 3]),
551 "Array index out of bounds"
552 )
553 })
554
555 it("rejects '-' for replace/remove", () => {
556 expectMessage(
557 () => JsonPatch.apply([{ op: "replace", path: "/-", value: 1 }], [1, 2, 3]),
558 `"-" is not valid for replace`
559 )
560
561 expectMessage(
562 () => JsonPatch.apply([{ op: "remove", path: "/-" }], [1, 2, 3]),
563 `"-" is not valid for remove`
564 )
565 })
566
567 it("rejects replace/remove of non-existent object members", () => {
568 expectMessage(
569 () => JsonPatch.apply([{ op: "replace", path: "/nonexistent", value: 1 }], { a: 1 }),
570 `does not exist`
571 )
572 expectMessage(
573 () => JsonPatch.apply([{ op: "remove", path: "/nonexistent" }], { a: 1 }),
574 `does not exist`
575 )
576 })
577
578 it("does not treat inherited properties as document members", () => {
579 const document = Object.create({ inherited: { value: 1 } }) as Schema.JsonObject
580
581 expectMessage(
582 () => JsonPatch.apply([{ op: "replace", path: "/inherited/value", value: 2 }], document),
583 "Cannot replace at"
584 )
585 expectMessage(
586 () => JsonPatch.apply([{ op: "remove", path: "/inherited" }], document),
587 "does not exist"
588 )
589 })
590
591 it("rejects add/replace when the parent is missing or not a container", () => {
592 expectMessage(
593 () => JsonPatch.apply([{ op: "add", path: "/a/b", value: 1 }], { a: null }),
594 "Cannot add at"
595 )
596 expectMessage(
597 () => JsonPatch.apply([{ op: "add", path: "/a/b", value: 1 }], {}),
598 "Cannot add at"
599 )
600
601 expectMessage(
602 () => JsonPatch.apply([{ op: "add", path: "/a/b", value: 1 }], { a: "string" }),
603 "not a container"
604 )
605 expectMessage(
606 () => JsonPatch.apply([{ op: "replace", path: "/a/b", value: 1 }], { a: 42 }),
607 "not a container"
608 )
609
610 expectMessage(
611 () => JsonPatch.apply([{ op: "add", path: "/a/b/c", value: 1 }], { a: { b: "not-object" } }),
612 "not a container"
613 )
614 })
615
616 it("rejects operations when a nested parent cannot be resolved", () => {
617 const cases: ReadonlyArray<[JsonPatch.JsonPatchOperation, Schema.Json]> = [
618 [{ op: "add", path: "/a/b/c", value: 1 }, { a: null }],
619 [{ op: "add", path: "/a/0/b", value: 1 }, { a: [] }],
620 [{ op: "replace", path: "/a/b", value: 1 }, {}],
621 [{ op: "remove", path: "/a/b" }, {}],
622 [{ op: "remove", path: "/a/b/c" }, { a: "not-object" }]
623 ]
624 for (const [operation, document] of cases) {
625 expectMessage(
626 () => JsonPatch.apply([operation], document),
627 `Cannot ${operation.op} at`
628 )
629 }
630 })
631
632 it("rejects remove at the root", () => {
633 expectMessage(
634 () => JsonPatch.apply([{ op: "remove", path: "" }], { a: 1 }),
635 "root"
636 )
637 })
638 })
639
640 describe("immutability", () => {
641 it("does not mutate input document", () => {
642 const original = { a: 1, b: [2, 3] }
643 const originalCopy = { a: 1, b: [2, 3] }
644
645 JsonPatch.apply([{ op: "replace", path: "/a", value: 10 }], original)
646 deepStrictEqual(original, originalCopy)
647
648 const originalArray = [1, 2, 3]
649 const originalArrayCopy = [1, 2, 3]
650
651 JsonPatch.apply([{ op: "add", path: "/1", value: 99 }], originalArray)
652 deepStrictEqual(originalArray, originalArrayCopy)
653 })
654
655 it("does not mutate patch array", () => {
656 const patch: JsonPatch.JsonPatch = [{ op: "add", path: "/b", value: 2 }]
657 const patchCopy = JSON.parse(JSON.stringify(patch))
658
659 JsonPatch.apply(patch, { a: 1 })
660 deepStrictEqual(patch, patchCopy)
661 })
662
663 it("preserves references outside the modified path", () => {
664 const unchanged = { value: 1 }
665 const original = { changed: { value: 1 }, unchanged }
666 const result = JsonPatch.apply([{ op: "replace", path: "/changed/value", value: 2 }], original)
667
668 strictEqual((result as any).unchanged, unchanged)
669 deepStrictEqual(original, { changed: { value: 1 }, unchanged: { value: 1 } })
670 })
671
672 it("handles '__proto__' as an own data property", () => {
673 const original = JSON.parse(`{"nested":{"__proto__":{"value":1}}}`)
674 const result = JsonPatch.apply(
675 [
676 { op: "replace", path: "/nested/__proto__/value", value: 2 },
677 { op: "add", path: "/__proto__", value: { value: 3 } }
678 ],
679 original
680 ) as any
681
682 strictEqual(Object.getPrototypeOf(result), Object.prototype)
683 strictEqual(Object.getPrototypeOf(result.nested), Object.prototype)
684 strictEqual(Object.hasOwn(result, "__proto__"), true)
685 strictEqual(Object.hasOwn(result.nested, "__proto__"), true)
686 deepStrictEqual(result.__proto__, { value: 3 })
687 deepStrictEqual(result.nested.__proto__, { value: 2 })
688 deepStrictEqual(original, JSON.parse(`{"nested":{"__proto__":{"value":1}}}`))
689 })
690 })
691
692 describe("empty patch optimization", () => {
693 it("returns original reference for empty patch", () => {
694 const doc = { a: 1, b: 2 }
695 const result = JsonPatch.apply([], doc)
696 strictEqual(result, doc)
697 })
698
699 it("returns original reference for empty patch with arrays", () => {
700 const arr = [1, 2, 3]
701 const result = JsonPatch.apply([], arr)
702 strictEqual(result, arr)
703 })
704
705 it("returns original reference for empty patch with primitives", () => {
706 const primitives: ReadonlyArray<Schema.Json> = [1, "hello", true, null]
707 for (const value of primitives) {
708 const result = JsonPatch.apply([], value)
709 strictEqual(result, value)
710 }
711 })
712 })
713
714 describe("sequential path dependencies", () => {
715 it("applies operations on paths created by earlier operations", () => {
716 const result = JsonPatch.apply(
717 [
718 { op: "add", path: "/new", value: {} },
719 { op: "add", path: "/new/nested", value: { value: 1 } },
720 { op: "replace", path: "/new/nested/value", value: 2 }
721 ],
722 {}
723 )
724 deepStrictEqual(result, { new: { nested: { value: 2 } } })
725 })
726
727 it("applies operations on array paths created by earlier operations", () => {
728 const result = JsonPatch.apply(
729 [
730 { op: "add", path: "/items", value: [] },
731 { op: "add", path: "/items/-", value: 1 },
732 { op: "add", path: "/items/-", value: 2 },
733 { op: "replace", path: "/items/0", value: 10 }
734 ],
735 {}
736 )
737 deepStrictEqual(result, { items: [10, 2] })
738 })
739
740 it("applies multiple operations on same path", () => {
741 const result = JsonPatch.apply(
742 [
743 { op: "add", path: "/counter", value: 0 },
744 { op: "replace", path: "/counter", value: 1 },
745 { op: "replace", path: "/counter", value: 2 }
746 ],
747 {}
748 )
749 deepStrictEqual(result, { counter: 2 })
750 })
751
752 it("applies remove then add on same path", () => {
753 const result = JsonPatch.apply(
754 [
755 { op: "remove", path: "/a" },
756 { op: "add", path: "/a", value: "new" }
757 ],
758 { a: "old", b: 2 }
759 )
760 deepStrictEqual(result, { a: "new", b: 2 })
761 })
762 })
763
764 describe("edge cases", () => {
765 it("handles operations on empty arrays", () => {
766 deepStrictEqual(
767 JsonPatch.apply([{ op: "add", path: "/-", value: 1 }], []),
768 [1]
769 )
770 deepStrictEqual(
771 JsonPatch.apply([{ op: "add", path: "/0", value: 1 }], []),
772 [1]
773 )
774 })
775
776 it("handles operations on empty objects", () => {
777 deepStrictEqual(
778 JsonPatch.apply([{ op: "add", path: "/key", value: "value" }], {}),
779 { key: "value" }
780 )
781 })
782
783 it("handles array operations at index 0", () => {
784 deepStrictEqual(
785 JsonPatch.apply([{ op: "replace", path: "/0", value: 10 }], [1, 2, 3]),
786 [10, 2, 3]
787 )
788 deepStrictEqual(
789 JsonPatch.apply([{ op: "remove", path: "/0" }], [1, 2, 3]),
790 [2, 3]
791 )
792 deepStrictEqual(
793 JsonPatch.apply([{ op: "add", path: "/0", value: 0 }], [1, 2, 3]),
794 [0, 1, 2, 3]
795 )
796 })
797
798 it("handles array operations at last index", () => {
799 const arr = [1, 2, 3]
800 deepStrictEqual(
801 JsonPatch.apply([{ op: "replace", path: "/2", value: 30 }], arr),
802 [1, 2, 30]
803 )
804 deepStrictEqual(
805 JsonPatch.apply([{ op: "remove", path: "/2" }], arr),
806 [1, 2]
807 )
808 })
809
810 it("handles single-element array operations", () => {
811 deepStrictEqual(
812 JsonPatch.apply([{ op: "replace", path: "/0", value: 2 }], [1]),
813 [2]
814 )
815 deepStrictEqual(
816 JsonPatch.apply([{ op: "remove", path: "/0" }], [1]),
817 []
818 )
819 deepStrictEqual(
820 JsonPatch.apply([{ op: "add", path: "/0", value: 0 }], [1]),
821 [0, 1]
822 )
823 })
824
825 it("handles single-property object operations", () => {
826 deepStrictEqual(
827 JsonPatch.apply([{ op: "replace", path: "/a", value: 2 }], { a: 1 }),
828 { a: 2 }
829 )
830 deepStrictEqual(
831 JsonPatch.apply([{ op: "remove", path: "/a" }], { a: 1 }),
832 {}
833 )
834 })
835 })
836
837 describe("root operations", () => {
838 it("applies root add", () => {
839 deepStrictEqual(
840 JsonPatch.apply([{ op: "add", path: "", value: { replacement: true } }], { old: true }),
841 { replacement: true }
842 )
843 })
844
845 it("applies root replace followed by nested operations", () => {
846 const result = JsonPatch.apply(
847 [
848 { op: "replace", path: "", value: {} },
849 { op: "add", path: "/a", value: 1 },
850 { op: "add", path: "/b", value: 2 }
851 ],
852 { old: "data" }
853 )
854 deepStrictEqual(result, { a: 1, b: 2 })
855 })
856
857 it("applies root replace to different types", () => {
858 deepStrictEqual(
859 JsonPatch.apply([{ op: "replace", path: "", value: [] }], { a: 1 }),
860 []
861 )
862 deepStrictEqual(
863 JsonPatch.apply([{ op: "replace", path: "", value: "string" }], 42),
864 "string"
865 )
866 deepStrictEqual(
867 JsonPatch.apply([{ op: "replace", path: "", value: null }], true),
868 null
869 )
870 })
871 })
872 })
873
874 describe("round-trip", () => {
875 const cases: ReadonlyArray<[Schema.Json, Schema.Json]> = [
876 [
877 {
878 users: [
879 { id: 1, name: "Alice", active: true },
880 { id: 2, name: "Bob", active: false }
881 ],
882 metadata: { version: 1, tags: ["v1"] }
883 },
884 {
885 users: [
886 { id: 1, name: "Alice Updated", active: true },
887 { id: 3, name: "Charlie", active: true }
888 ],
889 metadata: { version: 2, tags: ["v2", "latest"] }
890 }
891 ],
892 [
893 [1, 2, 3, 4, 5],
894 [1, 20, 3, 40, 50, 60]
895 ],
896 [
897 { a: 1, b: 2, c: 3 },
898 { a: 10, d: 4, e: 5 }
899 ],
900 [
901 { level1: { level2: { level3: { value: "old" } } } },
902 { level1: { level2: { level3: { value: "new", extra: "added" } } } }
903 ]
904 ]
905
906 it("apply(get(old, new), old) === new", () => {
907 for (const [oldValue, newValue] of cases) {
908 const patch = JsonPatch.get(oldValue, newValue)
909 expectAppliesTo(patch, oldValue, newValue)
910 }
911 })
912
913 it("round-trip with empty structures", () => {
914 const emptyCases: ReadonlyArray<[Schema.Json, Schema.Json]> = [
915 [[], [1, 2, 3]],
916 [[1, 2, 3], []],
917 [{}, { a: 1, b: 2 }],
918 [{ a: 1, b: 2 }, {}]
919 ]
920
921 for (const [oldValue, newValue] of emptyCases) {
922 const patch = JsonPatch.get(oldValue, newValue)
923 expectAppliesTo(patch, oldValue, newValue)
924 }
925 })
926
927 it("round-trip with nested arrays and objects", () => {
928 const oldValue: Schema.Json = {
929 matrix: [
930 [1, 2, 3],
931 [4, 5, 6],
932 [7, 8, 9]
933 ],
934 config: {
935 settings: {
936 nested: {
937 deep: { value: "old" }
938 }
939 }
940 }
941 }
942
943 const newValue: Schema.Json = {
944 matrix: [
945 [1, 2, 3],
946 [4, 50, 6],
947 [7, 8, 9],
948 [10, 11, 12]
949 ],
950 config: {
951 settings: {
952 nested: {
953 deep: { value: "new", extra: "field" }
954 }
955 }
956 }
957 }
958
959 const patch = JsonPatch.get(oldValue, newValue)
960 expectAppliesTo(patch, oldValue, newValue)
961 })
962
963 it("round-trip with complex array operations", () => {
964 const oldValue: Schema.Json = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
965 const newValue: Schema.Json = [10, 1, 20, 3, 40, 50, 6, 70, 8]
966
967 const patch = JsonPatch.get(oldValue, newValue)
968 expectAppliesTo(patch, oldValue, newValue)
969 })
970
971 it("round-trip with escaped keys", () => {
972 const oldValue: Schema.Json = {
973 "key/with/slash": 1,
974 "key~with~tilde": 2,
975 "normal": 3
976 }
977
978 const newValue: Schema.Json = {
979 "key/with/slash": 10,
980 "key~with~tilde": 20,
981 "normal": 30,
982 "new/key": 40
983 }
984
985 const patch = JsonPatch.get(oldValue, newValue)
986 expectAppliesTo(patch, oldValue, newValue)
987 })
988
989 it("round-trip with type changes", () => {
990 const typeChangeCases: ReadonlyArray<[Schema.Json, Schema.Json]> = [
991 [1, "string"],
992 ["string", 42],
993 [true, null],
994 [null, []],
995 [[], {}],
996 [{}, []]
997 ]
998
999 for (const [oldValue, newValue] of typeChangeCases) {
1000 const patch = JsonPatch.get(oldValue, newValue)
1001 expectAppliesTo(patch, oldValue, newValue)
1002 }
1003 })
1004
1005 it("round-trip preserves identical values (empty patch)", () => {
1006 const identicalCases: ReadonlyArray<Schema.Json> = [
1007 { a: 1, b: 2 },
1008 [1, 2, 3],
1009 "string",
1010 42,
1011 true,
1012 null,
1013 { nested: { deep: { value: "test" } } },
1014 [[1, 2], [3, 4]]
1015 ]
1016
1017 for (const value of identicalCases) {
1018 const patch = JsonPatch.get(value, value)
1019 deepStrictEqual(patch, [])
1020 const result = JsonPatch.apply(patch, value)
1021 deepStrictEqual(result, value)
1022 }
1023 })
1024 })
1025})