first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
# selective-undo
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements `SelectiveUndoStack<T>`, which extends `UndoStack<T>` with the ability to undo any command in the history by its ID — not just the most recent one. This is essential for collaborative editing, document editors with per-user undo, and any scenario where a user needs to reverse a specific past action without reversing everything that came after it.
The dependency-detection algorithm checks each later command against the target using a configurable `DependencyChecker<T>` predicate. If no dependencies are found, the target command is removed and all subsequent commands are re-applied to rebuild a consistent state.
## Features
- `undoById(commandId)` — selectively undoes a command; re-applies later commands
- `getDependents(commandId)` — returns IDs of commands that depend on the target
- `canUndoById(commandId)` — non-throwing check; returns `SelectiveUndoResult`
- Configurable `DependencyChecker<T>` — defaults to no dependencies
- `throwOnConflict` option — throws `UndoError(UNDO.DEPENDENCY_CONFLICT)` instead of returning `success: false`
- Unknown ID returns `{ success: false, conflictIds: [] }` without throwing
- OTel: increments `undo.undo.total` with `stack_type: 'selective'` on success
- Inherits all `UndoStack<T>` functionality: `push`, `undo`, `redo`, transactions, limits
## Usage: Basic Example
```ts
import { SelectiveUndoStack } from "@suaveplan/undo/selective/selective-undo";
import { createCommand } from "@suaveplan/undo/command/command";
interface Counter { value: number }
const stack = new SelectiveUndoStack<Counter>({ value: 0 });
const a = createCommand("A", (s) => ({ value: s.value + 1 }), (s) => ({ value: s.value - 1 }));
const b = createCommand("B", (s) => ({ value: s.value * 2 }), (s) => ({ value: Math.floor(s.value / 2) }));
stack.push(a); // value: 1
stack.push(b); // value: 2
// Undo command A (earlier), re-apply B
const result = stack.undoById(a.id);
if (result.success) {
console.log(stack.current.value); // 0 * 2 = 0
}
```
## Usage: Advanced Example
```ts
import { SelectiveUndoStack } from "@suaveplan/undo/selective/selective-undo";
import { createCommand } from "@suaveplan/undo/command/command";
import { UNDO_CODES, UndoError } from "@suaveplan/undo/errors/undo-error";
import type { DependencyChecker } from "@suaveplan/undo/types/undo.types";
interface Doc { fields: Record<string, string> }
// Commands that declare dependencies via metadata
const depCheck: DependencyChecker<Doc> = (earlier, later) =>
(later.metadata?.dependsOn as string[] | undefined)?.includes(earlier.id) ?? false;
const stack = new SelectiveUndoStack<Doc>(
{ fields: {} },
{ throwOnConflict: true, dependencyCheck: depCheck as DependencyChecker<unknown> }
);
const setName = createCommand("SetName", (d) => ({ ...d, fields: { ...d.fields, name: "Alice" } }), (d) => {
const { name: _, ...rest } = d.fields;
return { ...d, fields: rest };
});
const greet = createCommand("Greet", (d) => ({ ...d, fields: { ...d.fields, greeting: `Hi, ${d.fields["name"]}` } }), (d) => {
const { greeting: _, ...rest } = d.fields;
return { ...d, fields: rest };
}, { dependsOn: [setName.id] });
stack.push(setName);
stack.push(greet);
try {
stack.undoById(setName.id);
} catch (err) {
if (err instanceof UndoError && err.code === UNDO_CODES.DEPENDENCY_CONFLICT) {
console.error("Conflict:", (err.context as Record<string, unknown>)?.["conflictIds"]);
}
}
// Without throwOnConflict:
const check = stack.canUndoById(setName.id);
console.log(check.success); // false
console.log(check.conflictIds); // [greet.id]
```
## API Reference
### `new SelectiveUndoStack<T>(initial, options?, bus?)`
Options extend `UndoStackOptions` with:
| Option | Type | Default | Description |
|---|---|---|---|
| `throwOnConflict` | `boolean` | `false` | Throw instead of returning `success: false` |
| `dependencyCheck` | `DependencyChecker<T>` | `() => false` | Returns `true` if `later` depends on `earlier` |
### `undoById(commandId)` → `SelectiveUndoResult<T>`
Finds the command, checks for conflicts, re-applies subsequent commands. Returns `{ success: true, state, conflictIds: [] }` or `{ success: false, conflictIds }`.
### `getDependents(commandId)` → `string[]`
Returns IDs of later commands that depend on the given command. Non-throwing.
### `canUndoById(commandId)` → `SelectiveUndoResult<T>`
Checks conflicts without modifying state. Non-throwing.
## Implementation Notes
The re-application loop rebuilds `stateBefore` for each subsequent entry by tracking state as it would be after removing the target command. This ensures that `undo()` on any of those entries still works correctly after a selective undo. The `_throwOnConflict` and `_dependencyCheck` fields use `protected` visibility, consistent with the base class pattern, to allow further subclassing.