@zio.dev/zio-blocks 0.0.27 → 0.0.28

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.
@@ -0,0 +1,803 @@
1
+ ---
2
+ id: json-patch
3
+ title: "JsonPatch"
4
+ ---
5
+
6
+ `JsonPatch` is an **untyped, composable patch** for [`Json`](./json.md) values. It represents a sequence of operations that transform one `Json` value into another — computed automatically via a diff algorithm or constructed manually. The two fundamental operations are `JsonPatch.diff` to compute a patch between two `Json` values, and `JsonPatch#apply` to apply it.
7
+
8
+ `JsonPatch`:
9
+ - is a pure value — applying it never mutates the input
10
+ - is composable via `++`, sequencing two patches one after another
11
+ - supports three failure-handling modes: `Strict`, `Lenient`, and `Clobber`
12
+ - carries its own `Schema` instances for full serialization support
13
+ - converts bidirectionally to/from `DynamicPatch` for use in generic patching pipelines
14
+
15
+ The `JsonPatch` type wraps a sequence of operations:
16
+
17
+ ```scala
18
+ final case class JsonPatch(ops: Chunk[JsonPatch.JsonPatchOp])
19
+ ```
20
+
21
+ ## Motivation
22
+
23
+ In most systems, updating JSON data means transmitting the entire new value — even when only a single field changed. `JsonPatch` solves this by representing changes as a **first-class value** that can be:
24
+
25
+ - **Transmitted efficiently** — send only what changed, not the entire document
26
+ - **Stored for audit logs** — record every change for compliance, debugging, or undo
27
+ - **Composed** — merge multiple changes into a single atomic patch
28
+ - **Serialized** — persist patches to disk or a message queue and replay them later
29
+
30
+ ```
31
+ Source JSON Target JSON
32
+ ┌─────────────────────┐ ┌─────────────────────┐
33
+ │ { "name": "Alice", │ │ { "name": "Alice", │
34
+ │ "age": 25, │──diff──│ "age": 26, │
35
+ │ "city": "NYC" } │ │ "city": "NYC" } │
36
+ └─────────────────────┘ └─────────────────────┘
37
+ │
38
+ ▼
39
+ JsonPatch {
40
+ ObjectEdit(
41
+ Modify("age", NumberDelta(1))
42
+ )
43
+ }
44
+ │
45
+ ▼ apply
46
+ ┌─────────────────────┐
47
+ │ { "name": "Alice", │
48
+ │ "age": 26, │
49
+ │ "city": "NYC" } │
50
+ └─────────────────────┘
51
+ ```
52
+
53
+ The "hello world" for `JsonPatch` is diff-then-apply. We compute a patch from `source` to `target`, then verify that applying it to `source` reproduces `target`:
54
+
55
+ ```scala
56
+ import zio.blocks.schema.json.{Json, JsonPatch}
57
+
58
+ val source = Json.Object("name" -> Json.String("Alice"), "age" -> Json.Number(25))
59
+ val target = Json.Object("name" -> Json.String("Alice"), "age" -> Json.Number(26))
60
+
61
+ val patch: JsonPatch = JsonPatch.diff(source, target)
62
+ ```
63
+
64
+ Applying the patch to `source` always yields `Right(target)`:
65
+
66
+ ```scala
67
+ patch.apply(source) == Right(target)
68
+ // res1: Boolean = true
69
+ ```
70
+
71
+ ## Creating Patches
72
+
73
+ There are three ways to create a `JsonPatch`: compute one automatically with `JsonPatch.diff`, construct one manually with `JsonPatch.root` or `JsonPatch.apply`, or start from the identity patch `JsonPatch.empty`.
74
+
75
+ ### `JsonPatch.diff`
76
+
77
+ Computes the minimal `JsonPatch` that transforms `source` into `target`. Uses a smart diff strategy per value type — see the [Diffing Algorithm](#diffing-algorithm) section for details:
78
+
79
+ ```scala
80
+ object JsonPatch {
81
+ def diff(source: Json, target: Json): JsonPatch
82
+ }
83
+ ```
84
+
85
+ `JsonPatch.diff` is also available as the `Json#diff` extension method:
86
+
87
+ ```scala
88
+ import zio.blocks.schema.json.{Json, JsonPatch}
89
+
90
+ // Via companion object
91
+ val p1 = JsonPatch.diff(Json.Number(10), Json.Number(15))
92
+
93
+ // Via extension method on Json
94
+ val p2 = Json.Number(10).diff(Json.Number(15))
95
+
96
+ // Nested object diff produces minimal ObjectEdit
97
+ val p3 = JsonPatch.diff(
98
+ Json.Object("a" -> Json.Number(1), "b" -> Json.Number(2)),
99
+ Json.Object("a" -> Json.Number(1), "b" -> Json.Number(9))
100
+ )
101
+ // p3 only touches "b", leaves "a" unchanged
102
+ ```
103
+
104
+ ### `JsonPatch.root`
105
+
106
+ Creates a patch with a single operation applied at the root of the value:
107
+
108
+ ```scala
109
+ object JsonPatch {
110
+ def root(operation: JsonPatch.Op): JsonPatch
111
+ }
112
+ ```
113
+
114
+ For example, we can replace the root entirely, increment a number, or add a field to a root object:
115
+
116
+ ```scala
117
+ import zio.blocks.schema.json.{Json, JsonPatch}
118
+ import zio.blocks.schema.json.JsonPatch._
119
+ import zio.blocks.chunk.Chunk
120
+
121
+ // Replace the entire value
122
+ val replaceAll = JsonPatch.root(Op.Set(Json.Null))
123
+
124
+ // Increment a number at the root
125
+ val increment = JsonPatch.root(Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(1))))
126
+
127
+ // Add a field to a root object
128
+ val addField = JsonPatch.root(Op.ObjectEdit(Chunk(ObjectOp.Add("active", Json.Boolean(true)))))
129
+ ```
130
+
131
+ ### `JsonPatch.apply`
132
+
133
+ Creates a patch with a single operation applied at the specified `DynamicOptic` path. Use this when targeting a nested location within the value:
134
+
135
+ ```scala
136
+ object JsonPatch {
137
+ def apply(path: DynamicOptic, operation: JsonPatch.Op): JsonPatch
138
+ }
139
+ ```
140
+
141
+ Paths are built fluently on `DynamicOptic.root` using `.field(name)` to navigate object fields and `.at(index)` to navigate array elements. For instance, to increment `age` inside a `user` object:
142
+
143
+ ```scala
144
+ import zio.blocks.schema.json.{Json, JsonPatch}
145
+ import zio.blocks.schema.json.JsonPatch._
146
+ import zio.blocks.schema.DynamicOptic
147
+
148
+ val agePath = DynamicOptic.root.field("user").field("age")
149
+ val agePatch = JsonPatch(agePath, Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(1))))
150
+ val nested = Json.Object("user" -> Json.Object("name" -> Json.String("Alice"), "age" -> Json.Number(25)))
151
+ ```
152
+
153
+ Applying the patch navigates to the nested `age` field and increments it:
154
+
155
+ ```scala
156
+ agePatch.apply(nested)
157
+ // res5: Either[SchemaError, Json] = Right(
158
+ // Object(
159
+ // IndexedSeq(
160
+ // (
161
+ // "user",
162
+ // Object(IndexedSeq(("name", String("Alice")), ("age", Number(26))))
163
+ // )
164
+ // )
165
+ // )
166
+ // )
167
+ ```
168
+
169
+ ### `JsonPatch.empty`
170
+
171
+ The empty patch. Applying it to any `Json` value returns that value unchanged. `JsonPatch.empty` is the identity element for `++`:
172
+
173
+ ```scala
174
+ object JsonPatch {
175
+ val empty: JsonPatch
176
+ }
177
+ ```
178
+
179
+ `JsonPatch.empty` is useful as a neutral starting point when building patches conditionally:
180
+
181
+ ```scala
182
+ import zio.blocks.schema.json.{Json, JsonPatch}
183
+ ```
184
+
185
+ Both `JsonPatch#isEmpty` and applying `JsonPatch.empty` confirm the identity property:
186
+
187
+ ```scala
188
+ JsonPatch.empty.isEmpty
189
+ // res7: Boolean = true
190
+ JsonPatch.empty.apply(Json.Number(42))
191
+ // res8: Either[SchemaError, Json] = Right(Number(42))
192
+ (JsonPatch.empty ++ JsonPatch.empty).isEmpty
193
+ // res9: Boolean = true
194
+ ```
195
+
196
+ ### `JsonPatch.fromDynamicPatch`
197
+
198
+ Converts a generic `DynamicPatch` to a `JsonPatch`. Returns `Left[SchemaError]` for operations not representable in JSON:
199
+ - Temporal deltas (`InstantDelta`, `DurationDelta`, etc.) — JSON has no native time type
200
+ - Non-string map keys — JSON object keys must always be strings
201
+
202
+ All numeric delta types (`IntDelta`, `LongDelta`, `DoubleDelta`, etc.) are widened to `NumberDelta(BigDecimal)`:
203
+
204
+ ```scala
205
+ object JsonPatch {
206
+ def fromDynamicPatch(patch: DynamicPatch): Either[SchemaError, JsonPatch]
207
+ }
208
+ ```
209
+
210
+ The round-trip through `DynamicPatch` preserves numeric deltas:
211
+
212
+ ```scala
213
+ import zio.blocks.schema.json.{Json, JsonPatch}
214
+ import zio.blocks.schema.patch.DynamicPatch
215
+ import zio.blocks.schema.SchemaError
216
+
217
+ val original: JsonPatch = JsonPatch.diff(Json.Number(1), Json.Number(2))
218
+ val dynPatch: DynamicPatch = original.toDynamicPatch
219
+ val back: Either[SchemaError, JsonPatch] = JsonPatch.fromDynamicPatch(dynPatch)
220
+ ```
221
+
222
+ The roundtrip succeeds and the recovered patch equals the original:
223
+
224
+ ```scala
225
+ back == Right(original)
226
+ // res11: Boolean = true
227
+ ```
228
+
229
+ ## Core Operations
230
+
231
+ `JsonPatch` exposes operations for applying patches, composing them, and converting between formats. The three groups of operations are applying, composing, and converting.
232
+
233
+ ### Applying Patches
234
+
235
+ The primary way to use a `JsonPatch` is to call `JsonPatch#apply` or the `Json#patch` extension method, both of which accept an optional `PatchMode` argument.
236
+
237
+ #### `apply`
238
+
239
+ Applies this patch to a `Json` value. Returns `Right` with the patched value on success, or `Left[SchemaError]` on failure. The `mode` parameter controls failure handling — see [`PatchMode`](#patchmode):
240
+
241
+ ```scala
242
+ case class JsonPatch(ops: Chunk[JsonPatch.JsonPatchOp]) {
243
+ def apply(value: Json, mode: PatchMode = PatchMode.Strict): Either[SchemaError, Json]
244
+ }
245
+ ```
246
+
247
+ `apply` is also available via the `Json#patch` extension method. Both forms produce the same result:
248
+
249
+ ```scala
250
+ import zio.blocks.schema.json.{Json, JsonPatch}
251
+ import zio.blocks.schema.json.JsonPatch._
252
+ import zio.blocks.schema.patch.PatchMode
253
+ import zio.blocks.chunk.Chunk
254
+
255
+ val applyJson = Json.Object("score" -> Json.Number(10))
256
+ val applyPatch = JsonPatch.root(Op.ObjectEdit(Chunk(
257
+ ObjectOp.Modify("score", JsonPatch.root(Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(5)))))
258
+ )))
259
+ ```
260
+
261
+ The direct call and the extension method are equivalent:
262
+
263
+ ```scala
264
+ applyPatch.apply(applyJson)
265
+ // res13: Either[SchemaError, Json] = Right(
266
+ // Object(IndexedSeq(("score", Number(15))))
267
+ // )
268
+ applyJson.patch(applyPatch)
269
+ // res14: Either[SchemaError, Json] = Right(
270
+ // Object(IndexedSeq(("score", Number(15))))
271
+ // )
272
+ applyJson.patch(applyPatch, PatchMode.Lenient)
273
+ // res15: Either[SchemaError, Json] = Right(
274
+ // Object(IndexedSeq(("score", Number(15))))
275
+ // )
276
+ ```
277
+
278
+ #### `isEmpty`
279
+
280
+ Returns `true` if this patch contains no operations:
281
+
282
+ ```scala
283
+ case class JsonPatch(ops: Chunk[JsonPatch.JsonPatchOp]) {
284
+ def isEmpty: Boolean
285
+ }
286
+ ```
287
+
288
+ A patch computed between two identical values also produces an empty patch:
289
+
290
+ ```scala
291
+ import zio.blocks.schema.json.{Json, JsonPatch}
292
+ ```
293
+
294
+ ```scala
295
+ JsonPatch.empty.isEmpty
296
+ // res17: Boolean = true
297
+ JsonPatch.diff(Json.Number(1), Json.Number(1)).isEmpty
298
+ // res18: Boolean = true
299
+ ```
300
+
301
+ ### Composing Patches
302
+
303
+ `++` is the principal operator for building complex patches from smaller, focused ones. The `JsonPatch.empty` value is the identity element for `++`:
304
+
305
+ ```scala
306
+ case class JsonPatch(ops: Chunk[JsonPatch.JsonPatchOp]) {
307
+ def ++(that: JsonPatch): JsonPatch
308
+ }
309
+ ```
310
+
311
+ Concatenating two patches applies `this` first, then `that`. This allows building a single patch that updates multiple fields independently:
312
+
313
+ ```scala
314
+ import zio.blocks.schema.json.{Json, JsonPatch}
315
+ import zio.blocks.schema.json.JsonPatch._
316
+ import zio.blocks.schema.DynamicOptic
317
+
318
+ val renamePatch = JsonPatch(
319
+ DynamicOptic.root.field("name"),
320
+ Op.Set(Json.String("Bob"))
321
+ )
322
+ val incrAgePatch = JsonPatch(
323
+ DynamicOptic.root.field("age"),
324
+ Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(1)))
325
+ )
326
+ val combinedPatch = renamePatch ++ incrAgePatch
327
+ val personJson = Json.Object("name" -> Json.String("Alice"), "age" -> Json.Number(25))
328
+ ```
329
+
330
+ The combined patch applies both operations in sequence:
331
+
332
+ ```scala
333
+ combinedPatch.apply(personJson)
334
+ // res20: Either[SchemaError, Json] = Right(
335
+ // Object(IndexedSeq(("name", String("Bob")), ("age", Number(26))))
336
+ // )
337
+ ```
338
+
339
+ ### Converting
340
+
341
+ `toDynamicPatch` converts a `JsonPatch` to a [`DynamicPatch`](./patch.md). This is always safe — every JSON operation maps to a corresponding dynamic operation. `NumberDelta` widens to `BigDecimalDelta`:
342
+
343
+ ```scala
344
+ case class JsonPatch(ops: Chunk[JsonPatch.JsonPatchOp]) {
345
+ def toDynamicPatch: DynamicPatch
346
+ }
347
+ ```
348
+
349
+ To convert in the opposite direction, use `JsonPatch.fromDynamicPatch` — see [Creating Patches](#creating-patches) above:
350
+
351
+ ```scala
352
+ import zio.blocks.schema.json.{Json, JsonPatch}
353
+ import zio.blocks.schema.patch.DynamicPatch
354
+
355
+ val patch: JsonPatch = JsonPatch.diff(Json.Number(1), Json.Number(5))
356
+ val dyn: DynamicPatch = patch.toDynamicPatch
357
+ ```
358
+
359
+ ## PatchMode
360
+
361
+ `PatchMode` controls how `JsonPatch#apply` reacts when an operation's precondition is not met (e.g., a field is missing, or `ObjectOp.Add` targets a key that already exists):
362
+
363
+ | Mode | Behaviour |
364
+ |------|-----------|
365
+ | `PatchMode.Strict` (default) | Returns `Left[SchemaError]` on the first failure |
366
+ | `PatchMode.Lenient` | Silently skips failing operations; returns `Right` with partial result |
367
+ | `PatchMode.Clobber` | Overwrites on conflicts; forces through missing-field errors where possible |
368
+
369
+ `ObjectOp.Add` fails in `Strict` mode when the key already exists. In `Lenient` mode the conflicting add is silently skipped; in `Clobber` mode it overwrites the existing value:
370
+
371
+ ```scala
372
+ import zio.blocks.schema.json.{Json, JsonPatch}
373
+ import zio.blocks.schema.json.JsonPatch._
374
+ import zio.blocks.schema.patch.PatchMode
375
+ import zio.blocks.chunk.Chunk
376
+
377
+ val modeJson = Json.Object("a" -> Json.Number(1))
378
+ val modePatch = JsonPatch.root(Op.ObjectEdit(Chunk(ObjectOp.Add("a", Json.Number(99)))))
379
+ ```
380
+
381
+ The three modes produce different outcomes for the same conflicting patch:
382
+
383
+ ```scala
384
+ modeJson.patch(modePatch, PatchMode.Strict)
385
+ // res23: Either[SchemaError, Json] = Left(
386
+ // SchemaError(
387
+ // List(
388
+ // ExpectationMismatch(
389
+ // source = DynamicOptic(ArraySeq()),
390
+ // expectation = "Key 'a' already exists in object"
391
+ // )
392
+ // )
393
+ // )
394
+ // )
395
+ modeJson.patch(modePatch, PatchMode.Lenient)
396
+ // res24: Either[SchemaError, Json] = Right(
397
+ // Object(IndexedSeq(("a", Number(1))))
398
+ // )
399
+ modeJson.patch(modePatch, PatchMode.Clobber)
400
+ // res25: Either[SchemaError, Json] = Right(
401
+ // Object(IndexedSeq(("a", Number(99))))
402
+ // )
403
+ ```
404
+
405
+ ## Operation Types
406
+
407
+ A `JsonPatch` is a sequence of `JsonPatchOp` values. Each `JsonPatchOp` pairs a `DynamicOptic` path with an `Op`:
408
+
409
+ ```scala
410
+ final case class JsonPatchOp(path: DynamicOptic, operation: Op)
411
+ ```
412
+
413
+ The full `Op` hierarchy covers five cases, from full replacement to fine-grained array and object edits:
414
+
415
+ ```
416
+ Op (sealed trait)
417
+ ├── Op.Set — replace the target value entirely
418
+ ├── Op.PrimitiveDelta — numeric increment or string edit
419
+ │ ├── PrimitiveOp.NumberDelta
420
+ │ └── PrimitiveOp.StringEdit
421
+ │ ├── StringOp.Insert
422
+ │ ├── StringOp.Delete
423
+ │ ├── StringOp.Append
424
+ │ └── StringOp.Modify
425
+ ├── Op.ArrayEdit — insert / append / delete / modify array elements
426
+ │ ├── ArrayOp.Insert
427
+ │ ├── ArrayOp.Append
428
+ │ ├── ArrayOp.Delete
429
+ │ └── ArrayOp.Modify
430
+ ├── Op.ObjectEdit — add / remove / modify object fields
431
+ │ ├── ObjectOp.Add
432
+ │ ├── ObjectOp.Remove
433
+ │ └── ObjectOp.Modify
434
+ └── Op.Nested — groups a sub-patch under a shared path prefix
435
+ ```
436
+
437
+ ### `Op.Set`
438
+
439
+ Replaces the target value with a new `Json` value, regardless of the current value. Works on any `Json` type:
440
+
441
+ ```scala
442
+ final case class Set(value: Json) extends Op
443
+ ```
444
+
445
+ `Op.Set` can replace across types — for example, replacing a number with a string, or resetting a nested field to `null`:
446
+
447
+ ```scala
448
+ import zio.blocks.schema.json.{Json, JsonPatch}
449
+ import zio.blocks.schema.json.JsonPatch._
450
+ import zio.blocks.schema.DynamicOptic
451
+
452
+ val setString = JsonPatch.root(Op.Set(Json.String("replaced")))
453
+ val setNull = JsonPatch(DynamicOptic.root.field("status"), Op.Set(Json.Null))
454
+ val withStatus = Json.Object("status" -> Json.String("active"), "id" -> Json.Number(1))
455
+ ```
456
+
457
+ Both patches replace their target regardless of its current type:
458
+
459
+ ```scala
460
+ setString.apply(Json.Number(123))
461
+ // res27: Either[SchemaError, Json] = Right(String("replaced"))
462
+ setNull.apply(withStatus)
463
+ // res28: Either[SchemaError, Json] = Right(
464
+ // Object(IndexedSeq(("status", null), ("id", Number(1))))
465
+ // )
466
+ ```
467
+
468
+ ### `Op.PrimitiveDelta`
469
+
470
+ Applies a primitive mutation to a scalar value — either a numeric increment (`NumberDelta`) or a sequence of string edits (`StringEdit`):
471
+
472
+ ```scala
473
+ final case class PrimitiveDelta(op: PrimitiveOp) extends Op
474
+ ```
475
+
476
+ #### `PrimitiveOp.NumberDelta`
477
+
478
+ Adds `delta` to a `Json.Number`. Use a negative value to subtract. Fails if the target is not a `Json.Number`:
479
+
480
+ ```scala
481
+ final case class NumberDelta(delta: BigDecimal) extends PrimitiveOp
482
+ ```
483
+
484
+ Positive deltas increment; negative deltas decrement:
485
+
486
+ ```scala
487
+ import zio.blocks.schema.json.{Json, JsonPatch}
488
+ import zio.blocks.schema.json.JsonPatch._
489
+
490
+ val inc = JsonPatch.root(Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(5))))
491
+ val dec = JsonPatch.root(Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(-3))))
492
+ ```
493
+
494
+ ```scala
495
+ inc.apply(Json.Number(10))
496
+ // res30: Either[SchemaError, Json] = Right(Number(15))
497
+ dec.apply(Json.Number(10))
498
+ // res31: Either[SchemaError, Json] = Right(Number(7))
499
+ ```
500
+
501
+ #### `PrimitiveOp.StringEdit`
502
+
503
+ Applies a sequence of `StringOp` operations to a `Json.String`. `JsonPatch.diff` generates `StringEdit` automatically when it is more compact than a full `Set`:
504
+
505
+ ```scala
506
+ final case class StringEdit(ops: Chunk[StringOp]) extends PrimitiveOp
507
+ ```
508
+
509
+ The `StringOp` cases:
510
+
511
+ | Case | Parameters | Effect |
512
+ |------|-----------|--------|
513
+ | `StringOp.Insert(index, text)` | position, text | Inserts `text` before character `index` |
514
+ | `StringOp.Delete(index, length)` | position, count | Removes `length` characters starting at `index` |
515
+ | `StringOp.Append(text)` | text | Appends `text` to the end |
516
+ | `StringOp.Modify(index, length, text)` | position, count, text | Replaces `length` characters at `index` with `text` |
517
+
518
+ We can insert a prefix before the first character using `StringOp.Insert`:
519
+
520
+ ```scala
521
+ import zio.blocks.schema.json.{Json, JsonPatch}
522
+ import zio.blocks.schema.json.JsonPatch._
523
+ import zio.blocks.chunk.Chunk
524
+
525
+ val insertPatch = JsonPatch.root(
526
+ Op.PrimitiveDelta(PrimitiveOp.StringEdit(Chunk(StringOp.Insert(0, "Hello, "))))
527
+ )
528
+ ```
529
+
530
+ ```scala
531
+ insertPatch.apply(Json.String("world"))
532
+ // res33: Either[SchemaError, Json] = Right(String("Hello, world"))
533
+ ```
534
+
535
+ :::tip
536
+ For most use cases, let `JsonPatch.diff` generate `StringEdit` automatically. The diff algorithm uses an LCS (Longest Common Subsequence) comparison and only emits `StringEdit` when it produces fewer bytes than a plain `Set`.
537
+ :::
538
+
539
+ ### `Op.ArrayEdit`
540
+
541
+ Applies a sequence of `ArrayOp` operations to a `Json.Array`. Operations are applied in order, and each one sees the result of the previous:
542
+
543
+ ```scala
544
+ final case class ArrayEdit(ops: Chunk[ArrayOp]) extends Op
545
+ ```
546
+
547
+ The `ArrayOp` cases:
548
+
549
+ | Case | Parameters | Effect |
550
+ |------|-----------|--------|
551
+ | `ArrayOp.Insert(index, values)` | position, elements | Inserts `values` before `index` |
552
+ | `ArrayOp.Append(values)` | elements | Appends `values` to the end |
553
+ | `ArrayOp.Delete(index, count)` | position, count | Removes `count` elements starting at `index` |
554
+ | `ArrayOp.Modify(index, op)` | position, op | Applies `op` to the element at `index` |
555
+
556
+ Multiple `ArrayOp`s in a single `ArrayEdit` can be combined — here we transform `[1, 2, 3]` into `[0, 1, 2, 4]` in one pass:
557
+
558
+ ```scala
559
+ import zio.blocks.schema.json.{Json, JsonPatch}
560
+ import zio.blocks.schema.json.JsonPatch._
561
+ import zio.blocks.chunk.Chunk
562
+
563
+ val arrayPatch = JsonPatch.root(Op.ArrayEdit(Chunk(
564
+ ArrayOp.Insert(0, Chunk(Json.Number(0))),
565
+ ArrayOp.Delete(3, 1),
566
+ ArrayOp.Append(Chunk(Json.Number(4)))
567
+ )))
568
+ val originalArr = Json.Array(Json.Number(1), Json.Number(2), Json.Number(3))
569
+ ```
570
+
571
+ ```scala
572
+ arrayPatch.apply(originalArr)
573
+ // res35: Either[SchemaError, Json] = Right(
574
+ // Array(IndexedSeq(Number(0), Number(1), Number(2), Number(4)))
575
+ // )
576
+ ```
577
+
578
+ :::note
579
+ Array indices in `ArrayOp.Delete` and `ArrayOp.Modify` refer to the state of the array **after** all preceding ops in the same `ArrayEdit` have been applied.
580
+ :::
581
+
582
+ ### `Op.ObjectEdit`
583
+
584
+ Applies a sequence of `ObjectOp` operations to a `Json.Object`. Operations are applied in order:
585
+
586
+ ```scala
587
+ final case class ObjectEdit(ops: Chunk[ObjectOp]) extends Op
588
+ ```
589
+
590
+ The `ObjectOp` cases:
591
+
592
+ | Case | Parameters | Effect |
593
+ |------|-----------|--------|
594
+ | `ObjectOp.Add(key, value)` | field name, value | Adds a new field; fails in `Strict` mode if key exists |
595
+ | `ObjectOp.Remove(key)` | field name | Removes an existing field |
596
+ | `ObjectOp.Modify(key, patch)` | field name, sub-patch | Applies `patch` recursively to the field value |
597
+
598
+ A single `ObjectEdit` can add, remove, and modify fields in one operation:
599
+
600
+ ```scala
601
+ import zio.blocks.schema.json.{Json, JsonPatch}
602
+ import zio.blocks.schema.json.JsonPatch._
603
+ import zio.blocks.chunk.Chunk
604
+
605
+ val originalObj = Json.Object(
606
+ "name" -> Json.String("Alice"),
607
+ "age" -> Json.Number(25),
608
+ "city" -> Json.String("NYC")
609
+ )
610
+
611
+ val objPatch = JsonPatch.root(Op.ObjectEdit(Chunk(
612
+ ObjectOp.Add("email", Json.String("alice@example.com")),
613
+ ObjectOp.Remove("city"),
614
+ ObjectOp.Modify("age", JsonPatch.root(Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(1)))))
615
+ )))
616
+ ```
617
+
618
+ ```scala
619
+ objPatch.apply(originalObj)
620
+ // res37: Either[SchemaError, Json] = Right(
621
+ // Object(
622
+ // IndexedSeq(
623
+ // ("name", String("Alice")),
624
+ // ("age", Number(26)),
625
+ // ("email", String("alice@example.com"))
626
+ // )
627
+ // )
628
+ // )
629
+ ```
630
+
631
+ ### `Op.Nested`
632
+
633
+ Groups a sub-patch under a shared path prefix. `JsonPatch.diff` emits `Nested` automatically when multiple operations share a common navigation path — this avoids repeating the full path in each `JsonPatchOp`:
634
+
635
+ ```scala
636
+ final case class Nested(patch: JsonPatch) extends Op
637
+ ```
638
+
639
+ You rarely need to construct `Nested` manually; it is primarily an internal optimization used by the diff algorithm.
640
+
641
+ ## Diffing Algorithm
642
+
643
+ `JsonPatch.diff` (and its alias `Json#diff`) delegate to `JsonDiffer.diff`, which selects the most compact representation for each type of change:
644
+
645
+ | Value type | Change | Strategy |
646
+ |------------|--------|----------|
647
+ | Any | No change | No operation emitted |
648
+ | `Json.Number` | Value changed | `NumberDelta` — stores the numeric difference |
649
+ | `Json.String` | Value changed | `StringEdit` via LCS if smaller; otherwise `Set` |
650
+ | `Json.Array` | Elements changed | `ArrayEdit` with LCS-aligned `Insert`/`Delete`/`Append`/`Modify` |
651
+ | `Json.Object` | Fields changed | `ObjectEdit` with recursive per-field diff |
652
+ | Any | Type changed | `Set` — full replacement |
653
+
654
+ :::tip
655
+ `JsonPatch.diff` followed by `JsonPatch#apply` is always a lossless roundtrip: for any `source` and `target`, `JsonPatch.diff(source, target).apply(source) == Right(target)`.
656
+ :::
657
+
658
+ ## JsonPatch vs RFC 6902 JSON Patch
659
+
660
+ ZIO Blocks' `JsonPatch` is **not** an implementation of [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902). The two share the same motivation but differ in design:
661
+
662
+ | | ZIO Blocks `JsonPatch` | RFC 6902 JSON Patch |
663
+ |---|---|---|
664
+ | Operations | Typed ADT (`Op.Set`, `Op.ArrayEdit`, …) | String-tagged JSON objects (`"op": "replace"`) |
665
+ | Paths | `DynamicOptic` (typed, composable) | JSON Pointer strings (`"/a/b/0"`) |
666
+ | Number changes | `NumberDelta` (stores diff) | `replace` (stores full new value) |
667
+ | String changes | LCS-based `StringEdit` | `replace` only |
668
+ | Array changes | LCS-aligned insert/delete | `add`, `remove`, `replace` at absolute indices |
669
+ | Serialization | Via ZIO Blocks `Schema` in any format | Always JSON |
670
+ | Composition | `++` operator | Array concatenation |
671
+
672
+ Use `JsonPatch` when working within ZIO Blocks. For interoperability with RFC 6902 tooling, convert the patch to JSON using the built-in `Schema` instances and reformat as needed.
673
+
674
+ ## Advanced Usage
675
+
676
+ `JsonPatch`'s composability and first-class serializability unlock patterns beyond simple point-in-time updates.
677
+
678
+ ### Building a Change Log
679
+
680
+ Because `JsonPatch` is a pure value with a `Schema`, we can serialize every change and replay or audit it later:
681
+
682
+ ```scala
683
+ import zio.blocks.schema.json.{Json, JsonPatch}
684
+
685
+ // Every mutation is a patch — store it instead of overwriting
686
+ val v0 = Json.Object("count" -> Json.Number(0))
687
+ val v1 = Json.Object("count" -> Json.Number(1))
688
+ val v2 = Json.Object("count" -> Json.Number(2))
689
+
690
+ val log: List[JsonPatch] = List(
691
+ JsonPatch.diff(v0, v1),
692
+ JsonPatch.diff(v1, v2)
693
+ )
694
+
695
+ // Replay: reconstruct any historical state
696
+ val replay = log.foldLeft(v0: Json)((state, patch) => patch.apply(state).getOrElse(state))
697
+ assert(replay == v2)
698
+ ```
699
+
700
+ ### Composing Targeted Sub-Patches
701
+
702
+ We can build a single patch that updates multiple nested fields by combining focused per-field patches with `++`:
703
+
704
+ ```scala
705
+ import zio.blocks.schema.json.{Json, JsonPatch}
706
+ import zio.blocks.schema.json.JsonPatch._
707
+ import zio.blocks.schema.DynamicOptic
708
+
709
+ def setField(field: String, value: Json): JsonPatch =
710
+ JsonPatch(DynamicOptic.root.field(field), Op.Set(value))
711
+
712
+ val fieldPatch =
713
+ setField("status", Json.String("active")) ++
714
+ setField("updatedAt", Json.String("2025-01-01"))
715
+
716
+ val doc = Json.Object("status" -> Json.String("draft"), "id" -> Json.Number(42))
717
+ ```
718
+
719
+ Applying the composed patch updates both fields in one step:
720
+
721
+ ```scala
722
+ fieldPatch.apply(doc)
723
+ // res40: Either[SchemaError, Json] = Left(
724
+ // SchemaError(
725
+ // List(
726
+ // MissingField(source = DynamicOptic(ArraySeq()), fieldName = "updatedAt")
727
+ // )
728
+ // )
729
+ // )
730
+ ```
731
+
732
+ ## Integration
733
+
734
+ `JsonPatch` integrates with `Json`, `DynamicPatch`, and the ZIO Blocks serialization system. Each integration point is covered below.
735
+
736
+ ### With `Json`
737
+
738
+ `Json` exposes two extension methods as entry points into `JsonPatch`:
739
+
740
+ ```scala
741
+ import zio.blocks.schema.json.{Json, JsonPatch}
742
+ import zio.blocks.schema.patch.PatchMode
743
+
744
+ val source = Json.Object("x" -> Json.Number(1))
745
+ val target = Json.Object("x" -> Json.Number(2))
746
+
747
+ val patch: JsonPatch = source.diff(target) // compute patch
748
+ val result = source.patch(patch) // apply (Strict)
749
+ val lenient = source.patch(patch, PatchMode.Lenient)
750
+ ```
751
+
752
+ See [Json](./json.md) for the complete `Json` API.
753
+
754
+ ### With `DynamicPatch`
755
+
756
+ `JsonPatch` and `DynamicPatch` are bidirectionally convertible. This is useful when patches originate from the typed `Patch[S]` system and need to be applied to raw JSON:
757
+
758
+ ```scala
759
+ import zio.blocks.schema.json.{Json, JsonPatch}
760
+ import zio.blocks.schema.patch.DynamicPatch
761
+ import zio.blocks.schema.SchemaError
762
+
763
+ val jsonPatch: JsonPatch = JsonPatch.diff(Json.Number(1), Json.Number(3))
764
+
765
+ // JsonPatch → DynamicPatch (always succeeds)
766
+ val dynPatch: DynamicPatch = jsonPatch.toDynamicPatch
767
+
768
+ // DynamicPatch → JsonPatch (may fail for temporal ops or non-string keys)
769
+ val back: Either[SchemaError, JsonPatch] = JsonPatch.fromDynamicPatch(dynPatch)
770
+ ```
771
+
772
+ See [Patching](./patch.md) for the typed `Patch[S]` API.
773
+
774
+ ### Serialization
775
+
776
+ `JsonPatch` ships with `Schema` instances for all nested operation types, enabling round-trip serialization via any ZIO Blocks codec:
777
+
778
+ ```scala
779
+ import zio.blocks.schema.json.JsonPatch
780
+ import zio.blocks.schema.Schema
781
+
782
+ val schema: Schema[JsonPatch] = implicitly[Schema[JsonPatch]]
783
+ ```
784
+
785
+ See [Codec & Format](./codec.md) for how to derive and use codecs.
786
+
787
+ ## Examples
788
+
789
+ Runnable examples are in `schema-examples/src/main/scala/jsonpatch/`:
790
+
791
+ | File | Topic |
792
+ |------|-------|
793
+ | `JsonPatchDiffAndApplyExample.scala` | `JsonPatch.diff`, `Json#diff`, `Json#patch`, roundtrip guarantee |
794
+ | `JsonPatchManualBuildExample.scala` | `JsonPatch.root`, path-based patches, `JsonPatch.empty` |
795
+ | `JsonPatchOperationsExample.scala` | All `Op` types — `Set`, `NumberDelta`, `StringEdit`, `ArrayEdit`, `ObjectEdit` |
796
+ | `JsonPatchCompositionExample.scala` | `++`, `PatchMode`, `toDynamicPatch`, `fromDynamicPatch` |
797
+ | `CompleteJsonPatchExample.scala` | Collaborative document editor with a full patch log, replay, and sync |
798
+
799
+ Run any example with:
800
+
801
+ ```bash
802
+ sbt "schema-examples/runMain jsonpatch.CompleteJsonPatchExample"
803
+ ```