diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index fc143aa..0d101a2 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,19 +1,6 @@
# Architecture
-Internals, design decisions, and the full API reference. For getting started, see [README.md](./README.md).
-
----
-
-- [Data Flow](#data-flow)
-- [Entity Identity](#entity-identity)
-- [Versioning & Conflict Detection](#versioning--conflict-detection)
-- [Sanitization](#sanitization)
-- [StateHandler Interface](#statehandler-interface)
-- [Transition Modes In Depth](#transition-modes-in-depth)
-- [Async Patterns](#async-patterns)
-- [Module Map](#module-map)
-- [Performance Invariants](#performance-invariants)
-- [API Reference](#api-reference)
+Internals and design decisions. For usage, see [README.md](./README.md).
---
@@ -23,62 +10,23 @@ Internals, design decisions, and the full API reference. For getting started, se
-Two paths through the system:
-
**Write path** — dispatching actions:
-1. `stage`/`amend` dispatched — only the transitions list is updated, reducer state is untouched
-2. `commit` dispatched — reducer state is updated via the bound reducer, transition is removed
-3. After every mutation (gated by `===`), `sanitizeTransitions` replays all remaining transitions to detect no-ops and conflicts
+1. `stage`/`amend` — only the transitions list is updated, committed state is untouched
+2. `commit` — committed state is updated via the bound reducer, transition is removed
+3. After every mutation (gated by `===`), `sanitizeTransitions` replays remaining transitions to detect no-ops and conflicts
**Read path** — selecting state:
1. `selectOptimistic` replays pending transitions on top of committed state
-2. Returns the derived optimistic view — never stored, always computed
+2. Returns the derived view — never stored, always computed
3. Memoization is the consumer's responsibility via `createSelector`
---
## Entity Identity
-Every transition carries a string ID — the **stable link between a transition and its entity**. This ID is used everywhere: sanitization replays by ID, selectors look up by ID, deduplication matches on ID.
-
-**Default: `transitionId === entityId`.** Use `crudPrepare` to couple them automatically:
-
-```typescript
-const crud = crudPrepare('id');
-const createTodo = createTransitions('todos::add')(crud.create);
-
-dispatch(createTodo.stage(todo)); // transitionId auto-detected from todo.id
-dispatch(createTodo.amend(tid, amended)); // explicit — targets existing transition
-dispatch(createTodo.commit(tid)); // explicit
-```
-
-**Why only `stage` auto-detects:** `stage` initiates a new transition — the entity *is* the transition. `amend`/`commit`/`fail`/`stash` target an *existing* transition the consumer already holds a reference to. Auto-detecting on `amend` would be a footgun: an amended entity with a server-assigned ID would target the wrong transition.
-
-For edge-cases where `transitionId !== entityId` (batch ops, correlation IDs, temp-to-server ID mapping), write custom prepare functions.
-
----
-
-## Versioning & Conflict Detection
+Every transition carries a string ID — the stable link between a transition and its entity.
-Entities must carry a **monotonically increasing version** — `revision`, `updatedAt`, sequence number — anything orderable. Two curried comparators drive conflict detection:
-
-```typescript
-compare: (a: T) => (b: T) => 0 | 1 | -1 // version ordering
-eq: (a: T) => (b: T) => boolean // content equality at same version
-```
-
-During sanitization, `merge` calls `compare` on each entity:
-
-| `compare` result | Then check | Outcome |
-|------------------|------------|---------|
-| `1` (transition is newer) | — | **Valid** — keep |
-| `0` (same version) | `eq` returns `true` | **Skip** — no-op, discard |
-| `0` (same version) | `eq` returns `false` | **Conflict** — flag |
-| `-1` (transition is older) | — | **Conflict** — flag |
-
-These are thrown as `OptimisticMergeResult.SKIP` / `.CONFLICT` and caught by `sanitizeTransitions`.
-
-Without versioning, conflict detection degrades to content equality — it can't distinguish concurrent mutations from different clients.
+**Default: `transitionId === entityId`.** `crudPrepare` couples them automatically. Only `stage` auto-detects — `amend`/`commit`/`fail`/`stash` require explicit `transitionId` because the entity may carry a server-assigned ID that differs from the transition's.
---
@@ -90,18 +38,27 @@ Without versioning, conflict detection degrades to content equality — it can't
After every state mutation, `sanitizeTransitions` replays all pending transitions against committed state:
-1. Start with a shallow working copy of committed state (`Object.assign({}, state)` — the only copy in the system)
-2. For each transition: apply as-if-committed, check if state reference changed (`!==`), then `merge` to validate
-3. Result per transition: **keep** (valid), **discard** (no-op/skip), or **flag** (conflict)
+1. For each transition: apply as-if-committed, check if state reference changed (`!==`), then `merge` to validate
+2. Result per transition: **keep** (valid), **discard** (no-op/skip), or **flag** (conflict)
-Sanitization only runs when state actually changes — gated by referential equality (`===`).
+Only runs when state actually changes — gated by referential equality.
+
+---
+
+## Transition Modes
+
+`TransitionMode` controls both re-staging and failure behavior. Declared per action type at the `createTransitions` site — making invalid state combinations unrepresentable.
+
+- **`DEFAULT`** — overwrites on re-stage, flags on fail. Edits.
+- **`DISPOSABLE`** — overwrites on re-stage, drops on fail. Creates (entity never existed server-side).
+- **`REVERTIBLE`** — stores trailing fallback on re-stage, reverts on fail. Deletes (undo deletion).
---
## StateHandler Interface
```typescript
-interface StateHandler {
+interface StateHandler {
create: (state: State, dto: C) => State;
update: (state: State, dto: U) => State;
remove: (state: State, dto: D) => State;
@@ -109,126 +66,9 @@ interface StateHandler {
}
```
-`C`, `U`, `D` are scalar DTO generics — each operation takes a single object argument (identity + data together).
-
-**Critical invariant:** `update` and `remove` must return the **same reference** when nothing changed. Sanitization uses `===` to detect whether a transition had any effect. If your handler returns a new object on no-op, sanitization breaks.
-
-### Built-in handlers
-
-| Handler | State shape | DTO types | Options |
-|---------|-------------|-----------|---------|
-| `recordState` | `Record` | `C=T`, `U=Partial`, `D=Partial` | `{ key, compare, eq }` |
-| `nestedRecordState()` | `Record>` | `C=T`, `U=UpdateDTO`, `D=DeleteDTO` | `{ keys, compare, eq }` |
-| `singularState` | `T \| null` | `C=T`, `U=Partial`, `D=void` | `{ compare, eq }` |
-| `listState` | `T[]` | `C=T`, `U=Partial`, `D=Partial` | `{ key, compare, eq }` |
-
-### Auto-wired CRUD
-
-Built-in handlers expose a `wire` method via `WiredStateHandler`. When you pass a CRUD action map instead of a reducer function, `wire` handles action matching and payload routing:
-
-```typescript
-// wire does the routing — zero boilerplate
-optimistron('todos', initial, handler, {
- create: createTodo, update: editTodo, remove: deleteTodo,
-});
-```
-
-`optimistron()` uses function overloads to infer the CRUD map type from the handler's `wire` method, enforcing that each action matcher produces the right payload shape **at compile time**.
-
-### Custom handlers
-
-Implement `StateHandler` for any shape. The contract:
-
-1. `update`/`remove` must return the same reference on no-op
-2. `merge` must throw `OptimisticMergeResult.SKIP` for redundant transitions
-3. `merge` must throw `OptimisticMergeResult.CONFLICT` for stale transitions
-4. `merge` must return the merged state for valid transitions
-
----
-
-## Transition Modes In Depth
-
-`TransitionMode` is a single enum that controls both re-staging and failure behavior. Declared per action type at the `createTransitions` site — making invalid state combinations unrepresentable.
-
-### `DEFAULT` — edits
-
-- **Re-stage:** overwrites the existing transition
-- **Fail:** flags the transition as failed, keeps it in the list
-- **Use case:** user edits an entity, server rejects — show error, let user retry
-
-### `DISPOSABLE` — creates
-
-- **Re-stage:** overwrites the existing transition
-- **Fail:** drops the transition entirely
-- **Use case:** user creates an entity, server rejects — the entity never existed, remove it from view
-
-### `REVERTIBLE` — deletes
-
-- **Re-stage:** stores the replaced transition as a trailing fallback
-- **Fail:** stashes the transition (reverts to the trailing fallback)
-- **Use case:** user deletes an entity, server rejects — undo the deletion, restore the entity
-
----
-
-## Async Patterns
-
-Optimistron is transport-agnostic. The pattern is always: stage, then resolve.
-
-
-Component-level async
-
-```typescript
-const handleCreate = async (todo: Todo) => {
- dispatch(createTodo.stage(todo));
- try {
- const saved = await api.create(todo);
- dispatch(createTodo.amend(todo.id, saved));
- dispatch(createTodo.commit(todo.id));
- } catch (e) {
- dispatch(createTodo.fail(todo.id, e));
- }
-};
-```
-
-
-
-
-Thunks
-
-```typescript
-const createTodoThunk =
- (todo: Todo): ThunkAction =>
- async (dispatch) => {
- dispatch(createTodo.stage(todo));
- try {
- const saved = await api.create(todo);
- dispatch(createTodo.amend(todo.id, saved));
- dispatch(createTodo.commit(todo.id));
- } catch (e) {
- dispatch(createTodo.fail(todo.id, e));
- }
- };
-```
-
-
+**Critical:** `update` and `remove` must return the **same reference** when nothing changed — sanitization uses `===` to detect no-ops.
-
-Sagas
-
-```typescript
-function* createTodoSaga(action: ReturnType) {
- const transitionId = getTransitionMeta(action).id;
- try {
- const saved = yield call(api.create, action.payload);
- yield put(createTodo.amend(transitionId, saved));
- yield put(createTodo.commit(transitionId));
- } catch (e) {
- yield put(createTodo.fail(transitionId, e));
- }
-}
-```
-
-
+Built-in handlers (`recordState`, `nestedRecordState`, `singularState`, `listState`) implement this. Custom handlers must follow the same contract: return same reference on no-op, throw `OptimisticMergeResult.SKIP`/`.CONFLICT` from `merge`.
---
@@ -236,149 +76,41 @@ function* createTodoSaga(action: ReturnType) {
```
src/
-├── index.ts # Public API surface (barrel export)
-├── optimistron.ts # Factory: wraps reducers, returns { reducer, selectors }
-├── transitions.ts # Transition operations, processTransition, sanitizeTransitions
-├── reducer.ts # resolveReducer, bindReducer
-├── constants.ts # META_KEY
-│
-├── actions/
-│ ├── index.ts # Barrel: re-exports public API
-│ ├── transitions.ts # createTransition, createTransitions, resolveTransition
-│ ├── crud.ts # crudPrepare (single-key + multi-key overloads)
-│ └── types.ts # PreparePayload, PrepareError, ActionMeta, ItemPath, UpdateDTO, DeleteDTO
-│
-├── selectors/
-│ └── internal.ts # All selectors (returned from optimistron via selectors object, not exported)
-│
-├── state/
-│ ├── types.ts # TransitionState, StateHandler, WiredStateHandler, BoundStateHandler
-│ ├── factory.ts # bindStateFactory, buildTransitionState, transitionStateFactory
-│ ├── record.ts # recordState, nestedRecordState
-│ ├── singular.ts # singularState
-│ └── list.ts # listState
-│
-└── utils/
- ├── path.ts # getAt, setAt, removeAt — nested Record path traversal
- ├── types.ts # StringKeys, PathMap, Maybe, MaybeNull
- └── logger.ts # warn
+├── core/ # Pure Redux — no saga dependency
+│ ├── index.ts # Core barrel
+│ ├── optimistron.ts # Factory: wraps reducer, returns { reducer, selectors }
+│ ├── transitions.ts # Transition operations, processTransition, sanitizeTransitions
+│ ├── reducer.ts # resolveReducer, bindReducer
+│ ├── constants.ts # META_KEY
+│ ├── actions/
+│ │ ├── index.ts # Barrel
+│ │ ├── transitions.ts # createTransition, createTransitions, resolveTransition
+│ │ ├── crud.ts # crudPrepare
+│ │ ├── crud-transitions.ts # createCrudTransitions
+│ │ └── types.ts # TransitionActions, InferPayload, DTOs
+│ ├── selectors/
+│ │ └── internal.ts # All selectors (exposed via optimistron().selectors)
+│ ├── state/
+│ │ ├── types.ts # TransitionState, StateHandler, WiredStateHandler
+│ │ ├── factory.ts # bindStateFactory, buildTransitionState
+│ │ ├── record.ts # recordState, nestedRecordState
+│ │ ├── singular.ts # singularState
+│ │ └── list.ts # listState
+│ └── utils/
+│ ├── path.ts # getAt, setAt, removeAt
+│ ├── types.ts # StringKeys, PathMap, Maybe, MaybeNull
+│ └── logger.ts # warn
+├── saga/ # Saga orchestration effects
+│ ├── index.ts # Saga barrel
+│ └── effects.ts # watchTransition, handleTransition, retryFailed
+└── index.ts # Root barrel (core + saga)
```
-Key implementation details:
-
-- **`TransitionState`** wraps user state with a non-enumerable `transitions` list (via `Object.defineProperties` — hidden from serializers and spreads)
-- **`transitionStateFactory`** returns the previous state object when both `committed` and `transitions` are referentially equal (preserves memoization)
-- **`selectors`** are returned as a grouped object from `optimistron()` — no standalone exports, each slice is self-contained
-- **Action types** use `namespace::operation` format, matching uses `startsWith`
-
---
## Performance Invariants
-These are non-negotiable — the library design depends on them:
-
-1. **No full state copies.** The only shallow copy is `Object.assign({}, state)` in `sanitizeTransitions` — a mutable working copy, not a checkpoint.
-2. **`sanitizeTransitions` runs on every state mutation.** Keep it lean. No unnecessary allocations.
-3. **Referential equality (`===`) gates sanitization.** `transitionStateFactory` returns the previous state object when nothing changed.
-4. **`selectOptimistic` replays all transitions on every call.** Memoization is the consumer's job via `createSelector`. Fast-path returns early when `transitions.length === 0`.
-5. **Handler operations return the same reference on no-op.** This is how sanitization detects no-ops.
-
----
-
-## API Reference
-
-### `optimistron(namespace, initialState, handler, config, options?)`
-
-Creates an optimistic reducer wrapper. Returns `{ reducer, selectors }`.
-
-| Param | Type | Description |
-|-------|------|-------------|
-| `namespace` | `string` | Action type prefix (`"namespace::operation"`) |
-| `initialState` | `S` | Initial state value |
-| `handler` | `StateHandler` | State handler implementation |
-| `config` | `ReducerConfig` | CRUD action map or reducer function |
-| `options.sanitizeAction` | `(action) => action` | Optional action transform before sanitization |
-
-### `selectors`
-
-Returned from `optimistron()` as a grouped object. No selectors are exported from the library — they are all bound to the slice's `TransitionState`.
-
-#### `selectors.selectOptimistic(selector)`
-
-Replays pending transitions before applying the selector. Always wrap with `createSelector`:
-
-```typescript
-const { selectors } = optimistron('todos', initial, handler, config);
-
-const selectTodos = createSelector(
- (state: RootState) => state.todos,
- selectors.selectOptimistic((todos) => Object.values(todos.committed)),
-);
-```
-
-### `createTransitions(type, mode?)(prepare)`
-
-Creates a full set of transition action creators: `.stage`, `.amend`, `.commit`, `.fail`, `.stash`, `.match`.
-
-`prepare` can be a single prepare function (shared across operations) or an object with per-operation preparators:
-
-```typescript
-createTransitions('todos::add')({
- stage: (item: Todo) => ({ payload: item, transitionId: item.id }),
- commit: () => ({ payload: {} }),
-});
-```
-
-### `createCrudTransitions(namespace, key)` / `createCrudTransitions()(namespace, keys)`
-
-High-level helper that composes `crudPrepare` + `createTransitions` with golden-path modes:
-
-```typescript
-// Single-key
-const todo = createCrudTransitions('todos', 'id');
-// todo.create → DISPOSABLE (drop on fail)
-// todo.update → DEFAULT (flag on fail)
-// todo.remove → REVERTIBLE (stash on fail)
-
-// Multi-key — curried for key inference
-const item = createCrudTransitions()('projects', ['projectId', 'id']);
-```
-
-Each operation returns a full transition set (`.stage`, `.amend`, `.commit`, `.fail`, `.stash`, `.match`).
-
-### `crudPrepare(key)` / `crudPrepare()(keys)`
-
-Factory for CRUD prepare functions that couple `transitionId === entityId`. Use when you need custom modes or per-operation preparators:
-
-```typescript
-// Single-key (recordState, listState)
-const crud = crudPrepare('id');
-// crud.create(todo) → payload: todo, transitionId: todo.id
-// crud.update({ id, done }) → payload: { id, done }, transitionId: id
-// crud.remove({ id }) → payload: { id }, transitionId: id
-
-// Multi-key (nestedRecordState) — curried for key inference
-const crud = crudPrepare()(['projectId', 'id']);
-// transitionId: "projectId-value/id-value"
-```
-
-#### Per-entity selectors
-
-All returned on `selectors`, curried: `selectors.selector(id)(transitionState)`.
-
-| Selector | Returns |
-|----------|---------|
-| `selectIsOptimistic(id)` | `boolean` — transition is pending |
-| `selectIsFailed(id)` | `boolean` — transition has failed |
-| `selectIsConflicting(id)` | `boolean` — transition conflicts with committed state |
-| `selectFailure(id)` | `StagedAction \| undefined` — failed transition for entity |
-| `selectConflict(id)` | `StagedAction \| undefined` — conflicting transition for entity |
-| `selectFailures` | `(state) => StagedAction[]` — all failed transitions in this slice |
-
-### Enums
-
-```typescript
-Operation.STAGE | .AMEND | .COMMIT | .STASH | .FAIL
-TransitionMode.DEFAULT | .DISPOSABLE | .REVERTIBLE
-OptimisticMergeResult.SKIP | .CONFLICT
-```
+1. **No full state copies.** Only shallow copy is in `sanitizeTransitions` — a mutable working copy, not a checkpoint.
+2. **Referential equality (`===`) gates sanitization.** `transitionStateFactory` returns the previous state object when nothing changed.
+3. **`selectOptimistic` replays all transitions on every call.** Fast-path returns early when `transitions.length === 0`. Memoize with `createSelector`.
+4. **Handler operations return the same reference on no-op.** This is how sanitization detects no-ops.
diff --git a/README.md b/README.md
index f0083c0..e56c54b 100644
--- a/README.md
+++ b/README.md
@@ -5,47 +5,10 @@
+
-> Optimistic state management for Redux. Optimistic state is derived at the selector level by replaying transitions on top of committed state, similar to `git rebase`.
-
----
-
-## Why Optimistron?
-
-Optimistron tracks lightweight **transitions** (stage, amend, commit, fail) alongside your reducer state and replays them at read-time through `selectOptimistic`. No state snapshots or checkpoints are stored per operation.
-
-Optimistron was designed around an **event-driven saga architecture** — components dispatch intent via `stage`, and sagas (or listener middleware) orchestrate the transition lifecycle. It can be used with thunks or direct component dispatches, but the separation between intent and orchestration is where it fits most naturally.
-
-Designed for:
-
-- **Offline-first** — transitions queue up while disconnected, conflicts resolve on reconnect
-- **Large/normalized state** — state is derived, not copied
-
----
-
-## The Mental Model
-
-Think of each `optimistron()` reducer as a **git branch**:
-
-| Git | Optimistron |
-| -------------- | -------------------------------------------------------------------- |
-| Branch tip | **Committed state** — only `COMMIT` advances it |
-| Staged commits | **Transitions** — pending changes on top of committed state |
-| `git rebase` | **`selectOptimistic`** — replays transitions at read-time |
-| Merge conflict | **Sanitization** — detects no-ops and conflicts after every mutation |
-
-`STAGE`, `AMEND`, `FAIL`, `STASH` never touch reducer state — they only modify the transitions list. The optimistic view updates because `selectOptimistic` re-derives on the next read.
-
-There are no separate `isLoading` / `error` / `isOptimistic` flags — a pending transition represents the loading state, and a failed transition carries the error.
-
----
-
-## Transition Lifecycle
-
-
-
-
+> Saga-first optimistic state management for Redux. Components dispatch intent, sagas orchestrate the lifecycle, optimistic state is derived at read-time by replaying transitions — like `git rebase`.
---
@@ -53,16 +16,30 @@ There are no separate `isLoading` / `error` / `isOptimistic` flags — a pending
```bash
npm install @lostsolution/optimistron
-# ⚠️ not published yet
+# requires: redux ^5, @reduxjs/toolkit ^2, redux-saga ^1
```
+### Subpath exports
+
+| Import | Includes | Requires saga? |
+|--------|----------|----------------|
+| `@lostsolution/optimistron` | Core + saga effects | Yes |
+| `@lostsolution/optimistron/core` | Core only (reducers, actions, selectors) | No |
+| `@lostsolution/optimistron/saga` | Saga effects only | Yes |
+
+Use `/core` for thunks, listener middleware, or manual orchestration — no saga dependency.
+
---
## Quick Start
```typescript
-import { configureStore, createSelector } from '@reduxjs/toolkit';
-import { optimistron, createCrudTransitions, recordState } from '@lostsolution/optimistron';
+import { configureStore, createAction, createSelector } from '@reduxjs/toolkit';
+import createSagaMiddleware from 'redux-saga';
+import {
+ optimistron, createCrudTransitions, recordState,
+ watchTransition, retryFailed,
+} from '@lostsolution/optimistron';
// 1. Define your entity
type Todo = { id: string; value: string; done: boolean; revision: number };
@@ -83,130 +60,108 @@ const { reducer: todos, selectors } = optimistron(
);
// 4. Wire up the store
-const store = configureStore({ reducer: { todos } });
+const sagaMiddleware = createSagaMiddleware();
+const store = configureStore({
+ reducer: { todos },
+ middleware: (getDefault) => getDefault().concat(sagaMiddleware),
+});
-// 5. Select optimistic state (memoize with createSelector)
+// 5. Sagas — one line per transition
+function* rootSaga() {
+ yield watchTransition(todo.create, api.createTodo, {
+ amend: (payload, saved) => ({ ...payload, id: saved.id }),
+ });
+ yield watchTransition(todo.update, api.updateTodo);
+ yield watchTransition(todo.remove, api.deleteTodo);
+ // stash-on-fail is automatic for REVERTIBLE transitions (removes)
+
+ const retryAll = createAction('todos::retryAll');
+ yield retryFailed(retryAll, (state) => selectors.selectFailures(state.todos));
+}
+
+sagaMiddleware.run(rootSaga);
+
+// 6. Select optimistic state
const selectTodos = createSelector(
(state: RootState) => state.todos,
selectors.selectOptimistic((todos) => Object.values(todos.committed)),
);
-// 6. Dispatch transitions
-dispatch(todo.create.stage(item)); // optimistic — shows immediately
-dispatch(todo.create.commit(item.id)); // server confirmed — becomes committed state
-dispatch(todo.create.fail(item.id, error)); // server rejected — dropped (DISPOSABLE)
+// 7. Components only dispatch intent
+dispatch(todo.create.stage(item)); // shows immediately
+dispatch(todo.update.stage(edited)); // saga handles commit/fail
```
-> `createCrudTransitions` composes `crudPrepare` + `createTransitions` with golden-path modes: **DISPOSABLE** create, **DEFAULT** update, **REVERTIBLE** remove. For custom modes or per-operation preparators, use `crudPrepare` + `createTransitions` directly.
-
---
-## Three Rules
-
-1. **One ID, one entity** — each transition ID maps to exactly one entity
-2. **One at a time** — don't stage a new transition while one is already pending for the same ID
-3. **One operation per transition** — a single create, update, or delete
+## The Mental Model
----
+Think of each `optimistron()` reducer as a **git branch**:
-## Versioning
+| Git | Optimistron |
+| -------------- | -------------------------------------------------------------------- |
+| Branch tip | **Committed state** — only `COMMIT` advances it |
+| Staged commits | **Transitions** — pending changes on top of committed state |
+| `git rebase` | **`selectOptimistic`** — replays transitions at read-time |
+| Merge conflict | **Sanitization** — detects no-ops and conflicts after every mutation |
-Entities need a **monotonically increasing version** — `revision`, `updatedAt`, a sequence number. This is how sanitization tells "newer" from "stale":
+`STAGE`, `AMEND`, `FAIL`, `STASH` never touch reducer state — they only modify the transitions list. There are no separate `isLoading` / `error` / `isOptimistic` flags — a pending transition **is** the loading state, and a failed transition carries the error.
-```typescript
-// Shorthand — extracts version, compare is generated automatically
-version: (item) => item.revision,
-eq: (a, b) => boolean,
+---
-// Full control — provide your own compare
-compare: (a, b) => 0 | 1 | -1,
-eq: (a, b) => boolean,
-```
+## Transition Lifecycle
-Without versioning, conflict detection degrades to content equality only.
+