Both doctors go off call. Then I flip one setting and the bug aborts.
A working MVCC engine you can step through: run two transactions side by side and watch dirty reads, phantom reads, and write skew happen because the semantics produced them, not because an animation was scripted.
Two doctors are on call. Each opens a transaction, checks that the other one is still on call, sees that they are, and signs off for the night. Both reads were correct at the moment they ran. Both transactions commit. Now nobody is on call, the invariant that at least one doctor is covering has quietly broken, and no single line of code did anything wrong. That's write skew, and most engineers can name it without being able to say why raising an isolation level makes it disappear.
The table you memorized, made mechanical
Everyone has seen the grid: Read Committed prevents dirty reads, Repeatable Read prevents non-repeatable reads, Serializable prevents everything. It teaches the vocabulary and none of the machinery, so the knowledge evaporates the first time a real balance briefly goes negative.
Phantom Read replaces the grid with a working multi-version concurrency control engine you can drive. Two transactions run side by side on a shared table. You pick each one's isolation level and step them forward one action at a time. Every read resolves against a real snapshot; every write appends a real version to a real chain. When an anomaly fires, it fires because the rules produced it, not because a diagram was scripted to play it back.
Nothing here is faked
The whole engine lives in one dependency-free file, src/engine/mvcc.js, and it keeps a version
chain per row. Each version carries two stamps: xmin, the transaction that created it, and
xmax, the transaction that superseded or deleted it. A row that has been updated once looks like
this:
bob-oncall
v1 xmin 0 · xmax 2 (value 1) ← the original, retired by txn 2
v2 xmin 2 · xmax ∞ (value 0) ← the live version
A read is just a walk down that chain returning the first version visible to the reader. And visibility is the entire game. Isolation levels do not change what reading means; they change only which snapshot the reader carries. Read Committed takes a fresh snapshot at every statement, so it always sees the latest committed version. Repeatable Read takes one snapshot when the transaction begins and reuses it, which is why its reads stay stable but also why write skew slips right through: both doctors are looking at a frozen past where the other is still on call.
Serializable is snapshot isolation plus one extra check. At commit time it looks for read-write antidependencies: did this transaction read something another transaction then wrote out from under it? If so, one of them has to lose. First-updater-wins handles the write-write races; the antidependency check is what actually catches write skew. It's conservative, exactly like real snapshot-isolation databases, and the app is honest that it sometimes aborts a transaction that would have been fine.
The moment worth the build
Here is the whole reason the project exists. Run the write-skew scenario at Repeatable Read and step it to the end: both doctors go off call, the row flares, and the callout names the broken invariant. Now change nothing about the script. Raise both lanes to Serializable and replay the exact same clicks. This time the second commit hits the antidependency check and aborts with a serialization error. Same actions, same order, one setting different, and the outcome flips from silent data corruption to "the database saved you."
That contrast is produced by the model, not narrated at you. Because a scenario is data, not
code (a scripted pair of transactions in src/engine/scenarios.js), the engine executes it into
an immutable trace and the UI just renders a cursor over it. Replaying a prefix can never drift
from stepping to it, and every claim the UI makes is backed by the same engine running under
node:test. The demo you click and the test that passes in CI are the identical code.
Try it
Open the live app and pick the write-skew scenario
first. Leave both transactions at Repeatable Read, press → (or space) to step, End to jump
straight to where it breaks, and read the flare. Then raise both lanes to Serializable with the
RR/SER pills and replay: watch the loser get aborted where a second ago the data just quietly
rotted. Poke the version chains and the snapshot inspector while you do it. Five minutes of
stepping teaches the thing the grid never could.
This post is part of the build log: every app my automated factory ships gets written up here, honestly. Browse everything at apps.charliekrug.com. Comments are open below.
Loading comments…