Structural diff detection library for Scala 3.
| Module | Artifact | Description |
|---|---|---|
| diffact-core | "dev.hshn" %% "diffact" |
Core diffing — zero external dependencies |
| diffact-zio | "dev.hshn" %% "diffact-zio" |
ZPure state-diff integration |
| diffact-slick | "dev.hshn" %% "diffact-slick" |
Slick DBIO sync integration |
| diffact-zio-slick | "dev.hshn" %% "diffact-zio-slick" |
ZPure + Slick combined |
import diffact.*Three equivalent API styles:
Differ.diff(oldValue = 1, newValue = 2)
Differ.diff(2).from(1)
Differ.diff(1).to(2)
// all return Some(Changed(oldValue = 1, newValue = 2))Any type with equals works out of the box. Result type: Option[Difference[A]]
case class User(name: String)
Differ.diff(User("bob")).from(User("alice"))
// Some(Changed(oldValue = User("alice"), newValue = User("bob")))
Differ.diff(User("alice")).from(User("alice"))
// NoneWraps the underlying differ. Result type matches the inner differ (typically Option[Difference[A]]):
Differ.diff(Option("new")).from(Option.empty[String])
// Some(Added("new"))
Differ.diff(Option.empty[String]).from(Option("old"))
// Some(Removed("old"))
Differ.diff(Option("new")).from(Option("old"))
// Some(Changed(oldValue = "old", newValue = "new"))Result type: Seq[Difference[A]]. By default, elements are tracked by index:
Differ.diff(Seq(2)).from(Seq(1))
// Seq(Changed(oldValue = 1, newValue = 2))
Differ.diff(Seq(1, 2)).from(Seq(1))
// Seq(Added(2))Use trackBy to match elements by a custom key (see Identity Tracking):
case class Item(id: String, name: String)
given SeqDiffer[Item, String] = ValueDiffer[Item].trackBy(_.id).toSeq
val oldItems = Seq(Item("1", "alice"), Item("2", "bob"), Item("3", "charlie"))
val newItems = Seq(Item("2", "BOB"), Item("1", "alice"))
Differ.diff(newItems).from(oldItems)
// Seq(
// Removed(Item("3", "charlie")),
// Changed(oldValue = Item("2", "bob"), newValue = Item("2", "BOB")),
// )Result type: Seq[Difference[A]]. Detects added and removed elements only (no Changed):
Differ.diff(Set(2, 3)).from(Set(1, 2))
// contains Added(3) and Removed(1)Result type: Seq[(K, Difference[V])]. Detects added, removed, and changed values by key:
Differ.diff(Map("a" -> 10, "c" -> 3)).from(Map("a" -> 1, "b" -> 2))
// contains ("c", Added(3)), ("b", Removed(2)), ("a", Changed(oldValue = 1, newValue = 10))ValueDiffer#trackBy creates a TrackedValueDiffer that distinguishes between a value being modified (same identity) and replaced (different identity).
Result type: Difference.Tracked[A]
case class Plan(id: String, name: String)
val differ = ValueDiffer[Plan].trackBy(_.id)
// Same identity, different value → Changed
differ.diff(Plan("p1", "Basic"), Plan("p1", "Pro"))
// Difference.Tracked.Changed(oldValue = Plan("p1", "Basic"), newValue = Plan("p1", "Pro"))
// Different identity → Replaced
differ.diff(Plan("p1", "Basic"), Plan("p2", "Enterprise"))
// Difference.Tracked.Replaced(removedValue = Plan("p1", "Basic"), addedValue = Plan("p2", "Enterprise"))
// Same identity, same value → Unchanged
differ.diff(Plan("p1", "Basic"), Plan("p1", "Basic"))
// Difference.Tracked.UnchangedA TrackedValueDiffer can be lifted to a SeqDiffer via toSeq:
given SeqDiffer[Plan, String] = ValueDiffer[Plan].trackBy(_.id).toSeqDifference#map transforms a diff into a diff of a nested field, delegating to the appropriate Differ:
case class Foo(bar: String, baz: Seq[Baz])
case class Baz(id: String, qux: String)
given SeqDiffer[Baz, String] = ValueDiffer[Baz].trackBy(_.id).toSeq
val diff = Difference.Changed(
oldValue = Foo("1", Seq(Baz("b1", "q1"), Baz("b2", "q2"), Baz("b3", "q3"))),
newValue = Foo("1", Seq(Baz("b2", "q2222"), Baz("b1", "q1"))),
)
diff.map(_.baz)
// Seq(
// Removed(Baz("b3", "q3")),
// Changed(oldValue = Baz("b2", "q2"), newValue = Baz("b2", "q2222")),
// )The base result type for all diffs:
sealed trait Difference[+A]
case class Added[+A](value: A) extends Difference[A]
case class Removed[+A](value: A) extends Difference[A]
case class Changed[+A](oldValue: A, newValue: A) extends Difference[A]| Method | Description |
|---|---|
fold(added, removed, changed) |
Exhaustive dispatch without pattern match |
map(f)(using Differ[B]) |
Diff a nested field |
show(using Show[A]) |
Human-readable string (+alice, 1 → 2) |
Result type for TrackedValueDiffer. Represents the diff of a single identity-tracked value:
sealed trait Tracked[+A]
case object Unchanged extends Tracked[Nothing]
case class Added[+A](value: A) extends Tracked[A]
case class Removed[+A](value: A) extends Tracked[A]
case class Changed[+A](oldValue: A, newValue: A) extends Tracked[A]
case class Replaced[+A](removedValue: A, addedValue: A) extends Tracked[A]| Method | Description |
|---|---|
toDifferences |
Convert to Seq[Difference[A]] (Replaced becomes Removed + Added) |
show(using Show[A]) |
Human-readable string |
Module:
diffact-slick
Mix DiffactComponent into your Slick profile:
import diffact.slick.*
object MyProfile extends slick.jdbc.PostgresProfile with DiffactComponent {
object api extends JdbcAPI with DiffactApi
}
import MyProfile.api.*Sync is a builder for declaratively registering per-handler dispatch logic:
val handler = Sync[User]
.added(d => userTable += d.value)
.removed(d => userTable.filter(_.id === d.value.id).delete)
.changed(d => userTable.filter(_.id === d.oldValue.id).update(d.newValue))Dispatch to the appropriate handler based on the diff type:
// Option[Difference[A]] — from ValueDiffer
val diff: Option[Difference[User]] = Differ.diff(newUser).from(oldUser)
handler(diff)
// Difference.Tracked[A] — from TrackedValueDiffer
// Replaced is automatically handled as remove → add
val tracked: Difference.Tracked[Plan] = planDiffer.diff(oldPlan, newPlan)
handler(tracked)
// Seq[Difference[A]] — from SeqDiffer (per-element dispatch)
val diffs: Seq[Difference[Item]] = Differ.diff(newItems).from(oldItems)
handler(diffs)Use void to unify return types when handlers return different types, or when you only need a subset of handlers:
// Only handle changes — Added/Removed will fail at runtime
val changeOnly = Sync[User]
.changed(d => userTable.filter(_.id === d.oldValue.id).update(d.newValue))
.void
changeOnly(diff) // DBIOAction[Unit, ...]Sync.batchNel and Sync.batchSeq group differences by type and pass each group to the corresponding handler in a single call. Use these for bulk operations like batch inserts or deletes.
batchNel handlers receive NonEmptyList, guaranteeing at least one element:
val batchHandler = Sync.batchNel[Item]
.added(ds => itemTable ++= ds.toList.map(_.value))
.removed(ds => itemTable.filter(_.id inSet ds.toList.map(_.value.id)).delete)
.changed(ds => DBIO.sequence(ds.toList.map(d =>
itemTable.filter(_.id === d.oldValue.id).update(d.newValue)
)))
val diffs: Seq[Difference[Item]] = Differ.diff(newItems).from(oldItems)
batchHandler(diffs)batchSeq handlers receive Seq — useful when the downstream API already expects Seq:
val batchHandler = Sync.batchSeq[Item]
.added(ds => itemTable ++= ds.map(_.value))
.removed(ds => itemTable.filter(_.id inSet ds.map(_.value.id)).delete)
.voidZPure#runAllStateDiff diffs the initial state against the final state after running a ZPure computation:
import diffact.*
case class MyState(name: String, count: Int)
val computation = for {
_ <- ZPure.log("updating")
_ <- ZPure.update[MyState, MyState](s => s.copy(count = s.count + 1))
} yield ()
computation.runAllStateDiff(MyState("test", 0))
// Right((Chunk("updating"), Some(Changed(MyState("test", 0), MyState("test", 1))), ()))When the state is unchanged, the diff result is None. On error, returns Left(E).
Combines ZPure state-diff with Slick. Mix in both components:
import diffact.slick.*
object MyProfile extends slick.jdbc.PostgresProfile
with DiffactComponent
with DiffactZPureComponent {
object api extends JdbcAPI with DiffactApi with DiffactZPureApi
}
import MyProfile.api.*ZPure#runAllStateAsDBIO wraps runAllStateDiff in a DBIOAction:
val action = for {
currentState <- readAggregate(id)
result <- stateMachine.runAllStateAsDBIO(currentState).semiflatMap {
case (events, None, a) => DBIO.successful(a) // no change
case (events, Some(diff), a) => write(diff) as a // sync diff to DB
}
} yield result
db.run(action.transactionally)ValueDiffer#contramap creates a differ that uses a projection for equality comparison while keeping results in the original type:
case class Item(id: String, name: String)
// Only compare by name — ignore id changes
given ValueDiffer[Item] = ValueDiffer[String].contramap(_.name)
given SeqDiffer[Item, String] = ValueDiffer[Item].trackBy(_.id).toSeq
val oldItems = Seq(Item("1", "alice"), Item("2", "bob"))
val newItems = Seq(Item("1", "alice"), Item("2", "BOB"))
Differ.diff(newItems).from(oldItems)
// Seq(Changed(oldValue = Item("2", "bob"), newValue = Item("2", "BOB")))