diff --git a/.changeset/b43ec17f.md b/.changeset/b43ec17f.md new file mode 100644 index 0000000..93c9cc4 --- /dev/null +++ b/.changeset/b43ec17f.md @@ -0,0 +1,9 @@ +--- +'@transactional-reducer/core': patch +'@transactional-reducer/react': patch +--- + +Return engine instance directly from useTransactionalReducer + +The second return value is now the TransactionalReducer engine instance +instead of a wrapper API object. Use engine.state instead of api.getDraft(). diff --git a/README.md b/README.md index a575234..31eac9c 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ # transactional-reducer -为 reducer 模式提供事务(Transaction)支持的状态管理库。允许你将一组 dispatch 操作包裹在事务中,支持**提交(commit)**和**回滚(rollback)**,就像数据库事务一样。 +A state management library that adds **transaction** support to the reducer pattern. It lets you wrap a group of dispatch operations in a transaction with **commit** and **rollback** semantics — just like database transactions. -## 特性 +## Features -- **乐观更新 + 自动回滚**:先乐观地更新状态,异步操作失败时自动撤销变更 -- **可取消的异步任务**:相同 id 的事务自动取消前一个,避免竞态条件 -- **灵活的去重策略**:`rollback`、`commit`、`reuse`、`reject` 四种策略 -- **嵌套事务**:支持父子事务,子事务可独立提交或随父事务回滚 -- **提交边界**:`onError: "commit"` 的子事务在父事务回滚时被保留 -- **框架无关**:核心引擎可用于任何 JavaScript 环境 +- **Optimistic updates with automatic rollback**: Optimistically update state first; changes are automatically reverted if the async operation fails +- **Cancellable async tasks**: A new transaction with the same ID automatically cancels the previous one, preventing race conditions +- **Flexible deduplication strategies**: Four strategies — `rollback`, `commit`, `reuse`, and `reject` +- **Nested transactions**: Parent and child transactions; children can commit independently or roll back with their parent +- **Commit boundaries**: A child transaction with `onError: "commit"` is preserved even when its parent rolls back +- **Framework-agnostic**: The core engine works in any JavaScript environment -## 包 +## Packages -| 包 | 说明 | +| Package | Description | |---|---| -| [`@transactional-reducer/core`](packages/core/README.md) | 核心引擎,框架无关 | -| [`@transactional-reducer/react`](packages/react/README.md) | React Hook(`useTransactionalReducer`) | +| [`@transactional-reducer/core`](packages/core/README.md) | Core engine — framework-agnostic | +| [`@transactional-reducer/react`](packages/react/README.md) | React Hook (`useTransactionalReducer`) | -## 快速开始 +## Quick Start ```ts import { TransactionalReducer } from "@transactional-reducer/core"; @@ -32,24 +32,24 @@ const reducer = (state, action) => { const engine = new TransactionalReducer(reducer, { count: 0 }); -// 乐观更新 + 自动回滚 +// Optimistic update with automatic rollback await engine.run(async (tx) => { tx.dispatch({ type: "inc" }); await fetch("/api/inc"); - // 成功 → 自动 commit;失败 → 自动 rollback + // Success → auto-commit; failure → auto-rollback }); ``` -React 用法: +React usage: ```tsx import { useTransactionalReducer } from "@transactional-reducer/react"; function Counter() { - const [state, api] = useTransactionalReducer(reducer, { count: 0 }); + const [state, engine] = useTransactionalReducer(reducer, { count: 0 }); const handleOptimisticInc = () => - api.run(async (tx) => { + engine.run(async (tx) => { tx.dispatch({ type: "inc" }); await fetch("/api/inc"); }); @@ -57,13 +57,13 @@ function Counter() { return (

{state.count}

- +
); } ``` -## 开发 +## Development ```bash pnpm install diff --git a/README.zh_CN.md b/README.zh_CN.md new file mode 100644 index 0000000..0dd9b5c --- /dev/null +++ b/README.zh_CN.md @@ -0,0 +1,76 @@ +# transactional-reducer + +为 reducer 模式提供事务(Transaction)支持的状态管理库。允许你将一组 dispatch 操作包裹在事务中,支持**提交(commit)**和**回滚(rollback)**,就像数据库事务一样。 + +## 特性 + +- **乐观更新 + 自动回滚**:先乐观地更新状态,异步操作失败时自动撤销变更 +- **可取消的异步任务**:相同 id 的事务自动取消前一个,避免竞态条件 +- **灵活的去重策略**:`rollback`、`commit`、`reuse`、`reject` 四种策略 +- **嵌套事务**:支持父子事务,子事务可独立提交或随父事务回滚 +- **提交边界**:`onError: "commit"` 的子事务在父事务回滚时被保留 +- **框架无关**:核心引擎可用于任何 JavaScript 环境 + +## 包 + +| 包 | 说明 | +|---|---| +| [`@transactional-reducer/core`](packages/core/README.md) | 核心引擎,框架无关 | +| [`@transactional-reducer/react`](packages/react/README.md) | React Hook(`useTransactionalReducer`) | + +## 快速开始 + +```ts +import { TransactionalReducer } from "@transactional-reducer/core"; + +const reducer = (state, action) => { + switch (action.type) { + case "inc": return { count: state.count + 1 }; + case "dec": return { count: state.count - 1 }; + } +}; + +const engine = new TransactionalReducer(reducer, { count: 0 }); + +// 乐观更新 + 自动回滚 +await engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + await fetch("/api/inc"); + // 成功 → 自动 commit;失败 → 自动 rollback +}); +``` + +React 用法: + +```tsx +import { useTransactionalReducer } from "@transactional-reducer/react"; + +function Counter() { + const [state, engine] = useTransactionalReducer(reducer, { count: 0 }); + + const handleOptimisticInc = () => + engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + await fetch("/api/inc"); + }); + + return ( +
+

{state.count}

