Skip to content

SQLite changesets

The liteorm.org/dialect/sqlite/changeset package exposes SQLite’s SESSION extension through LiteORM. A changeset is a compact binary diff of the rows a set of statements touched. You can capture one on a database, invert it, concatenate it with another, and apply it to a different database with a Go conflict handler. The classic uses are audit logs, one-way replication, and undo.

Like the search package, this is SQLite-only and capability-gated: Capture and Apply take a liteorm.Session opened by liteorm.org/dialect/sqlite.

changeset.Capture records every mutation a function makes to the listed tables and returns the serialized changeset. It pins a dedicated connection so the recording session and the mutations share one physical connection — so the mutations inside the function must run against the session Capture hands back, not the original handle.

import "liteorm.org/dialect/sqlite/changeset"
cs, err := changeset.Capture(ctx, db, []string{"users", "orders"},
func(ctx context.Context, s liteorm.Session) error {
// Mutations MUST use s — the pinned session — to be recorded.
_, err := s.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, "Ada", 1)
return err
},
)

Pass an empty (or nil) table slice to record every table that has a primary key. The returned cs is a []byte you can store, send over a wire, or apply elsewhere.

changeset.Apply replays a captured changeset onto another database — the same one, or a replica:

err := changeset.Apply(ctx, replica, cs)

With no conflict handler, any conflict aborts the whole apply. To resolve conflicts row by row, pass changeset.WithConflictHandler. It receives the conflict type and returns an action:

err := changeset.Apply(ctx, replica, cs,
changeset.WithConflictHandler(func(t changeset.ConflictType) changeset.ConflictAction {
switch t {
case changeset.ConflictNotFound:
return changeset.Omit // skip this change
case changeset.ConflictData:
return changeset.Replace // overwrite the target row
default:
return changeset.Abort // abort the whole apply
}
}),
)

The conflict types are ConflictData, ConflictNotFound, ConflictConflict, ConflictConstraint, and ConflictForeignKey; the actions are Omit, Replace, and Abort. changeset.WithTableFilter restricts which tables a changeset applies to.

changeset.Invert reverses a changeset — applying the result undoes the original, which makes it an undo log:

undo, err := changeset.Invert(ctx, db, cs)
// applying undo reverses what cs did
err = changeset.Apply(ctx, db, undo)

changeset.Concat joins two changesets into one, as if the second were recorded immediately after the first:

combined, err := changeset.Concat(ctx, db, csA, csB)
  • Audit log — capture every mutation of a request, store the changeset; the binary diff is a precise record of what changed.
  • One-way replication — capture on a primary, ship the changeset, apply on a replica with a conflict handler for divergence.
  • Undo — keep the inverted changeset; apply it to roll a transaction’s effect back later.