All questions

Undoable Database II

Premium

Undoable Database II

Build an in-memory record database — think a table of users, each keyed by an id — that supports undo and redo the way a data grid does: a burst of edits is one Ctrl+Z step, not one per cell. The user pastes ten rows, retypes a name, deletes a row; that is many small mutations, but a single undo should roll back the whole burst. Your database keeps two layers: a draft that buffers uncommitted mutations, and a stack of committed checkpoints that undo and redo navigate between. Mutations land in the draft; a commit() is the moment you declare "this is one undoable unit" and freeze the whole table as a checkpoint.

Signature

class UndoableDatabase {
  constructor();

  insert(record): id;             // stage an insert in the draft; return the id
  update(id, patch): void;        // shallow-merge patch into the draft copy
  delete(id): void;               // stage a delete in the draft
  get(id): record | undefined;    // read the effective record (draft applied)
  getAll(): record[];             // read every effective record
  commit(): void;                 // snapshot the draft as ONE checkpoint
  undo(): void;                   // restore the previous committed table
  redo(): void;                   // restore the next committed table
  canUndo(): boolean;
  canRedo(): boolean;
}

insert uses record.id if you supply one, otherwise it assigns an auto-incrementing id; either way it returns the id it used. get/getAll always read the effective state — the committed table with the current draft applied on top — so uncommitted edits are visible immediately.

Examples

// Insert + update + delete, ONE commit → a single undo reverts the whole batch.
const db = new UndoableDatabase();
const a = db.insert({ name: 'Ada' });
const b = db.insert({ name: 'Bob' });
db.update(a, { role: 'admin' });
db.delete(b);              // all of this lives in the draft, no undo step yet
db.commit();               // the burst becomes ONE checkpoint

db.get(a);                 // → { id: a, name: 'Ada', role: 'admin' }
db.get(b);                 // → undefined
db.undo();                 // one step reverts insert A, insert B, update, delete
db.getAll();               // → []
db.canUndo();              // → false  (back at the empty start)
// A commit AFTER an undo invalidates the redo stack.
const db = new UndoableDatabase();
const a = db.insert({ name: 'Ada' }); db.commit();
const b = db.insert({ name: 'Bob' }); db.commit();

db.undo();                 // back to just { Ada }; B is redoable
db.canRedo();              // → true
db.insert({ name: 'Carol' });
db.commit();               // diverged: the B branch is discarded
db.canRedo();              // → false
db.get(b);                 // → undefined  (B is gone for good)

Notes

  • Batching is the whole point. Many insert/update/delete calls before a commit collapse into a single undoable step. None of them push a checkpoint on their own — only commit does, and one undo reverts the entire batch.
  • Draft vs. committed. get/getAll always read the draft (the effective current table). undo/redo operate on committed checkpoints: each restores the whole table to the checkpoint it lands on and rebuilds the draft from it. An uncommitted draft is not itself an undo step — commit it first if you want it preserved.
  • A divergent commit truncates the redo stack. After an undo, committing new work abandons the forward branch you stepped off of. You cannot redo into history you have diverged from.
  • Snapshots are independent. Each checkpoint is a value, not a shared reference. Mutating a record returned by get/getAll, or mutating one after a later commit, must not corrupt stored history.
  • A no-op commit adds nothing. If the draft is identical to the current checkpoint (nothing staged since the last commit), commit does not push a step, so undo never stops on a phantom checkpoint.
  • update is a shallow merge. update(id, { role: 'admin' }) overwrites only the role field and keeps the rest of the record; updating an id that doesn't exist in the draft is a no-op.
  • Boundaries are safe. undo at the oldest checkpoint and redo at the newest are no-ops that leave the table unchanged rather than throwing.
  • Don't worry about concurrent commits, querying by fields other than id, or persisting history across sessions — those are out of scope.

Unlock the solution & editor

  • Runnable editor + tests

    Solve in the browser with instant Jest feedback.

  • Detailed solutions

    Walkthroughs, edge cases, and complexity notes.

  • Multi-framework variants

    React, Vue, Vanilla, Angular — same question, different stacks.

Upgrade to Premium