1. The Problem
Build a key-value store where every set and delete is appended to a log. On startup, the store rebuilds its state by replaying that log from the beginning — so data survives a restart or crash.
Why this project? The append-only log + replay is how real databases (Redis AOF, Postgres WAL, Kafka) achieve durability and crash recovery. It is one of the most important ideas in systems design.
2. How to Think About This
Two things: an in-memory map for fast reads, and an append-only log of every write. set/delete update the map AND append an operation record to the log. On start, replay reads the whole log in order and re-applies each operation, reconstructing the exact final state. (Here the log is an in-memory array so it runs in the browser; a real store appends lines to a file — the replay logic is byte-for-byte the same.)
3. The Build
export class KVStore {
constructor(log = []) {
this.log = log; // append-only log of operations
this.data = new Map();
this._replay();
}
// Rebuild state by replaying every logged operation in order.
_replay() {
for (const op of this.log) {
if (op.type === "set") this.data.set(op.key, op.value);
else if (op.type === "delete") this.data.delete(op.key);
}
}
set(key, value) {
this.data.set(key, value);
this.log.push({ type: "set", key, value }); // durable append
}
get(key) {
return this.data.has(key) ? this.data.get(key) : null;
}
delete(key) {
this.data.delete(key);
this.log.push({ type: "delete", key });
}
}
// Demo: writes go to the log...
const store = new KVStore();
store.set("name", "Alice");
store.set("city", "Mumbai");
store.delete("city");
// ...and a fresh store replaying that same log rebuilds the exact state.
const recovered = new KVStore(store.log);
console.log("name:", recovered.get("name")); // Alice (survived "restart")
console.log("city:", recovered.get("city")); // null (was deleted)
4. Test & Prove
import { KVStore } from "./kvstore.mjs";
let pass = 0, fail = 0;
function test(desc, fn) {
try { fn(); console.log(" \u2713", desc); pass++; }
catch (e) { console.log(" \u2717", desc, "\u2014", e.message); fail++; }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || "assertion failed"); }
test("set then get returns the value", () => {
const s = new KVStore();
s.set("a", 1);
assert(s.get("a") === 1);
});
test("get on a missing key returns null", () => {
assert(new KVStore().get("nope") === null);
});
test("delete removes a key", () => {
const s = new KVStore();
s.set("a", 1);
s.delete("a");
assert(s.get("a") === null);
});
test("state is rebuilt by replaying the log (crash recovery)", () => {
const s = new KVStore();
s.set("name", "Alice");
s.set("city", "Mumbai");
s.delete("city");
const recovered = new KVStore(s.log); // fresh store, same log
assert(recovered.get("name") === "Alice");
assert(recovered.get("city") === null);
});
test("the log records every write in order", () => {
const s = new KVStore();
s.set("a", 1);
s.delete("a");
assert(s.log.length === 2 && s.log[0].type === "set" && s.log[1].type === "delete");
});
console.log(`\n${pass} passed, ${fail} failed`);
5. The Interface
node kvstore.mjs name: Alice city: null
6. Run It
node kvstore.mjs
node kvstore.test.mjs🎯 Try this next
- Persist the log to a real file with node:fs (append lines, read on start)
- Add log compaction: rewrite the log keeping only the latest value per key
- Add a snapshot so replay does not have to start from the very beginning
- Add expiry (TTL) by logging an expire operation with a timestamp