Files
skills/docs/foundation/flow/undo/src/merge/command-merger.md
2026-05-29 08:20:56 +01:00

4.2 KiB

command-merger

(no submodules)

Purpose

Implements CommandMerger<T>, a utility for coalescing high-frequency user actions — such as rapid keystroke edits, slider movements, or drag operations — into a single undoable step. Commands are accumulated in a pending buffer and flushed into a single composite command when a count or time window closes, or when two consecutive commands fail the merge predicate.

The injectable Clock makes time-window behavior deterministic in tests without needing real timers or setTimeout. The default merge predicate merges consecutive commands with identical labels; callers can supply any custom predicate.

Features

  • add(command) — adds to buffer; returns merged command if window closes, else undefined
  • flush() — immediately flushes buffer; returns merged command or undefined if empty
  • reset() — discards pending commands without returning them
  • canMerge(a, b) — checks predicate without mutating state
  • Getters: hasPending, pendingCount
  • maxCount window — flushes after accumulating N commands
  • timeWindowMs window — flushes when elapsed time exceeds threshold
  • Default predicate: merge consecutive commands with the same label
  • Injectable Clock for deterministic tests
  • Options validated with CommandMergerOptionsSchema

Usage: Basic Example

import { CommandMerger } from "@suaveplan/undo/merge/command-merger";
import { createCommand } from "@suaveplan/undo/command/command";
import { UndoStack } from "@suaveplan/undo/stack/stack";

interface Counter { value: number }
const stack = new UndoStack<Counter>({ value: 0 });
const merger = new CommandMerger<Counter>({ maxCount: 3 });

for (const delta of [1, 1, 1, 2]) {
  const cmd = createCommand("Inc", (s) => ({ value: s.value + delta }), (s) => ({ value: s.value - delta }));
  const flushed = merger.add(cmd);
  if (flushed) stack.push(flushed);
}

// Flush remaining
const last = merger.flush();
if (last) stack.push(last);

Usage: Advanced Example

import { CommandMerger } from "@suaveplan/undo/merge/command-merger";
import { createCommand } from "@suaveplan/undo/command/command";

interface Doc { text: string }

let time = 0;
const clock = () => ++time;

// Custom predicate: only merge "Type" commands
const merger = new CommandMerger<Doc>(
  { timeWindowMs: 500, clock },
  (a, b) => a.label === "Type" && b.label === "Type"
);

const typeChar = (ch: string) =>
  createCommand<Doc>("Type", (d) => ({ text: d.text + ch }), (d) => ({ text: d.text.slice(0, -1) }));

merger.add(typeChar("H")); // pending
merger.add(typeChar("i")); // pending
time = 600; // advance clock past window
const flushed = merger.add(typeChar("!")); // triggers time-window flush
// flushed = merged "Hi" command; "!" starts new pending
const remaining = merger.flush(); // flushes "!"

API Reference

new CommandMerger<T>(options?, predicate?)

Param Type Description
options CommandMergerOptions maxCount, timeWindowMs, clock
predicate MergePredicate<T> (a, b) => boolean — default: same label

add(command) → Command<T> | undefined

Returns a flushed composite when: time window closes, count limit reached, or predicate fails for the new command against the last pending. Returns undefined when the command is accumulated.

flush() → Command<T> | undefined

Returns the merged command for all pending items, or undefined if nothing is pending.

reset() → void

Discards all pending commands.

canMerge(a, b) → boolean

Calls the merge predicate. Non-mutating.

Getters

  • hasPending — true when there are accumulated commands
  • pendingCount — number of commands in the buffer

Implementation Notes

The _doFlush method returns the single command directly (without wrapping in composeCommands) when the buffer contains only one entry, avoiding unnecessary composite overhead. The time window is measured from the first add call after a flush. Non-mergeable commands trigger an implicit flush of the existing buffer before the new command starts a fresh buffer — so add can return the old buffer's merged command in this case.