recollect

Structural JSON diff/patch for MoonBit — compute and apply PatchOp sequences between Json trees

diff
patch
json
sync
Download zip
Author
Version
0.2.1
License
Apache-2.0
Last updated
3 months ago
Downloads
36

#recollect

tiye/recollect is a structural JSON diff/patch library for MoonBit.

It computes a compact sequence of PatchOp operations between two Json trees and applies them to reconstruct the target state. The library is designed for immutable-data workflows such as Cumulo and Respo, where you want to ship structural deltas over the network and rebuild the next state locally.

#Why this package exists

recollect is built for state synchronization in immutable applications:

  • compute diffs from an old state to a new state
  • serialize patches for transport
  • apply patches without mutating the previous value
  • preserve structural sharing on unchanged subtrees

That makes it suitable for render loops and server/client sync models that rely on stable equality checks and replacing the whole root value.

#Immutable data behavior

  • diff_json / diff_value are read-only
  • apply_patch / apply_patches / apply_to_value always return a new value
  • unchanged branches are structurally shared with the original tree
  • the input value is never mutated in place

let new_remote = @recollect.apply_to_value(old_remote, patches) catch { _ => old_remote } // old_remote is still untouched

Do not keep your app state inside a mutable wrapper and patch it in place. Replace the whole value with the newly returned one, otherwise structural equality-based update logic may stop working correctly.

#API

  • diff_json(old, new) — compute Array[PatchOp] between two Json trees
  • diff_value(old, new) — compute patches after serializing typed values with ToJson
  • apply_patch(root, patch) — apply one PatchOp, return new Json
  • apply_patches(root, patches) — apply a sequence of patches, return new Json
  • apply_to_value(value, patches) — patch a typed value via ToJson/FromJson

Patch format:

  • PathSegment: Field(String) | Index(Int)
  • PatchOp: Set | Remove | Insert | Delete | Move

Insert / Delete / Move are only emitted for keyed arrays: arrays whose items are JSON objects carrying a unique string "id" field. Plain arrays fall back to positional diffing.

#Import

import { "tiye/recollect" @recollect, }

#Json example

let before : Json = { "todos": [{ "id": "t-1", "title": "A", "done": false }], } let after : Json = { "todos": [ { "id": "t-1", "title": "A", "done": true }, { "id": "t-2", "title": "B", "done": false }, ], } let patches = @recollect.diff_json(before, after) let rebuilt = @recollect.apply_patches(before, patches) // rebuilt == after

#Typed value example

struct Todo { id : String title : String done : Bool } derive(FromJson, ToJson) struct AppState { todos : Array[Todo] } derive(FromJson, ToJson) let before = AppState::{ todos: [{ id: "t-1", title: "A", done: false }] } let after = AppState::{ todos: [ { id: "t-1", title: "A", done: true }, { id: "t-2", title: "B", done: false }, ] } let patches = @recollect.diff_value(before, after) let rebuilt : AppState = @recollect.apply_to_value(before, patches) catch { _ => before } // rebuilt == after

#Implementation notes

The core algorithm in recollect works on Json trees.

This is intentional, but it also reflects a current limitation of MoonBit: there is no built-in macro system or runtime reflection that lets the library generate a generic field-level diff/patch implementation directly for arbitrary user-defined structs.

Because of that, the typed API is currently implemented as a JSON round trip:

  • diff_value(a, b) = a.to_json() + b.to_json() + diff_json(...)
  • apply_to_value(value, patches) = value.to_json() + apply_patches(...) + from_json()

So the patch format is effectively a JSON-path-based structural delta.

#Trade-off

This design keeps the library generic and simple to integrate with any type that implements ToJson / FromJson, but it also means the typed API pays extra cost in:

  • serialization
  • deserialization
  • intermediate Json allocation
  • rebuilding typed values from patched Json

In practice, the main overhead is usually not the patch list itself, but the struct ↔ JSON conversion around it.

If you care about throughput on large states, prefer these guidelines:

  • use diff_json / apply_patches when your data is already represented as Json
  • use the typed API when convenience and integration matter more than raw speed
  • expect apply_to_value to be noticeably more expensive than raw JSON patching

Benchmark notes and measurements live in bench/BENCH_REPORT.md.

#Keyed array behavior

When both old and new arrays are arrays of objects with unique string id fields, recollect diffs them by identity instead of position. That allows local Move / Insert / Delete operations instead of rewriting neighboring items.

