// ───────────────────────────────────────────────────────────────────────────── // MEMENTO [Behavioral] // ───────────────────────────────────────────────────────────────────────────── // INTENT : Without violating encapsulation, capture and externalize an object's internal state so the // object can be restored to this state later. // PROBLEM : You need undo / rollback / "restore draft" without every caller poking at private fields — the // object should hand out an opaque snapshot and accept it back. // SAY : Participants: Originator (creates & restores from mementos), Memento (opaque state snapshot — // only the Originator reads inside), Caretaker (stores mementos, never inspects them). Pitch: // "save a sealed snapshot now; hand it back later to roll back." // SEEN IN : Redux DevTools time travel (array of past states); editor undo via document snapshots; // `history.pushState(state)` / `history.state`; DB `SAVEPOINT` / `ROLLBACK TO`; Git commits & // `git stash`; form-draft autosave to localStorage; VM/container checkpoints; Jest snapshots (-ish). // VS : Command stores the OPERATION (and how to invert it) — small, but every op needs an undo; // Memento stores the STATE — simple, but can be large. Prototype clones to create a NEW object; // Memento captures state to restore the SAME object later. // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── Classic shape (classes, so the participant names are visible) const mementoState = new WeakMap(); // only this module (the Originator) can read payloads class DocumentMemento { // Memento — opaque handle; caretakers see only `label` constructor(label, state) { this.label = label; mementoState.set(this, state); Object.freeze(this); } } class TextDocument { // Originator #title; #body; #tags; constructor(title) { this.#title = title; this.#body = ''; this.#tags = new Set(); } edit(body) { this.#body = body; return this; } tag(t) { this.#tags.add(t); return this; } rename(t) { this.#title = t; return this; } get snapshot() { return { title: this.#title, body: this.#body, tags: [...this.#tags].sort() }; } save(label = '') { return new DocumentMemento(label, { title: this.#title, body: this.#body, tags: [...this.#tags] }); } restore(memento) { const s = mementoState.get(memento); if (!s) throw new TypeError('not a memento of this originator'); this.#title = s.title; this.#body = s.body; this.#tags = new Set(s.tags); } } class History { // Caretaker — stores mementos, never looks inside #stack = []; push(m) { this.#stack.push(m); } pop() { return this.#stack.pop(); } get labels() { return this.#stack.map((m) => m.label); } get length() { return this.#stack.length; } } const doc = new TextDocument('Spec'); const history = new History(); history.push(doc.save('initial')); doc.edit('v1 draft').tag('draft'); history.push(doc.save('after v1')); doc.edit('v2 — final').tag('reviewed').rename('Spec (final)'); assert.deepEqual(doc.snapshot, { title: 'Spec (final)', body: 'v2 — final', tags: ['draft', 'reviewed'] }); assert.deepEqual(history.labels, ['initial', 'after v1']); doc.restore(history.pop()); assert.deepEqual(doc.snapshot, { title: 'Spec', body: 'v1 draft', tags: ['draft'] }); // previous state back doc.restore(history.pop()); assert.deepEqual(doc.snapshot, { title: 'Spec', body: '', tags: [] }); // original state assert.equal(history.length, 0); const m = doc.edit('x').save('x'); assert.equal(JSON.stringify(m), '{"label":"x"}'); // state is NOT exposed — only the originator can read it doc.edit('y').tag('t'); doc.restore(m); assert.deepEqual(doc.snapshot, { title: 'Spec', body: 'x', tags: [] }); // restore after further edits assert.throws(() => doc.restore({ label: 'forged' }), /not a memento/); // ── Idiomatic JS shape: immutable state + array of snapshots (Redux DevTools–style time travel) const createStore = (reducer, initial) => { const past = []; const future = []; let state = initial; return { dispatch(action) { past.push(state); state = reducer(state, action); future.length = 0; }, undo() { if (past.length) { future.push(state); state = past.pop(); } }, redo() { if (future.length) { past.push(state); state = future.pop(); } }, get state() { return state; }, get canUndo() { return past.length > 0; }, }; }; const reducer = (s, a) => (a.type === 'add' ? { ...s, items: [...s.items, a.sku] } : s); const store = createStore(reducer, { items: [] }); store.dispatch({ type: 'add', sku: 'A' }); store.dispatch({ type: 'add', sku: 'B' }); assert.deepEqual(store.state.items, ['A', 'B']); store.undo(); assert.deepEqual(store.state.items, ['A']); // snapshots ARE the old immutable objects — zero copying store.redo(); assert.deepEqual(store.state.items, ['A', 'B']); store.undo(); store.undo(); store.undo(); // extra undo is a no-op assert.deepEqual(store.state.items, []); assert.equal(store.canUndo, false); // Mutable state? Snapshot with structuredClone so later mutation can't corrupt the memento. const draft = { to: ['a@x.io'], body: 'hi' }; const saved = structuredClone(draft); draft.to.push('b@x.io'); draft.body = 'hello'; assert.deepEqual(saved, { to: ['a@x.io'], body: 'hi' }); // memento unaffected by the mutation Object.assign(draft, structuredClone(saved)); assert.deepEqual(draft, { to: ['a@x.io'], body: 'hi' }); console.log('ok: design-patterns/behavioral/memento.js');