CodeOrigin All articles
Programming Languages

One File, Many Truths: A First-Principles Walk Through Append-Only Storage

CodeOrigin
One File, Many Truths: A First-Principles Walk Through Append-Only Storage

Databases often arrive as black boxes: a connection string, a query planner, a backup schedule, and a vague sense that durability is somebody else's problem. That abstraction is useful, but it hides a primitive simple enough to fit in a short file. An append-only log records facts in order and never rewrites history. Everything else — indexes, snapshots, replication, recovery — can be understood as a projection over that log.

That is the first-principles move worth making in 2026. Before choosing a storage tool, understand the shape of the data flow. For event streams, audit trails, and write-ahead recovery, the log is not an implementation detail. It is the architecture.

The Contract: Append, Replay, Recover

An append-only store makes one central promise: new records are added to the end, and existing records are left alone. A key-value store can be layered on top because each record can describe a change: set this key to this value, or delete this key. Replay means reading those records in order and folding them into a map. Recovery means doing that fold after a crash.

The minimal interface has two operations. Append writes a record and forces it to durable storage. Replay reads records from the beginning and rebuilds state. Everything else is an optimization.

A Minimal Store in TypeScript

Start with newline-delimited JSON. It is not the fastest format, but it makes the mechanics visible. Each line is one record. A null value is a tombstone, which records a deletion without pretending the key never existed.

import { open, readFile, writeFile, rename } from 'node:fs/promises';

type Entry = { key: string; value: string | null };

const handle = await open('store.log', 'a');

export async function append(entry: Entry) {
  const line = JSON.stringify(entry) + '\n';
  await handle.write(line);
  await handle.sync();
}

export async function load(): Promise<Map<string, string>> {
  const data = await readFile('store.log', 'utf8');
  const map = new Map<string, string>();
  for (const line of data.split('\n')) {
    if (!line) continue;
    const entry = JSON.parse(line) as Entry;
    if (entry.value === null) map.delete(entry.key);
    else map.set(entry.key, entry.value);
  }
  return map;
}

Opening the file in append mode keeps writers from casually overwriting earlier records. The sync call is the durability boundary. It is also the expensive part. A production system batches writes and syncs less often, accepting a small window of loss in exchange for throughput. That trade-off belongs in the design document, not in a comment added after an incident.

Framing matters, too. Newline-delimited JSON is readable, but a partial write can leave a truncated line. Production logs usually add a length prefix or checksum. A checksum lets replay skip a corrupt tail instead of refusing to start at all.

Snapshots: Trading Replay Time for Space

Replay is linear. That is fine for a toy store and painful for a log with millions of records. A snapshot stores the current map plus the log offset it represents. Startup loads the snapshot, then replays only records after that offset.

export async function snapshot(map: Map<string, string>, offset: number) {
  const body = JSON.stringify({ offset, entries: Array.from(map) });
  await writeFile('snapshot.json.tmp', body);
  await rename('snapshot.json.tmp', 'snapshot.json');
}

The atomic rename is the important detail. Write the temporary file first, sync it, then rename it into place. On the same filesystem the rename is atomic, so readers see either the old snapshot or the new one. Directory metadata should be synced as well if the platform requires it for crash safety.

Snapshots also change the meaning of the log. The log remains the source of truth, but the snapshot becomes a shortcut. If the snapshot is lost, replay still works. If the log is compacted after the snapshot, the snapshot and the remaining log together become the recoverable state.

Compaction: Rewriting the Log Without Losing the Truth

A log that only grows is simple but not sustainable. Compaction walks the log, keeps the latest value for each live key, and writes a new log. Tombstones can be dropped once a snapshot proves they are no longer needed for older readers. The old file is replaced by atomic rename.

const live = new Map<string, string>();
for (const entry of replayEntries('store.log')) {
  if (entry.value === null) live.delete(entry.key);
  else live.set(entry.key, entry.value);
}
await writeNewLog('store.log.tmp', live);
await rename('store.log.tmp', 'store.log');

Compaction is where append-only systems earn their complexity. It reduces replay time and disk usage, but it also introduces coordination: writers must not append to the old file during the swap, or those records must be captured and replayed into the new one. A single-writer process makes this manageable. A multi-writer service needs a more careful handoff.

Why This Mental Model Pays Off

The append-only log shows up everywhere once you start looking. Kafka partitions are logs with consumer offsets. PostgreSQL's write-ahead log records changes before pages are flushed. Log-structured merge trees use logs and compaction to turn random writes into sequential ones. Event sourcing is the same idea applied at the application layer.

The practical lesson for US teams planning storage in 2026 is not that every problem needs a log. It is that the log clarifies the question. If the workload is an ordered stream with multiple consumers, a log is a natural fit. If the workload is point reads and range scans over mutable rows, a B-tree may be simpler. If writes dominate and reads can tolerate layered indexes, an LSM design may be appropriate. The log is the primitive; the database is the policy layer around it.

Guardrails Before Production

A small store is not a database. Before trusting one with real data, add checksums, bound the log size, monitor replay time, and define what happens when sync fails. Use a single writer or a clear coordination scheme. Test recovery by killing the process mid-write. Test compaction by interrupting the rename. The goal is not to rebuild PostgreSQL in an afternoon. The goal is to stop treating durability as a property that appears by accident.

That is the value of tracing the database down to one file that only grows. The log is simple enough to reason about and powerful enough to explain much of the storage stack. Once that model is clear, tool selection becomes less about brand names and more about the shape of the truth being recorded.

All Articles

Related Articles

Tokens, Trees, and Scope: Building a Small Interpreter to Understand Languages

Tokens, Trees, and Scope: Building a Small Interpreter to Understand Languages

Before the Abuse Arrives: A Rate-Limiter Ladder From Naive Counters to Token Buckets

Before the Abuse Arrives: A Rate-Limiter Ladder From Naive Counters to Token Buckets

Before the Next Feature: A Legacy JavaScript Audit for Teams That Inherited Speed

Before the Next Feature: A Legacy JavaScript Audit for Teams That Inherited Speed