Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

@cubic-dev-ai cubic-dev-ai Bot Jun 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Pin GitHub Actions to a full commit SHA instead of a mutable tag. This reduces supply-chain risk from tag retargeting.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/ci.yml, line 12:

<comment>Pin GitHub Actions to a full commit SHA instead of a mutable tag. This reduces supply-chain risk from tag retargeting.</comment>

<file context>
@@ -0,0 +1,23 @@
+  ci:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: voidzero-dev/setup-vp@v1
</file context>
Fix with cubic


on:
push:
branches: [main]
pull_request:

jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true

- run: vp install
- run: vp check # format, lint, and type checks
- run: vp test run
- run: vp pack # build the library
- run: vp dlx publint
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
*.log
.DS_Store
52 changes: 52 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# @b2m9/reversible — agent guide

`@b2m9/reversible` is a headless, synchronous undo/redo engine that stores
inverse operations (`do`/`undo`), not snapshots, and never owns your state. The
public contract is in `src/types.ts` (JSDoc) and the model is explained in
`README.md` — read those first. This file lists only what those don't: the
constraints the compiler won't enforce, and how we make changes here.

## Toolchain

ESM-only, Node ≥22. Toolchain is Vite+ (`vp`), not plain npm:
`vp check` (format/lint/types), `vp test run`, `vp pack` (build).

## Constraints to preserve

- **Zero runtime dependencies.** The core ships only its own code.
- **Synchronous only.** `do`, `undo`, and the transaction builder must not
return thenables — the engine throws if they do.
- **Headless.** The engine runs callbacks and moves a cursor; it never reads or
writes application state.
- **No reentrancy.** Public mutations throw if called from inside a
`do`/`undo`/rollback (`assertIdle`).
- **Notify only on real change.** A failed `commit`, an empty `transaction`, and
a no-op `undo`/`redo`/`jump` notify _nobody_.
- **Checkpoints anchor to entries.** Pruned when their entry is evicted (`limit`
overflow) or dropped (redo branch truncated by a fresh commit); `revertTo`
throws on an unknown/pruned name.
- **Rollback is reverse-order and best-effort.** A throwing inverse stops
rollback and surfaces.

## Design principles

This library's value is what it _doesn't_ do. Hold the line:

- Prefer removing code to adding it. A new option, parameter, or branch must
earn its place — if a behavior composes from existing primitives, don't add a
primitive for it.
- Every public API entry is a lifetime maintenance cost. Default to "no"; make
the use case prove the surface is necessary.
- A feature change that only adds public surface area is suspect. When
proposing one, say what it lets us delete or simplify.

## Comments

Match the comments already in `src/` — they are the style guide. The pattern:

- Explain _why_, not _what_: the invariant being held or the failure being
guarded, never a paraphrase of the code below.
- Only at decision points — a guard, a non-obvious ordering, a deliberate
no-op. Self-evident lines get nothing.
- Terse, full sentences, present tense. When in doubt, imitate the nearest
existing comment.
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# @b2m9/reversible

The agent guide for this project is maintained in a single source of truth,
`AGENTS.md` (the cross-tool standard). Claude Code reads it via this import:

@AGENTS.md
158 changes: 158 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# @b2m9/reversible

Headless, framework-agnostic undo/redo for any state. You give it reversible
operations; it gives you `undo`/`redo`/`jump` traversal, grouped transactions,
checkpoints, and timeline scrubbing. It does **not** own your state, but composes
with whatever store you already have.

```bash
npm install @b2m9/reversible
```

ESM-only. Zero runtime dependencies.

## The model: command, not snapshot

Most undo libraries snapshot your whole state into a `past`/`future` stack.
`reversible` stores **inverse operations** instead. You `commit` a `do`/`undo`
pair; the engine runs `do` and remembers how to reverse it.

| | Snapshot-based | **@b2m9/reversible** |
| ---------------------- | ----------------------------------- | ------------------------------------------- |
| Coupling | Higher-order reducer / store plugin | Headless, zero-dep, any store or none |
| History model | Full copies of `present` | Inverse operations (`do`/`undo`) |
| Memory cost | State size × history depth | Inverse-payload size — usually ≪ a snapshot |
| Non-serializable state | Effectively unsupported | Supported — you write the inverse |
| Grouping | Auto-capture heuristics | Explicit `transaction()` boundary |
| What's undoable | Filter config | Caller decides — just don't `commit()` it |

The engine holds one ordered list of entries and a **cursor**:

```
committed entries
┌──────┬──────┬──────┐ ┌──────┐
│ e1 │ e2 │ e3 │ cursor │ e4 │
└──────┴──────┴──────┘ ▲ └──────┘
undoable here redoable
```

Everything left of the cursor can be undone; everything right can be redone.
"Present state" is **your app's**, not the engine's and it only knows how to move
along the timeline of effects.