+ +
+ ); +} +``` + +## 开发 + +```bash +pnpm install +pnpm build +pnpm test +``` + +## License + +MIT diff --git a/packages/core/README.md b/packages/core/README.md index 03ced77..4ce9ce0 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,24 +1,24 @@ # @transactional-reducer/core -为 reducer 模式提供事务(Transaction)支持的状态管理引擎。允许你将一组 dispatch 操作包裹在事务中,支持**提交(commit)**和**回滚(rollback)**,就像数据库事务一样。 +A state management engine that adds transaction support to the reducer pattern. It allows you to wrap a group of dispatch operations in a transaction with **commit** and **rollback** semantics, just like a database transaction. -框架无关——可用于 React、Vue、Node.js 或任何 JavaScript 环境。 +Framework-agnostic — works with React, Vue, Node.js, or any JavaScript environment. -## 核心价值 +## Core Value -- **乐观更新 + 自动回滚**:先乐观地更新状态,异步操作失败时自动撤销变更 -- **可取消的异步任务**:相同 id 的事务自动取消前一个,避免竞态条件;`onCancel` 支持在取消时主动清理资源(如中止网络请求) -- **灵活的去重策略**:`onDuplicate` 支持四种策略——`rollback`(回滚旧事务)、`commit`(提交旧事务)、`reuse`(复用旧事务)、`reject`(拒绝创建) -- **嵌套事务**:支持父子事务,子事务可独立提交或随父事务回滚 -- **提交边界**:`onError: "commit"` 的子事务在父事务回滚时被保留,实现"部分成功"语义 +- **Optimistic updates + automatic rollback**: Optimistically update state first, then automatically revert changes if the async operation fails +- **Cancellable async tasks**: A new transaction with the same id automatically cancels the previous one, preventing race conditions; `onCancel` supports proactive resource cleanup (e.g., aborting network requests) +- **Flexible deduplication strategies**: `onDuplicate` supports four strategies — `rollback` (roll back the old transaction), `commit` (commit the old transaction), `reuse` (reuse the old transaction), `reject` (reject the creation) +- **Nested transactions**: Parent-child transactions are supported; child transactions can commit independently or roll back with the parent +- **Commit boundary**: A child transaction with `onError: "commit"` is preserved when the parent rolls back, enabling "partial success" semantics -## 安装 +## Installation ```bash npm install @transactional-reducer/core ``` -## 快速开始 +## Quick Start ```ts import { TransactionalReducer } from "@transactional-reducer/core"; @@ -35,24 +35,24 @@ const reducer = (state: State, action: Action): State => { const engine = new TransactionalReducer(reducer, { count: 0 }); -// 普通 dispatch —— 不可回滚 +// Non-transactional dispatch — not rollable back engine.dispatch({ type: "inc" }); console.log(engine.state); // { count: 1 } -// 事务性 dispatch —— 可回滚 +// Transactional dispatch — rollable back engine.run(async (tx) => { - tx.dispatch({ type: "inc" }); // 乐观更新 - await fetch("/api/inc"); // 异步请求 - // 成功 → 自动 commit;失败 → 自动 rollback + tx.dispatch({ type: "inc" }); // optimistic update + await fetch("/api/inc"); // async request + // success → auto commit; failure → auto rollback }); -// 手动管理生命周期 +// Manual lifecycle management const tx = engine.create(); tx.dispatch({ type: "inc" }); tx.rollback(); -console.log(engine.state); // { count: 1 }(回滚了) +console.log(engine.state); // { count: 1 } (rolled back) -// 订阅状态变化 +// Subscribe to state changes engine.subscribe((state) => { console.log("state changed:", state); }); @@ -60,9 +60,9 @@ engine.subscribe((state) => { --- -## API 参考 +## API Reference -### 导出 +### Exports ```ts import { @@ -91,62 +91,90 @@ class TransactionalReducer { run(task: (tx: TransactionHandle) => R, options?: TransactionOptions): R; create(options?: TransactionOptions): TransactionHandle; getTransaction(id: string): TransactionHandle | undefined; + commitAll(): void; + rollbackAll(): void; } ``` #### `engine.state` -当前状态。每次 dispatch 后立即更新。 +The current state. Updated immediately after each dispatch. #### `engine.subscribe(listener)` -订阅状态变化。返回取消订阅的函数。 +Subscribe to state changes. Returns an unsubscribe function. ```ts const unsubscribe = engine.subscribe((state) => { console.log(state); }); -unsubscribe(); // 取消订阅 +unsubscribe(); // unsubscribed ``` #### `engine.dispatch(action)` -普通 dispatch,不可回滚。当没有活跃事务时,不会记录到 action log(不可能回滚,日志纯属开销)。当有活跃事务时,会记录到 action log 以确保回滚重放时保留。 +Non-transactional dispatch, not rollable back. When no transaction is active, the action is not recorded in the action log (rollback is impossible, so logging is pure overhead). When a transaction is active, the action is recorded in the action log to ensure it is preserved during rollback replay. #### `engine.run(task, options?)` -启动一个根事务并自动管理生命周期: +Starts a root transaction with automatic lifecycle management: -- **同步任务成功** → 先回滚所有仍活跃的子事务,再提交 -- **同步任务抛错** → 根据 `onError` 决定回滚或提交 -- **异步任务成功** → Promise resolve 后先回滚所有仍活跃的子事务,再提交 -- **异步任务抛错** → Promise reject 后根据 `onError` 决定回滚或提交 +- **Sync task succeeds** → rolls back all still-active child transactions, then commits +- **Sync task throws** → rolls back or commits based on `onError` +- **Async task succeeds** → after Promise resolves, rolls back all still-active child transactions, then commits +- **Async task throws** → after Promise rejects, rolls back or commits based on `onError` -`task` 的返回值会被原样返回(包括 Promise),方便链式调用。 +The return value of `task` is returned as-is (including Promises), enabling chained calls. -> **注意**:`run`/`spawn` 在提交前会自动回滚所有仍活跃的子事务。这意味着如果父事务先完成,尚未结束的子事务会被强制回滚。这与手动调用 `tx.commit()` 的行为不同——手动 `commit()` 不会自动回滚活跃子事务。 +> **Note**: `run`/`spawn` automatically roll back all still-active child transactions before committing. This means if the parent transaction finishes first, any unfinished child transactions are forcibly rolled back. This differs from manually calling `tx.commit()` — manual `commit()` does not automatically roll back active child transactions. #### `engine.create(options?)` -手动创建根事务。你需要自行调用 `tx.commit()` 或 `tx.rollback()` 来结束事务。 +Manually creates a root transaction. You are responsible for calling `tx.commit()` or `tx.rollback()` to end the transaction. -> **与 `run` 的区别**: -> - `create` 不提供自动生命周期管理(不会在成功/失败时自动提交/回滚) -> - `create` 不会在提交前自动回滚活跃子事务 -> - `create` 支持所有去重策略,包括 `reuse`(返回旧事务句柄) -> - 在异步场景中,建议在 `commit()`/`rollback()` 前手动检查 `tx.isStale()` +> **Differences from `run`**: +> - `create` does not provide automatic lifecycle management (no auto commit/rollback on success/failure) +> - `create` does not automatically roll back active child transactions before committing +> - `create` supports all deduplication strategies, including `reuse` (returns the old transaction handle) +> - In async scenarios, it is recommended to manually check `tx.isStale()` before calling `commit()`/`rollback()` #### `engine.getTransaction(id)` -按 id 查找事务。返回 `TransactionHandle` 或 `undefined`。 +Looks up a transaction by id. Returns a `TransactionHandle` or `undefined`. + +#### `engine.commitAll()` + +Commits all active root transactions. Each root transaction rolls back its active child transactions first, then commits. Silently ignored when no transactions are active. + +```ts +const tx1 = engine.create({ id: "tx1" }); +const tx2 = engine.create({ id: "tx2" }); +tx1.dispatch({ type: "inc" }); +tx2.dispatch({ type: "inc" }); + +engine.commitAll(); // both tx1 and tx2 are committed +``` + +#### `engine.rollbackAll()` + +Rolls back all active root transactions, cascading to child transactions. Silently ignored when no transactions are active. + +```ts +const tx1 = engine.create({ id: "tx1" }); +const tx2 = engine.create({ id: "tx2" }); +tx1.dispatch({ type: "inc" }); +tx2.dispatch({ type: "inc" }); + +engine.rollbackAll(); // both tx1 and tx2 are rolled back, state restored +``` ### TransactionOptions ```ts interface TransactionOptions { - id?: string; // 事务 id,用于去重和查找 - onError?: OnErrorStrategy; // "rollback" | "commit",默认 "rollback" - onDuplicate?: OnDuplicateStrategy; // "rollback" | "reuse" | "commit" | "reject",默认 "rollback" + id?: string; // transaction id, used for deduplication and lookup + onError?: OnErrorStrategy; // "rollback" | "commit", default "rollback" + onDuplicate?: OnDuplicateStrategy; // "rollback" | "reuse" | "commit" | "reject", default "rollback" } ``` @@ -162,6 +190,7 @@ interface TransactionHandle { spawn(task: (tx: TransactionHandle) => R, options?: TransactionOptions): R; commit(): void; rollback(): void; + finalize(): void; isStale(): boolean; onCancel(callback: () => void): void; } @@ -169,40 +198,51 @@ interface TransactionHandle { #### `tx.dispatch(action)` -在事务内派发 action。如果事务已过期,静默忽略。 +Dispatches an action within the transaction. Silently ignored if the handle is stale. #### `tx.spawn(task, options?)` -创建子事务并自动管理生命周期(同 `run`)。如果父事务已过期,抛出错误。 +Creates a child transaction with automatic lifecycle management (same as `run`). Throws an error if the parent handle is stale. -子事务的 `id` 不会自动拼接父事务 id,由用户完全控制,需注意避免冲突。 +The child transaction's `id` is not automatically prefixed with the parent's id — the user has full control and should take care to avoid collisions. #### `tx.commit()` -提交事务。如果事务已过期,静默忽略。 +Commits the transaction. Silently ignored if the handle is stale. -- **子事务提交**:仅标记为 `"committed"`,仍在父事务范围内。父事务回滚也会撤销已提交子事务的变更。 -- **根事务提交**:将 action 永久化,清理事务记录。 +- **Child transaction commit**: Only marks the status as `"committed"`, remaining within the parent's scope. If the parent rolls back, the committed child's changes are also reverted. +- **Root transaction commit**: Makes the actions permanent and cleans up the transaction record. #### `tx.rollback()` -回滚事务。如果事务已过期,静默忽略。参见[回滚算法](#回滚算法的六个阶段)。 +Rolls back the transaction. Silently ignored if the handle is stale. See [Rollback Algorithm](#the-six-phases-of-the-rollback-algorithm). + +#### `tx.finalize()` + +Rolls back all active child transactions, then commits the transaction. This is the same "success" lifecycle that `run()` and `spawn()` perform automatically — here exposed for manual use. Silently ignored if the handle is stale. + +```ts +const tx = engine.create({ id: "edit" }); +tx.dispatch({ type: "inc" }); +// ... async work done, children may still be active +tx.finalize(); // rollback active children, then commit +``` #### `tx.isStale()` -检查句柄是否过期。过期条件:`transactionsRef` 中该 id 持有不同对象,或句柄 status 不再 `"active"`。 +Checks whether the handle is stale. A handle is stale when `transactionsRef` holds a different object for that id, or the handle's status is no longer `"active"`. #### `tx.onCancel(callback)` -注册取消回调。如果事务已过期,回调立即执行。参见 [onCancel 触发时机](#oncancel-触发时机)。 +Registers a cancellation callback. If the handle is already stale, the callback executes immediately. See [onCancel Trigger Timing](#oncancel-trigger-timing). ### TransactionalReducerOptions ```ts interface TransactionalReducerOptions { - idGenerator?: () => string; // 自定义 id 生成器 - snapshot?: (state: S) => S; // 自定义快照函数(默认 structuredClone) - onDuplicate?: OnDuplicateStrategy; // 全局去重策略默认值(默认 "rollback") + idGenerator?: () => string; // custom id generator + snapshot?: (state: S) => S; // custom snapshot function (default: structuredClone) + onDuplicate?: OnDuplicateStrategy; // global default deduplication strategy (default: "rollback") } ``` @@ -212,8 +252,8 @@ interface TransactionalReducerOptions { type OnErrorStrategy = "rollback" | "commit" ``` -- `"rollback"`:任务抛错时回滚事务(默认) -- `"commit"`:任务抛错时保留变更(提交边界) +- `"rollback"`: Roll back the transaction when the task throws (default) +- `"commit"`: Preserve changes when the task throws (commit boundary) ### OnDuplicateStrategy @@ -221,33 +261,33 @@ type OnErrorStrategy = "rollback" | "commit" type OnDuplicateStrategy = "rollback" | "reuse" | "commit" | "reject" ``` -参见[去重策略](#去重--onduplicate-策略)。 +See [Deduplication Strategies](#deduplication--onduplicate-strategies). --- -## 使用指南 +## Usage Guide -### 1. 普通 Dispatch +### 1. Non-transactional Dispatch ```ts -engine.dispatch({ type: "inc" }); // 不可回滚 +engine.dispatch({ type: "inc" }); // not rollable back ``` -### 2. 乐观更新 + 自动回滚 +### 2. Optimistic Update + Automatic Rollback ```ts await engine.run(async (tx) => { tx.dispatch({ type: "setSaving", value: true }); tx.dispatch({ type: "updateData", value: newData }); await saveToServer(newData); - // 成功 → 自动 commit - // 失败 → 自动 rollback + // success → auto commit + // failure → auto rollback }); ``` -### 3. 可取消的异步任务 + onCancel +### 3. Cancellable Async Tasks + onCancel -给事务指定 `id`,相同 id 的新事务会自动取消(回滚)旧事务: +Assign an `id` to the transaction; a new transaction with the same id will automatically cancel (roll back) the old one: ```ts async function handleSearch(query: string) { @@ -260,26 +300,26 @@ async function handleSearch(query: string) { }, { id: "search" }); } -// 用户快速输入 "a"、"ab"、"abc": -// - "a" 和 "ab" 的请求被自动回滚 -// - 只有 "abc" 的结果保留 +// User quickly types "a", "ab", "abc": +// - Requests for "a" and "ab" are automatically rolled back +// - Only the results for "abc" are preserved ``` -### 4. 手动管理事务生命周期 +### 4. Manual Transaction Lifecycle Management ```ts const tx = engine.create({ id: "edit-form" }); tx.dispatch({ type: "updateField", field: "name", value: "new" }); -// 保存 +// Save await saveToServer(engine.state); tx.commit(); -// 或取消 +// Or cancel tx.rollback(); ``` -### 5. 嵌套事务(spawn) +### 5. Nested Transactions (spawn) ```ts await engine.run(async (tx) => { @@ -295,15 +335,15 @@ await engine.run(async (tx) => { }, { id: "submit" }); ``` -关键行为: +Key behaviors: -- 子事务提交后仍在父事务范围内——父事务回滚也会撤销子事务 -- 子事务回滚不影响父事务 -- 子事务 id 由用户指定,不会自动拼接 +- A committed child transaction remains within the parent's scope — if the parent rolls back, the child's changes are also reverted +- A rolled-back child transaction does not affect the parent +- Child transaction ids are user-specified and not auto-prefixed -### 6. 提交边界(onError: "commit") +### 6. Commit Boundary (onError: "commit") -`onError: "commit"` 创建提交边界——父事务回滚时保留该子事务: +`onError: "commit"` creates a commit boundary — the child transaction is preserved when the parent rolls back: ```ts await engine.run(async (tx) => { @@ -311,18 +351,18 @@ await engine.run(async (tx) => { childTx.dispatch({ type: "updateCache", value: data }); }, { id: "local-cache", onError: "commit" }); - await submitToServer(); // 失败 → 整个事务 rollback - // 但 local-cache 的变更被保留 + await submitToServer(); // fails → entire transaction rolls back + // but local-cache changes are preserved }, { id: "submit" }); ``` -提交边界的语义: +Commit boundary semantics: -- 父事务回滚时,保留的子事务变为独立根事务(`parentId` 设为 `null`) -- 提交边界覆盖整个子树 -- 保留的子事务可以继续操作 +- When the parent rolls back, preserved child transactions become independent root transactions (`parentId` is set to `null`) +- The commit boundary covers the entire subtree +- Preserved child transactions can continue to be used -### 7. 混合 onError 策略 +### 7. Mixed onError Strategies ```ts await engine.run(async (tx) => { @@ -338,13 +378,13 @@ await engine.run(async (tx) => { await placeOrder(); }, { id: "order" }); -// placeOrder() 失败: -// - cart-update 保留 -// - ui-effects 回滚 -// - tx 自身回滚 +// placeOrder() fails: +// - cart-update preserved +// - ui-effects rolled back +// - tx itself rolled back ``` -### 8. 并发事务 +### 8. Concurrent Transactions ```ts const [result1, result2] = await Promise.all([ @@ -361,26 +401,26 @@ const [result1, result2] = await Promise.all([ ]); ``` -每个事务独立管理,回滚其中一个不影响另一个。 +Each transaction is managed independently; rolling back one does not affect the other. -### 9. 状态订阅 +### 9. State Subscription ```ts const unsubscribe = engine.subscribe((state) => { render(state); }); -// 在事务内每次 dispatch 都会触发通知 +// Every dispatch inside a transaction triggers a notification engine.run((tx) => { - tx.dispatch({ type: "inc" }); // 触发通知 - tx.dispatch({ type: "inc" }); // 触发通知 - tx.rollback(); // 触发通知(恢复状态) + tx.dispatch({ type: "inc" }); // triggers notification + tx.dispatch({ type: "inc" }); // triggers notification + tx.rollback(); // triggers notification (state restored) }); ``` -### 10. 自定义快照函数 +### 10. Custom Snapshot Function -默认使用 `structuredClone`。如果状态包含不可克隆对象: +The default uses `structuredClone`. If your state contains non-clonable objects: ```ts const engine = new TransactionalReducer(reducer, initialState, { @@ -392,7 +432,7 @@ const engine = new TransactionalReducer(reducer, initialState, { }); ``` -### 11. 自定义 ID 生成器 +### 11. Custom ID Generator ```ts let counter = 0; @@ -403,123 +443,123 @@ const engine = new TransactionalReducer(reducer, initialState, { --- -## 核心机制详解 +## Core Mechanisms ### Action Log + Snapshot + Replay -事务回滚不是简单的"恢复快照",而是**快照 + 重放**: +Transaction rollback is not simply "restore a snapshot" — it uses a **snapshot + replay** approach: -1. 事务创建时,记录当前状态的快照(snapshot)和 action log 的起始位置(snapshotIndex) -2. 事务内的每次 dispatch 都记录到 action log 中,附带 `txId` 标识 -3. 回滚时,从快照开始重放所有 action log 条目,**跳过属于回滚事务的条目** +1. When a transaction is created, a snapshot of the current state and the starting position of the action log (snapshotIndex) are recorded +2. Each dispatch within the transaction is recorded in the action log with a `txId` tag +3. On rollback, the action log is replayed from the snapshot, **skipping entries belonging to the rolled-back transaction** -这种设计确保回滚仅撤销目标事务的变更,同时保留: +This design ensures that rollback only reverts the target transaction's changes while preserving: -- 事务期间发生的普通(非事务)dispatch -- 并发兄弟事务的 action -- `onError: "commit"` 的后代事务的 action +- Non-transactional dispatches that occurred during the transaction +- Actions from concurrent sibling transactions +- Actions from descendant transactions with `onError: "commit"` ``` -时间线: - ┌─ snapshot ─┬─── tx1 dispatch ────┬─── 普通 dispatch ────┬─── tx2 dispatch ────┐ - │ │ inc │ inc │ dec │ - └────────────┴──────────────────────┴───────────────────────┴──────────────────────┘ +Timeline: + ┌─ snapshot ─┬─── tx1 dispatch ────┬─── non-transactional dispatch ────┬─── tx2 dispatch ────┐ + │ │ inc │ inc │ dec │ + └────────────┴──────────────────────┴────────────────────────────────────┴──────────────────────┘ -tx1 rollback → 从 snapshot 重放,跳过 tx1 的 inc,保留普通 dispatch 的 inc 和 tx2 的 dec +tx1 rollback → replay from snapshot, skip tx1's inc, preserve the non-transactional dispatch's inc and tx2's dec ``` -### Generation 机制与过期句柄 +### Generation Mechanism and Stale Handles -当相同 id 的事务被替换时(去重机制),旧句柄变为"过期"。过期检测通过 **generation** 实现: +When a transaction with the same id is replaced (via deduplication), the old handle becomes "stale". Stale detection is implemented through a **generation** counter: -1. 每次用相同 id 创建新事务时,`generationRef` 中该 id 的 generation 递增 -2. 旧句柄闭包绑定的 generation 不再匹配 `generationRef` 中的新值 -3. `isStale()` 检查两个条件:`transactionsRef` 中该 id 是否持有不同对象,或句柄的 status 是否不再是 `"active"` +1. Each time a new transaction is created with the same id, the generation for that id in `generationRef` is incremented +2. The old handle's closure-bound generation no longer matches the new value in `generationRef` +3. `isStale()` checks two conditions: whether `transactionsRef` holds a different object for that id, or whether the handle's status is no longer `"active"` -过期句柄的操作行为: +Behavior of operations on stale handles: -- `dispatch` → 忽略 -- `commit` / `rollback` → 忽略 -- `spawn` → 抛出错误 -- `onCancel` → 立即执行回调 +- `dispatch` → ignored +- `commit` / `rollback` → ignored +- `spawn` → throws an error +- `onCancel` → callback executes immediately -这防止了异步回调在过期句柄上误操作(例如,旧的搜索请求完成后不会覆盖新的搜索结果)。 +This prevents async callbacks from inadvertently operating on stale handles (e.g., an old search request completing won't overwrite new search results). -### 去重 / onDuplicate 策略 +### Deduplication / onDuplicate Strategies -当创建事务时指定了 `id`,如果相同 id 的活跃事务已存在,会根据 `onDuplicate` 策略处理: +When creating a transaction with an `id`, if an active transaction with the same id already exists, the `onDuplicate` strategy determines the behavior: -| 策略 | 行为 | 适用场景 | -|------|------|----------| -| `rollback`(默认) | 回滚旧事务,创建新的 | 搜索/验证——新请求取代旧请求 | -| `commit` | 提交旧事务(含回滚其活跃子事务),创建新的 | 旧任务视为已完成 | -| `reuse` | `create`:返回旧句柄;`run`/`spawn`:抛错 | 编辑表单——只允许一个实例 | -| `reject` | 抛错,拒绝创建 | 严格禁止并发 | +| Strategy | Behavior | Use Case | +|----------|----------|----------| +| `rollback` (default) | Rolls back the old transaction, creates a new one | Search/validation — new request supersedes the old one | +| `commit` | Commits the old transaction (including rolling back its active children), creates a new one | Treat the old task as completed | +| `reuse` | `create`: returns the old handle; `run`/`spawn`: throws an error | Edit forms — only one instance allowed | +| `reject` | Throws an error, rejects creation | Strictly forbid concurrency | -策略优先级:`TransactionOptions.onDuplicate` > `TransactionalReducerOptions.onDuplicate` > `"rollback"` +Strategy priority: `TransactionOptions.onDuplicate` > `TransactionalReducerOptions.onDuplicate` > `"rollback"` ```ts -// rollback(默认行为)——新请求取代旧请求 +// rollback (default) — new request supersedes the old one engine.run(async (tx) => { ... }, { id: "search" }); -// reuse——只允许一个实例,复用已有事务 +// reuse — only one instance allowed, reuse existing transaction const tx = engine.create({ id: "edit-form", onDuplicate: "reuse" }); -// reject——严格禁止并发 +// reject — strictly forbid concurrency engine.run(async (tx) => { ... }, { id: "save", onDuplicate: "reject" }); -// commit——旧任务视为已完成 +// commit — treat the old task as completed engine.run(async (tx) => { ... }, { id: "refresh", onDuplicate: "commit" }); ``` -> **注意**:`reuse` 对 `run` 和 `spawn` 无效——它们会抛错而非复用旧事务,因为 `run`/`spawn` 的自动生命周期管理无法安全地应用于已有事务。 +> **Note**: `reuse` does not work with `run` and `spawn` — they throw an error instead of reusing the old transaction, because the automatic lifecycle management of `run`/`spawn` cannot be safely applied to an existing transaction. -### onCancel 触发时机 +### onCancel Trigger Timing -- 事务被去重替换(相同 id 的新事务回滚旧事务)→ 触发 -- 事务被手动 `rollback()` → 触发 -- 事务因父事务回滚而被回滚(在 rollbackSet 中)→ 触发 -- 事务因父事务自动提交而被强制回滚(`run`/`spawn` 完成时回滚仍活跃的子事务)→ 触发 -- 事务被 `commit()` → **不触发** -- `onError: "commit"` 的子事务在父事务回滚时被保留 → **不触发** +- Transaction replaced by deduplication (a new transaction with the same id rolls back the old one) → triggers +- Transaction manually rolled back via `rollback()` → triggers +- Transaction rolled back due to parent rollback (in rollbackSet) → triggers +- Transaction forcibly rolled back due to parent auto-commit (`run`/`spawn` rolls back still-active children on completion) → triggers +- Transaction committed via `commit()` → **does not trigger** +- Child transaction with `onError: "commit"` preserved during parent rollback → **does not trigger** -特殊行为: +Special behaviors: -- 如果事务已过期(`isStale()` 返回 true),回调立即执行 -- 可以注册多个回调,依次执行 -- 回调不会双重触发 +- If the handle is already stale (`isStale()` returns true), the callback executes immediately +- Multiple callbacks can be registered; they execute in order +- Callbacks are not double-triggered -### 子事务提交 vs 根事务提交 +### Child Transaction Commit vs Root Transaction Commit -**子事务提交**:仅将 status 标记为 `"committed"`。记录保留在 `transactionsRef` 中,父事务仍可管理它。父事务回滚也会撤销已提交子事务的变更。 +**Child transaction commit**: Only marks the status as `"committed"`. The record remains in `transactionsRef` and the parent can still manage it. If the parent rolls back, the committed child's changes are also reverted. -**根事务提交**: +**Root transaction commit**: -1. 将此事务及其后代的 action log 条目重新标记为普通 dispatch(`txId: null`),使其永久化 -2. 从 `transactionsRef` 中删除根事务记录 -3. 清理已提交的后代记录 -4. 如果没有活跃事务剩余,清空整个 action log 和事务映射 +1. Relabels this transaction's and its descendants' action log entries as non-transactional dispatches (`txId: null`), making them permanent +2. Removes the root transaction record from `transactionsRef` +3. Cleans up committed descendant records +4. If no active transactions remain, clears the entire action log and transaction map -### 回滚算法的六个阶段 +### The Six Phases of the Rollback Algorithm -若事务句柄已过期,`_rollback()` 立即返回。以下仅描述句柄仍活跃时的行为: +If the transaction handle is stale, `_rollback()` returns immediately. The following describes the behavior when the handle is still active: -1. **分类后代**:将后代分为 `preserveSet`(`onError: "commit"` 的子树)和 `rollbackSet` -2. **标记 skipped**:将 `rollbackSet` 的 action 标记为 `skipped` -3. **重新标记 preserveSet**:将已提交的保留子事务的 action 重新标记为普通 dispatch(`txId: null`) -4. **重放**:从快照重放,跳过 `skipped` 条目 -5. **分离保留的事务**:已提交的保留事务被删除;活跃的保留事务 `parentId` 设为 `null`(变为独立根),并更新其 snapshot -6. **最终清理**:若无活跃事务剩余,清空所有数据 +1. **Classify descendants**: Partition descendants into `preserveSet` (subtrees with `onError: "commit"`) and `rollbackSet` +2. **Mark skipped**: Mark actions from `rollbackSet` as `skipped` +3. **Relabel preserveSet**: Relabel actions from committed preserved child transactions as non-transactional dispatches (`txId: null`) +4. **Replay**: Replay from snapshot, skipping `skipped` entries +5. **Detach preserved transactions**: Committed preserved transactions are deleted; active preserved transactions have `parentId` set to `null` (becoming independent roots) and their snapshots are updated +6. **Final cleanup**: If no active transactions remain, clear all data -### 自动清理 +### Automatic Cleanup -当没有活跃事务时,action log、事务映射和 generation 映射会被清空。因为不可能再发生回滚,日志纯属开销。 +When no transactions are active, the action log, transaction map, and generation map are cleared. Since rollback is no longer possible, the log is pure overhead. --- -## 常见场景 +## Common Scenarios -### 搜索自动取消 +### Search with Auto-Cancel ```ts const handleSearch = debounce(async (query: string) => { @@ -535,7 +575,7 @@ const handleSearch = debounce(async (query: string) => { }, 300); ``` -### 表单编辑 + 取消恢复 +### Form Editing + Cancel to Restore ```ts const tx = engine.create({ id: "edit-profile" }); @@ -558,7 +598,7 @@ function cancel() { } ``` -### 多步骤提交 + 部分保留 +### Multi-Step Submit with Partial Preservation ```ts await engine.run(async (tx) => { @@ -575,16 +615,16 @@ await engine.run(async (tx) => { --- -## 注意事项 +## Caveats -1. **事务 id 冲突**:子事务的 id 不会自动拼接父事务 id,由用户完全控制。需注意避免不同父事务下的子事务使用相同 id。 +1. **Transaction id collisions**: Child transaction ids are not automatically prefixed with the parent's id — the user has full control. Take care to avoid using the same id for child transactions under different parents. -2. **过期句柄安全**:对过期句柄的 `dispatch`/`commit`/`rollback` 会被静默忽略,`spawn` 会抛出错误。这是设计行为,防止异步回调干扰新事务。 +2. **Stale handle safety**: `dispatch`/`commit`/`rollback` on a stale handle are silently ignored; `spawn` throws an error. This is by design to prevent async callbacks from interfering with new transactions. -3. **快照性能**:默认使用 `structuredClone`,对大型状态对象可能有性能开销。可通过 `snapshot` 选项提供更轻量的克隆函数。 +3. **Snapshot performance**: The default uses `structuredClone`, which may have performance overhead for large state objects. You can provide a lighter-weight clone function via the `snapshot` option. -4. **幂等性要求**:由于回滚使用"快照 + 重放"机制,reducer 应尽量保持幂等性——相同 action 在不同基础状态上应产生合理的结果。 +4. **Idempotency requirements**: Since rollback uses a "snapshot + replay" mechanism, reducers should strive to be idempotent — the same action should produce reasonable results when applied to different base states. -5. **同步 vs 异步**:`run` 和 `spawn` 对同步任务和异步任务的生命周期管理略有不同。同步任务执行期间不可能过期,无需额外检查;异步任务的 Promise 回调中会检查过期状态。 +5. **Sync vs async**: `run` and `spawn` handle lifecycle management slightly differently for sync vs async tasks. Sync tasks cannot become stale during execution, so no additional checks are needed; async tasks check for stale state in their Promise callbacks. -6. **onCancel 与 AbortError**:使用 `onCancel` + `AbortController` 取消异步请求后,被取消事务的 Promise 会以 `AbortError` reject(而非静默跳过 commit 后 resolve)。这是预期行为——取消意味着任务中止,错误应传播给调用方。 +6. **onCancel and AbortError**: After using `onCancel` + `AbortController` to cancel an async request, the cancelled transaction's Promise will reject with an `AbortError` (rather than silently skipping commit and resolving). This is expected behavior — cancellation means the task is aborted, and the error should propagate to the caller. diff --git a/packages/core/README.zh_CN.md b/packages/core/README.zh_CN.md new file mode 100644 index 0000000..0956f31 --- /dev/null +++ b/packages/core/README.zh_CN.md @@ -0,0 +1,630 @@ +# @transactional-reducer/core + +为 reducer 模式提供事务(Transaction)支持的状态管理引擎。允许你将一组 dispatch 操作包裹在事务中,支持**提交(commit)**和**回滚(rollback)**,就像数据库事务一样。 + +框架无关——可用于 React、Vue、Node.js 或任何 JavaScript 环境。 + +## 核心价值 + +- **乐观更新 + 自动回滚**:先乐观地更新状态,异步操作失败时自动撤销变更 +- **可取消的异步任务**:相同 id 的事务自动取消前一个,避免竞态条件;`onCancel` 支持在取消时主动清理资源(如中止网络请求) +- **灵活的去重策略**:`onDuplicate` 支持四种策略——`rollback`(回滚旧事务)、`commit`(提交旧事务)、`reuse`(复用旧事务)、`reject`(拒绝创建) +- **嵌套事务**:支持父子事务,子事务可独立提交或随父事务回滚 +- **提交边界**:`onError: "commit"` 的子事务在父事务回滚时被保留,实现"部分成功"语义 + +## 安装 + +```bash +npm install @transactional-reducer/core +``` + +## 快速开始 + +```ts +import { TransactionalReducer } from "@transactional-reducer/core"; + +type State = { count: number }; +type Action = { type: "inc" } | { type: "dec" }; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "inc": return { count: state.count + 1 }; + case "dec": return { count: state.count - 1 }; + } +}; + +const engine = new TransactionalReducer(reducer, { count: 0 }); + +// 普通 dispatch —— 不可回滚 +engine.dispatch({ type: "inc" }); +console.log(engine.state); // { count: 1 } + +// 事务性 dispatch —— 可回滚 +engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); // 乐观更新 + await fetch("/api/inc"); // 异步请求 + // 成功 → 自动 commit;失败 → 自动 rollback +}); + +// 手动管理生命周期 +const tx = engine.create(); +tx.dispatch({ type: "inc" }); +tx.rollback(); +console.log(engine.state); // { count: 1 }(回滚了) + +// 订阅状态变化 +engine.subscribe((state) => { + console.log("state changed:", state); +}); +``` + +--- + +## API 参考 + +### 导出 + +```ts +import { + TransactionalReducer, + type Transaction, + type TransactionHandle, + type TransactionOptions, + type TransactionalReducerOptions, + type OnErrorStrategy, + type OnDuplicateStrategy, + type ActionLogEntry, + type Ref, +} from "@transactional-reducer/core"; +``` + +### TransactionalReducer + +```ts +class TransactionalReducer { + constructor(reducer: (state: S, action: A) => S, initialState: S, options?: TransactionalReducerOptions); + + get state(): S; + subscribe(listener: (state: S) => void): () => void; + + dispatch(action: A): void; + run(task: (tx: TransactionHandle) => R, options?: TransactionOptions): R; + create(options?: TransactionOptions): TransactionHandle; + getTransaction(id: string): TransactionHandle | undefined; + commitAll(): void; + rollbackAll(): void; +} +``` + +#### `engine.state` + +当前状态。每次 dispatch 后立即更新。 + +#### `engine.subscribe(listener)` + +订阅状态变化。返回取消订阅的函数。 + +```ts +const unsubscribe = engine.subscribe((state) => { + console.log(state); +}); +unsubscribe(); // 取消订阅 +``` + +#### `engine.dispatch(action)` + +普通 dispatch,不可回滚。当没有活跃事务时,不会记录到 action log(不可能回滚,日志纯属开销)。当有活跃事务时,会记录到 action log 以确保回滚重放时保留。 + +#### `engine.run(task, options?)` + +启动一个根事务并自动管理生命周期: + +- **同步任务成功** → 先回滚所有仍活跃的子事务,再提交 +- **同步任务抛错** → 根据 `onError` 决定回滚或提交 +- **异步任务成功** → Promise resolve 后先回滚所有仍活跃的子事务,再提交 +- **异步任务抛错** → Promise reject 后根据 `onError` 决定回滚或提交 + +`task` 的返回值会被原样返回(包括 Promise),方便链式调用。 + +> **注意**:`run`/`spawn` 在提交前会自动回滚所有仍活跃的子事务。这意味着如果父事务先完成,尚未结束的子事务会被强制回滚。这与手动调用 `tx.commit()` 的行为不同——手动 `commit()` 不会自动回滚活跃子事务。 + +#### `engine.create(options?)` + +手动创建根事务。你需要自行调用 `tx.commit()` 或 `tx.rollback()` 来结束事务。 + +> **与 `run` 的区别**: +> - `create` 不提供自动生命周期管理(不会在成功/失败时自动提交/回滚) +> - `create` 不会在提交前自动回滚活跃子事务 +> - `create` 支持所有去重策略,包括 `reuse`(返回旧事务句柄) +> - 在异步场景中,建议在 `commit()`/`rollback()` 前手动检查 `tx.isStale()` + +#### `engine.getTransaction(id)` + +按 id 查找事务。返回 `TransactionHandle` 或 `undefined`。 + +#### `engine.commitAll()` + +提交所有活跃的根事务。每个根事务会先回滚其活跃子事务,再提交。无活跃事务时静默忽略。 + +```ts +const tx1 = engine.create({ id: "tx1" }); +const tx2 = engine.create({ id: "tx2" }); +tx1.dispatch({ type: "inc" }); +tx2.dispatch({ type: "inc" }); + +engine.commitAll(); // tx1 和 tx2 都被提交 +``` + +#### `engine.rollbackAll()` + +回滚所有活跃的根事务,级联回滚子事务。无活跃事务时静默忽略。 + +```ts +const tx1 = engine.create({ id: "tx1" }); +const tx2 = engine.create({ id: "tx2" }); +tx1.dispatch({ type: "inc" }); +tx2.dispatch({ type: "inc" }); + +engine.rollbackAll(); // tx1 和 tx2 都被回滚,状态恢复 +``` + +### TransactionOptions + +```ts +interface TransactionOptions { + id?: string; // 事务 id,用于去重和查找 + onError?: OnErrorStrategy; // "rollback" | "commit",默认 "rollback" + onDuplicate?: OnDuplicateStrategy; // "rollback" | "reuse" | "commit" | "reject",默认 "rollback" +} +``` + +### TransactionHandle + +```ts +interface TransactionHandle { + readonly id: string; + readonly parentId: string | null; + readonly onError: OnErrorStrategy; + + dispatch(action: A): void; + spawn(task: (tx: TransactionHandle) => R, options?: TransactionOptions): R; + commit(): void; + rollback(): void; + finalize(): void; + isStale(): boolean; + onCancel(callback: () => void): void; +} +``` + +#### `tx.dispatch(action)` + +在事务内派发 action。如果事务已过期,静默忽略。 + +#### `tx.spawn(task, options?)` + +创建子事务并自动管理生命周期(同 `run`)。如果父事务已过期,抛出错误。 + +子事务的 `id` 不会自动拼接父事务 id,由用户完全控制,需注意避免冲突。 + +#### `tx.commit()` + +提交事务。如果事务已过期,静默忽略。 + +- **子事务提交**:仅标记为 `"committed"`,仍在父事务范围内。父事务回滚也会撤销已提交子事务的变更。 +- **根事务提交**:将 action 永久化,清理事务记录。 + +#### `tx.rollback()` + +回滚事务。如果事务已过期,静默忽略。参见[回滚算法](#回滚算法的六个阶段)。 + +#### `tx.finalize()` + +回滚所有活跃的子事务,然后提交该事务。这是 `run()` 和 `spawn()` 自动执行的"成功"生命周期——此处暴露为手动使用。如果事务已过期,静默忽略。 + +```ts +const tx = engine.create({ id: "edit" }); +tx.dispatch({ type: "inc" }); +// ... 异步工作完成,子事务可能仍活跃 +tx.finalize(); // 回滚活跃子事务,然后提交 +``` + +#### `tx.isStale()` + +检查句柄是否过期。过期条件:`transactionsRef` 中该 id 持有不同对象,或句柄 status 不再 `"active"`。 + +#### `tx.onCancel(callback)` + +注册取消回调。如果事务已过期,回调立即执行。参见 [onCancel 触发时机](#oncancel-触发时机)。 + +### TransactionalReducerOptions + +```ts +interface TransactionalReducerOptions { + idGenerator?: () => string; // 自定义 id 生成器 + snapshot?: (state: S) => S; // 自定义快照函数(默认 structuredClone) + onDuplicate?: OnDuplicateStrategy; // 全局去重策略默认值(默认 "rollback") +} +``` + +### OnErrorStrategy + +```ts +type OnErrorStrategy = "rollback" | "commit" +``` + +- `"rollback"`:任务抛错时回滚事务(默认) +- `"commit"`:任务抛错时保留变更(提交边界) + +### OnDuplicateStrategy + +```ts +type OnDuplicateStrategy = "rollback" | "reuse" | "commit" | "reject" +``` + +参见[去重策略](#去重--onduplicate-策略)。 + +--- + +## 使用指南 + +### 1. 普通 Dispatch + +```ts +engine.dispatch({ type: "inc" }); // 不可回滚 +``` + +### 2. 乐观更新 + 自动回滚 + +```ts +await engine.run(async (tx) => { + tx.dispatch({ type: "setSaving", value: true }); + tx.dispatch({ type: "updateData", value: newData }); + await saveToServer(newData); + // 成功 → 自动 commit + // 失败 → 自动 rollback +}); +``` + +### 3. 可取消的异步任务 + onCancel + +给事务指定 `id`,相同 id 的新事务会自动取消(回滚)旧事务: + +```ts +async function handleSearch(query: string) { + await engine.run(async (tx) => { + const ac = new AbortController(); + tx.onCancel(() => ac.abort()); + tx.dispatch({ type: "setLoading", value: true }); + const results = await fetchResults(query, { signal: ac.signal }); + tx.dispatch({ type: "setResults", value: results }); + }, { id: "search" }); +} + +// 用户快速输入 "a"、"ab"、"abc": +// - "a" 和 "ab" 的请求被自动回滚 +// - 只有 "abc" 的结果保留 +``` + +### 4. 手动管理事务生命周期 + +```ts +const tx = engine.create({ id: "edit-form" }); +tx.dispatch({ type: "updateField", field: "name", value: "new" }); + +// 保存 +await saveToServer(engine.state); +tx.commit(); + +// 或取消 +tx.rollback(); +``` + +### 5. 嵌套事务(spawn) + +```ts +await engine.run(async (tx) => { + tx.dispatch({ type: "setSubmitting", value: true }); + + await tx.spawn(async (childTx) => { + childTx.dispatch({ type: "setValidating", value: true }); + const isValid = await validateForm(); + if (!isValid) throw new Error("validation failed"); + }, { id: "validate" }); + + await submitForm(); +}, { id: "submit" }); +``` + +关键行为: + +- 子事务提交后仍在父事务范围内——父事务回滚也会撤销子事务 +- 子事务回滚不影响父事务 +- 子事务 id 由用户指定,不会自动拼接 + +### 6. 提交边界(onError: "commit") + +`onError: "commit"` 创建提交边界——父事务回滚时保留该子事务: + +```ts +await engine.run(async (tx) => { + await tx.spawn(async (childTx) => { + childTx.dispatch({ type: "updateCache", value: data }); + }, { id: "local-cache", onError: "commit" }); + + await submitToServer(); // 失败 → 整个事务 rollback + // 但 local-cache 的变更被保留 +}, { id: "submit" }); +``` + +提交边界的语义: + +- 父事务回滚时,保留的子事务变为独立根事务(`parentId` 设为 `null`) +- 提交边界覆盖整个子树 +- 保留的子事务可以继续操作 + +### 7. 混合 onError 策略 + +```ts +await engine.run(async (tx) => { + tx.dispatch({ type: "setOrderStatus", value: "pending" }); + + await tx.spawn(async (childTx) => { + childTx.dispatch({ type: "removeFromCart", itemId }); + }, { id: "cart-update", onError: "commit" }); + + await tx.spawn(async (childTx) => { + childTx.dispatch({ type: "showSpinner", value: true }); + }, { id: "ui-effects", onError: "rollback" }); + + await placeOrder(); +}, { id: "order" }); +// placeOrder() 失败: +// - cart-update 保留 +// - ui-effects 回滚 +// - tx 自身回滚 +``` + +### 8. 并发事务 + +```ts +const [result1, result2] = await Promise.all([ + engine.run(async (tx) => { + tx.dispatch({ type: "setUsersLoading", value: true }); + const users = await fetchUsers(); + tx.dispatch({ type: "setUsers", value: users }); + }, { id: "fetch-users" }), + engine.run(async (tx) => { + tx.dispatch({ type: "setPostsLoading", value: true }); + const posts = await fetchPosts(); + tx.dispatch({ type: "setPosts", value: posts }); + }, { id: "fetch-posts" }), +]); +``` + +每个事务独立管理,回滚其中一个不影响另一个。 + +### 9. 状态订阅 + +```ts +const unsubscribe = engine.subscribe((state) => { + render(state); +}); + +// 在事务内每次 dispatch 都会触发通知 +engine.run((tx) => { + tx.dispatch({ type: "inc" }); // 触发通知 + tx.dispatch({ type: "inc" }); // 触发通知 + tx.rollback(); // 触发通知(恢复状态) +}); +``` + +### 10. 自定义快照函数 + +默认使用 `structuredClone`。如果状态包含不可克隆对象: + +```ts +const engine = new TransactionalReducer(reducer, initialState, { + snapshot: (state) => ({ + ...state, + data: [...state.data], + ref: state.ref, + }), +}); +``` + +### 11. 自定义 ID 生成器 + +```ts +let counter = 0; +const engine = new TransactionalReducer(reducer, initialState, { + idGenerator: () => `tx_${++counter}`, +}); +``` + +--- + +## 核心机制详解 + +### Action Log + Snapshot + Replay + +事务回滚不是简单的"恢复快照",而是**快照 + 重放**: + +1. 事务创建时,记录当前状态的快照(snapshot)和 action log 的起始位置(snapshotIndex) +2. 事务内的每次 dispatch 都记录到 action log 中,附带 `txId` 标识 +3. 回滚时,从快照开始重放所有 action log 条目,**跳过属于回滚事务的条目** + +这种设计确保回滚仅撤销目标事务的变更,同时保留: + +- 事务期间发生的普通(非事务)dispatch +- 并发兄弟事务的 action +- `onError: "commit"` 的后代事务的 action + +``` +时间线: + ┌─ snapshot ─┬─── tx1 dispatch ────┬─── 普通 dispatch ────┬─── tx2 dispatch ────┐ + │ │ inc │ inc │ dec │ + └────────────┴──────────────────────┴───────────────────────┴──────────────────────┘ + +tx1 rollback → 从 snapshot 重放,跳过 tx1 的 inc,保留普通 dispatch 的 inc 和 tx2 的 dec +``` + +### Generation 机制与过期句柄 + +当相同 id 的事务被替换时(去重机制),旧句柄变为"过期"。过期检测通过 **generation** 实现: + +1. 每次用相同 id 创建新事务时,`generationRef` 中该 id 的 generation 递增 +2. 旧句柄闭包绑定的 generation 不再匹配 `generationRef` 中的新值 +3. `isStale()` 检查两个条件:`transactionsRef` 中该 id 是否持有不同对象,或句柄的 status 是否不再是 `"active"` + +过期句柄的操作行为: + +- `dispatch` → 忽略 +- `commit` / `rollback` → 忽略 +- `spawn` → 抛出错误 +- `onCancel` → 立即执行回调 + +这防止了异步回调在过期句柄上误操作(例如,旧的搜索请求完成后不会覆盖新的搜索结果)。 + +### 去重 / onDuplicate 策略 + +当创建事务时指定了 `id`,如果相同 id 的活跃事务已存在,会根据 `onDuplicate` 策略处理: + +| 策略 | 行为 | 适用场景 | +|------|------|----------| +| `rollback`(默认) | 回滚旧事务,创建新的 | 搜索/验证——新请求取代旧请求 | +| `commit` | 提交旧事务(含回滚其活跃子事务),创建新的 | 旧任务视为已完成 | +| `reuse` | `create`:返回旧句柄;`run`/`spawn`:抛错 | 编辑表单——只允许一个实例 | +| `reject` | 抛错,拒绝创建 | 严格禁止并发 | + +策略优先级:`TransactionOptions.onDuplicate` > `TransactionalReducerOptions.onDuplicate` > `"rollback"` + +```ts +// rollback(默认行为)——新请求取代旧请求 +engine.run(async (tx) => { ... }, { id: "search" }); + +// reuse——只允许一个实例,复用已有事务 +const tx = engine.create({ id: "edit-form", onDuplicate: "reuse" }); + +// reject——严格禁止并发 +engine.run(async (tx) => { ... }, { id: "save", onDuplicate: "reject" }); + +// commit——旧任务视为已完成 +engine.run(async (tx) => { ... }, { id: "refresh", onDuplicate: "commit" }); +``` + +> **注意**:`reuse` 对 `run` 和 `spawn` 无效——它们会抛错而非复用旧事务,因为 `run`/`spawn` 的自动生命周期管理无法安全地应用于已有事务。 + +### onCancel 触发时机 + +- 事务被去重替换(相同 id 的新事务回滚旧事务)→ 触发 +- 事务被手动 `rollback()` → 触发 +- 事务因父事务回滚而被回滚(在 rollbackSet 中)→ 触发 +- 事务因父事务自动提交而被强制回滚(`run`/`spawn` 完成时回滚仍活跃的子事务)→ 触发 +- 事务被 `commit()` → **不触发** +- `onError: "commit"` 的子事务在父事务回滚时被保留 → **不触发** + +特殊行为: + +- 如果事务已过期(`isStale()` 返回 true),回调立即执行 +- 可以注册多个回调,依次执行 +- 回调不会双重触发 + +### 子事务提交 vs 根事务提交 + +**子事务提交**:仅将 status 标记为 `"committed"`。记录保留在 `transactionsRef` 中,父事务仍可管理它。父事务回滚也会撤销已提交子事务的变更。 + +**根事务提交**: + +1. 将此事务及其后代的 action log 条目重新标记为普通 dispatch(`txId: null`),使其永久化 +2. 从 `transactionsRef` 中删除根事务记录 +3. 清理已提交的后代记录 +4. 如果没有活跃事务剩余,清空整个 action log 和事务映射 + +### 回滚算法的六个阶段 + +若事务句柄已过期,`_rollback()` 立即返回。以下仅描述句柄仍活跃时的行为: + +1. **分类后代**:将后代分为 `preserveSet`(`onError: "commit"` 的子树)和 `rollbackSet` +2. **标记 skipped**:将 `rollbackSet` 的 action 标记为 `skipped` +3. **重新标记 preserveSet**:将已提交的保留子事务的 action 重新标记为普通 dispatch(`txId: null`) +4. **重放**:从快照重放,跳过 `skipped` 条目 +5. **分离保留的事务**:已提交的保留事务被删除;活跃的保留事务 `parentId` 设为 `null`(变为独立根),并更新其 snapshot +6. **最终清理**:若无活跃事务剩余,清空所有数据 + +### 自动清理 + +当没有活跃事务时,action log、事务映射和 generation 映射会被清空。因为不可能再发生回滚,日志纯属开销。 + +--- + +## 常见场景 + +### 搜索自动取消 + +```ts +const handleSearch = debounce(async (query: string) => { + await engine.run(async (tx) => { + const ac = new AbortController(); + tx.onCancel(() => ac.abort()); + tx.dispatch({ type: "setQuery", value: query }); + tx.dispatch({ type: "setLoading", value: true }); + const results = await searchAPI(query, { signal: ac.signal }); + tx.dispatch({ type: "setResults", value: results }); + tx.dispatch({ type: "setLoading", value: false }); + }, { id: "search" }); +}, 300); +``` + +### 表单编辑 + 取消恢复 + +```ts +const tx = engine.create({ id: "edit-profile" }); + +function updateField(field: string, value: string) { + tx.dispatch({ type: "updateField", field, value }); +} + +async function save() { + try { + await saveProfile(engine.state); + tx.commit(); + } catch { + tx.rollback(); + } +} + +function cancel() { + tx.rollback(); +} +``` + +### 多步骤提交 + 部分保留 + +```ts +await engine.run(async (tx) => { + await tx.spawn(async (childTx) => { + childTx.dispatch({ type: "lockItems", items }); + await lockInventory(items); + }, { id: "lock-inventory", onError: "commit" }); + + tx.dispatch({ type: "setPaymentProcessing", value: true }); + await processPayment(paymentInfo); + tx.dispatch({ type: "setPaymentProcessing", value: false }); +}, { id: "checkout" }); +``` + +--- + +## 注意事项 + +1. **事务 id 冲突**:子事务的 id 不会自动拼接父事务 id,由用户完全控制。需注意避免不同父事务下的子事务使用相同 id。 + +2. **过期句柄安全**:对过期句柄的 `dispatch`/`commit`/`rollback` 会被静默忽略,`spawn` 会抛出错误。这是设计行为,防止异步回调干扰新事务。 + +3. **快照性能**:默认使用 `structuredClone`,对大型状态对象可能有性能开销。可通过 `snapshot` 选项提供更轻量的克隆函数。 + +4. **幂等性要求**:由于回滚使用"快照 + 重放"机制,reducer 应尽量保持幂等性——相同 action 在不同基础状态上应产生合理的结果。 + +5. **同步 vs 异步**:`run` 和 `spawn` 对同步任务和异步任务的生命周期管理略有不同。同步任务执行期间不可能过期,无需额外检查;异步任务的 Promise 回调中会检查过期状态。 + +6. **onCancel 与 AbortError**:使用 `onCancel` + `AbortController` 取消异步请求后,被取消事务的 Promise 会以 `AbortError` reject(而非静默跳过 commit 后 resolve)。这是预期行为——取消意味着任务中止,错误应传播给调用方。 diff --git a/packages/core/src/Transaction.ts b/packages/core/src/Transaction.ts index 5864600..ddbb354 100644 --- a/packages/core/src/Transaction.ts +++ b/packages/core/src/Transaction.ts @@ -35,34 +35,42 @@ export interface TransactionHandle { spawn(task: (tx: TransactionHandle) => R, options?: SpawnOptions): R; commit(): void; rollback(): void; + finalize(): void; isStale(): boolean; onCancel(callback: () => void): void; } +// Transaction 内部接口,由引擎使用而非公开 API。 +// Transaction 实现此接口,TransactionalReducer 通过其访问内部方法。 +export interface TransactionInternal extends TransactionHandle { + parentId: string | null; + snapshot: S; + readonly snapshotIndex: number; + status: "active" | "committed" | "rolledback"; + cancelCallbacks: (() => void)[]; + + classifyRollback(): { rollbackSet: Set; preserveSet: Set }; +} + export interface TransactionalReducerOptions { idGenerator?: () => string; snapshot?: (state: S) => S; onDuplicate?: OnDuplicateStrategy; } -// Transaction 引擎接口,定义 Transaction 对引擎的依赖。 -// 由 TransactionalReducer 类实现。 -export interface TransactionEngine { +// Transaction 能力依赖,由 TransactionalReducer 通过闭包注入。 +// 不在公开 API 中导出。 +export interface TransactionDeps { readonly reducer: (state: S, action: A) => S; readonly options: TransactionalReducerOptions | undefined; readonly stateRef: Ref; readonly actionLogRef: Ref[]>; - readonly transactionsRef: Ref>>; + readonly transactionsRef: Ref>>; readonly generationRef: Ref>; - _createTx( - id: string | undefined, - parentId: string | null, - onError: OnErrorStrategy, - onDuplicate: OnDuplicateStrategy, - ): Transaction; - _runWithTx(tx: Transaction, task: (tx: TransactionHandle) => R): R; - _applyAction(action: A): void; - _notify(): void; + createTx: (id: string | undefined, parentId: string | null, onError: OnErrorStrategy, onDuplicate: OnDuplicateStrategy) => Transaction; + runWithTx: (tx: Transaction, task: (tx: TransactionHandle) => R) => R; + applyAction: (action: A) => void; + notify: () => void; } export function _generateId(): string { @@ -71,7 +79,7 @@ export function _generateId(): string { export function _getAllDescendants( txId: string, - transactions: Map>, + transactions: Map>, ): string[] { const result: string[] = []; for (const [, tx] of transactions) { @@ -85,7 +93,7 @@ export function _getAllDescendants( export function _isDescendantOf( candidateTxId: string | null, ancestorTxId: string, - transactions: Map>, + transactions: Map>, ): boolean { if (candidateTxId === null) return false; let current: string | null = candidateTxId; @@ -99,7 +107,7 @@ export function _isDescendantOf( export function _cleanupCommittedDescendants( parentId: string, - transactions: Map>, + transactions: Map>, ): void { for (const [id, tx] of transactions) { if (tx.parentId === parentId && tx.status === "committed") { @@ -122,7 +130,7 @@ export function _cleanupCommittedDescendants( // 因为它的父事务已不存在。 // ──────────────────────────────────────────────────────────────────────────── -export class Transaction implements TransactionHandle { +export class Transaction implements TransactionHandle, TransactionInternal { readonly id: string; parentId: string | null; readonly onError: OnErrorStrategy; @@ -132,10 +140,10 @@ export class Transaction implements TransactionHandle { status: "active" | "committed" | "rolledback" = "active"; cancelCallbacks: (() => void)[] = []; - private engine: TransactionEngine; + deps: TransactionDeps; constructor( - engine: TransactionEngine, + deps: TransactionDeps, id: string, parentId: string | null, onError: OnErrorStrategy, @@ -143,7 +151,7 @@ export class Transaction implements TransactionHandle { snapshot: S, snapshotIndex: number, ) { - this.engine = engine; + this.deps = deps; this.id = id; this.parentId = parentId; this.onError = onError; @@ -159,7 +167,7 @@ export class Transaction implements TransactionHandle { // 所以 `current !== this` 为 true;提交后 status 变化, // 所以 `this.status !== "active"` 为 true。 isStale(): boolean { - const current = this.engine.transactionsRef.current.get(this.id); + const current = this.deps.transactionsRef.current.get(this.id); return current !== this || this.status !== "active"; } @@ -173,12 +181,12 @@ export class Transaction implements TransactionHandle { dispatch(action: A): void { if (this.isStale()) return; - this.engine.actionLogRef.current.push({ + this.deps.actionLogRef.current.push({ action, txId: this.id, generation: this.generation, }); - this.engine._applyAction(action); + this.deps.applyAction(action); } // spawn 在创建子事务前执行三重过期检查: @@ -189,11 +197,11 @@ export class Transaction implements TransactionHandle { // 旧句柄的 generation 不匹配 generationRef 中的新 generation。 // 没有此检查,过期句柄可能在新事务下派生子事务。 spawn(task: (tx: TransactionHandle) => R, options?: SpawnOptions): R { - const current = this.engine.transactionsRef.current.get(this.id); + const current = this.deps.transactionsRef.current.get(this.id); if ( current !== this || this.status !== "active" || - this.generation !== this.engine.generationRef.current.get(this.id) + this.generation !== this.deps.generationRef.current.get(this.id) ) { throw new Error(`Cannot spawn from transaction "${this.id}": parent is no longer active`); } @@ -204,25 +212,31 @@ export class Transaction implements TransactionHandle { // 之前的 validate_name 任务。 const childId = options?.id ?? _generateId(); const childOnError = options?.onError ?? "rollback"; - const childOnDuplicate = options?.onDuplicate ?? this.engine.options?.onDuplicate ?? "rollback"; + const childOnDuplicate = options?.onDuplicate ?? this.deps.options?.onDuplicate ?? "rollback"; if (childOnDuplicate === "reuse" && options?.id) { - const existingChild = this.engine.transactionsRef.current.get(options.id); + const existingChild = this.deps.transactionsRef.current.get(options.id); if (existingChild?.status === "active") { throw new Error(`Cannot spawn: transaction "${options.id}" is already active`); } } - const childTx = this.engine._createTx(childId, this.id, childOnError, childOnDuplicate); - return this.engine._runWithTx(childTx, task); + const childTx = this.deps.createTx(childId, this.id, childOnError, childOnDuplicate); + return this.deps.runWithTx(childTx, task); } commit(): void { if (this.isStale()) return; - this._commit(); + this.#commit(); } rollback(): void { if (this.isStale()) return; - this._rollback(); + this.#rollback(); + } + + finalize(): void { + if (this.isStale()) return; + this.#rollbackActiveDescendants(); + this.#commit(); } // ─── _commit ────────────────────────────────────────────────────────── @@ -241,20 +255,20 @@ export class Transaction implements TransactionHandle { // 4. 如果没有活跃事务剩余,清空整个 action log 和事务映射—— // 不可能再发生回滚,日志是不必要的开销。 // ──────────────────────────────────────────────────────────────────────── - _commit(): void { + #commit(): void { if (this.parentId !== null) { this.status = "committed"; } else { this.status = "committed"; - for (let i = this.snapshotIndex; i < this.engine.actionLogRef.current.length; i++) { - const entry = this.engine.actionLogRef.current[i]!; + for (let i = this.snapshotIndex; i < this.deps.actionLogRef.current.length; i++) { + const entry = this.deps.actionLogRef.current[i]!; if (entry.skipped) continue; if ( entry.txId === this.id || - _isDescendantOf(entry.txId, this.id, this.engine.transactionsRef.current) + _isDescendantOf(entry.txId, this.id, this.deps.transactionsRef.current) ) { - this.engine.actionLogRef.current[i] = { + this.deps.actionLogRef.current[i] = { action: entry.action, txId: null, generation: 0, @@ -262,14 +276,10 @@ export class Transaction implements TransactionHandle { } } - this.engine.transactionsRef.current.delete(this.id); - _cleanupCommittedDescendants(this.id, this.engine.transactionsRef.current); + this.deps.transactionsRef.current.delete(this.id); + _cleanupCommittedDescendants(this.id, this.deps.transactionsRef.current); - if (!this._hasActiveTransactions()) { - this.engine.actionLogRef.current = []; - this.engine.transactionsRef.current.clear(); - this.engine.generationRef.current.clear(); - } + this.#cleanupIfDone(); } } @@ -323,21 +333,20 @@ export class Transaction implements TransactionHandle { // 然后对每个活跃保留事务调用 _cleanupCommittedDescendants, // 清理其子树中已提交的子事务。现在安全了,因为保留事务已是独立根。 // ──────────────────────────────────────────────────────────────────────── - _rollback(): void { - if (this.isStale()) return; - - const descendants = _getAllDescendants(this.id, this.engine.transactionsRef.current); + // ─── classifyRollback ────────────────────────────────────────────────── + // + // 分类后代并标记 action log(阶段 1-3)。 + // 返回 rollbackSet 和 preserveSet,供调用方统一重放。 + // ──────────────────────────────────────────────────────────────────────── + classifyRollback(): { rollbackSet: Set; preserveSet: Set } { + const descendants = _getAllDescendants(this.id, this.deps.transactionsRef.current); - // 阶段 1:分类后代 const preserveSet = new Set(); const visited = new Set(); for (const descId of descendants) { if (visited.has(descId)) continue; - const descTx = this.engine.transactionsRef.current.get(descId); + const descTx = this.deps.transactionsRef.current.get(descId); if (descTx?.onError === "commit") { - // 从 descTx 向上遍历到 this.id,检查是否有中间祖先 - // 已在 preserveSet 中。如果有,descTx 已被该祖先的 - // 提交边界覆盖。 let underPreserve = false; let current: string | null = descTx.parentId; while (current !== null && current !== this.id) { @@ -345,13 +354,11 @@ export class Transaction implements TransactionHandle { underPreserve = true; break; } - current = this.engine.transactionsRef.current.get(current)?.parentId ?? null; + current = this.deps.transactionsRef.current.get(current)?.parentId ?? null; } if (!underPreserve) { - // 此后代是提交边界。保留它及其整个子树 - // (不能部分保留子树)。 preserveSet.add(descId); - const subDescendants = _getAllDescendants(descId, this.engine.transactionsRef.current); + const subDescendants = _getAllDescendants(descId, this.deps.transactionsRef.current); for (const subId of subDescendants) { preserveSet.add(subId); visited.add(subId); @@ -370,22 +377,20 @@ export class Transaction implements TransactionHandle { } // 阶段 2:将回滚 action 标记为 skipped - for (let i = this.snapshotIndex; i < this.engine.actionLogRef.current.length; i++) { - const entry = this.engine.actionLogRef.current[i]!; + for (let i = this.snapshotIndex; i < this.deps.actionLogRef.current.length; i++) { + const entry = this.deps.actionLogRef.current[i]!; if (entry.txId !== null && rollbackSet.has(entry.txId)) { entry.skipped = true; } } // 阶段 3:将保留的 action 重新标记为普通 dispatch - // 必须在阶段 2 之后执行,以免保留的 action(txId 变为 null 后) - // 被 rollbackSet 检查意外捕获。 - for (let i = this.snapshotIndex; i < this.engine.actionLogRef.current.length; i++) { - const entry = this.engine.actionLogRef.current[i]!; + for (let i = this.snapshotIndex; i < this.deps.actionLogRef.current.length; i++) { + const entry = this.deps.actionLogRef.current[i]!; if (!entry.skipped && entry.txId !== null && preserveSet.has(entry.txId)) { - const preservedTx = this.engine.transactionsRef.current.get(entry.txId); + const preservedTx = this.deps.transactionsRef.current.get(entry.txId); if (preservedTx?.status === "committed") { - this.engine.actionLogRef.current[i] = { + this.deps.actionLogRef.current[i] = { action: entry.action, txId: null, generation: 0, @@ -394,28 +399,36 @@ export class Transaction implements TransactionHandle { } } + return { rollbackSet, preserveSet }; + } + + #rollback(): void { + if (this.isStale()) return; + + const { rollbackSet, preserveSet } = this.classifyRollback(); + // 阶段 4:从快照重放,跳过已回滚的 action let replayState = this.snapshot; - for (let i = this.snapshotIndex; i < this.engine.actionLogRef.current.length; i++) { - const entry = this.engine.actionLogRef.current[i]!; + for (let i = this.snapshotIndex; i < this.deps.actionLogRef.current.length; i++) { + const entry = this.deps.actionLogRef.current[i]!; if (entry.skipped) continue; - replayState = this.engine.reducer(replayState, entry.action); + replayState = this.deps.reducer(replayState, entry.action); } // 在删除前将回滚记录标记为 rolledback(可能被尚未完成的 // 异步回调引用) for (const id of rollbackSet) { - const record = this.engine.transactionsRef.current.get(id); + const record = this.deps.transactionsRef.current.get(id); if (record) record.status = "rolledback"; } - this.engine.stateRef.current = replayState; - this.engine._notify(); + this.deps.stateRef.current = replayState; + this.deps.notify(); // 触发 rollbackSet 中每个事务的 onCancel 回调。 // 使用 copy-and-clear 模式防止双重触发和重入注册。 for (const id of rollbackSet) { - const record = this.engine.transactionsRef.current.get(id); + const record = this.deps.transactionsRef.current.get(id); if (record?.cancelCallbacks.length) { const callbacks = [...record.cancelCallbacks]; record.cancelCallbacks = []; @@ -425,35 +438,30 @@ export class Transaction implements TransactionHandle { // 从 transactionsRef 中删除已回滚的记录 for (const id of rollbackSet) { - this.engine.transactionsRef.current.delete(id); + this.deps.transactionsRef.current.delete(id); } // 阶段 5:分离保留的事务 for (const id of preserveSet) { - const record = this.engine.transactionsRef.current.get(id); + const record = this.deps.transactionsRef.current.get(id); if (record) { if (record.status === "committed") { - this.engine.transactionsRef.current.delete(id); + this.deps.transactionsRef.current.delete(id); } else if (record.status === "active") { const needsDetach = record.parentId !== null && rollbackSet.has(record.parentId); if (needsDetach) { record.parentId = null; - const childDescendants = _getAllDescendants(id, this.engine.transactionsRef.current); + const childDescendants = _getAllDescendants(id, this.deps.transactionsRef.current); const childOwnSet = new Set(); childOwnSet.add(id); for (const descId of childDescendants) { childOwnSet.add(descId); } let newSnapshot = this.snapshot; - for (let i = this.snapshotIndex; i < this.engine.actionLogRef.current.length; i++) { - const entry = this.engine.actionLogRef.current[i]!; + for (let i = this.snapshotIndex; i < record.snapshotIndex; i++) { + const entry = this.deps.actionLogRef.current[i]!; if (entry.skipped) continue; - if ( - entry.txId !== null && - (rollbackSet.has(entry.txId) || childOwnSet.has(entry.txId)) - ) - continue; - newSnapshot = this.engine.reducer(newSnapshot, entry.action); + newSnapshot = this.deps.reducer(newSnapshot, entry.action); } record.snapshot = newSnapshot; } @@ -464,52 +472,51 @@ export class Transaction implements TransactionHandle { // 清理每个活跃保留子树中已提交的后代。 // 现在安全了,因为保留事务已是独立根。 for (const id of preserveSet) { - const record = this.engine.transactionsRef.current.get(id); + const record = this.deps.transactionsRef.current.get(id); if (record?.status === "active") { - _cleanupCommittedDescendants(id, this.engine.transactionsRef.current); + _cleanupCommittedDescendants(id, this.deps.transactionsRef.current); } } - // 阶段 6:若无活跃事务剩余,清空所有内容。 - // action log 仅用于回滚重放;没有活跃事务就不可能回滚, - // 日志纯属开销。 - if (!this._hasActiveTransactions()) { - this.engine.actionLogRef.current = []; - this.engine.transactionsRef.current.clear(); - this.engine.generationRef.current.clear(); - } + this.#cleanupIfDone(); } - - // 仅回滚直接的活跃子事务,而非所有后代。 - // 每个子事务的 _rollback() 递归处理自己的子树, + // 每个子事务的 #rollback() 递归处理自己的子树, // 包括自己的 onError:"commit" 边界。 - _rollbackActiveDescendants(): void { + #rollbackActiveDescendants(): void { const activeChildren: string[] = []; - for (const [, tx] of this.engine.transactionsRef.current) { + for (const [, tx] of this.deps.transactionsRef.current) { if (tx.parentId === this.id && tx.status === "active") { activeChildren.push(tx.id); } } for (const id of activeChildren) { - const tx = this.engine.transactionsRef.current.get(id); + const tx = this.deps.transactionsRef.current.get(id); if (tx?.status === "active") { - tx._rollback(); + (tx as Transaction).#rollback(); } } let replayState = this.snapshot; - for (let i = this.snapshotIndex; i < this.engine.actionLogRef.current.length; i++) { - const entry = this.engine.actionLogRef.current[i]!; + for (let i = this.snapshotIndex; i < this.deps.actionLogRef.current.length; i++) { + const entry = this.deps.actionLogRef.current[i]!; if (entry.skipped) continue; - replayState = this.engine.reducer(replayState, entry.action); + replayState = this.deps.reducer(replayState, entry.action); } - this.engine.stateRef.current = replayState; - this.engine._notify(); + this.deps.stateRef.current = replayState; + this.deps.notify(); } - private _hasActiveTransactions(): boolean { - for (const tx of this.engine.transactionsRef.current.values()) { + #hasActiveTransactions(): boolean { + for (const tx of this.deps.transactionsRef.current.values()) { if (tx.status === "active") return true; } return false; } + + #cleanupIfDone(): void { + if (!this.#hasActiveTransactions()) { + this.deps.actionLogRef.current = []; + this.deps.transactionsRef.current.clear(); + this.deps.generationRef.current.clear(); + } + } } diff --git a/packages/core/src/TransactionalReducer.ts b/packages/core/src/TransactionalReducer.ts index 5a9b972..e6e57f9 100644 --- a/packages/core/src/TransactionalReducer.ts +++ b/packages/core/src/TransactionalReducer.ts @@ -9,8 +9,9 @@ import { type OnDuplicateStrategy, type TransactionOptions, type TransactionHandle, + type TransactionInternal, type TransactionalReducerOptions, - type TransactionEngine, + type TransactionDeps, } from "./Transaction"; export type { @@ -25,15 +26,15 @@ export type { export type { Transaction }; -export class TransactionalReducer implements TransactionEngine { +export class TransactionalReducer { readonly reducer: (state: S, action: A) => S; readonly options: TransactionalReducerOptions | undefined; readonly stateRef: Ref; readonly actionLogRef: Ref[]>; - readonly transactionsRef: Ref>>; + readonly transactionsRef: Ref>>; readonly generationRef: Ref>; - private _listeners = new Set<(state: S) => void>(); + #listeners = new Set<(state: S) => void>(); constructor( reducer: (state: S, action: A) => S, @@ -53,15 +54,15 @@ export class TransactionalReducer implements TransactionEngine { } subscribe(listener: (state: S) => void): () => void { - this._listeners.add(listener); + this.#listeners.add(listener); return () => { - this._listeners.delete(listener); + this.#listeners.delete(listener); }; } - _notify(): void { + #notify(): void { const state = this.stateRef.current; - for (const listener of this._listeners) { + for (const listener of this.#listeners) { listener(state); } } @@ -70,10 +71,10 @@ export class TransactionalReducer implements TransactionEngine { // 这确保它们在回滚重放中被保留(txId:null,不在任何 rollbackSet 中)。 // 无活跃事务时日志不必要——不可能发生回滚,因此跳过日志记录。 dispatch(action: A): void { - if (this._hasActiveTransactions()) { + if (this.#hasActiveTransactions()) { this.actionLogRef.current.push({ action, txId: null, generation: 0 }); } - this._applyAction(action); + this.#applyAction(action); } run(task: (tx: TransactionHandle) => R, options?: TransactionOptions): R { @@ -84,8 +85,8 @@ export class TransactionalReducer implements TransactionEngine { throw new Error(`Cannot run: transaction "${options.id}" is already active`); } } - const tx = this._createTx(options?.id, null, options?.onError ?? "rollback", strategy); - return this._runWithTx(tx, task); + const tx = this.#createTx(options?.id, null, options?.onError ?? "rollback", strategy); + return this.#runWithTx(tx, task); } create(options?: TransactionOptions): TransactionHandle { @@ -94,16 +95,124 @@ export class TransactionalReducer implements TransactionEngine { const existing = this.transactionsRef.current.get(options.id); if (existing?.status === "active") return existing; } - return this._createTx(options?.id, null, options?.onError ?? "rollback", strategy); + return this.#createTx(options?.id, null, options?.onError ?? "rollback", strategy); } getTransaction(id: string): TransactionHandle | undefined { return this.transactionsRef.current.get(id); } - _applyAction(action: A): void { + rollbackAll(): void { + const roots: TransactionInternal[] = []; + for (const tx of this.transactionsRef.current.values()) { + if (tx.parentId === null && tx.status === "active") { + roots.push(tx); + } + } + if (roots.length === 0) return; + + // 阶段 1-3:对所有根事务统一分类并标记 action log + const allRollbackSet = new Set(); + const allPreserveSet = new Set(); + let earliestSnapshotIndex = Infinity; + let earliestSnapshot: S | undefined; + + for (const tx of roots) { + if (tx.isStale()) continue; + const { rollbackSet, preserveSet } = tx.classifyRollback(); + for (const id of rollbackSet) allRollbackSet.add(id); + for (const id of preserveSet) allPreserveSet.add(id); + if (tx.snapshotIndex < earliestSnapshotIndex) { + earliestSnapshotIndex = tx.snapshotIndex; + earliestSnapshot = tx.snapshot; + } + } + + if (allRollbackSet.size === 0) return; + + // 阶段 4:从最早的快照统一重放 + let replayState = earliestSnapshot as S; + for (let i = earliestSnapshotIndex; i < this.actionLogRef.current.length; i++) { + const entry = this.actionLogRef.current[i]!; + if (entry.skipped) continue; + replayState = this.reducer(replayState, entry.action); + } + + // 标记 rolledback、触发 onCancel、删除记录 + for (const id of allRollbackSet) { + const record = this.transactionsRef.current.get(id); + if (record) record.status = "rolledback"; + } + + this.stateRef.current = replayState; + this.#notify(); + + for (const id of allRollbackSet) { + const record = this.transactionsRef.current.get(id); + if (record?.cancelCallbacks.length) { + const callbacks = [...record.cancelCallbacks]; + record.cancelCallbacks = []; + for (const cb of callbacks) cb(); + } + } + + for (const id of allRollbackSet) { + this.transactionsRef.current.delete(id); + } + + // 阶段 5:分离保留的事务 + for (const id of allPreserveSet) { + const record = this.transactionsRef.current.get(id); + if (record) { + if (record.status === "committed") { + this.transactionsRef.current.delete(id); + } else if (record.status === "active") { + const needsDetach = record.parentId !== null && allRollbackSet.has(record.parentId); + if (needsDetach) { + record.parentId = null; + let newSnapshot = earliestSnapshot as S; + for (let i = earliestSnapshotIndex; i < record.snapshotIndex; i++) { + const entry = this.actionLogRef.current[i]!; + if (entry.skipped) continue; + newSnapshot = this.reducer(newSnapshot, entry.action); + } + record.snapshot = newSnapshot; + } + } + } + } + + // 清理保留子树中已提交的后代 + for (const id of allPreserveSet) { + const record = this.transactionsRef.current.get(id); + if (record?.status === "active") { + _cleanupCommittedDescendants(id, this.transactionsRef.current); + } + } + + // 阶段 6:最终清理 + if (!this.#hasActiveTransactions()) { + this.actionLogRef.current = []; + this.transactionsRef.current.clear(); + this.generationRef.current.clear(); + } + } + + commitAll(): void { + const roots: TransactionInternal[] = []; + for (const tx of this.transactionsRef.current.values()) { + if (tx.parentId === null && tx.status === "active") { + roots.push(tx); + } + } + for (const tx of roots) { + tx.finalize(); + } + } + + #applyAction(action: A): void { this.stateRef.current = this.reducer(this.stateRef.current, action); - this._notify(); + this.#notify(); } // ─── _createTx ──────────────────────────────────────────────────────── @@ -127,7 +236,7 @@ export class TransactionalReducer implements TransactionEngine { // (parentId === this.id 匹配)并错误地回滚它们 // 这就是 runWithTx 在 _commit/_rollback 前检查过期的原因。 // ──────────────────────────────────────────────────────────────────────── - _createTx( + #createTx( id: string | undefined, parentId: string | null, onError: OnErrorStrategy, @@ -138,11 +247,10 @@ export class TransactionalReducer implements TransactionEngine { if (existing?.status === "active") { switch (onDuplicate) { case "rollback": - existing._rollback(); + existing.rollback(); break; case "commit": - existing._rollbackActiveDescendants(); - existing._commit(); + existing.finalize(); if (existing.parentId !== null) { for (let i = existing.snapshotIndex; i < this.actionLogRef.current.length; i++) { const entry = this.actionLogRef.current[i]!; @@ -168,11 +276,31 @@ export class TransactionalReducer implements TransactionEngine { } } - const generation = this._nextGeneration(txId); + const generation = this.#nextGeneration(txId); const snapshot = (this.options?.snapshot ?? structuredClone)(this.stateRef.current); const snapshotIndex = this.actionLogRef.current.length; - const tx = new Transaction(this, txId, parentId, onError, generation, snapshot, snapshotIndex); + const tx = new Transaction( + { + reducer: this.reducer, + options: this.options, + stateRef: this.stateRef, + actionLogRef: this.actionLogRef, + transactionsRef: this.transactionsRef, + generationRef: this.generationRef, + createTx: (id, parentId, onError, onDuplicate) => + this.#createTx(id, parentId, onError, onDuplicate), + runWithTx: (tx, task) => this.#runWithTx(tx, task), + applyAction: (action) => this.#applyAction(action), + notify: () => this.#notify(), + }, + txId, + parentId, + onError, + generation, + snapshot, + snapshotIndex, + ); this.transactionsRef.current.set(txId, tx); return tx; @@ -196,7 +324,7 @@ export class TransactionalReducer implements TransactionEngine { // // 对于同步任务,执行期间不可能过期(无异步暂停),无需检查。 // ──────────────────────────────────────────────────────────────────────── - _runWithTx(tx: Transaction, task: (tx: TransactionHandle) => R): R { + #runWithTx(tx: Transaction, task: (tx: TransactionHandle) => R): R { try { const result = task(tx); if (result instanceof Promise) { @@ -205,53 +333,44 @@ export class TransactionalReducer implements TransactionEngine { // 过期检查:如果事务已被替换(例如第二次 run 使用相同 id), // 跳过提交——新事务现在拥有该 id。 if (!tx.isStale()) { - tx._rollbackActiveDescendants(); - tx._commit(); + tx.finalize(); } return r; }, (e) => { if (tx.onError === "commit") { - // onError:"commit" 表示出错时保留变更。 - // 仍需过期检查——过期句柄绝不能提交 - // (会从 transactionsRef 删除新事务)。 if (!tx.isStale()) { - tx._rollbackActiveDescendants(); - tx._commit(); + tx.finalize(); } } else { - // _rollback 内部有自己的过期检查, - // 此处无需额外检查。 - tx._rollback(); + tx.rollback(); } throw e; }, ) as unknown as R; } // 同步成功:同步执行期间不可能过期 - tx._rollbackActiveDescendants(); - tx._commit(); + tx.finalize(); return result; } catch (e) { // 同步错误:同样不可能过期 if (tx.onError === "commit") { - tx._rollbackActiveDescendants(); - tx._commit(); + tx.finalize(); } else { - tx._rollback(); + tx.rollback(); } throw e; } } - private _nextGeneration(txId: string): number { + #nextGeneration(txId: string): number { const prev = this.generationRef.current.get(txId) ?? 0; const next = prev + 1; this.generationRef.current.set(txId, next); return next; } - private _hasActiveTransactions(): boolean { + #hasActiveTransactions(): boolean { for (const tx of this.transactionsRef.current.values()) { if (tx.status === "active") return true; } diff --git a/packages/core/test/commitAll-rollbackAll.test.ts b/packages/core/test/commitAll-rollbackAll.test.ts new file mode 100644 index 0000000..d19258e --- /dev/null +++ b/packages/core/test/commitAll-rollbackAll.test.ts @@ -0,0 +1,425 @@ +import { describe, expect, it, vi } from "vitest"; +import { setup } from "./helpers"; + +describe("TransactionalReducer", () => { + describe("rollbackAll", () => { + it("rolls back all active root transactions", () => { + const engine = setup(); + const tx1 = engine.create({ id: "tx1" }); + const tx2 = engine.create({ id: "tx2" }); + + tx1.dispatch({ type: "inc" }); + tx2.dispatch({ type: "inc" }); + expect(engine.state).toEqual({ count: 2 }); + + engine.rollbackAll(); + expect(engine.state).toEqual({ count: 0 }); + }); + + it("preserves non-transactional dispatches before transactions", () => { + const engine = setup(); + engine.dispatch({ type: "inc" }); + const tx = engine.create({ id: "tx1" }); + tx.dispatch({ type: "inc" }); + expect(engine.state).toEqual({ count: 2 }); + + engine.rollbackAll(); + expect(engine.state).toEqual({ count: 1 }); + }); + + it("is a no-op when no active transactions exist", () => { + const engine = setup(); + engine.dispatch({ type: "inc" }); + engine.rollbackAll(); + expect(engine.state).toEqual({ count: 1 }); + }); + + it("cascades to child transactions", () => { + const engine = setup(); + const tx = engine.create({ id: "parent" }); + tx.dispatch({ type: "inc" }); + tx.spawn((child) => { + child.dispatch({ type: "inc" }); + }); + expect(engine.state).toEqual({ count: 2 }); + + engine.rollbackAll(); + expect(engine.state).toEqual({ count: 0 }); + }); + + it("triggers onCancel callbacks", () => { + const engine = setup(); + const tx = engine.create({ id: "tx1" }); + let cancelled = false; + tx.onCancel(() => { + cancelled = true; + }); + + engine.rollbackAll(); + expect(cancelled).toBe(true); + }); + + it("rolls back pending async transactions created via run", async () => { + const engine = setup(); + let resolve1!: () => void; + let resolve2!: () => void; + const p1 = new Promise((r) => { resolve1 = r; }); + const p2 = new Promise((r) => { resolve2 = r; }); + + const promise1 = engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + await p1; + tx.dispatch({ type: "inc" }); + }, { id: "a" }); + + const promise2 = engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + await p2; + tx.dispatch({ type: "inc" }); + }, { id: "b" }); + + expect(engine.state).toEqual({ count: 2 }); + + engine.rollbackAll(); + expect(engine.state).toEqual({ count: 0 }); + + resolve1(); + resolve2(); + await promise1.catch(() => {}); + await promise2.catch(() => {}); + }); + + it("async completions after rollbackAll are silently ignored", async () => { + const engine = setup(); + let resolve!: () => void; + const p = new Promise((r) => { resolve = r; }); + + const promise = engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + await p; + tx.dispatch({ type: "inc" }); + }, { id: "task" }); + + expect(engine.state).toEqual({ count: 1 }); + engine.rollbackAll(); + expect(engine.state).toEqual({ count: 0 }); + + resolve(); + await promise.catch(() => {}); + expect(engine.state).toEqual({ count: 0 }); + }); + + it("triggers onCancel for pending async transactions", async () => { + const engine = setup(); + const onCancel = vi.fn(); + let resolve!: () => void; + const p = new Promise((r) => { resolve = r; }); + + const promise = engine.run(async (tx) => { + tx.onCancel(onCancel); + tx.dispatch({ type: "inc" }); + await p; + }, { id: "task" }); + + expect(onCancel).not.toHaveBeenCalled(); + engine.rollbackAll(); + expect(onCancel).toHaveBeenCalledTimes(1); + + resolve(); + await promise.catch(() => {}); + }); + + it("AbortController integration: abort pending requests on rollbackAll", async () => { + const engine = setup(); + const ac = new AbortController(); + let resolve!: () => void; + const p = new Promise((r) => { resolve = r; }); + + const promise = engine.run(async (tx) => { + tx.onCancel(() => ac.abort()); + tx.dispatch({ type: "inc" }); + await p; + }, { id: "fetch" }); + + expect(ac.signal.aborted).toBe(false); + engine.rollbackAll(); + expect(ac.signal.aborted).toBe(true); + expect(engine.state).toEqual({ count: 0 }); + + resolve(); + await promise.catch(() => {}); + }); + + it("rolls back multiple concurrent async transactions independently", async () => { + const engine = setup(); + let resolveA!: () => void; + let resolveB!: () => void; + const pA = new Promise((r) => { resolveA = r; }); + const pB = new Promise((r) => { resolveB = r; }); + + const promiseA = engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + await pA; + }, { id: "a" }); + + const promiseB = engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + await pB; + }, { id: "b" }); + + expect(engine.state).toEqual({ count: 2 }); + + engine.rollbackAll(); + expect(engine.state).toEqual({ count: 0 }); + + resolveA(); + resolveB(); + await promiseA.catch(() => {}); + await promiseB.catch(() => {}); + }); + + it("rollbackAll during nested async spawn cancels children", async () => { + const engine = setup(); + let resolveParent!: () => void; + let resolveChild!: () => void; + const pParent = new Promise((r) => { resolveParent = r; }); + const pChild = new Promise((r) => { resolveChild = r; }); + + const promise = engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + tx.spawn(async (child) => { + child.dispatch({ type: "inc" }); + await pChild; + }); + await pParent; + }, { id: "parent" }); + + expect(engine.state).toEqual({ count: 2 }); + + engine.rollbackAll(); + expect(engine.state).toEqual({ count: 0 }); + + resolveParent(); + resolveChild(); + await promise.catch(() => {}); + }); + + it("allows new transactions after rollbackAll", async () => { + const engine = setup(); + let resolve!: () => void; + const p = new Promise((r) => { resolve = r; }); + + const promise = engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + await p; + }, { id: "old" }); + + engine.rollbackAll(); + expect(engine.state).toEqual({ count: 0 }); + + engine.dispatch({ type: "set", value: 42 }); + expect(engine.state).toEqual({ count: 42 }); + + resolve(); + await promise.catch(() => {}); + }); + + it("rollbackAll with mixed sync-committed and async-pending transactions", async () => { + const engine = setup(); + engine.dispatch({ type: "inc" }); + + const syncTx = engine.create({ id: "sync" }); + syncTx.dispatch({ type: "inc" }); + + let resolve!: () => void; + const p = new Promise((r) => { resolve = r; }); + const promise = engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + await p; + }, { id: "async" }); + + expect(engine.state).toEqual({ count: 3 }); + + engine.rollbackAll(); + expect(engine.state).toEqual({ count: 1 }); + + resolve(); + await promise.catch(() => {}); + }); + + it("rollbackAll preserves onError:commit children across multiple roots", async () => { + const engine = setup(); + let resolve1!: () => void; + let resolve2!: () => void; + const p1 = new Promise((r) => { resolve1 = r; }); + const p2 = new Promise((r) => { resolve2 = r; }); + + const promise1 = engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + tx.spawn(async (child) => { + child.dispatch({ type: "inc" }); + await p1; + }, { id: "child1", onError: "commit" }); + tx.dispatch({ type: "inc" }); + }, { id: "root1" }); + + const promise2 = engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + tx.spawn(async (child) => { + child.dispatch({ type: "set", value: 100 }); + await p2; + }, { id: "child2", onError: "commit" }); + tx.dispatch({ type: "inc" }); + }, { id: "root2" }); + + // root1: inc(→1) + child1.inc(→2) + inc(→3) = 3 + // root2: inc(→4) + child2.set(100) + inc(→101) + expect(engine.state).toEqual({ count: 101 }); + + engine.rollbackAll(); + + // Children with onError:commit should be preserved as independent roots + // root1's and root2's dispatches are rolled back. + // child1.inc gives 1, child2.set:100 overwrites to 100 + expect(engine.state).toEqual({ count: 100 }); + + resolve1(); + resolve2(); + await promise1.catch(() => {}); + await promise2.catch(() => {}); + expect(engine.state).toEqual({ count: 100 }); + }); + }); + + describe("finalize", () => { + it("rolls back active child transactions and commits", async () => { + const engine = setup(); + const tx = engine.create({ id: "parent" }); + tx.dispatch({ type: "inc" }); + let resolveChild!: () => void; + const childDone = new Promise((r) => { resolveChild = r; }); + tx.spawn(async (child) => { + child.dispatch({ type: "inc" }); + await childDone; + }); + expect(engine.state).toEqual({ count: 2 }); + + tx.finalize(); + // Active child should be rolled back, parent committed + expect(engine.state).toEqual({ count: 1 }); + expect(tx.isStale()).toBe(true); + + resolveChild(); + await childDone.catch(() => {}); + }); + + it("preserves committed children", () => { + const engine = setup(); + const tx = engine.create({ id: "parent" }); + tx.dispatch({ type: "inc" }); + tx.spawn((child) => { + child.dispatch({ type: "inc" }); + }); + // spawn auto-commits sync child + expect(engine.state).toEqual({ count: 2 }); + + tx.finalize(); + // Committed child's inc should be preserved, parent committed + expect(engine.state).toEqual({ count: 2 }); + expect(tx.isStale()).toBe(true); + }); + + it("is a no-op when handle is stale", () => { + const engine = setup(); + const tx = engine.create({ id: "tx" }); + tx.dispatch({ type: "inc" }); + tx.commit(); + expect(tx.isStale()).toBe(true); + + engine.dispatch({ type: "inc" }); + tx.finalize(); + expect(engine.state).toEqual({ count: 2 }); + }); + + it("rolls back descendants recursively", async () => { + const engine = setup(); + const tx = engine.create({ id: "root" }); + tx.dispatch({ type: "inc" }); + let resolveL1!: () => void; + let resolveL2!: () => void; + const l1Done = new Promise((r) => { resolveL1 = r; }); + const l2Done = new Promise((r) => { resolveL2 = r; }); + tx.spawn(async (l1) => { + l1.dispatch({ type: "inc" }); + l1.spawn(async (l2) => { + l2.dispatch({ type: "inc" }); + await l2Done; + }, { id: "l2" }); + await l1Done; + }, { id: "l1" }); + expect(engine.state).toEqual({ count: 3 }); + + tx.finalize(); + // l1 and l2 are still active → rolled back + expect(engine.state).toEqual({ count: 1 }); + + resolveL1(); + resolveL2(); + await l1Done.catch(() => {}); + await l2Done.catch(() => {}); + }); + }); + + describe("commitAll", () => { + it("commits all active root transactions", () => { + const engine = setup(); + const tx1 = engine.create({ id: "tx1" }); + const tx2 = engine.create({ id: "tx2" }); + + tx1.dispatch({ type: "inc" }); + tx2.dispatch({ type: "inc" }); + expect(engine.state).toEqual({ count: 2 }); + + engine.commitAll(); + expect(engine.state).toEqual({ count: 2 }); + expect(tx1.isStale()).toBe(true); + expect(tx2.isStale()).toBe(true); + }); + + it("rolls back active child transactions before committing", async () => { + const engine = setup(); + const tx = engine.create({ id: "parent" }); + tx.dispatch({ type: "inc" }); + let resolveChild: () => void; + const childDone = new Promise((r) => { + resolveChild = r; + }); + tx.spawn(async (child) => { + child.dispatch({ type: "inc" }); + await childDone; + }); + expect(engine.state).toEqual({ count: 2 }); + + engine.commitAll(); + expect(engine.state).toEqual({ count: 1 }); + resolveChild!(); + }); + + it("is a no-op when no active transactions exist", () => { + const engine = setup(); + engine.dispatch({ type: "inc" }); + engine.commitAll(); + expect(engine.state).toEqual({ count: 1 }); + }); + + it("allows normal dispatch after commitAll", () => { + const engine = setup(); + const tx = engine.create({ id: "tx1" }); + tx.dispatch({ type: "inc" }); + engine.commitAll(); + + engine.dispatch({ type: "inc" }); + expect(engine.state).toEqual({ count: 2 }); + }); + }); +}); diff --git a/packages/react/README.md b/packages/react/README.md index 87f6d05..32ead6a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,16 +1,16 @@ # @transactional-reducer/react -为 React 的 `useReducer` 提供事务(Transaction)支持的 Hook。将 [`@transactional-reducer/core`](../core/README.md) 引擎封装为 React 友好的 API。 +A React Hook that adds transaction support to `useReducer`. It wraps the [`@transactional-reducer/core`](../core/README.md) engine in a React-friendly API. -> 事务的核心概念(回滚算法、去重策略、提交边界、过期句柄等)均在 [`@transactional-reducer/core`](../core/README.md) 中详细说明。本文档仅描述 React Hook 的用法。 +> Core concepts (rollback algorithm, deduplication strategy, commit boundary, stale handle, etc.) are documented in detail in [`@transactional-reducer/core`](../core/README.md). This document covers only the React Hook usage. -## 安装 +## Installation ```bash npm install @transactional-reducer/react @transactional-reducer/core ``` -## 快速开始 +## Quick Start ```tsx import { useTransactionalReducer } from "@transactional-reducer/react"; @@ -26,24 +26,24 @@ const reducer = (state: State, action: Action): State => { }; function Counter() { - const [state, api] = useTransactionalReducer(reducer, { count: 0 }); + const [state, engine] = useTransactionalReducer(reducer, { count: 0 }); - // 普通 dispatch —— 不可回滚 - const handleInc = () => api.dispatch({ type: "inc" }); + // non-transactional dispatch — cannot be rolled back + const handleInc = () => engine.dispatch({ type: "inc" }); - // 事务性 dispatch —— 可回滚 + // transactional dispatch — can be rolled back const handleOptimisticInc = () => - api.run(async (tx) => { - tx.dispatch({ type: "inc" }); // 乐观更新 UI - await fetch("/api/inc"); // 异步请求 - // 成功 → 自动 commit;失败 → 自动 rollback + engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); // optimistic update to UI + await fetch("/api/inc"); // async request + // success → auto-commit; failure → auto-rollback }); return (

Count: {state.count}

- +
); } @@ -51,91 +51,89 @@ function Counter() { --- -## API 参考 +## API Reference -### 签名 +### Signature ```ts function useTransactionalReducer( reducer: (state: S, action: A) => S, initialState: S, options?: TransactionalReducerOptions, -): [ - S, - { - dispatch: (action: A) => void; - run(task: (tx: TransactionHandle
) => R, options?: TransactionOptions): R; - create(options?: TransactionOptions): TransactionHandle; - getDraft(): S; - getTransaction(id: string): TransactionHandle | undefined; - }, -]; +): [S, TransactionalReducer]; ``` -`TransactionalReducerOptions`、`TransactionOptions`、`TransactionHandle` 等类型均从 [`@transactional-reducer/core`](../core/README.md#api-参考) 导出。 +The second element of the tuple is a [`TransactionalReducer`](../core/README.md#transactionalreducer) engine instance — the same object used in framework-agnostic code. All engine methods are available: -### 返回值 +- `engine.state` — current state (synchronous) +- `engine.dispatch(action)` — non-transactional dispatch +- `engine.run(task, options?)` — start a root transaction with automatic lifecycle management +- `engine.create(options?)` — manually create a root transaction +- `engine.getTransaction(id)` — find a transaction by ID +- `engine.commitAll()` — commit all active root transactions +- `engine.rollbackAll()` — roll back all active root transactions +- `engine.subscribe(listener)` — subscribe to state changes -返回一个元组 `[state, api]`: +Types such as `TransactionalReducerOptions`, `TransactionOptions`, and `TransactionHandle` are all exported from [`@transactional-reducer/core`](../core/README.md#api-reference). -| 字段 | 类型 | 说明 | -|------|------|------| -| `state` | `S` | 当前状态(由 React 渲染周期驱动) | -| `api.dispatch` | `(action: A) => void` | 普通 dispatch,不可回滚 | -| `api.run` | 见下方 | 启动根事务,自动管理生命周期 | -| `api.create` | 见下方 | 手动创建根事务 | -| `api.getDraft` | `() => S` | 获取最新 draft 状态(绕过 React 批处理延迟) | -| `api.getTransaction` | `(id: string) => TransactionHandle \| undefined` | 按 id 查找事务 | +### Return Value -### `api.run(task, options?)` +Returns a tuple `[state, engine]`: -启动根事务并自动管理生命周期。行为与 [`engine.run()`](../core/README.md#engineruntask-options) 一致。 +| Field | Type | Description | +|-------|------|-------------| +| `state` | `S` | Current state (driven by React's render cycle) | +| `engine` | `TransactionalReducer` | The engine instance — access all methods directly | -### `api.create(options?)` +### `engine.run(task, options?)` -手动创建根事务。行为与 [`engine.create()`](../core/README.md#enginecreateoptions) 一致。 +Starts a root transaction with automatic lifecycle management. Behaves identically to [`engine.run()`](../core/README.md#engineruntask-options). -### `api.getDraft()` +### `engine.create(options?)` -返回引擎的即时状态。React 的状态更新可能被批处理或延迟,在异步回调中 `state` 可能不是最新的。`getDraft()` 始终返回最新值。 +Manually creates a root transaction. Behaves identically to [`engine.create()`](../core/README.md#enginecreateoptions). + +### `engine.state` + +Returns the engine's instantaneous state. React state updates may be batched or deferred, so `state` (the first tuple element) might be stale inside async callbacks. `engine.state` always returns the most up-to-date value. ```tsx -await api.run(async (tx) => { +await engine.run(async (tx) => { tx.dispatch({ type: "inc" }); - // state.count 可能还是旧值(React 批处理) - const currentCount = api.getDraft().count; // 最新值 + // state.count may still be the old value (React batching) + const currentCount = engine.state.count; // latest value tx.dispatch({ type: "set", value: currentCount * 2 }); }); ``` -### `api.getTransaction(id)` +### `engine.getTransaction(id)` -按 id 查找事务,等同于 [`engine.getTransaction()`](../core/README.md#enginegettransactionid)。 +Finds a transaction by ID. Equivalent to [`engine.getTransaction()`](../core/README.md#enginegettransactionid). --- -## 使用指南 +## Usage Guide -### 1. 乐观更新 + 自动回滚 +### 1. Optimistic Update + Auto-Rollback ```tsx async function handleSave() { - await api.run(async (tx) => { + await engine.run(async (tx) => { tx.dispatch({ type: "setSaving", value: true }); tx.dispatch({ type: "updateData", value: newData }); await saveToServer(newData); - // 成功 → 自动 commit;失败 → 自动 rollback + // success → auto-commit; failure → auto-rollback }); } ``` -### 2. 可取消的异步任务 +### 2. Cancellable Async Tasks -给事务指定 `id`,相同 id 的新事务会自动取消旧事务([去重策略](../core/README.md#去重--onduplicate-策略)): +Assign an `id` to a transaction; a new transaction with the same ID will automatically cancel the old one ([deduplication strategy](../core/README.md#deduplication--onduplicate-strategy)): ```tsx async function handleSearch(query: string) { - await api.run(async (tx) => { + await engine.run(async (tx) => { const ac = new AbortController(); tx.onCancel(() => ac.abort()); tx.dispatch({ type: "setLoading", value: true }); @@ -145,15 +143,15 @@ async function handleSearch(query: string) { } ``` -### 3. 手动管理事务生命周期 +### 3. Manual Transaction Lifecycle Management ```tsx function EditForm() { - const [state, api] = useTransactionalReducer(reducer, initialState); + const [state, engine] = useTransactionalReducer(reducer, initialState); const txRef = useRef>(); const startEditing = () => { - txRef.current = api.create({ id: "edit-form" }); + txRef.current = engine.create({ id: "edit-form" }); }; const updateField = (field: string, value: string) => { @@ -162,7 +160,7 @@ function EditForm() { const save = async () => { try { - await saveProfile(api.getDraft()); + await saveProfile(engine.state); txRef.current?.commit(); } catch { txRef.current?.rollback(); @@ -175,10 +173,10 @@ function EditForm() { } ``` -### 4. 嵌套事务(spawn) +### 4. Nested Transactions (spawn) ```tsx -await api.run(async (tx) => { +await engine.run(async (tx) => { tx.dispatch({ type: "setSubmitting", value: true }); await tx.spawn(async (childTx) => { @@ -192,16 +190,16 @@ await api.run(async (tx) => { }, { id: "submit" }); ``` -### 5. 并发事务 +### 5. Concurrent Transactions ```tsx const [result1, result2] = await Promise.all([ - api.run(async (tx) => { + engine.run(async (tx) => { tx.dispatch({ type: "setUsersLoading", value: true }); const users = await fetchUsers(); tx.dispatch({ type: "setUsers", value: users }); }, { id: "fetch-users" }), - api.run(async (tx) => { + engine.run(async (tx) => { tx.dispatch({ type: "setPostsLoading", value: true }); const posts = await fetchPosts(); tx.dispatch({ type: "setPosts", value: posts }); @@ -211,18 +209,18 @@ const [result1, result2] = await Promise.all([ --- -## 更多 +## Learn More -- **核心概念**(回滚算法、generation 机制、去重策略、提交边界、onCancel 等):参见 [`@transactional-reducer/core`](../core/README.md#核心机制详解) -- **常见场景**(搜索自动取消、多步骤提交 + 部分保留):参见 [`@transactional-reducer/core`](../core/README.md#常见场景) -- **注意事项**:参见 [`@transactional-reducer/core`](../core/README.md#注意事项) +- **Core concepts** (rollback algorithm, generation mechanism, deduplication strategy, commit boundary, onCancel, etc.): See [`@transactional-reducer/core`](../core/README.md#core-mechanics) +- **Common scenarios** (search auto-cancel, multi-step commit + partial retain): See [`@transactional-reducer/core`](../core/README.md#common-scenarios) +- **Caveats**: See [`@transactional-reducer/core`](../core/README.md#caveats) --- -## React 特有注意事项 +## React-Specific Notes -1. **React 批处理**:在异步回调中,React 的 `state` 可能不是最新的。使用 `api.getDraft()` 获取即时状态。 +1. **React batching**: Inside async callbacks, React's `state` may not be up to date. Use `engine.state` to get the instantaneous state. -2. **API 稳定性**:`api` 对象及其方法(`dispatch`、`run` 等)在组件整个生命周期中引用稳定,可安全地省略 `useEffect`/`useCallback` 的依赖项。 +2. **Engine stability**: The `engine` reference is stable throughout the component's lifecycle (backed by `useRef`), so it can safely be omitted from `useEffect`/`useCallback` dependency arrays. -3. **组件隔离**:每个组件实例持有独立的引擎实例(通过 `useRef`),状态不会跨组件共享。 +3. **Component isolation**: Each component instance holds an independent engine instance; state is not shared across components. diff --git a/packages/react/README.zh_CN.md b/packages/react/README.zh_CN.md new file mode 100644 index 0000000..5df951e --- /dev/null +++ b/packages/react/README.zh_CN.md @@ -0,0 +1,226 @@ +# @transactional-reducer/react + +为 React 的 `useReducer` 提供事务(Transaction)支持的 Hook。将 [`@transactional-reducer/core`](../core/README.md) 引擎封装为 React 友好的 API。 + +> 事务的核心概念(回滚算法、去重策略、提交边界、过期句柄等)均在 [`@transactional-reducer/core`](../core/README.md) 中详细说明。本文档仅描述 React Hook 的用法。 + +## 安装 + +```bash +npm install @transactional-reducer/react @transactional-reducer/core +``` + +## 快速开始 + +```tsx +import { useTransactionalReducer } from "@transactional-reducer/react"; + +type State = { count: number }; +type Action = { type: "inc" } | { type: "dec" }; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "inc": return { count: state.count + 1 }; + case "dec": return { count: state.count - 1 }; + } +}; + +function Counter() { + const [state, engine] = useTransactionalReducer(reducer, { count: 0 }); + + // 普通 dispatch —— 不可回滚 + const handleInc = () => engine.dispatch({ type: "inc" }); + + // 事务性 dispatch —— 可回滚 + const handleOptimisticInc = () => + engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); // 乐观更新 UI + await fetch("/api/inc"); // 异步请求 + // 成功 → 自动 commit;失败 → 自动 rollback + }); + + return ( +
+

Count: {state.count}

+ + +
+ ); +} +``` + +--- + +## API 参考 + +### 签名 + +```ts +function useTransactionalReducer( + reducer: (state: S, action: A) => S, + initialState: S, + options?: TransactionalReducerOptions, +): [S, TransactionalReducer]; +``` + +元组的第二个元素是 [`TransactionalReducer`](../core/README.md#transactionalreducer) 引擎实例——与直接在框架无关代码中使用的对象相同。所有引擎方法均可直接调用: + +- `engine.state` — 当前状态(同步读取) +- `engine.dispatch(action)` — 普通 dispatch,不可回滚 +- `engine.run(task, options?)` — 启动根事务,自动管理生命周期 +- `engine.create(options?)` — 手动创建根事务 +- `engine.getTransaction(id)` — 按 id 查找事务 +- `engine.commitAll()` — 提交所有活跃根事务 +- `engine.rollbackAll()` — 回滚所有活跃根事务 +- `engine.subscribe(listener)` — 订阅状态变更 + +`TransactionalReducerOptions`、`TransactionOptions`、`TransactionHandle` 等类型均从 [`@transactional-reducer/core`](../core/README.md#api-参考) 导出。 + +### 返回值 + +返回一个元组 `[state, engine]`: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `state` | `S` | 当前状态(由 React 渲染周期驱动) | +| `engine` | `TransactionalReducer` | 引擎实例——直接调用所有方法 | + +### `engine.run(task, options?)` + +启动根事务并自动管理生命周期。行为与 [`engine.run()`](../core/README.md#engineruntask-options) 一致。 + +### `engine.create(options?)` + +手动创建根事务。行为与 [`engine.create()`](../core/README.md#enginecreateoptions) 一致。 + +### `engine.state` + +返回引擎的即时状态。React 的状态更新可能被批处理或延迟,在异步回调中 `state`(元组的第一个元素)可能不是最新的。`engine.state` 始终返回最新值。 + +```tsx +await engine.run(async (tx) => { + tx.dispatch({ type: "inc" }); + // state.count 可能还是旧值(React 批处理) + const currentCount = engine.state.count; // 最新值 + tx.dispatch({ type: "set", value: currentCount * 2 }); +}); +``` + +### `engine.getTransaction(id)` + +按 id 查找事务,等同于 [`engine.getTransaction()`](../core/README.md#enginegettransactionid)。 + +--- + +## 使用指南 + +### 1. 乐观更新 + 自动回滚 + +```tsx +async function handleSave() { + await engine.run(async (tx) => { + tx.dispatch({ type: "setSaving", value: true }); + tx.dispatch({ type: "updateData", value: newData }); + await saveToServer(newData); + // 成功 → 自动 commit;失败 → 自动 rollback + }); +} +``` + +### 2. 可取消的异步任务 + +给事务指定 `id`,相同 id 的新事务会自动取消旧事务([去重策略](../core/README.md#去重--onduplicate-策略)): + +```tsx +async function handleSearch(query: string) { + await engine.run(async (tx) => { + const ac = new AbortController(); + tx.onCancel(() => ac.abort()); + tx.dispatch({ type: "setLoading", value: true }); + const results = await fetchResults(query, { signal: ac.signal }); + tx.dispatch({ type: "setResults", value: results }); + }, { id: "search" }); +} +``` + +### 3. 手动管理事务生命周期 + +```tsx +function EditForm() { + const [state, engine] = useTransactionalReducer(reducer, initialState); + const txRef = useRef>(); + + const startEditing = () => { + txRef.current = engine.create({ id: "edit-form" }); + }; + + const updateField = (field: string, value: string) => { + txRef.current?.dispatch({ type: "updateField", field, value }); + }; + + const save = async () => { + try { + await saveProfile(engine.state); + txRef.current?.commit(); + } catch { + txRef.current?.rollback(); + } + }; + + const cancel = () => { + txRef.current?.rollback(); + }; +} +``` + +### 4. 嵌套事务(spawn) + +```tsx +await engine.run(async (tx) => { + tx.dispatch({ type: "setSubmitting", value: true }); + + await tx.spawn(async (childTx) => { + childTx.dispatch({ type: "setValidating", value: true }); + const isValid = await validateForm(); + if (!isValid) throw new Error("validation failed"); + }, { id: "validate" }); + + await submitForm(); + tx.dispatch({ type: "setSubmitting", value: false }); +}, { id: "submit" }); +``` + +### 5. 并发事务 + +```tsx +const [result1, result2] = await Promise.all([ + engine.run(async (tx) => { + tx.dispatch({ type: "setUsersLoading", value: true }); + const users = await fetchUsers(); + tx.dispatch({ type: "setUsers", value: users }); + }, { id: "fetch-users" }), + engine.run(async (tx) => { + tx.dispatch({ type: "setPostsLoading", value: true }); + const posts = await fetchPosts(); + tx.dispatch({ type: "setPosts", value: posts }); + }, { id: "fetch-posts" }), +]); +``` + +--- + +## 更多 + +- **核心概念**(回滚算法、generation 机制、去重策略、提交边界、onCancel 等):参见 [`@transactional-reducer/core`](../core/README.md#核心机制详解) +- **常见场景**(搜索自动取消、多步骤提交 + 部分保留):参见 [`@transactional-reducer/core`](../core/README.md#常见场景) +- **注意事项**:参见 [`@transactional-reducer/core`](../core/README.md#注意事项) + +--- + +## React 特有注意事项 + +1. **React 批处理**:在异步回调中,React 的 `state` 可能不是最新的。使用 `engine.state` 获取即时状态。 + +2. **引擎稳定性**:`engine` 引用在组件整个生命周期中稳定(基于 `useRef`),可安全地省略 `useEffect`/`useCallback` 的依赖项。 + +3. **组件隔离**:每个组件实例持有独立的引擎实例,状态不会跨组件共享。 diff --git a/packages/react/src/useTransactionalReducer.ts b/packages/react/src/useTransactionalReducer.ts index c9753f2..6d83a85 100644 --- a/packages/react/src/useTransactionalReducer.ts +++ b/packages/react/src/useTransactionalReducer.ts @@ -1,54 +1,31 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { TransactionalReducer, - type TransactionOptions, - type TransactionHandle, type TransactionalReducerOptions, } from "@transactional-reducer/core"; export { + TransactionalReducer, type TransactionOptions, type TransactionHandle, type TransactionalReducerOptions, type OnErrorStrategy, type OnDuplicateStrategy, type ActionLogEntry, - TransactionalReducer, } from "@transactional-reducer/core"; export function useTransactionalReducer( reducer: (state: S, action: A) => S, initialState: S, options?: TransactionalReducerOptions, -): [ - S, - { - dispatch: (action: A) => void; - run(task: (tx: TransactionHandle
) => R, options?: TransactionOptions): R; - create(options?: TransactionOptions): TransactionHandle; - getDraft(): S; - getTransaction(id: string): TransactionHandle | undefined; - }, -] { +): [S, TransactionalReducer] { const [state, setState] = useState(initialState); - const engine = useRef | null>( + const engine = useRef>( new TransactionalReducer(reducer, initialState, options), ); - useEffect(() => engine.current!.subscribe(setState), []); - - const api = useMemo( - () => ({ - dispatch: (action: A) => engine.current!.dispatch(action), - run: (task: (tx: TransactionHandle) => R, opts?: TransactionOptions) => - engine.current!.run(task, opts), - create: (opts?: TransactionOptions) => engine.current!.create(opts), - getDraft: () => engine.current!.state, - getTransaction: (id: string) => engine.current!.getTransaction(id), - }), - [], - ); + useEffect(() => engine.current.subscribe(setState), []); - return [state, api] as const; + return [state, engine.current] as const; } diff --git a/packages/react/test/useTransactionalReducer.test.ts b/packages/react/test/useTransactionalReducer.test.ts index ada4480..34a60bb 100644 --- a/packages/react/test/useTransactionalReducer.test.ts +++ b/packages/react/test/useTransactionalReducer.test.ts @@ -58,16 +58,16 @@ describe("useTransactionalReducer", () => { }); }); - describe("getDraft", () => { - it("returns current draft state", () => { + describe("engine.state", () => { + it("returns current state", () => { const { result } = renderHook(() => useTransactionalReducer(reducer, initialState)); - expect(result.current[1].getDraft()).toEqual({ count: 0 }); + expect(result.current[1].state).toEqual({ count: 0 }); act(() => { result.current[1].dispatch({ type: "inc" }); }); - expect(result.current[1].getDraft()).toEqual({ count: 1 }); + expect(result.current[1].state).toEqual({ count: 1 }); }); }); @@ -273,7 +273,7 @@ describe("useTransactionalReducer", () => { it("accepts custom snapshot function", () => { const { result } = renderHook(() => useTransactionalReducer(reducer, initialState, { - snapshot: (s) => ({ count: s.count }), + snapshot: (s: State) => ({ count: s.count }), }), ); @@ -376,17 +376,17 @@ describe("useTransactionalReducer", () => { }); }); - describe("api stability", () => { - it("api object reference is stable across renders", () => { + describe("engine stability", () => { + it("engine reference is stable across renders", () => { const { result, rerender } = renderHook(() => useTransactionalReducer(reducer, initialState)); - const firstApi = result.current[1]; + const firstEngine = result.current[1]; rerender(); - const secondApi = result.current[1]; - expect(firstApi).toBe(secondApi); + const secondEngine = result.current[1]; + expect(firstEngine).toBe(secondEngine); }); - it("dispatch function is stable across renders", () => { + it("dispatch method is stable across renders", () => { const { result, rerender } = renderHook(() => useTransactionalReducer(reducer, initialState)); const firstDispatch = result.current[1].dispatch;