npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@typedworks/history

v1.0.0-dev.2

Published

Framework-neutral reversible operation history with Commands and Raph adapters

Readme

@typedworks/history

Framework-neutral history of reversible domain operations with optional integrations for @typedworks/commands and @raphy-js/raph.

Record domain changes as reversible operations. One History owner serializes them and exposes undo, redo, transactions and immutable snapshots.

Try it in two minutes

npm install @typedworks/history@dev

Save this as try-history.mjs and run node try-history.mjs:

import { createHistory, defineHistoryOperation } from '@typedworks/history'

let count = 0
const add = defineHistoryOperation({
  id: 'counter.add',
  prepare(amount) {
    return {
      run() { count += amount; return count },
      undo() { count -= amount },
    }
  },
})

const history = createHistory({ id: 'counter', limit: 10 })
console.log(await history.execute(add, 2)) // 2
await history.undo()
console.log(count) // 0
await history.redo()
console.log(count) // 2
history.dispose()

In TypeScript, defineHistoryOperation<Input, Output>(...) checks the input and result types. The examples below show individual features; names such as document, editorCommands and editorNode represent objects from your application.

Entrypoints

import {
  createHistory,
  defineHistoryOperation,
} from '@typedworks/history'
import {
  bindHistoryCommands,
  registerUndoableCommand,
} from '@typedworks/history/commands'
import {
  createRaphHistory,
  findRaphHistory,
} from '@typedworks/history/raph'

The root entrypoint has no Commands, Raph, browser or UI dependency. The two integration entrypoints use optional peer dependencies supplied by the host application. Install @typedworks/commands@dev when using /commands, or @raphy-js/[email protected] when using /raph.

Reversible operations

An operation captures the state needed to reverse one domain change:

const renameDocument = defineHistoryOperation<
  { title: string },
  { title: string }
>({
  id: 'document.rename',

  prepare({ title }) {
    const previous = document.title

    return {
      run() {
        document.title = title
        return { title }
      },

      undo() {
        document.title = previous
      },

      // Optional. When omitted, redo calls run again.
      redo() {
        document.title = title
      },
    }
  },
})

const history = createHistory({
  id: 'document:42',
  limit: 100,
})

const result = await history.execute(renameDocument, {
  title: 'Architecture',
})

await history.undo()
await history.redo()

prepare() runs immediately before run() in the serial History queue. It is the place to capture the previous value. Failed operations do not enter the stack. didChange can explicitly classify a successful no-op:

return {
  run: () => machine.send(event),
  undo: () => machine.send(inverseEvent),
  didChange: result => result.status === 'transitioned',
}

run, undo, redo and prepare may all be asynchronous. An external signal is available in both prepare and step contexts:

await history.execute(loadAndApply, input, {
  signal: controller.signal,
  metadata: { documentId: '42' },
})

Cancellation is cooperative. A pre-aborted signal prevents the step from running. Once run() has started, the operation must stop its own external work; a successfully completed change is recorded even if the signal was aborted in the meantime.

Snapshots, checkpoint and lifecycle

const snapshot = history.getSnapshot()

snapshot.canUndo
snapshot.canRedo
snapshot.busy
snapshot.dirty
snapshot.current
snapshot.past
snapshot.future // the next redo entry is first

const unsubscribe = history.subscribe((next) => {
  renderHistory(next)
}, { emitCurrent: true })

Entries expose stable ids, operation ids, timestamps, metadata and the number of coalesced executions. They do not expose executable closures.

history.markClean() // current state becomes the saved checkpoint
history.clear() // forget transitions without changing the domain model
history.dispose() // abort lifecycle and reject future operations

dirty compares domain-state identity with the last markClean() checkpoint, so undo can return it to false. clear() does not imply that the model was saved.

Transactions

Several operations can form one user-visible undo step:

await history.transaction({
  id: 'canvas.paste-selection',
  metadata: { source: 'clipboard' },
}, async (transaction) => {
  await transaction.execute(insertNodes, nodes)
  await transaction.execute(selectNodes, nodes.map(node => node.id))
})

Child operations are serialized. If the callback, a child operation or its signal fails, completed children are undone in reverse order. Undo and redo of a committed transaction also use reverse and forward order respectively.

If undo, redo or rollback cannot restore a consistent state, History becomes faulted and rejects further transitions. The application must reconcile its domain state and call clear() before continuing. History cannot make arbitrary external effects transactional by itself.

Coalescing

Repeated changes such as typing or dragging can stay one undo step:

await history.execute(moveSelection, delta, {
  coalesce: {
    key: `selection:${selection.id}:move`,
    windowMs: 250,
  },
})

Only consecutive executions of the same operation, with the same key and inside the time window, are combined. A checkpoint, undo/redo, transaction, different operation or expired window starts a new entry. All constituent steps are kept, so undo still restores the state before the first execution.

Diagnostics

const stop = history.observe((event) => {
  // execute | transaction | undo | redo
  // started | committed | skipped | failed | cancelled
  diagnostics.record(event)
}, {
  statuses: ['failed', 'cancelled'],
})

Observer failures are isolated from domain execution. This stream reports History lifecycle facts; it is not a general application EventBus.

Commands integration

Undoability belongs to a particular scoped handler, not to the global command identity:

import { defineCommand } from '@typedworks/commands'
import { registerUndoableCommand } from '@typedworks/history/commands'

const renameCommand = defineCommand<
  { title: string },
  { title: string }
>('document.rename')

const unregister = registerUndoableCommand({
  scope: editorCommands,
  command: renameCommand,
  history,
  operation: renameDocument,
  getHistoryOptions: (_input, commandContext) => ({
    metadata: { editorId: commandContext.scope.id },
    coalesce: { key: 'document-title', windowMs: 500 },
  }),
})

The adapter forwards cancellation and records command id, invocation id, scope and origin in entry metadata. It records only a successful changed operation.

Existing Commands definitions can control the same history:

bindHistoryCommands({
  scope: editorCommands,
  history,
  undo: undoCommand,
  redo: redoCommand,
})

editorCommands.describe(undoCommand, () => ({
  title: i18n.t('commands.undo'),
  enabled: history.getSnapshot().canUndo,
}))

Presentation and localization remain application-owned. If Commands dispatches an invocation remotely and never calls the local handler, the remote side must own the corresponding history record.

Raph integration

const editorHistory = createRaphHistory(editorNode, {
  id: 'editor-history',
  limit: 100,
})

editorHistory.history // framework-neutral History
editorHistory.state.get().canUndo // Raph reactive snapshot

The adapter disposes History with its Raph owner. Descendants can resolve the nearest user-visible undo boundary without copying the node tree:

const inherited = findRaphHistory(toolNode)

A child node can create its own History to shadow the ancestor. Not every node should do so: ownership belongs to a user-visible undo boundary such as a document, editor or canvas.

Live Raph machines are intentionally not rewound automatically. Their resources, timers, tasks, actors and external effects require explicit inverse semantics, so a command handler should execute a domain-specific History operation that sends the forward and inverse machine events.