@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@devSave 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 operationsdirty 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 snapshotThe 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.
