Committing data is only half the point. The other half is being able to ask what the data used to be, what changed between two moments, and who changed it.
library(doltliter)
con <- DBI::dbConnect(Doltlite(), tempfile(fileext = ".db"))
dolt_config(con, user.name = "Ada Lovelace", user.email = "ada@example.com")Let’s build a table with a few revisions behind it. The primary key
matters here: dolt_blame() needs one, because without a
stable row identity there is nothing to attribute a change
to.
DBI::dbWriteTable(
con, "readings",
data.frame(id = 1:3,
sensor = c("A", "B", "C"),
value = c(10.0, 20.0, 30.0)),
field.types = c(id = "INTEGER PRIMARY KEY")
)
dolt_commit(con, "First calibration run")
DBI::dbExecute(con, "UPDATE readings SET value = 21.5 WHERE id = 2")
#> [1] 1
dolt_commit(con, "Recalibrate sensor B")
DBI::dbExecute(con, "INSERT INTO readings VALUES (4, 'D', 40.0)")
#> [1] 1
dolt_commit(con, "Add sensor D")What happened, and when
dolt_log(con)[, c("committer", "date", "message")]
#> committer date message
#> 1 Ada Lovelace 2026-09-27 13:36:35 Add sensor D
#> 2 Ada Lovelace 2026-09-27 13:36:35 Recalibrate sensor B
#> 3 Ada Lovelace 2026-09-27 13:36:35 First calibration run
#> 4 doltlite 2026-09-27 13:36:35 Initialize data repositorydolt_diff() works one level up — which tables
changed in each commit, rather than which rows:
dolt_diff(con, "readings")[, c("commit_hash", "data_change", "schema_change")]
#> commit_hash data_change schema_change
#> 1 85cf5df3e98cec3c464921d767617d83ac4d9e47 1 0
#> 2 e92f754b43673443f840c1254211d1db1a16b377 1 0
#> 3 4f43c0ce56a31870fbfa60f5aa4a1f96427e61f0 1 1Uncommitted work shows up under the pseudo-commit
WORKING, which is a convenient way to see whether anything
is pending:
DBI::dbExecute(con, "UPDATE readings SET value = 41.0 WHERE id = 4")
#> [1] 1
dolt_diff(con, "readings")$commit_hash[1]
#> [1] "WORKING"
dolt_status(con)
#> table_name staged status
#> 1 readings 0 modifiedRow-level diffs
dolt_table_diff() is the workhorse. Give it two
endpoints and it returns one row per changed row, with
from_ and to_ columns side by side:
d <- dolt_table_diff(con, "readings", from = "HEAD", to = "WORKING")
d[, c("diff_type", "to_id", "from_value", "to_value")]
#> diff_type to_id from_value to_value
#> 1 modified 4 40 41Endpoints are anything DoltLite understands as a ref — a branch, a
tag, a commit hash, HEAD~2, WORKING:
dolt_table_diff(con, "readings", from = "HEAD~2", to = "HEAD")[
, c("diff_type", "to_id", "from_value", "to_value")]
#> diff_type to_id from_value to_value
#> 1 modified 2 20 21.5
#> 2 added 4 NA 40.0A range string works too, which is handy when comparing branches. Two dots are the two endpoints; three dots is merge-base to the right endpoint:
dolt_table_diff(con, "readings", range = "main..feature")
dolt_table_diff(con, "readings", range = "main...feature")For a summary rather than the rows themselves:
dolt_diff_stat(con, "HEAD~2", "HEAD")
#> table_name rows_unmodified rows_added rows_deleted rows_modified cells_added
#> 1 readings 2 1 0 1 3
#> cells_deleted cells_modified old_row_count new_row_count old_cell_count
#> 1 0 1 3 4 9
#> new_cell_count
#> 1 12
dolt_reset(con, "hard")Reading the past directly
dolt_at() reads a table as it was at some revision — no
checkout, no branch switching, just a query against a historical
snapshot:
lg <- dolt_log(con)
first <- lg$commit_hash[lg$message == "First calibration run"]
dolt_at(con, "readings", first)
#> id sensor value
#> 1 1 A 10
#> 2 2 B 20
#> 3 3 C 30
DBI::dbReadTable(con, "readings")
#> id sensor value
#> 1 1 A 10.0
#> 2 2 B 21.5
#> 3 3 C 30.0
#> 4 4 D 40.0dolt_history() goes wider: every version of every row
across the commit ancestry, with the commit metadata attached. It is the
right tool for “plot this value over time”:
h <- dolt_history(con, "readings")
h[h$id == 2, c("id", "sensor", "value", "commit_hash")]
#> id sensor value commit_hash
#> 2 2 B 21.5 85cf5df3e98cec3c464921d767617d83ac4d9e47
#> 6 2 B 21.5 e92f754b43673443f840c1254211d1db1a16b377
#> 9 2 B 20.0 4f43c0ce56a31870fbfa60f5aa4a1f96427e61f0Who changed this row
dolt_blame() attributes each live row to the
commit that last set it:
dolt_blame(con, "readings")[, c("id", "committer", "message")]
#> id committer message
#> 1 1 Ada Lovelace First calibration run
#> 2 2 Ada Lovelace Recalibrate sensor B
#> 3 3 Ada Lovelace First calibration run
#> 4 4 Ada Lovelace Add sensor DIt walks first parents from HEAD. Schema-only changes
such as ALTER TABLE ... ADD COLUMN do not update blame, so
a column addition will not make every row look freshly touched.
Content hashes
Every table root and the catalog as a whole are content-addressed, and the table and database hashes are history-independent: the same rows hash the same regardless of how they got there.
dolt_hashof(con, "HEAD") # commit hash — covers the timestamp too
#> [1] "85cf5df3e98cec3c464921d767617d83ac4d9e47"
dolt_hashof_table(con, "readings")
#> [1] "6cf508013fc3b1e71c3686c3d24fcb1ab0a78d02"
dolt_hashof_db(con)
#> [1] "b39b752292634f8afaac792b6302126f1ae80874"That makes dolt_hashof_table() a cheap way to ask “did
this table actually change?” across two databases or two points in time,
without diffing.
Generating a patch
dolt_patch() turns a diff into ordered, executable
SQLite statements — useful for review, or for replaying a change
somewhere else:
p <- dolt_patch(con, "HEAD~1", "HEAD")
p[, c("statement_order", "diff_type", "statement")]
#> statement_order diff_type
#> 1 1 data
#> statement
#> 1 INSERT INTO "readings"("id","sensor","value") VALUES (4,'D',40);
DBI::dbDisconnect(con)