> **The one responsibility you own:** `do` and `undo` must be true inverses.
> The engine keeps its own bookkeeping consistent; it cannot fix a broken inverse.

## One tiny example

The external `count` is the point: your state, wherever it lives.

```ts
import { createHistory } from "@b2m9/reversible";

const history = createHistory();
let count = 0;

history.commit({
label: "increment",
do: () => {
count += 1;
},
undo: () => {
count -= 1;
},
});

history.undo(); // count === 0
history.redo(); // count === 1
history.jump(-1); // count === 0 — scrub the whole timeline
```

## One transaction example

Group many operations into one atomic entry — undone and redone as a unit.

```ts
history.transaction('type "hello"', (tx) => {
for (const ch of "hello") {
tx.commit({
do: () => doc.append(ch),
undo: () => doc.trimEnd(1),
});
}
});

history.undo(); // removes the whole word, not one letter
```

Wire it to UI with `subscribe`, which hands each listener a meta snapshot:

```ts
const off = history.subscribe((m) => {
undoBtn.disabled = !m.canUndo;
undoBtn.title = m.undoLabel ? `Undo: ${m.undoLabel}` : "Undo";
});
```

## Failure rules

- **Synchronous only.** `do`/`undo` must run synchronously. If one returns a
thenable, the engine throws loudly rather than fire-and-forget a promise it
can't reverse.
- **Atomic transactions (best-effort).** If anything throws before a
`transaction` builder returns, the applied operations roll back in reverse and
the entry never enters history. "Best-effort" because atomicity holds only as
far as your inverses allow.
- **Failed `commit` is a no-op.** If `do` throws, nothing is recorded, the cursor
doesn't move, and the redo branch is left intact.
- **Boundaries clamp.** `undo()`/`redo()` return `false` at the ends. `jump(n)`
clamps and returns the signed count of steps actually moved (`jump(n) === n`
means fully moved). A throw mid-`jump` stops at the last successful step.
- **A new commit after undo clears the redo branch.** Traversal never does.
- **Reentrancy throws.** Calling back into the engine from inside a running
`do`/`undo`/rollback throws. `subscribe` listeners fire after the operation
settles, so calling back from a listener is fine.

## API

```ts
const history = createHistory({ limit: 100 }); // limit is optional

history.commit({ label?, do, undo });
history.transaction(label, (tx) => tx.commit({ do, undo }));

history.undo(); // boolean
history.redo(); // boolean
history.jump(n); // signed steps moved

history.canUndo; // booleans / derived meta
history.canRedo;
history.undoLabel; // string | undefined
history.redoLabel;
history.position; // cursor in [0, length]
history.length;

history.checkpoint(name);
history.hasCheckpoint(name); // gate revertTo the way canUndo gates undo
history.revertTo(name); // throws if the name is unknown or was pruned

history.clear();
history.subscribe((meta) => { /* ... */ }); // returns unsubscribe
```

With `limit`, the oldest entries are evicted past the cap. Checkpoints anchor to
the entry they sit after, so they stay correct as eviction renumbers positions; a
checkpoint whose anchor is evicted (or dropped when a commit clears a redo branch)
is pruned, and `revertTo` on it throws.

## Non-goals

- **It doesn't own your state.** No `present`, no store, no deep-clone. A thin
state-owning convenience wrapper may land later, layered on top.
- **No async.** Reversing remote or async effects is a compensation-and-idempotency
problem this package deliberately does not solve.
- **No branching timelines.** Checkpoints are markers on one line, not forks.

## License

MIT © 2026 Bob Massarczyk
57 changes: 57 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "@b2m9/reversible",
"version": "0.1.0",
"description": "Headless, framework-agnostic undo/redo for any state — a small command stack of reversible operations with grouped transactions, checkpoints, and timeline scrubbing.",
"keywords": [
"checkpoint",
"command-pattern",
"headless",
"history",
"redo",
"state",
"time-travel",
"transaction",
"undo"
],
"homepage": "https://github.com/b2m9/reversible#readme",
"bugs": {
"url": "https://github.com/b2m9/reversible/issues"
},
"license": "MIT",
"author": "Bob Massarczyk <bob@b2m9.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/b2m9/reversible.git"
},
"files": [
"dist"
],
"type": "module",
"sideEffects": false,
"exports": {
".": "./dist/index.mjs",
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "vp pack",
"dev": "vp pack --watch",
"test": "vp test run",
"check": "vp check",
"check:exports": "vp pack && vp dlx publint && vp dlx @arethetypeswrong/cli --pack --profile esm-only",
"prepublishOnly": "vp run build"
},
"devDependencies": {
"@types/node": "^25.6.2",
"@typescript/native-preview": "7.0.0-dev.20260509.2",
"bumpp": "^11.1.0",
"typescript": "^6.0.3",
"vite-plus": "catalog:"
},
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@11.7.0"
}
Loading
Loading