// ───────────────────────────────────────────────────────────────────────────── // COMMAND [Behavioral] // ───────────────────────────────────────────────────────────────────────────── // INTENT : Encapsulate a request as an object, letting you parameterize clients with different requests, // queue or log requests, and support undoable operations. // PROBLEM : You need undo/redo, a job queue, macro recording or an audit log — all of which need "the thing // to do" to be a VALUE you can store, replay, and reverse. // SAY : Participants: Command (execute()/undo()), ConcreteCommand (binds Receiver + args), Receiver (does // the actual work), Invoker (triggers commands, keeps history), Client (creates commands). Pitch: // "turn a method call into an object so you can store, queue, undo and replay it." // SEEN IN : Redux / useReducer actions (`{ type, payload }` dispatched to a reducer); job queues (BullMQ jobs, // SQS messages) and event sourcing; editor undo stacks (ProseMirror transactions, Monaco edit ops); // DB migrations with `up()` / `down()`; CLI frameworks (commander/oclif command objects); // callbacks handed to `setTimeout`/`queueMicrotask` — a function IS a command object in JS. // VS : Strategy = HOW to do one thing (interchangeable algorithm, no history); Command = WHAT to do (a // request + its args, often undoable/queueable). Memento stores STATE to restore; Command stores // the OPERATION and knows how to reverse it. // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── Classic shape (classes, so the participant names are visible) class TextBuffer { // Receiver constructor(text = '') { this.text = text; } insert(pos, str) { this.text = this.text.slice(0, pos) + str + this.text.slice(pos); } remove(pos, len) { const removed = this.text.slice(pos, pos + len); this.text = this.text.slice(0, pos) + this.text.slice(pos + len); return removed; } } class InsertCommand { // ConcreteCommand constructor(buffer, pos, str) { Object.assign(this, { buffer, pos, str }); } execute() { this.buffer.insert(this.pos, this.str); } undo() { this.buffer.remove(this.pos, this.str.length); } } class DeleteCommand { // ConcreteCommand — remembers what it removed constructor(buffer, pos, len) { Object.assign(this, { buffer, pos, len, removed: '' }); } execute() { this.removed = this.buffer.remove(this.pos, this.len); } undo() { this.buffer.insert(this.pos, this.removed); } } class Editor { // Invoker — owns history, never touches text itself #undo = []; #redo = []; run(cmd) { cmd.execute(); this.#undo.push(cmd); this.#redo.length = 0; } undo() { const c = this.#undo.pop(); if (c) { c.undo(); this.#redo.push(c); } return !!c; } redo() { const c = this.#redo.pop(); if (c) { c.execute(); this.#undo.push(c); } return !!c; } get depth() { return { undo: this.#undo.length, redo: this.#redo.length }; } } const buf = new TextBuffer(); const editor = new Editor(); editor.run(new InsertCommand(buf, 0, 'Hello World')); editor.run(new DeleteCommand(buf, 5, 6)); // remove ' World' editor.run(new InsertCommand(buf, 5, ', Ada')); assert.equal(buf.text, 'Hello, Ada'); assert.equal(editor.undo(), true); assert.equal(buf.text, 'Hello'); editor.undo(); assert.equal(buf.text, 'Hello World'); // delete reversed using the remembered text editor.redo(); assert.equal(buf.text, 'Hello'); assert.deepEqual(editor.depth, { undo: 2, redo: 1 }); editor.run(new InsertCommand(buf, 5, '!')); // a new command clears the redo stack assert.deepEqual(editor.depth, { undo: 3, redo: 0 }); assert.equal(editor.redo(), false); while (editor.undo()); // unwind everything assert.equal(buf.text, ''); // ── Idiomatic JS shape: plain { do, undo } objects + a history; and Redux-style serializable actions const makeHistory = () => { const done = []; const undone = []; return { exec(cmd) { cmd.do(); done.push(cmd); undone.length = 0; }, undo() { const c = done.pop(); if (c) { c.undo(); undone.push(c); } }, redo() { const c = undone.pop(); if (c) { c.do(); done.push(c); } }, }; }; const cart = new Map(); const addItem = (sku, qty) => ({ do: () => cart.set(sku, (cart.get(sku) ?? 0) + qty), undo: () => { const n = cart.get(sku) - qty; if (n) cart.set(sku, n); else cart.delete(sku); }, }); const h = makeHistory(); h.exec(addItem('A', 2)); h.exec(addItem('B', 1)); h.exec(addItem('A', 1)); assert.deepEqual([...cart], [['A', 3], ['B', 1]]); h.undo(); h.undo(); assert.deepEqual([...cart], [['A', 2]]); h.redo(); assert.deepEqual([...cart], [['A', 2], ['B', 1]]); // Redux: the action IS the command; the reducer is the receiver; dispatch is the invoker. // Serializable commands = loggable, replayable, time-travel-debuggable, shippable over a queue. const reducer = (state, action) => { switch (action.type) { case 'deposit': return { ...state, cents: state.cents + action.cents }; case 'withdraw': return { ...state, cents: state.cents - action.cents }; default: return state; } }; const log = [{ type: 'deposit', cents: 500 }, { type: 'withdraw', cents: 120 }, { type: 'deposit', cents: 20 }]; assert.equal(log.reduce(reducer, { cents: 0 }).cents, 400); // replay the log assert.equal(JSON.parse(JSON.stringify(log)).reduce(reducer, { cents: 0 }).cents, 400); // survives a queue/DB assert.equal(log.slice(0, 2).reduce(reducer, { cents: 0 }).cents, 380); // replay to any point console.log('ok: design-patterns/behavioral/command.js');