Thin Tips and Honest History
Current values still win by last-writer-wins. Time travel still walks history when you need it. What changed is what we refuse to store on every write — and what we keep instead of a second, fatter log.
LastDB is a local-first store: apps declare shapes, write fields, read current records, and optionally reconcile with peers. Under the hood, each field value is content-addressed (immutable bodies) and reached through a tip — a small pointer that says “this record key is currently this content id, written at this clock, by this device.” For a long time, that pointer was not small. And next to it grew a second structure: an always-on mutation event log that could rival the tips in bulk.
We just spent a focused pass simplifying both. The apps we build on LastDB — a knowledge base and a kanban board among them — kept working. The product surface (mutation, query, multi-device last-writer-wins) stayed. The on-disk story got more honest.
What we were carrying
A tip answers one question: what is current for this field key? The answer only needs a content id, a clock, and a device identity for ties. We were also serializing per-tip crypto and provenance on the default write path. On a real working database that meant on the order of a hundred-plus megabytes of tip payload alone — not values, just pointers dressed as certificates.
Separately, every mutation could append a history event: a fat JSON row with old and new content ids, field keys, timestamps, and leftover signature fields often empty. That log powered as-of rewinds, but it was paid for on every write whether you needed deep time travel or not. After a purge it grew back as soon as the app kept writing.
The lesson we kept repeating
Storage cost is a product decision. If the default path pays for features almost nobody uses on every keystroke, the live database slowly becomes an archive of machinery, not of user data.
Simplification one: thin tips
Tips on the default write path are now thin: content id, write clock, device id — and an optional link used for history (below). Signature and pubkey fields stay readable for older on-disk rows (dual-read) but are no longer emitted on ordinary local writes. Trust for sync remains at the account and channel layer; we do not re-prove every field tip on every read.
Migration rewrote legacy fat tips in place under the same keys. No freeze: readers already accepted both shapes; writers emitted thin only. On a real multi-hundred-megabyte main tree, tip bulk dropped by tens of megabytes of live payload the moment the rewrite finished. Host file size is a different story (compaction is still a separate project) — but live logical size and every new write got cheaper immediately.
Simplification two: stop the always-on event log
We stopped writing the mutation-event stream on the default path. Current reads never needed it: they follow tip → content. If you only want latest, the log was pure tax.
We also made purge honest. “Keep latest” used to mean “keep one history row per key,” which left almost the entire log in place when depth was already one. Purge with keep-last zero deletes the event rows entirely. After that, new writes do not refill them.
Simplification three: honest history as a tip chain
One-step “previous content id on the tip” is not enough for real as-of. If you want history, you want a chain. So on overwrite we now archive the previous tip head as its own small version node and point the new head at it:
That chain is per record key, not a snapshot of the whole field. Each node is still pointer-sized: content id, clock, device, previous version id. Content bodies stay in content rows. As-of walks the chain until the clock is old enough. Multi-device last-writer-wins still compares the head tip’s clock triple; the chain rides along with the winning head.
Features kept: current reads, LWW merge, optional deep as-of per key. Features we stopped paying for by default: crypto-on-every-tip, and a second fat event log that duplicated the same story in a bulkier format.
What the live footprint looks like
After the pass, a working primary database told a clearer story about itself. Logical main tree on the order of a quarter gigabyte of live keys: roughly two-fifths content bodies, about a third current tips (now thin), most of the rest scan/sample indexes for keyed fields. Tip version archives started small and grow only when you overwrite. The host file can still look multi-gigabyte because free space inside the store file is not punched out on every delete — that is freelist reality, not a return of fat tips.
What we did not claim
We did not invent a free lunch. Indexes for listing and sampling still cost real keys. Content is still the largest single value class when fields are large. Compacting the on-disk file to match live logical size is still a separate job. We did remove default costs that were not buying product on the hot path.
How we cut over without drama
The pattern was boring on purpose: dual-read old and new tip shapes, write only the new shape, rewrite existing fat tips under the same keys, prove the candidate binary on a throwaway copy of real data, then swap the primary binary with a durable backup in hand. No write freeze. Apps kept calling the same mutation and query APIs. If something had failed the probe, the live node would still be on the previous binary.
Why this is the LastDB posture
Local-first only works if the laptop stays the place of truth under load. Every always-on byte competes with that. Thin tips and tip-version history are the same idea as schema evolution we have written about before: expand what the product needs, subtract what the default path no longer earns.
The database still stores current field values. It still reconciles peers by last writer. It still can answer “what was this key before?” by walking a real chain. It just stops pretending every tip needs a ceremony and every write needs a second diary entry.
Related: The Fix Was Subtraction, Machinery Listening to Silence, and apps we run on LastDB.