GUIDE · Projects

Industry-coding project skeletons: in-memory DB + banking system

lesson3 sectionsruns in browser36 asserts224 lines

At a glance

SHAPE
one class, one Map as the source of truth, records are small objects you can ADD FIELDS TO later, tiny private helpers, every public method short. New levels arrive as OPTIONAL trailing params or NEW methods; existing signatures never change, so level-1 tests keep passing.
TIME
timestamp is PASSED IN. Never read Date.now() inside. Tests are deterministic and timestamps are strictly increasing between calls.
RETURNS
match the spec EXACTLY. These tasks love '' / 'true' / 'false' / 'account1(500), account2(300)' string formats and null-vs-'' details.
SORT
"by X desc, ties by id asc" -> (a, b) => b.x - a.x || a.id.localeCompare(b.id)
SAY
single source of truthbackward compatibleencapsulatedeterministiclazy expiry on readsnapshot/restore

The idea#

Picture a school office with a wall of lockers and a cash box on the counter. The clock is the time written on the board. A kid hands you a numbered slip, and that number is the time.

Both interview builds share one shape. One class. One map as the source of truth. Records are small objects you can add fields to later. A new level arrives as an optional trailing parameter or a new method. Old signatures stay, so level 1 tests still pass.

Time is an argument named timestamp. You never call Date.now() inside the class. The tests are deterministic and the timestamps increase from call to call. Returns match the spec: '', 'true', 'false', strings like account1(500), and null versus an empty string. A rank of X descending with id ascending compares as (a, b) => b.x - a.x || a.id.localeCompare(b.id).

The locker wall is an in-memory database: a key, then a field, then a value, plus an expiry and a way to photograph the wall and hang it back up later. The cash box is a bank: accounts, money moving, a ranking of who has sent the most, payments that wait until a due time, and a merge that keeps the old account id usable.

Why it matters#

The spec grows by level, and the old signatures stay. Level 1 is the basic reads and writes. Later levels add a scan or a ranking, then time, then a snapshot or a merge. Optional trailing parameters are how a method grows. Rewriting a level 1 parameter list fails the old tests.

A call to the real clock makes two runs disagree. Compare against the timestamp you were handed. A stored time less than or equal to that number is due.

An expired field is invisible on every read. One helper decides that from the timestamp, and a backup stores remaining lifetime so a later restore can rebuild the expiry.

Misses return the spec's miss value. A missing account returns null from a deposit. A missing field returns '' from a get, unless that spec says null. The sort and its tie-break are part of the answer.

A small example#