If array elements do not expose unique string id values, the library falls back to positional diffing.

#Validation

Run checks locally with:

moon check moon test

#Notes for extraction and publishing

This package is already organized as a standalone MoonBit module:

  • package docs live next to the implementation
  • tests cover both JSON-first APIs and typed round trips
  • benchmark fixtures and reports are kept under bench/

If MoonBit later provides stronger compile-time metaprogramming support, recollect can evolve toward a more direct typed diff/patch path with less JSON conversion overhead. For now, the JSON-path-based approach is the portable implementation that works across user-defined data types.

PatchOp

pub(all) enum PatchOp {
Set(Array[PathSegment], Json)
Remove(Array[PathSegment])
Insert(Array[PathSegment], Int, Json)
Delete(Array[PathSegment], Int)
Move(Array[PathSegment], Int, Int)
} derive(Eq, ToJson,
Debug
,
FromJson
)

A single structural edit operation on a Json tree.

Patch operations are always non-destructive with respect to the input: every apply_* function returns a new tree rather than mutating the original. This makes the patch format safe to use with immutable-data architectures (e.g. Cumulo server state, Respo stores).

VariantMeaning
SetOverwrite a value at path (creates intermediate nodes if absent)
RemoveDelete the field or array element addressed by path
InsertSplice a new element into the array at path before index
DeleteRemove the element at index from the array at path
MoveRe-order an element within the array at path

Insert/Delete/Move are only emitted for keyed arrays — arrays whose every element is a JSON object with a unique string "id" field. Plain arrays fall back to positional Set patches.

PathSegment

pub(all) enum PathSegment {
Field(String)
Index(Int)
} derive(Eq, ToJson,
Debug
,
FromJson
)

A single segment in a path through a Json tree.

Paths are sequences of segments used by PatchOp to address nested locations. Field("key") traverses into a JSON object; Index(n) traverses into a JSON array.

apply_patch

fn apply_patch(root : Json, patch : PatchOp) -> Json

Apply a single PatchOp to root and return the resulting Json tree.

The input root is never mutated — all intermediate nodes on the affected path are copied, producing a new tree that shares unchanged subtrees with the original. This structural sharing makes the function safe to use in immutable-data architectures.

Panics (via abort) if the path in the patch is structurally inconsistent with the tree (e.g. Field segment on an array node).

apply_patches

fn apply_patches(root : Json, patches : Array[PatchOp]) -> Json

Apply a sequence of PatchOps to root, threading the result of each operation as the input to the next.

Like apply_patch, the original root is never mutated.

An empty patches slice returns root unchanged.

let new_json = apply_patches(old_json, diff_json(old_json, new_json))
assert_eq(new_json, new_json)

apply_to_value

Apply patches to a typed MoonBit value by round-tripping through Json.

The value is serialized to Json via ToJson, the patches are applied with apply_patches, and the result is deserialized back to T via FromJson. Raises @json.JsonDecodeError if the patched JSON cannot be decoded into T.

This is the primary function used by Cumulo-style clients to advance their local state when receiving a Delta event from the server:

let new_remote = @recollect.apply_to_value(old_remote, patches)
catch { err => ... }

Because both serialization and deserialization produce new values, the original value is never mutated.

diff_json

fn diff_json(old_json : Json, new_json : Json) -> Array[PatchOp]

Compute a minimal sequence of PatchOps that transforms old_json into new_json.

The diff is structural: objects are compared field-by-field, arrays are compared element-by-element (or by "id" key when all elements carry unique string ids). Unchanged subtrees produce no patches.

The result can be serialized (via ToJson/FromJson on PatchOp) and sent over a network, then applied on the other side with apply_patches.

let patches = diff_json(
{ "count": 1 },
{ "count": 2, "label": "hi" },
)
// patches: [Set(path=[Field("count")], value=2),
// Set(path=[Field("label")], value="hi")]

diff_value

fn[T : ToJson] diff_value(old_value : T, new_value : T) -> Array[PatchOp]

Compute a minimal patch sequence by first serializing both values to Json via ToJson, then delegating to diff_json.

This is the typical entry point when working with typed MoonBit structs. The type T must derive (or implement) ToJson; it does not need FromJson at the diff stage.

struct Counter { value : Int } derive(ToJson)
let patches = diff_value({ value: 1 }, { value: 2 })

Source Files