first commit
This commit is contained in:
115
docs/foundation/flow/undo/src/selective/selective-undo.md
Normal file
115
docs/foundation/flow/undo/src/selective/selective-undo.md
Normal 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.
|
||||
Reference in New Issue
Block a user