One key-value store, ten modules
Across ten modules you build one real system, a key-value store, one part at a time, the way real requirements arrive. Each module extends what the last one built. No agents, no autocomplete, just you, the editor, and the tests.
Before writing a single challenge, I read your practice history. Your last session was August 12: four TypeScript challenges, all four eventually green. You pressed “I'm stuck” eight times and revealed two solutions. Forty-four days later, recall on what you solved has decayed to about 23%.
You knew these, and they've faded. They get short drills with no re-explaining.
These never landed. They get re-taught from a different angle before the test.
Bars show recall today. Module 1 goes after api-boundaries, from a new angle.
The order follows how real systems usually grow: core operations, then time, then ordering, then undo, then concurrency. Your shaky skills (async coordination, debouncing, listener hygiene) sit in modules 7–9, once the basics are fluent again. I write each module after reading how the last one went.
45 minutes per challenge, with no autocomplete or agents. That includes the editor's own suggestions: read them if they appear, but don't lean on them.
After about 15 minutes with no progress, press “I'm stuck”. I read your actual attempts and give the smallest hint that unblocks you. You don't need to paste anything.
The ledger rewards solving it cold. Think, write, then run. Running after every line teaches the tests, not the skill.
Last time, api-boundaries and abstraction-design didn't land: two challenges ended in a reveal. The explanation then was about layering, meaning which function calls which. This time we come at it from the other side: what must always be true about your data, and who is allowed to break it.
Our store holds string keys and string values. It also answers count(value): how many keys hold this value right now? You could loop over every key each time, but on a big store you'd keep a second map, a value index, from value to count. Now you have two structures that must agree:
The invariant: for every value v, counts.get(v) equals the number of keys whose value is v. It holds after every single operation, not just usually.
That sentence is the design. A boundary is the small set of places allowed to change state. The fewer there are, the fewer places you have to check the promise. If set and delete are the only ways in, you verify the invariant in two functions and you're done. If a caller can reach in and touch the map directly, you can't promise anything.
Step through a correct store. Watch what each operation has to do to keep both tables agreeing. Three of these steps are the ones people forget.
Validation is the boundary. If input gets checked after you've started changing state, a bad input leaves you half-changed: three keys written, the fourth rejected, and the index now reflects a batch that half happened. The rule is check everything, then change anything. Parse and stage first, then commit in one pass that can't fail.
If " a " and "a" are meant to be the same key, normalisation has to happen at every entry point: set, get, delete, and bulk writes. Normalise in three of the four and you've created a key that can be written but never read back. One normaliseKey function called by every public method is how you make that impossible, rather than just unlikely.
Once every write goes through one path and every read goes through one path, module 2's expiry turns into a change to the read path plus an injected clock. That's the payoff for being strict now.
Check · Why does set("a", "x") on a key that already holds "x" need special care?
Because a naive “decrement old, increment new” is fine, but a naive “always increment new” counts "x" twice. Either compare first and return early, or always undo the old value before applying the new one. Both keep the promise.
A teammate shipped this store, and dashboards built on count() slowly drift away from reality. Find where the invariant breaks and fix it. Every test must pass, and count, size and get must agree after any sequence of operations.
Budget · 20 minutes · aim for one runBulk imports are where stores get corrupted: half the batch lands, then row 37 turns out to be bad. Do challenge 1 first, because this starter contains its fix. Then extend the store:
Tomorrow's cold rewrite: delete everything and rebuild challenge 2's solution from a blank file in under 20 minutes. Then tell me you're done. I'll read the ledger and write module 2, expiry with an injected clock, based on how these two actually went.