An in-memory key-value database engine built from scratch in TypeScript. Redis-compatible on the wire, plus one thing Redis doesn't have: an automatic cache invalidation graph.
Declare that a cached key is derived from other keys, and strata deletes it for you whenever any source changes — transitively. Redis has no concept of a relationship between keys, so that bookkeeping normally lives in application code, where forgetting one line means serving stale data silently.
SET user:1 alice
SET cache:profile:1 "<rendered html>"
DEPEND cache:profile:1 user:1
SET user:1 bob → cache:profile:1 is gone, automatically
- Quick start
- What this is and isn't
- Architecture
- How each piece works
- The cache dependency graph
- Command reference
- Configuration
- Tests
- Project layout
- Design decisions
- Known limits
- Next steps
Requires Node 18+.
npm installThen three terminals, all from the project root:
npm run dev:backend # the engine → :6380 (RESP) + :8080 (dashboard API)
npm run dev:frontend # the dashboard → :5173
node scripts/seed.js # sample data (exits when done)Open http://localhost:5173. Click cache:profile:1 to see its place in the dependency graph.
The third terminal is now free for the CLI:
npm run dev:cliBecause strata speaks real RESP, the actual redis-cli works too:
redis-cli -p 6380 SET user:1 aliceIn the dashboard Console tab (one line at a time):
EXISTS cache:profile:1 cache:feed:1 → 2
SET user:1 bob
EXISTS cache:profile:1 cache:feed:1 → 0
Both caches vanished from a single write, transitively.
It is a working in-memory database: RESP protocol, five data types, expiry, persistence, pub/sub, ~57 commands, a CLI, and a web dashboard. Real Redis clients connect to it.
It isn't production infrastructure. No transactions, clustering, replication, or TLS. Written in TypeScript rather than C. Don't put real user data in it or expose port 6380 publicly.
The point is understanding how a database works from the socket up — and then adding something genuinely new on top.
Three separate programs that agree on protocols, not on code:
┌──────────────┐ RESP over TCP ┌─────────────────────────┐
│ CLI client │ ─────────────────► │ │
└──────────────┘ :6380 │ │
│ strata engine │
┌──────────────┐ │ │
│ redis-cli / │ ─────────────────► │ ┌─────────────────┐ │
│ any client │ :6380 │ │ Store (Map) │ │
└──────────────┘ │ │ + dep graph │ │
│ └─────────────────┘ │
┌──────────────┐ HTTP + WebSocket │ │ │
│ Dashboard │ ◄────────────────► │ ▼ │
│ (React) │ :8080 │ AOF log file │
└──────────────┘ └─────────────────────────┘
Why two ports? Browsers can't open raw TCP sockets, so the dashboard can't speak RESP. Database clients get RESP on 6380; the dashboard gets a JSON HTTP API plus a WebSocket on 8080. Same engine, two front doors.
Why 6380? One above Redis's default 6379, so you can run both side by side and compare behaviour.
Everything else in this repo is a way of talking to this one class.
Keyspace. A Map<string, Entry>. O(1) average get/set/delete — the same complexity class as the hash table real Redis uses. A million keys is as fast as ten.
Five types, matching Redis: string, list, hash, set, zset. Each entry is tagged with its type, so wrong-type operations (LPUSH on a string) fail loudly with WRONGTYPE instead of corrupting data.
Expiry uses two strategies at once. Neither works alone:
- Lazy — when a key is read, check if it's expired and delete it then. Cheap, but a key nobody reads would sit in RAM forever.
- Active — a background timer samples ~20 keys every 200ms and evicts expired ones. This is Redis's
activeExpireCyclein miniature. Sampling rather than full-scanning is what keeps it from stalling on a large keyspace.
Lazy alone leaks memory. Active alone can't guarantee an expired key is never returned. Together they cover both.
Every mutation calls touch(), which cascades invalidation through the dependency graph. Doing it in the store rather than in each command handler means the feature can't be forgotten when someone adds a new command.
RESP is how Redis clients and servers talk. It's deliberately trivial to parse, which is part of why Redis is fast. The first byte tells you the type:
| Byte | Type | Example |
|---|---|---|
+ |
Simple string | +OK\r\n |
- |
Error | -ERR unknown command\r\n |
: |
Integer | :42\r\n |
$ |
Bulk string | $5\r\nhello\r\n |
* |
Array | *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n |
A command is always an array of bulk strings. SET foo bar goes over the wire as:
*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
Bulk strings are length-prefixed ($5 = exactly 5 bytes follow), which is why values can contain spaces, newlines, or binary data without ambiguity.
The parser is streaming, and this is the subtle part. TCP is a byte stream, not a message stream. One socket.on("data") event may contain half a command, or three commands at once. So the parser buffers incoming bytes and only yields a command once it has fully arrived, returning null otherwise. The server drains in a while loop for exactly this reason.
Assuming "one read = one command" is the classic way to write a protocol server that passes every test and breaks under real load.
A sorted array gives fast lookup via binary search, but O(n) insert — everything after the insertion point shifts. A balanced tree fixes that but needs rotation logic that's genuinely hard to get right.
A skip list is the clever middle path, and it's what real Redis uses.
Keep a normal sorted linked list at level 0. Build sparser "express lane" lists above it: each node is promoted upward with probability 1/4, so level 1 holds ~1/4 of nodes, level 2 ~1/16.
To search, start top-left and move right while the next node is still too small; when it would overshoot, drop down a level. You skip huge spans near the top and narrow in as you descend — binary search over a linked list.
The trick is that heights are random rather than rebalanced. No rotations, no rebalancing code, and the probabilistic guarantee is strong enough that Redis ships it. Expected O(log n) insert, delete, and rank.
Each node also stores a span (how many level-0 nodes its forward pointer jumps over), which is what makes ZRANK O(log n) instead of a linear count.
A flat map of command name to handler. Real Redis uses the same pattern internally. Handlers take (store, args) and are deliberately connection-unaware, so adding a command is a few lines that touch nothing else.
Arity is checked before the handler runs, so handlers can assume valid input.
Subscriptions break the assumption everything else relies on. Every normal command is request/response and connection-agnostic — GET doesn't care who's asking. Pub/sub must remember which socket listens to which channel, and push messages to sockets that didn't ask for anything right now.
That's why it lives outside the command table: the TCP server intercepts these commands before dispatch so it can pass the connection in.
A subscriber is anything with a send(), which is why the dashboard's WebSocket can subscribe through the same broker.
Two details that are easy to miss:
- Subscribers must be removed on disconnect, or the broker holds a reference to every client that ever connected — a leak that only appears under real traffic.
- A subscribed connection is restricted to pub/sub commands, matching Redis.
AOF = Append-Only File. Every write command is appended as it happens. On startup the log is replayed to rebuild identical state.
Disk is off the read path entirely. Reads never touch disk — that's why persistence doesn't slow the engine down. The AOF grows, but the Map doesn't get slower.
Two details worth understanding:
Commands are RESP-encoded, not space-separated. The naive "join with spaces" format silently corrupts any value containing a space: SET msg "hello world" would replay as SET msg hello, losing data. RESP is length-prefixed, so values can contain anything.
rewrite() compacts the log. Without it the file grows forever — a key written a million times keeps a million entries though only the last matters. Compaction regenerates the minimum command set from live state, writes to a temp file, and renames. Rename is atomic on POSIX, so a crash mid-rewrite leaves the old log intact rather than a half-written one.
The TCP server gives each connection its own parser but shares one Store — that shared store is what makes this a database rather than per-connection scratch space.
The API server exposes JSON endpoints for the dashboard, plus a WebSocket that pushes stats every second.
Two transports on purpose: stats arrive over WebSocket because they change every tick; the key list is polled every 2 seconds because it rarely changes. Not every piece of state needs the same delivery mechanism.
Cache invalidation is famously one of the two hard problems in computer science.
You store a user in user:1. Rendering their profile page is slow, so you cache the finished HTML in cache:profile:1.
The user changes their name. Redis has no idea those two keys are related. Your application must remember to delete the cache — in the settings handler, the admin panel, the migration script, the webhook. Miss one path and you serve stale data silently, sometimes for days, with no error anywhere.
Declare the relationship once, where the cache is built:
SET user:1 alice
SET cache:profile:1 "<rendered html>"
DEPEND cache:profile:1 user:1
From then on, any write to user:1 invalidates cache:profile:1 automatically.
Cascading works transitively, so a chain collapses in one write:
DEPEND cache:feed:1 cache:profile:1
SET user:1 carol # invalidates BOTH profile and feed
async function getProfile(userId) {
const cacheKey = `cache:profile:${userId}`;
const cached = await nova.get(cacheKey);
if (cached) return cached; // hit
const html = await renderProfile(userId); // slow path
await nova.set(cacheKey, html);
await nova.depend(cacheKey, [`user:${userId}`, `user:${userId}:settings`]);
return html;
}Then anywhere that updates a user:
await nova.set(`user:${userId}`, JSON.stringify(newData));
// cache:profile:N is already gone. No cleanup code needed.Declaring the dependency at cache-write time is deliberate: the code that builds the cache is the code that knows what it read.
The graph stores key names, not values — two adjacency maps of short strings. Thousands of relationships cost a few hundred KB, and the write path does one map lookup that returns empty for keys with no dependents.
This is a deliberate contrast with value-snapshot approaches (an earlier version of this project did per-write snapshots for rollback), whose cost scales with the size of the data itself.
Events publish to the reserved channel __strata:invalidate, so applications can warm a new value or record a metric without polling:
SUBSCRIBE __strata:invalidate
| Setting | Value |
|---|---|
| Build Command | npm install && npm run build -w backend |
| Start Command | node backend/dist/index.js |
Environment variables:
| Key | Value |
|---|---|
strata_PASSWORD |
a strong password — the service is public |
strata_DATA_DIR |
/tmp/strata |
The server reads PORT first, which is how Render (and most PaaS hosts)
assign a port.
Two constraints worth knowing. A Render web service exposes exactly one
HTTP port, so the dashboard API works but the RESP/TCP port is unreachable —
redis-cli can't connect to a deployed instance. And the free tier has an
ephemeral filesystem that's wiped on redeploy, and sleeps after ~15 minutes
idle, so a cold first request takes ~30 seconds and starts with an empty
keyspace.
| Setting | Value |
|---|---|
| Build Command | npm install && npm run build -w frontend |
| Publish Directory | frontend/dist |
VITE_API_BASE |
https://your-backend.onrender.com |
VITE_API_BASE is only needed in production. Locally, Vite's dev proxy
forwards /api to port 8080, so leave it unset. See frontend/.env.example.
Strings
SET (EX/PX/NX/XX/KEEPTTL) · GET · MGET · MSET · SETEX · INCR · DECR · INCRBY · DECRBY · APPEND · STRLEN
Generic
DEL · EXISTS · TYPE · KEYS · RENAME · EXPIRE · PERSIST · TTL · DBSIZE · FLUSHALL · INFO · COMMAND · PING · ECHO
Lists
LPUSH · RPUSH · LPOP · RPOP · LLEN · LRANGE
Hashes
HSET · HGET · HDEL · HEXISTS · HKEYS · HVALS · HLEN · HGETALL
Sets
SADD · SREM · SISMEMBER · SCARD · SMEMBERS
Sorted sets
ZADD · ZREM · ZSCORE · ZRANK · ZCARD · ZRANGE (WITHSCORES) · ZRANGEBYSCORE
Pub/Sub
SUBSCRIBE · PSUBSCRIBE · UNSUBSCRIBE · PUBLISH · PUBSUB CHANNELS
Dependency graph
| Command | Does |
|---|---|
DEPEND <derived> <source...> |
Declare what a cached key is built from |
UNDEPEND <key> |
Remove a key from the graph entirely |
DEPS <key> |
Keys invalidated when this one changes |
DEPENDSON <key> |
What this key was built from |
CASCADE <key> |
Preview the full blast radius, without deleting |
INVALIDATE <key> |
Delete a key and everything downstream |
GRAPH |
Every declared edge |
Auth
AUTH — only when strata_PASSWORD is set.
Values containing spaces need quotes:
SET msg "hello world"
| Variable | Default | Purpose |
|---|---|---|
strata_PORT |
6380 |
RESP/TCP port |
strata_API_PORT |
8080 |
Dashboard HTTP + WebSocket port |
strata_DATA_DIR |
./data |
Where the AOF log lives |
strata_PASSWORD |
unset | If set, clients must AUTH first |
cd backend
npm testWorks on Windows, macOS, and Linux — the runner is plain Node, with no shell script or /tmp assumptions.
Five suites, all driving a real server over a real socket rather than reaching into internals:
| Suite | Covers |
|---|---|
commands.test.js |
Every command, error cases, expiry |
pubsub.test.js |
Delivery, patterns, subscribe-mode restriction, cleanup |
depgraph.test.js |
Cascade, transitivity, cycle safety, manual invalidation |
depgraph-persist.test.js |
The graph survives a restart and still fires |
persistence.test.js |
Kills and restarts the server; all five types survive |
skiplist.test.ts |
200 randomized trials against a naive reference, including rank/span |
strata/
├── backend/
│ ├── src/
│ │ ├── core/store.ts the database
│ │ ├── core/skiplist.ts O(log n) sorted sets
│ │ ├── core/depgraph.ts dependency / invalidation graph
│ │ ├── protocol/resp.ts RESP encoder + streaming parser
│ │ ├── commands/index.ts command table (~57 commands)
│ │ ├── pubsub/pubsub.ts channel + pattern broker
│ │ ├── persistence/aof.ts append-only log + compaction
│ │ ├── server/tcpServer.ts RESP server :6380
│ │ ├── server/apiServer.ts HTTP + WS API :8080
│ │ └── index.ts startup, replay, shutdown
│ └── tests/ five suites + cross-platform runner
├── cli/src/index.ts redis-cli-style REPL
├── scripts/seed.js sample data loader
├── docs/how-it-works.md one command traced end to end
└── frontend/src/
├── api/client.ts typed HTTP client
├── hooks/useLiveStats.ts WebSocket + REST hybrid
└── components/ Sidebar, KeyDetail, DependencyView, CommandConsole
These are the parts worth being able to defend.
Invalidation deletes rather than recomputes. The engine has no idea how a cache was built; only the application does. Deleting turns a correctness problem (stale data served silently) into a cache miss the application already handles.
Cycles are survivable.
Users will accidentally declare a → b → a. Cascade is breadth-first with a visited set, so it terminates. DEPEND replies OK CYCLE n to warn without failing.
Edges outlive invalidation.
A cache that gets invalidated will be rebuilt, so it keeps its declared sources. Only UNDEPEND removes them.
DEPEND is written to the AOF.
Otherwise the graph would be empty after a reboot and invalidation would silently stop working — the worst kind of bug.
Replay suppresses invalidation. Rebuilding from the log would otherwise invalidate caches the log is about to recreate, so state would come back wrong — and differently depending on log order.
AOF replay bypasses the TCP server. Replay calls the dispatcher directly. Route it through the normal server path and every restart re-appends the whole log to itself; the file doubles each boot.
Only successful writes are logged. Logging a failed command would corrupt state on replay, since it would be re-applied at startup as though it had worked.
Empty collections are deleted.
Popping the last element of a list removes the key, matching Redis. Keeping an empty key would make EXISTS disagree with LLEN.
Worth stating before someone finds them:
- No transactions —
MULTI/EXEC/WATCHare unimplemented. - AOF rewrite is synchronous. Redis forks a background process; this blocks briefly during compaction.
- No replication or clustering. Single node only.
- No
maxmemoryeviction. At capacity this would fill RAM until Node crashed. Redis fails cleanly instead of letting the OS swap. - AUTH is plaintext and there's no TLS.
- Single-threaded, like real Redis — but without Redis's I/O threading for network reads.
In-memory databases trade capacity for speed. When RAM fills, the OS starts swapping and performance collapses — worse than a disk-based database, which is at least designed for disk access patterns.
That's why Redis isn't a replacement for Postgres. It sits in front of one, holding the small hot slice you touch constantly: sessions, counters, leaderboards, cached renders. If a cache entry is lost, you re-fetch from the source. Slower once, but nothing breaks.
maxmemorywith LRU eviction — a real answer for what happens at capacity. Self-contained, and forces you to think about access-frequency tracking.MULTI/EXEC/WATCHtransactions — forces per-connection command queues, the same architectural problem pub/sub already made you solve once.- Background AOF rewrite via a worker thread or child process.
- Secondary indexes —
FIND users WHERE age > 30, which Redis genuinely can't do. The natural second differentiator. - Replication — a second node that replays the primary's command stream. Pub/sub is already most of the transport you'd need.