JS
class TinyStore {  #records = new Map();   set(key, field, value, timestamp = 0) {    if (!this.#records.has(key)) this.#records.set(key, new Map());    this.#records.get(key).set(field, { value, writtenAt: timestamp });    return '';  }   get(key, field) {    const cell = this.#records.get(key)?.get(field);    return cell ? cell.value : '';  }} const store = new TinyStore();store.set('user', 'name', 'Ada', 10);const name = store.get('user', 'name');

name is 'Ada', and a missing field comes back as ''. The sheet's database uses this nesting, key to field to cell, and puts an expiry on the cell.

What the code below is doing#

The database is one class. #records maps a key to a field map, and each field maps to { value, expiresAt }. expiresAt === null means the field never expires. Otherwise it expires at that timestamp, and a read at exactly expiresAt is already dead. #backups is a list of { at, snapshot } in time order.

Two helpers carry the rules. alive is "the cell exists and has not expired at now." liveFields returns the living pairs for a key. Level 1 is set, get, and delete. set grows an optional ttl: the expiry is timestamp + ttl, or null when no ttl was passed. set returns ''. get returns the value, or '' on a miss or an expiry. delete removes the field even if it is already dead, returns the string 'true' only when it was alive, and drops the key when its field map is empty.

Level 2 lists living fields as field(value), comma-separated and sorted by field name. scan uses an empty prefix. A missing key scans to ''. Level 3 is the optional ttl checked on each read. Level 4 stores remaining ttl, expiresAt - timestamp, and returns the count of non-empty records as a string. Restore uses the latest backup at or before the requested time, or '' when none exists, and rebuilds each expiry as that restore timestamp plus the remaining ttl.

The bank's #accounts map holds { balance, outgoing, createdAt, mergedInto }. #payments holds { from, to, amount, cashbackPct, dueAt, status }, with ids like payment1. #acct follows mergedInto so an old id still reaches the survivor.

Level 1 creates an account at balance 0 and returns false if the id exists. Deposit and transfer return the source balance, or null on a missing account, a non-positive amount, a transfer to the same account after merges, or insufficient funds. A transfer adds to outgoing. Level 2 ranks spenders who have not been merged away, outgoing descending, id ascending, formatted id(outgoing).

Level 3 schedules a payment and moves the money when it comes due. Every timestamped operation first runs processScheduledPayments, ordered by due time, then id. If the sender can pay, move the amount, count it as outgoing, add floor(amount * percent / 100) back at once, and mark it processed. Otherwise mark it failed. Status is null unless the caller is the payer.

Level 4 adds balance and outgoing onto the survivor, zeros the other account, and sets mergedInto. The old id stays, and the ranking skips it.

Before you submit, match every return, including '' versus null versus 'false', and the sort's tie-break. Expired rows stay invisible. A missing id returns the miss value. Earlier levels still pass. Due work runs at the top of any method that takes a timestamp.

When you talk about it, say this: one map is the source of truth, new levels add optional parameters or new methods, the timestamp is passed in, and expiry is decided lazily on read.

Everything below is the demonstration. Same idea, written as code you can run.

JSjavascript/ica-project-skeletons.js · setup
'use strict';const assert = require('node:assert/strict');
01

A. IN-MEMORY DATABASE (records -> fields -> values; TTL; backup/restore)#

JS
class InMemoryDB {  #records = new Map();                 // key -> Map<field, { value, expiresAt|null }>  #backups = [];                        // [{ at, snapshot }] in time order   // ── helpers ──  #alive(cell, now) { return cell !== undefined && (cell.expiresAt === null || cell.expiresAt > now); }  #liveFields(key, now) {    const rec = this.#records.get(key);    if (!rec) return [];    return [...rec].filter(([, cell]) => this.#alive(cell, now));  }   // ── level 1: set / get / delete ──  set(key, field, value, timestamp = 0, ttl = null) {        // ttl added at level 3 as an optional param    if (!this.#records.has(key)) this.#records.set(key, new Map());    this.#records.get(key).set(field, { value, expiresAt: ttl === null ? null : timestamp + ttl });    return '';  }  get(key, field, timestamp = 0) {    const cell = this.#records.get(key)?.get(field);    return this.#alive(cell, timestamp) ? cell.value : '';   // spec said '' on miss (sometimes null — read it!)  }  delete(key, field, timestamp = 0) {    const rec = this.#records.get(key);    const existed = this.#alive(rec?.get(field), timestamp);    rec?.delete(field);    if (rec && rec.size === 0) this.#records.delete(key);    return existed ? 'true' : 'false';  }   // ── level 2: scan / scanByPrefix  ->  "field1(value1), field2(value2)" sorted by field ──  scan(key, timestamp = 0) { return this.scanByPrefix(key, '', timestamp); }  scanByPrefix(key, prefix, timestamp = 0) {    return this.#liveFields(key, timestamp)      .filter(([f]) => f.startsWith(prefix))      .sort(([a], [b]) => a.localeCompare(b))      .map(([f, cell]) => `${f}(${cell.value})`)      .join(', ');  }   // ── level 3: TTL — handled by the optional `ttl` on set() and `timestamp` on reads (lazy expiry) ──   // ── level 4: backup / restore ──  backup(timestamp) {    const snapshot = [];    for (const [key] of this.#records) {      const fields = this.#liveFields(key, timestamp).map(([f, cell]) =>        [f, { value: cell.value, remainingTtl: cell.expiresAt === null ? null : cell.expiresAt - timestamp }]);      if (fields.length) snapshot.push([key, fields]);    }    this.#backups.push({ at: timestamp, snapshot });    return String(snapshot.length);                          // number of non-empty records  }  restore(timestamp, restoreFrom) {    const b = [...this.#backups].reverse().find(x => x.at <= restoreFrom);   // latest at or before    if (!b) return '';    this.#records = new Map(b.snapshot.map(([key, fields]) => [      key,      new Map(fields.map(([f, s]) => [f, { value: s.value, expiresAt: s.remainingTtl === null ? null : timestamp + s.remainingTtl }])),    ]));    return '';  }}{  const db = new InMemoryDB();  db.set('A', 'CA', 'G'); db.set('A', 'D', 'H');  // TTL  db.set('T', 'x', '1', 100, 50);                            // alive for [100, 150)  // backup/restore  db.set('T', 'y', '2', 120, 30);                            // alive [120, 150)  db.delete('A', 'C', 131);}
Verified by 17 asserts
JS
  assert.equal(db.set('A', 'B', 'E'), '');  assert.equal(db.set('A', 'C', 'F'), '');  assert.equal(db.get('A', 'B'), 'E');  assert.equal(db.get('A', 'D'), '');  assert.equal(db.delete('A', 'B'), 'true');  assert.equal(db.delete('A', 'D'), 'false');  assert.equal(db.scan('A'), 'C(F)');  assert.equal(db.scanByPrefix('A', 'C'), 'C(F), CA(G)');  assert.equal(db.scan('missing'), '');  assert.equal(db.get('T', 'x', 149), '1');  assert.equal(db.get('T', 'x', 150), '');  assert.equal(db.delete('T', 'x', 150), 'false');  assert.equal(db.backup(130), '2');                         // A and T (T.y has 20 left; T.x has 20 left)  assert.equal(db.restore(200, 135), '');  assert.equal(db.get('A', 'C', 200), 'F');  assert.equal(db.get('T', 'y', 219), '2');                  // 20 remaining from 200  assert.equal(db.get('T', 'y', 220), '');
02

B. BANKING SYSTEM (accounts, deposit/transfer, top spenders, scheduled payments w/ cashback, merge)#

JS
class Bank {  #accounts = new Map();                // id -> { balance, outgoing, createdAt, mergedInto }  #payments = new Map();                // paymentId -> { from, to, amount, cashbackPct, dueAt, status }  #paymentSeq = 0;   #acct(id) {                           // follow merges so old ids keep working    let a = this.#accounts.get(id);    while (a?.mergedInto) a = this.#accounts.get(a.mergedInto);    return a ?? null;  }   // ── level 1 ──  createAccount(id, timestamp) {    if (this.#accounts.has(id)) return false;    this.#accounts.set(id, { id, balance: 0, outgoing: 0, createdAt: timestamp, mergedInto: null });    return true;  }  deposit(id, timestamp, amount) {    this.processScheduledPayments(timestamp);                // every op first settles what's due (level 3)    const a = this.#acct(id);    if (!a || amount <= 0) return null;    a.balance += amount;    return a.balance;  }  transfer(fromId, toId, timestamp, amount) {    this.processScheduledPayments(timestamp);    const from = this.#acct(fromId), to = this.#acct(toId);    if (!from || !to || from === to || amount <= 0 || from.balance < amount) return null;    from.balance -= amount; from.outgoing += amount;    to.balance += amount;    return from.balance;  }   // ── level 2: ranking ──  topSpenders(timestamp, n) {    this.processScheduledPayments(timestamp);    return [...this.#accounts.values()]      .filter(a => !a.mergedInto)      .sort((a, b) => b.outgoing - a.outgoing || a.id.localeCompare(b.id))      .slice(0, n)      .map(a => `${a.id}(${a.outgoing})`);  }   // ── level 3: scheduled payments with cashback ──  schedulePayment(fromId, toId, timestamp, amount, cashbackPct, delay = 0) {    this.processScheduledPayments(timestamp);    if (!this.#acct(fromId) || !this.#acct(toId)) return null;    const paymentId = `payment${++this.#paymentSeq}`;    this.#payments.set(paymentId, { from: fromId, to: toId, amount, cashbackPct, dueAt: timestamp + delay, status: 'SCHEDULED' });    return paymentId;  }  processScheduledPayments(now) {                            // idempotent; call at the top of every op    const due = [...this.#payments.entries()]      .filter(([, p]) => p.status === 'SCHEDULED' && p.dueAt <= now)      .sort(([ia, a], [ib, b]) => a.dueAt - b.dueAt || ia.localeCompare(ib));   // deterministic order    for (const [, p] of due) {      const from = this.#acct(p.from), to = this.#acct(p.to);      if (from && to && from.balance >= p.amount) {        from.balance -= p.amount; from.outgoing += p.amount; to.balance += p.amount;        from.balance += Math.floor(p.amount * p.cashbackPct / 100);        p.status = 'PROCESSED';      } else p.status = 'FAILED';    }  }  getPaymentStatus(accountId, timestamp, paymentId) {    this.processScheduledPayments(timestamp);    const p = this.#payments.get(paymentId);    return p && this.#acct(p.from) === this.#acct(accountId) ? p.status : null;  }   // ── level 4: merge ──  mergeAccounts(id1, id2, timestamp) {    this.processScheduledPayments(timestamp);    const a = this.#acct(id1), b = this.#acct(id2);    if (!a || !b || a === b) return false;    a.balance += b.balance; a.outgoing += b.outgoing;    b.balance = 0; b.outgoing = 0; b.mergedInto = a.id;      // keep b's id resolvable -> history stays valid    return true;  }}{  const bank = new Bank();  const pid = bank.schedulePayment('acc1', 'acc2', 10, 100, 10, 5);   // due at 15, 10% cashback}
Verified by 19 asserts
JS
  assert.equal(bank.createAccount('acc1', 1), true);  assert.equal(bank.createAccount('acc1', 2), false);  assert.equal(bank.createAccount('acc2', 3), true);  assert.equal(bank.deposit('acc1', 4, 1000), 1000);  assert.equal(bank.deposit('nope', 5, 10), null);  assert.equal(bank.transfer('acc1', 'acc2', 6, 300), 700);  assert.equal(bank.transfer('acc1', 'acc2', 7, 5000), null);  assert.equal(bank.transfer('acc1', 'acc1', 8, 1), null);  assert.deepEqual(bank.topSpenders(9, 5), ['acc1(300)', 'acc2(0)']);  assert.equal(pid, 'payment1');  assert.equal(bank.getPaymentStatus('acc1', 11, pid), 'SCHEDULED');  assert.equal(bank.getPaymentStatus('acc2', 11, pid), null);          // not the payer  assert.equal(bank.deposit('acc1', 15, 0), null);                      // triggers processing even though it fails  assert.equal(bank.getPaymentStatus('acc1', 16, pid), 'PROCESSED');  assert.equal(bank.deposit('acc1', 17, 1), 700 - 100 + 10 + 1);       // cashback landed  assert.deepEqual(bank.topSpenders(18, 1), ['acc1(400)']);  assert.equal(bank.mergeAccounts('acc1', 'acc2', 19), true);  assert.equal(bank.deposit('acc2', 20, 1), 611 + 400 + 1);             // acc2 now resolves to acc1  assert.deepEqual(bank.topSpenders(21, 5), ['acc1(400)']);
03

C. CHECKLIST before you submit a level#

JS
// [ ] every return matches the spec's TYPE and FORMAT ('' vs null vs 'false')// [ ] sort order + tie-breaker exactly as written// [ ] expired things are invisible to EVERY read path (get/scan/count/backup)// [ ] operations on missing ids/keys return the specified "miss" value, never throw// [ ] earlier-level tests still pass (run them again — you changed shared code)// [ ] processScheduled/expiry runs at the top of every op that takes a timestamp