@b2m9/reversible 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bob Massarczyk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # @b2m9/reversible
2
+
3
+ Headless, framework-agnostic undo/redo for any state. You give it reversible
4
+ operations; it gives you `undo`/`redo`/`jump` traversal, grouped transactions,
5
+ checkpoints, and timeline scrubbing. It does **not** own your state, but composes
6
+ with whatever store you already have.
7
+
8
+ ```bash
9
+ npm install @b2m9/reversible
10
+ ```
11
+
12
+ ESM-only. Zero runtime dependencies.
13
+
14
+ ## The model: command, not snapshot
15
+
16
+ Most undo libraries snapshot your whole state into a `past`/`future` stack.
17
+ `reversible` stores **inverse operations** instead. You `commit` a `do`/`undo`
18
+ pair; the engine runs `do` and remembers how to reverse it.
19
+
20
+ | | Snapshot-based | **@b2m9/reversible** |
21
+ | ---------------------- | ----------------------------------- | ------------------------------------------- |
22
+ | Coupling | Higher-order reducer / store plugin | Headless, zero-dep, any store or none |
23
+ | History model | Full copies of `present` | Inverse operations (`do`/`undo`) |
24
+ | Memory cost | State size × history depth | Inverse-payload size — usually ≪ a snapshot |
25
+ | Non-serializable state | Effectively unsupported | Supported — you write the inverse |
26
+ | Grouping | Auto-capture heuristics | Explicit `transaction()` boundary |
27
+ | What's undoable | Filter config | Caller decides — just don't `commit()` it |
28
+
29
+ The engine holds one ordered list of entries and a **cursor**:
30
+
31
+ ```
32
+ committed entries
33
+ ┌──────┬──────┬──────┐ ┌──────┐
34
+ │ e1 │ e2 │ e3 │ cursor │ e4 │
35
+ └──────┴──────┴──────┘ ▲ └──────┘
36
+ undoable here redoable
37
+ ```
38
+
39
+ Everything left of the cursor can be undone; everything right can be redone.
40
+ "Present state" is **your app's**, not the engine's and it only knows how to move
41
+ along the timeline of effects.
42
+
43
+ > **The one responsibility you own:** `do` and `undo` must be true inverses.
44
+ > The engine keeps its own bookkeeping consistent; it cannot fix a broken inverse.
45
+
46
+ ## One tiny example
47
+
48
+ The external `count` is the point: your state, wherever it lives.
49
+
50
+ ```ts
51
+ import { createHistory } from "@b2m9/reversible";
52
+
53
+ const history = createHistory();
54
+ let count = 0;
55
+
56
+ history.commit({
57
+ label: "increment",
58
+ do: () => {
59
+ count += 1;
60
+ },
61
+ undo: () => {
62
+ count -= 1;
63
+ },
64
+ });
65
+
66
+ history.undo(); // count === 0
67
+ history.redo(); // count === 1
68
+ history.jump(-1); // count === 0 — scrub the whole timeline
69
+ ```
70
+
71
+ ## One transaction example
72
+
73
+ Group many operations into one atomic entry — undone and redone as a unit.
74
+
75
+ ```ts
76
+ history.transaction('type "hello"', (tx) => {
77
+ for (const ch of "hello") {
78
+ tx.commit({
79
+ do: () => doc.append(ch),
80
+ undo: () => doc.trimEnd(1),
81
+ });
82
+ }
83
+ });
84
+
85
+ history.undo(); // removes the whole word, not one letter
86
+ ```
87
+
88
+ Wire it to UI with `subscribe`, which hands each listener a meta snapshot:
89
+
90
+ ```ts
91
+ const off = history.subscribe((m) => {
92
+ undoBtn.disabled = !m.canUndo;
93
+ undoBtn.title = m.undoLabel ? `Undo: ${m.undoLabel}` : "Undo";
94
+ });
95
+ ```
96
+
97
+ ## Failure rules
98
+
99
+ - **Synchronous only.** `do`/`undo` must run synchronously. If one returns a
100
+ thenable, the engine throws loudly rather than fire-and-forget a promise it
101
+ can't reverse.
102
+ - **Atomic transactions (best-effort).** If anything throws before a
103
+ `transaction` builder returns, the applied operations roll back in reverse and
104
+ the entry never enters history. "Best-effort" because atomicity holds only as
105
+ far as your inverses allow.
106
+ - **Failed `commit` is a no-op.** If `do` throws, nothing is recorded, the cursor
107
+ doesn't move, and the redo branch is left intact.
108
+ - **Boundaries clamp.** `undo()`/`redo()` return `false` at the ends. `jump(n)`
109
+ clamps and returns the signed count of steps actually moved (`jump(n) === n`
110
+ means fully moved). A throw mid-`jump` stops at the last successful step.
111
+ - **A new commit after undo clears the redo branch.** Traversal never does.
112
+ - **Reentrancy throws.** Calling back into the engine from inside a running
113
+ `do`/`undo`/rollback throws. `subscribe` listeners fire after the operation
114
+ settles, so calling back from a listener is fine.
115
+
116
+ ## API
117
+
118
+ ```ts
119
+ const history = createHistory({ limit: 100 }); // limit is optional
120
+
121
+ history.commit({ label?, do, undo });
122
+ history.transaction(label, (tx) => tx.commit({ do, undo }));
123
+
124
+ history.undo(); // boolean
125
+ history.redo(); // boolean
126
+ history.jump(n); // signed steps moved
127
+
128
+ history.canUndo; // booleans / derived meta
129
+ history.canRedo;
130
+ history.undoLabel; // string | undefined
131
+ history.redoLabel;
132
+ history.position; // cursor in [0, length]
133
+ history.length;
134
+
135
+ history.checkpoint(name);
136
+ history.hasCheckpoint(name); // gate revertTo the way canUndo gates undo
137
+ history.revertTo(name); // throws if the name is unknown or was pruned
138
+
139
+ history.clear();
140
+ history.subscribe((meta) => { /* ... */ }); // returns unsubscribe
141
+ ```
142
+
143
+ With `limit`, the oldest entries are evicted past the cap. Checkpoints anchor to
144
+ the entry they sit after, so they stay correct as eviction renumbers positions; a
145
+ checkpoint whose anchor is evicted (or dropped when a commit clears a redo branch)
146
+ is pruned, and `revertTo` on it throws.
147
+
148
+ ## Non-goals
149
+
150
+ - **It doesn't own your state.** No `present`, no store, no deep-clone. A thin
151
+ state-owning convenience wrapper may land later, layered on top.
152
+ - **No async.** Reversing remote or async effects is a compensation-and-idempotency
153
+ problem this package deliberately does not solve.
154
+ - **No branching timelines.** Checkpoints are markers on one line, not forks.
155
+
156
+ ## License
157
+
158
+ MIT © 2026 Bob Massarczyk
@@ -0,0 +1,86 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * A reversible operation: a `do`/`undo` pair the engine runs and remembers how
4
+ * to reverse. Both must be **synchronous** and **true inverses** of each other —
5
+ * that is the one responsibility the caller owns.
6
+ */
7
+ interface Reversible {
8
+ /** Optional label, surfaced as `undoLabel`/`redoLabel` for UI tooltips. */
9
+ label?: string;
10
+ /** Run now and on every redo. Must be synchronous. */
11
+ do: () => void;
12
+ /** Run on undo. Must be synchronous. */
13
+ undo: () => void;
14
+ }
15
+ /** The builder handed to a {@link History.transaction} callback. */
16
+ interface TransactionBuilder {
17
+ commit(action: Reversible): void;
18
+ }
19
+ /** A read-only snapshot of the engine's derived state, delivered to subscribers. */
20
+ interface HistoryMeta {
21
+ readonly canUndo: boolean;
22
+ readonly canRedo: boolean;
23
+ readonly undoLabel?: string;
24
+ readonly redoLabel?: string;
25
+ readonly position: number;
26
+ readonly length: number;
27
+ }
28
+ interface History {
29
+ /** Run an operation now and append it as a single undoable entry. */
30
+ commit(action: Reversible): void;
31
+ /**
32
+ * Group many operations into one atomic entry. Operations apply eagerly; if
33
+ * anything throws before `build` returns, the applied operations roll back in
34
+ * reverse, the entry never enters history, and the error rethrows.
35
+ */
36
+ transaction(label: string, build: (tx: TransactionBuilder) => void): void;
37
+ /** Undo one entry. Returns `false` (a no-op) at the start of history. */
38
+ undo(): boolean;
39
+ /** Redo one entry. Returns `false` (a no-op) at the end of history. */
40
+ redo(): boolean;
41
+ /**
42
+ * Move `n` steps (`n < 0` undo, `n > 0` redo), clamped at the ends. Returns
43
+ * the signed number of steps actually moved, so `jump(n) === n` means "fully
44
+ * moved". A throw mid-jump stops at the last successful step and rethrows.
45
+ */
46
+ jump(n: number): number;
47
+ readonly canUndo: boolean;
48
+ readonly canRedo: boolean;
49
+ readonly undoLabel?: string;
50
+ readonly redoLabel?: string;
51
+ /**
52
+ * Cursor index in `[0, length]`. `canUndo === position > 0` and
53
+ * `canRedo === position < length`.
54
+ */
55
+ readonly position: number;
56
+ readonly length: number;
57
+ /** Tag the current position with a name. Re-tagging an existing name moves it. */
58
+ checkpoint(name: string): void;
59
+ hasCheckpoint(name: string): boolean;
60
+ /** Jump to a checkpoint. Throws if the name is unknown or was pruned. */
61
+ revertTo(name: string): void;
62
+ /** Drop all entries and checkpoints; reset the cursor to 0. */
63
+ clear(): void;
64
+ /**
65
+ * Subscribe to history changes for UI binding; the listener reads its own
66
+ * application state. Returns an unsubscribe function.
67
+ */
68
+ subscribe(listener: (meta: HistoryMeta) => void): () => void;
69
+ }
70
+ interface HistoryOptions {
71
+ /**
72
+ * Cap on retained entries. When exceeded, the oldest entries are evicted
73
+ * (observable through `length`/`canUndo`). Must be a positive integer.
74
+ */
75
+ limit?: number;
76
+ }
77
+ //#endregion
78
+ //#region src/core.d.ts
79
+ /**
80
+ * Create a headless undo/redo engine. It tracks only the undo/redo stacks and a
81
+ * cursor — your application state lives wherever it already does. See the
82
+ * package brief for the full design.
83
+ */
84
+ declare function createHistory(options?: HistoryOptions): History;
85
+ //#endregion
86
+ export { type History, type HistoryMeta, type HistoryOptions, type Reversible, type TransactionBuilder, createHistory };
package/dist/index.mjs ADDED
@@ -0,0 +1,257 @@
1
+ //#region src/transaction.ts
2
+ /**
3
+ * True if `value` is a thenable (exposes a callable `then`). The single source of
4
+ * truth for the engine's synchronous-only contract: a `do`/`undo` or transaction
5
+ * builder that returns one has detached work the engine can never reverse.
6
+ */
7
+ function isThenable(value) {
8
+ return value !== null && value !== void 0 && typeof value.then === "function";
9
+ }
10
+ /**
11
+ * Run a transaction: apply each committed operation eagerly, and on success
12
+ * collapse them into one entry whose `do` replays forward and whose `undo`
13
+ * replays the inverses in strict reverse order.
14
+ *
15
+ * If anything throws before `build` returns — a committed op or the build
16
+ * callback itself — the applied operations roll back in reverse and no entry is
17
+ * recorded. Best-effort: if a rollback inverse itself throws, that error
18
+ * surfaces and rollback stops.
19
+ */
20
+ function runTransaction(engine, label, build) {
21
+ engine.assertIdle();
22
+ engine.beginMutation();
23
+ const ops = [];
24
+ let closed = false;
25
+ const tx = { commit(action) {
26
+ if (closed) throw new Error("reversible: transaction builder used after the transaction finished");
27
+ engine.runGuarded(action.do);
28
+ ops.push({
29
+ do: action.do,
30
+ undo: action.undo
31
+ });
32
+ } };
33
+ try {
34
+ const result = build(tx);
35
+ closed = true;
36
+ if (isThenable(result)) {
37
+ if (result instanceof Promise) result.catch(() => {});
38
+ throw new TypeError("reversible: transaction builder must be synchronous (it returned a thenable)");
39
+ }
40
+ } catch (error) {
41
+ closed = true;
42
+ try {
43
+ for (let i = ops.length - 1; i >= 0; i -= 1) engine.runGuarded(ops[i].undo);
44
+ } finally {
45
+ engine.endMutation();
46
+ }
47
+ throw error;
48
+ }
49
+ engine.endMutation();
50
+ if (ops.length === 0) return;
51
+ engine.append({
52
+ label,
53
+ do() {
54
+ for (const op of ops) engine.runGuarded(op.do);
55
+ },
56
+ undo() {
57
+ for (let i = ops.length - 1; i >= 0; i -= 1) engine.runGuarded(ops[i].undo);
58
+ }
59
+ });
60
+ engine.notify();
61
+ }
62
+ //#endregion
63
+ //#region src/core.ts
64
+ /** Sentinel anchor for a checkpoint at position 0 (before all entries). */
65
+ const START = Symbol("reversible.start");
66
+ /**
67
+ * Create a headless undo/redo engine. It tracks only the undo/redo stacks and a
68
+ * cursor — your application state lives wherever it already does. See the
69
+ * package brief for the full design.
70
+ */
71
+ function createHistory(options = {}) {
72
+ const { limit } = options;
73
+ if (limit !== void 0 && (!Number.isInteger(limit) || limit < 1)) throw new TypeError("reversible: limit must be a positive integer");
74
+ const entries = [];
75
+ let cursor = 0;
76
+ const checkpoints = /* @__PURE__ */ new Map();
77
+ const listeners = /* @__PURE__ */ new Set();
78
+ let mutating = false;
79
+ /** Reentrancy guard: reject any public mutation while user code is running. */
80
+ const assertIdle = () => {
81
+ if (mutating) throw new Error("reversible: reentrant engine call from inside a do/undo/rollback");
82
+ };
83
+ /** Run a user operation, throwing loudly if it returns a thenable. */
84
+ const runGuarded = (fn) => {
85
+ if (isThenable(fn())) throw new TypeError("reversible: do/undo must be synchronous (it returned a thenable)");
86
+ };
87
+ const buildMeta = () => Object.freeze({
88
+ canUndo: cursor > 0,
89
+ canRedo: cursor < entries.length,
90
+ undoLabel: cursor > 0 ? entries[cursor - 1].label : void 0,
91
+ redoLabel: cursor < entries.length ? entries[cursor].label : void 0,
92
+ position: cursor,
93
+ length: entries.length
94
+ });
95
+ const notify = () => {
96
+ if (listeners.size === 0) return;
97
+ const meta = buildMeta();
98
+ for (const listener of listeners) listener(meta);
99
+ };
100
+ const pruneCheckpoints = (removed) => {
101
+ if (checkpoints.size === 0 || removed.length === 0) return;
102
+ const removedSet = new Set(removed);
103
+ for (const [name, anchor] of checkpoints) if (anchor !== START && removedSet.has(anchor)) checkpoints.delete(name);
104
+ };
105
+ const append = (entry) => {
106
+ if (cursor < entries.length) pruneCheckpoints(entries.splice(cursor));
107
+ entries.push(entry);
108
+ cursor += 1;
109
+ if (limit !== void 0 && entries.length > limit) {
110
+ const overflow = entries.length - limit;
111
+ pruneCheckpoints(entries.splice(0, overflow));
112
+ cursor -= overflow;
113
+ }
114
+ };
115
+ /**
116
+ * Walk the cursor `n` steps, running each inverse/forward op. Clamps at the
117
+ * ends. May throw mid-way, leaving the cursor at the last successful step.
118
+ */
119
+ const traverse = (n) => {
120
+ const dir = n < 0 ? -1 : 1;
121
+ let remaining = Math.abs(n);
122
+ while (remaining > 0) {
123
+ if (dir < 0) {
124
+ if (cursor === 0) break;
125
+ runGuarded(entries[cursor - 1].undo);
126
+ cursor -= 1;
127
+ } else {
128
+ if (cursor === entries.length) break;
129
+ runGuarded(entries[cursor].do);
130
+ cursor += 1;
131
+ }
132
+ remaining -= 1;
133
+ }
134
+ };
135
+ /** Guarded traversal shared by jump/undo/redo/revertTo. Returns steps moved (signed). */
136
+ const move = (n) => {
137
+ const before = cursor;
138
+ mutating = true;
139
+ let caught;
140
+ try {
141
+ traverse(n);
142
+ } catch (error) {
143
+ caught = { error };
144
+ } finally {
145
+ mutating = false;
146
+ }
147
+ const moved = cursor - before;
148
+ if (moved !== 0) notify();
149
+ if (caught) throw caught.error;
150
+ return moved;
151
+ };
152
+ const commit = (action) => {
153
+ assertIdle();
154
+ mutating = true;
155
+ try {
156
+ runGuarded(action.do);
157
+ } catch (error) {
158
+ mutating = false;
159
+ throw error;
160
+ }
161
+ mutating = false;
162
+ append({
163
+ label: action.label,
164
+ do: action.do,
165
+ undo: action.undo
166
+ });
167
+ notify();
168
+ };
169
+ const undo = () => {
170
+ assertIdle();
171
+ if (cursor === 0) return false;
172
+ move(-1);
173
+ return true;
174
+ };
175
+ const redo = () => {
176
+ assertIdle();
177
+ if (cursor === entries.length) return false;
178
+ move(1);
179
+ return true;
180
+ };
181
+ const jump = (n) => {
182
+ assertIdle();
183
+ if (n === 0) return 0;
184
+ return move(n);
185
+ };
186
+ const checkpoint = (name) => {
187
+ assertIdle();
188
+ checkpoints.set(name, cursor === 0 ? START : entries[cursor - 1]);
189
+ };
190
+ const hasCheckpoint = (name) => checkpoints.has(name);
191
+ const revertTo = (name) => {
192
+ assertIdle();
193
+ const anchor = checkpoints.get(name);
194
+ if (anchor === void 0) throw new Error(`reversible: unknown checkpoint "${name}"`);
195
+ move((anchor === START ? 0 : entries.indexOf(anchor) + 1) - cursor);
196
+ };
197
+ const clear = () => {
198
+ assertIdle();
199
+ if (entries.length === 0 && checkpoints.size === 0) return;
200
+ entries.length = 0;
201
+ cursor = 0;
202
+ checkpoints.clear();
203
+ notify();
204
+ };
205
+ const subscribe = (listener) => {
206
+ listeners.add(listener);
207
+ return () => {
208
+ listeners.delete(listener);
209
+ };
210
+ };
211
+ const internals = {
212
+ assertIdle,
213
+ beginMutation: () => {
214
+ mutating = true;
215
+ },
216
+ endMutation: () => {
217
+ mutating = false;
218
+ },
219
+ runGuarded,
220
+ append,
221
+ notify
222
+ };
223
+ return {
224
+ commit,
225
+ transaction: (label, build) => {
226
+ runTransaction(internals, label, build);
227
+ },
228
+ undo,
229
+ redo,
230
+ jump,
231
+ checkpoint,
232
+ hasCheckpoint,
233
+ revertTo,
234
+ clear,
235
+ subscribe,
236
+ get canUndo() {
237
+ return cursor > 0;
238
+ },
239
+ get canRedo() {
240
+ return cursor < entries.length;
241
+ },
242
+ get undoLabel() {
243
+ return cursor > 0 ? entries[cursor - 1].label : void 0;
244
+ },
245
+ get redoLabel() {
246
+ return cursor < entries.length ? entries[cursor].label : void 0;
247
+ },
248
+ get position() {
249
+ return cursor;
250
+ },
251
+ get length() {
252
+ return entries.length;
253
+ }
254
+ };
255
+ }
256
+ //#endregion
257
+ export { createHistory };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@b2m9/reversible",
3
+ "version": "0.1.0",
4
+ "description": "Headless, framework-agnostic undo/redo for any state — a small command stack of reversible operations with grouped transactions, checkpoints, and timeline scrubbing.",
5
+ "keywords": [
6
+ "checkpoint",
7
+ "command-pattern",
8
+ "headless",
9
+ "history",
10
+ "redo",
11
+ "state",
12
+ "time-travel",
13
+ "transaction",
14
+ "undo"
15
+ ],
16
+ "homepage": "https://github.com/b2m9/reversible#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/b2m9/reversible/issues"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Bob Massarczyk <bob@b2m9.com>",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/b2m9/reversible.git"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "exports": {
32
+ ".": "./dist/index.mjs",
33
+ "./package.json": "./package.json"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "vp pack",
40
+ "dev": "vp pack --watch",
41
+ "test": "vp test run",
42
+ "check": "vp check",
43
+ "check:exports": "vp pack && vp dlx publint && vp dlx @arethetypeswrong/cli --pack --profile esm-only",
44
+ "prepublishOnly": "vp run build"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^25.6.2",
48
+ "@typescript/native-preview": "7.0.0-dev.20260509.2",
49
+ "bumpp": "^11.1.0",
50
+ "typescript": "^6.0.3",
51
+ "vite-plus": "catalog:"
52
+ },
53
+ "engines": {
54
+ "node": ">=22"
55
+ },
56
+ "packageManager": "pnpm@11.7.0"
57
+ }