A customer disputes an invoice.
"You billed us Starter on March 1. We were on Pro from February 20."
Support opens the customers table. It says Pro. The updated_at column says March 15. The row that existed on March 1, the one the billing job actually read, is gone. Nobody can say what the system believed when it sent that invoice. Everyone can only say what it believes now.
Two clocks, not one
That dispute has two different times in it, and most app schemas collapse them into one.
Valid time is when something was true in the world. Acme was on Pro from February 20.
Transaction time is when your system found out. You learned about the upgrade on March 15.
A bi-temporal database keeps both, for every fact, and never overwrites either. You can then ask four kinds of question instead of one: what is true now, what was true on a date, what did we believe at some past moment, and what did we believe at that moment about some other date. The billing dispute is the last kind. "On March 1, what did we think Acme's plan was on March 1?"
Most app databases keep one clock at best, and only for the latest row. An UPDATE destroys the old value. updated_at is a transaction time for the latest write only. An effective_from column gives you valid time, but the first time someone corrects it in place, the old belief is gone. The usual patch is an audit table filled by triggers, and it tends to be partial, hand-rolled, and never queried until the day a lawyer asks.
So the bug class this fixes is not exotic. It is any question of the form "why did the system do that, back then?": a disputed invoice, a compliance review, a model that made a recommendation from data that has since been corrected.
A small database that keeps both
Minigraf is an embedded graph database in Rust that stores every fact with both clocks. It was a Show HN this week (thread, 34 points and 29 comments when I read it), and it sits at 117 stars. Embedded means SQLite-style: a library in your process and one .graph file, no server. It ships bindings for Python, Node, browser WASM, Swift, Kotlin and C, and queries are Datalog.
The two clocks show up as two query keys. This is the shape from the project's own demo file and grammar tests:
(query [:find ?company
:as-of 1
:valid-at "2024-01-01"
:where [:alice :employment/company ?company]])
:valid-at picks the date in the world. :as-of picks the moment in the database's history, either as a transaction counter (:as-of 3) or a UTC timestamp (:as-of "2024-01-15T10:00:00Z"). Writes take :valid-from and :valid-to when you want to backdate or bound a fact. Leave out :valid-at and you get what is valid now.
I replayed the invoice dispute
I did not build it from source. I downloaded the v2.0.3 prebuilt macOS binary from the project's GitHub releases, checked its SHA-256 against the published one, and piped Datalog into its REPL in a scratch folder. It is a 1.2 MB executable.
The script writes Acme's plan, waits, notes the time (the moment "we billed"), then applies the correction from support:
MG=./minigraf
echo '(transact {:valid-from "2026-01-01"} [[:acme :plan :starter]])' | $MG --file acme.graph
sleep 2; BILLED=$(date -u +%Y-%m-%dT%H:%M:%SZ); sleep 2
printf '%s\n' \
'(retract [[:acme :plan :starter]])' \
'(transact {:valid-from "2026-01-01" :valid-to "2026-02-20"} [[:acme :plan :starter]])' \
'(transact {:valid-from "2026-02-20"} [[:acme :plan :pro]])' | $MG --file acme.graph
Then the two questions support actually needs answered (output trimmed to the answer rows):
(query [:find ?plan :as-of "2026-10-06T05:49:36Z" :valid-at "2026-03-01" :where [:acme :plan ?plan]])
:starter
(query [:find ?plan :valid-at "2026-03-01" :where [:acme :plan ?plan]])
:pro
That is the whole dispute in two lines. When we billed, the system believed Starter, so the invoice was consistent with what it knew. Today it knows Pro, so the invoice was wrong and owes a credit. Both answers come from the same file, and neither required anyone to have built an audit table in advance.
The transaction ids it printed are Unix milliseconds, so the :as-of timestamp is plain wall-clock time. I checked them against my BILLED stamp with a one-line Node script, and it sat between the first write and the correction, as it should.
The part that surprised me
My first attempt skipped the retract. I wrote Starter from January 1, then Pro from February 20, and asked about March 1. I got both:
?plan
--------------------
:starter
:pro
Valid time does not supersede anything by itself. A new value does not end the old one; an open window stays open until you close it. Datomic, the system this design borrows from, has a cardinality-one schema flag that retracts the old value for you. Minigraf has no schema yet (optional validation is on its roadmap), so closing the window is your job.
On v2.x, closing it means retracting the fact and re-asserting it with a :valid-to. Even writing the same fact again with a new end date does not replace the old window, which the maintainer filed as issue #435 and scheduled for v3.0.0. A related bug, #436, notes that a window whose end comes before its start is accepted without an error.
My correction also ran as three separate transactions, and I could see the gap: :as-of 2, after the retract and before the re-assert, returns no plan at all. The Rust API has explicit write transactions (begin_write, then commit) to make that all-or-nothing, but the REPL pipe I used does not.
How mature is it
Be fair about scale. This is a solo-maintained project, v2.0.3 shipped this week, and there are 43 open issues. The maintainer is unusually candid about them:
- A pinned known-issues list (#421). On every v2.x release, two values of one attribute written in a single transaction can read back as one value. The workaround is one value per call; the fix needs a new file format and lands in v3.0.0. Checkpoints are also not crash-atomic yet, though committed data survives in the write-ahead log.
- A production-readiness tracker (#383) whose stated goal is to make it safe as "the only copy of important data". Read that as: not yet.
-
The npm package did not load on my Mac.
npm install minigrafgave me 2.0.3, butrequire('minigraf')threwMODULE_NOT_FOUND. The loader looks for an unscopedminigraf-darwin-universal, while npm installs@minigraf/darwin-universal, still pinned at 1.0.0. The release binary worked fine.
The HN thread pushed back on the premise too. One commenter, who maintains another embedded graph database, asked why a Cypher database with timestamp properties on edges would not cover this. Another said that whenever they question a new graph database, the answer comes back as Postgres. A third could not picture the real-world queries.
My answer to the first two: timestamp properties give you valid time. The second clock only exists if you never update in place, and that is a discipline most teams lose the first time someone fixes a typo in production. Postgres can do it too, with an append-only table and separate valid-time and recorded-time columns. The point is not which engine. The point is that the history has to be the default, not a feature someone remembers to bolt on.
Who should care
If you store anything a person or a model acts on, someone will eventually ask what the system believed when it acted. Agent memory is the obvious new case: an agent recommended something on Tuesday, a fact it used was corrected on Thursday, and now you need to replay Tuesday.
You do not need Minigraf to take the lesson. Append instead of overwrite, and give every row a "true from" and a "recorded at" that are different columns. If you want to see what it feels like when the database does that for you, the binary and a ten-line script are enough.
What is the last question you could not answer because your database had already overwritten the answer?
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.