-
Notifications
You must be signed in to change notification settings - Fork 0
feat: minimal implementation #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| name: CI | ||
|
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| node_modules | ||
| dist | ||
| *.log | ||
| .DS_Store |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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