Files
suaveplan/packages/foundation/flow/undo/src/compression/history-compressor.md
2026-05-27 23:47:53 +01:00

4.5 KiB

history-compressor

(no submodules)

Purpose

Provides HistoryCompressor<T> and the standalone compressHistory function for reducing memory usage in long-running undo histories. Old entries beyond a configurable compressionDepth are squashed by label using composeCommands, grouping consecutive same-label commands into a single composite command. An optional maxHistorySize further limits the total retained entries by dropping the oldest compressed entries.

Compression is non-destructive with respect to the current application state — the rebuilt stack always reflects the same current value as the original stack. The CompressionResult reports how many entries were squashed and dropped, enabling callers to decide whether re-compression is worthwhile.

Features

  • HistoryCompressor<T> class with configurable compressionDepth and maxHistorySize
  • compress(stack) → CompressionResult & { stack: UndoStack<T> } — squashes and returns a new stack
  • compressHistory<T>(stack, options?) — standalone function equivalent
  • Groups consecutive same-label entries in the old region into composite commands
  • Respects maxHistorySize by dropping the oldest entries beyond the limit
  • OTel: increments undo.compress.total; records undo.compress.ratio histogram
  • Validates options with HistoryCompressorOptionsSchema

Usage: Basic Example

import { compressHistory } from "@suaveplan/undo/compression/history-compressor";
import { UndoStack } from "@suaveplan/undo/stack/stack";
import { createCommand } from "@suaveplan/undo/command/command";

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

for (let i = 0; i < 100; i++) {
  stack.push(createCommand("Inc", (s) => ({ value: s.value + 1 }), (s) => ({ value: s.value - 1 })));
}

const { stack: compressed, squashedCount, newSize } = compressHistory(stack, {
  compressionDepth: 20,
  maxHistorySize: 30,
});

console.log(squashedCount); // number of entries squashed into composites
console.log(newSize);       // entries in the compressed stack
console.log(compressed.current.value); // same as original stack

Usage: Advanced Example

import { HistoryCompressor } from "@suaveplan/undo/compression/history-compressor";
import { UndoStack } from "@suaveplan/undo/stack/stack";
import { createCommand } from "@suaveplan/undo/command/command";

interface Doc { version: number }

const compressor = new HistoryCompressor<Doc>({
  compressionDepth: 50,
  maxHistorySize: 100,
});

const stack = new UndoStack<Doc>({ version: 0 });
for (let i = 0; i < 200; i++) {
  stack.push(createCommand("Bump", (d) => ({ version: d.version + 1 }), (d) => ({ version: d.version - 1 })));
}

const result = compressor.compress(stack);
console.log(result.squashedCount); // entries merged
console.log(result.droppedCount);  // entries removed for maxHistorySize
console.log(result.newSize);       // final stack depth
console.log(result.stack.current.version); // 200 — state preserved

// Compression is idempotent within depth:
const second = compressor.compress(result.stack);
console.log(second.squashedCount); // 0 (nothing to squash)

API Reference

new HistoryCompressor<T>(options?)

Options validated with HistoryCompressorOptionsSchema.

Option Type Default Description
compressionDepth number 50 Entries to keep uncompressed at the top
maxHistorySize number undefined Maximum entries after compression

compress(stack) → CompressionResult & { stack: UndoStack<T> }

Returns a new UndoStack with compressed history. Does not mutate the original stack.

compressHistory<T>(stack, options?) → CompressionResult & { stack: UndoStack<T> }

Standalone version. Same semantics as compress.

CompressionResult

interface CompressionResult {
  squashedCount: number;  // original entries merged into fewer composites
  droppedCount: number;   // entries removed by maxHistorySize
  newSize: number;        // total entries in the result stack
}

Implementation Notes

The squash pass groups consecutive same-label entries — only adjacent runs with matching labels are merged. Non-adjacent groups of the same label are treated independently. The rebuilt stack directly inserts StackEntry objects without re-executing commands, so the current pointer is set from the last entry's post-execute state. This avoids re-running potentially expensive execute closures at compression time.