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.
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.
// 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)
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.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.undo, committing new work abandons the forward branch you stepped off of. You cannot redo into history you have diverged from.get/getAll, or mutating one after a later commit, must not corrupt stored history.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.undo at the oldest checkpoint and redo at the newest are no-ops that leave the table unchanged rather than throwing.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.