bff-store 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +45 -0
- package/CONTEXT.md +53 -0
- package/README.md +223 -0
- package/dist/cli.js +32577 -0
- package/dist/index.d.mts +232 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.mjs +430 -0
- package/dist/package.json +62 -0
- package/dist/server/entry.d.mts +94 -0
- package/dist/server/entry.d.ts +94 -0
- package/dist/server/entry.js +573 -0
- package/dist/server/entry.mjs +533 -0
- package/dist/server-V7WCW4ZB.mjs +530 -0
- package/dist/storage/jsonl-entry.d.mts +42 -0
- package/dist/storage/jsonl-entry.d.ts +42 -0
- package/dist/storage/jsonl-entry.js +112 -0
- package/dist/storage/jsonl-entry.mjs +74 -0
- package/dist/storage/mongodb-entry.d.mts +40 -0
- package/dist/storage/mongodb-entry.d.ts +40 -0
- package/dist/storage/mongodb-entry.js +114 -0
- package/dist/storage/mongodb-entry.mjs +86 -0
- package/docs/BUG_DIAGNOSIS_REMOTE_STORAGE_OPTIONS.md +104 -0
- package/docs/BUG_FIX_REMOTE_STORAGE_OPTIONS.md +63 -0
- package/docs/BUG_FIX_SESSION_2026-06-03.md +171 -0
- package/docs/IMPLEMENTATION.md +333 -0
- package/docs/PLAN.md +153 -0
- package/docs/REMOTE_STORAGE_CONFIG.md +125 -0
- package/docs/SIDECAR_SERVER.md +184 -0
- package/package.json +62 -0
- package/scripts/adapt-dist-package.js +33 -0
- package/src/atomCreator.ts +76 -0
- package/src/createStore.ts +77 -0
- package/src/debouncer.ts +84 -0
- package/src/index.ts +35 -0
- package/src/server/cli.ts +62 -0
- package/src/server/entityIdCache.ts +57 -0
- package/src/server/entry.ts +12 -0
- package/src/server/handlers.ts +271 -0
- package/src/server/index.ts +182 -0
- package/src/server/router.ts +74 -0
- package/src/server.ts +5 -0
- package/src/storage/adapters/remoteStorage.ts +70 -0
- package/src/storage/base.ts +28 -0
- package/src/storage/index.ts +9 -0
- package/src/storage/jsonl-entry.ts +9 -0
- package/src/storage/jsonl.ts +111 -0
- package/src/storage/memory.ts +49 -0
- package/src/storage/mongodb-entry.ts +9 -0
- package/src/storage/mongodb.ts +132 -0
- package/src/storage/protocol.ts +170 -0
- package/src/storage/transport.ts +95 -0
- package/src/types.ts +76 -0
- package/src/useStore.ts +83 -0
- package/tests/atomCreator.test.ts +153 -0
- package/tests/createStore.test.ts +126 -0
- package/tests/debouncer.test.ts +125 -0
- package/tests/server.test.ts +158 -0
- package/tests/storage/jsonl.test.ts +132 -0
- package/tests/storage/memory.test.ts +101 -0
- package/tests/storage/mongodb.test.ts +40 -0
- package/tests/storage/remoteStorage.test.ts +126 -0
- package/tests/useStore.test.tsx +147 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +53 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Bug Fix Session — 2026-06-03
|
|
2
|
+
|
|
3
|
+
本次会话诊断并修复了 4 个 bug,覆盖类型解析、协议层、存储适配器三个层面。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Bug 1 (P0): `'backend' does not exist in type 'RemoteStorageOptions'`
|
|
8
|
+
|
|
9
|
+
### 现象
|
|
10
|
+
|
|
11
|
+
`tests/next_test` 中 `"bff-store": "file:../../dist"` 安装时,TypeScript 报错:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Object literal may only specify known properties, and 'backend' does not exist in type 'RemoteStorageOptions'.ts(2353)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### 根因
|
|
18
|
+
|
|
19
|
+
**因素一(主因):dist/package.json 路径错误。** 根目录 `package.json` 的路径字段带有 `./dist/` 前缀:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
"main": "dist/index.js",
|
|
23
|
+
"exports": { ".": { "types": "./dist/index.d.ts", ... } }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
当通过 `"file:../../dist"` 本地安装时,包根目录就是 `dist/` 本身,`./dist/index.d.ts` 解析为 `dist/dist/index.d.ts` —— 文件不存在。
|
|
27
|
+
|
|
28
|
+
| 场景 | 包根目录 | `./dist/index.d.ts` 解析 | 存在? |
|
|
29
|
+
|------|----------|-------------------------|------|
|
|
30
|
+
| npm 发布 | 项目根 | `项目根/dist/index.d.ts` | 是 |
|
|
31
|
+
| `file:../../dist` | `dist/` | `dist/dist/index.d.ts` | 否 |
|
|
32
|
+
|
|
33
|
+
**因素二(助推):全局安装了旧版本。** 本地 symlink 解析失败后,TypeScript 沿目录树向上查找,在 `/Users/Admin/node_modules/bff-store/` 找到旧版 v0.1.1,该版本 `RemoteStorageOptions` 无 `backend` 字段。
|
|
34
|
+
|
|
35
|
+
### 修复
|
|
36
|
+
|
|
37
|
+
1. **新增** `scripts/adapt-dist-package.js` — 构建后将 `dist/package.json` 路径改写为相对 dist/:
|
|
38
|
+
- `dist/index.js` → `index.js`
|
|
39
|
+
- `./dist/index.d.ts` → `./index.d.ts`
|
|
40
|
+
- `./dist/storage/...` → `./storage/...`
|
|
41
|
+
|
|
42
|
+
2. **修改** `package.json` build 脚本:
|
|
43
|
+
```
|
|
44
|
+
"build": "tsup && cp package.json dist/package.json && node scripts/adapt-dist-package.js && for f in dist/*.d.mts; do cp \"$f\" \"${f%.d.mts}.d.ts\"; done"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 涉及文件
|
|
48
|
+
|
|
49
|
+
| 文件 | 操作 |
|
|
50
|
+
|------|------|
|
|
51
|
+
| `scripts/adapt-dist-package.js` | 新增 |
|
|
52
|
+
| `package.json` | 修改 build 脚本 |
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Bug 2 (P0): GET/DELETE 请求拿不到 `mongoUrl`
|
|
57
|
+
|
|
58
|
+
### 现象
|
|
59
|
+
|
|
60
|
+
BFF 模式下,POST (`/storage/set`) 正常,但 GET (`/storage/get`) 返回:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
{"error":"Error: mongoUrl is required for mongodb backend"}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 根因
|
|
67
|
+
|
|
68
|
+
`src/storage/protocol.ts` 的 `buildUrl` / `buildBatchGetUrl` / `buildBatchSetUrl` 方法只将 `backend` 和 `entityId` 拼入 URL query params,`mongoUrl`、`mongoDb`、`jsonlDir` 仅通过 POST body 传递(`createStorageWithProtocol` 中将 `backendConfig` spread 到 body)。
|
|
69
|
+
|
|
70
|
+
GET/DELETE 无 body → server 端 `resolveStorage` 从 URL query params 解析不到 `mongoUrl` → 尝试 `mongodbStorage({ url: undefined })` → 抛错。
|
|
71
|
+
|
|
72
|
+
### 修复
|
|
73
|
+
|
|
74
|
+
抽取 `appendBackendParams()` 方法,将全部 backend 参数拼入 URL query params:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
private appendBackendParams(params: URLSearchParams): void {
|
|
78
|
+
if (this.backendConfig.backend) params.set('backend', this.backendConfig.backend);
|
|
79
|
+
if (this.backendConfig.mongoUrl) params.set('mongoUrl', this.backendConfig.mongoUrl);
|
|
80
|
+
if (this.backendConfig.mongoDb) params.set('mongoDb', this.backendConfig.mongoDb);
|
|
81
|
+
if (this.backendConfig.jsonlDir) params.set('jsonlDir', this.backendConfig.jsonlDir);
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 涉及文件
|
|
86
|
+
|
|
87
|
+
| 文件 | 操作 |
|
|
88
|
+
|------|------|
|
|
89
|
+
| `src/storage/protocol.ts` | 新增 `appendBackendParams()`,`buildUrl` / `buildBatchGetUrl` / `buildBatchSetUrl` 调用之 |
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Bug 3 (P2): JSONL adapter 静默失败(entityId 默认 null)
|
|
94
|
+
|
|
95
|
+
### 现象
|
|
96
|
+
|
|
97
|
+
BFF server 使用 JSONL backend 时,SET 返回 `{"success":true}` 但数据未写入磁盘,GET 返回 `{"value":null}`。
|
|
98
|
+
|
|
99
|
+
### 根因
|
|
100
|
+
|
|
101
|
+
`src/storage/jsonl.ts` 中 `entityId` 初始值为 `null`,GET 和 SET 操作均有 `if (!entityId) return` 守卫,导致静默跳过。对比 MongoDB adapter 默认 `currentEntityId = 'default'`,行为不一致。
|
|
102
|
+
|
|
103
|
+
### 修复
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// Before
|
|
107
|
+
let entityId: string | null = null;
|
|
108
|
+
|
|
109
|
+
// After
|
|
110
|
+
let entityId: string = 'default';
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 涉及文件
|
|
114
|
+
|
|
115
|
+
| 文件 | 操作 |
|
|
116
|
+
|------|------|
|
|
117
|
+
| `src/storage/jsonl.ts` | `entityId` 默认值 `null` → `'default'` |
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Bug 4 (P2): MongoDB 副本集连接失败
|
|
122
|
+
|
|
123
|
+
### 现象
|
|
124
|
+
|
|
125
|
+
BFF server 连接 MongoDB 时报:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
MongoServerSelectionError: connection <monitor> to 172.16.42.101:27017 closed
|
|
129
|
+
TopologyDescription { type: 'ReplicaSetNoPrimary', setName: 'rs0' }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### 根因
|
|
133
|
+
|
|
134
|
+
`localhost:27017` 属于副本集 `rs0`,成员地址 `172.16.42.101:27017`。MongoDB driver 自动发现副本集后切换到该地址,但该地址网络不通。
|
|
135
|
+
|
|
136
|
+
### 修复
|
|
137
|
+
|
|
138
|
+
在 MongoDB URL 中拼接 `?directConnection=true&authSource=admin` 绕过副本集发现。
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
const mongoUrl = `mongodb://${user}:${pwd}@${host}/${db}?directConnection=true&authSource=admin`;
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 涉及文件
|
|
145
|
+
|
|
146
|
+
| 文件 | 操作 |
|
|
147
|
+
|------|------|
|
|
148
|
+
| `tests/next_test/src/app/page.tsx` | 添加 URL 参数 |
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 测试覆盖
|
|
153
|
+
|
|
154
|
+
| 文件 | 内容 |
|
|
155
|
+
|------|------|
|
|
156
|
+
| `tests/next_test/test-types.ts` | 原始 bug 用例 — `backend: 'jsonl'` |
|
|
157
|
+
| `tests/next_test/test-similar-root.tsx` | 同上 .tsx 版本 |
|
|
158
|
+
| `tests/next_test/test-regression-remote-storage.ts` | 全参数组合:jsonl / mongodb / 可选字段 / 默认构造 |
|
|
159
|
+
| `tests/next_test/src/app/test-similar.tsx` | .tsx 中 remoteStorage 类型测试 |
|
|
160
|
+
| `tests/next_test/src/app/test-regression-store.tsx` | createStore + remoteStorage 组合测试 |
|
|
161
|
+
| `tests/next_test/src/app/test-mongodb.tsx` | MongoDB 前端组件测试 |
|
|
162
|
+
| `tests/next_test/test-mongodb-direct.ts` | mongodbStorage 直接调用测试脚本 |
|
|
163
|
+
| `tests/next_test/test-mongodb-remote.ts` | remoteStorage → MongoDB 路由测试脚本 |
|
|
164
|
+
| `tests/next_test/src/app/page.tsx` | 浏览器端到端测试(可切换 jsonl/mongodb) |
|
|
165
|
+
|
|
166
|
+
## 验证结果
|
|
167
|
+
|
|
168
|
+
- `npm run build`(项目根):构建成功
|
|
169
|
+
- `npx tsc --noEmit`(tests/next_test):0 errors
|
|
170
|
+
- `curl` 测试 JSONL SET/GET/DELETE 全链路:通过
|
|
171
|
+
- `curl` 测试 MongoDB SET/GET/DELETE 全链路:通过
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# 核心实现详解
|
|
2
|
+
|
|
3
|
+
## 1. Storage 接口
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
// src/storage/base.ts
|
|
7
|
+
export interface Storage {
|
|
8
|
+
get<T>(key: string): Promise<T | null>;
|
|
9
|
+
set<T>(key: string, value: T): Promise<void>;
|
|
10
|
+
remove(key: string): Promise<void>;
|
|
11
|
+
getMultiple?<T>(keys: string[]): Promise<Map<string, T>>; // 可选:批量读取
|
|
12
|
+
setMultiple?<T>(entries: Map<string, T>): Promise<void>; // 可选:批量写入
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
所有存储适配器都实现此接口,实现存储层的可插拔。
|
|
17
|
+
|
|
18
|
+
### 1.1 JSONL 存储
|
|
19
|
+
|
|
20
|
+
**文件结构**: `{dir}/{entityId}/{key}.jsonl`
|
|
21
|
+
|
|
22
|
+
每行一条 JSON,方便追溯历史和追加写入:
|
|
23
|
+
|
|
24
|
+
```jsonl
|
|
25
|
+
{"key":"theme","value":"科幻小说","timestamp":1704067200000}
|
|
26
|
+
{"key":"theme","value":"奇幻小说","timestamp":1704067201000}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
读取时取最后一行(最新值),写入时 append。
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// src/storage/jsonl.ts
|
|
33
|
+
export function jsonlStorage(options?: JsonlStorageOptions): JsonlStorageInstance {
|
|
34
|
+
const baseDir = options?.dir ?? './sessions';
|
|
35
|
+
|
|
36
|
+
const storage: Storage = {
|
|
37
|
+
async get<T>(key: string): Promise<T | null> {
|
|
38
|
+
const filePath = getFilePath(entityId, key);
|
|
39
|
+
if (!fs.existsSync(filePath)) return null;
|
|
40
|
+
|
|
41
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
42
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
43
|
+
if (lines.length === 0) return null;
|
|
44
|
+
|
|
45
|
+
// 取最后一行
|
|
46
|
+
const lastLine = lines[lines.length - 1];
|
|
47
|
+
const entry: JsonlEntry = JSON.parse(lastLine);
|
|
48
|
+
return entry.value as T;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
52
|
+
const filePath = getFilePath(entityId, key);
|
|
53
|
+
const entry = { key, value, timestamp: Date.now() };
|
|
54
|
+
const line = JSON.stringify(entry) + '\n';
|
|
55
|
+
fs.appendFileSync(filePath, line, 'utf-8');
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return { storage, name: 'jsonl', setEntityId };
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 1.2 MongoDB 存储
|
|
64
|
+
|
|
65
|
+
使用 MongoDB 的 append-only 模式,每次写入插入新文档,读取时取最新:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// src/storage/mongodb.ts
|
|
69
|
+
const storage: Storage = {
|
|
70
|
+
async get<T>(key: string): Promise<T | null> {
|
|
71
|
+
const entry = await collection.findOne(
|
|
72
|
+
{ key },
|
|
73
|
+
{ sort: { timestamp: -1 } } // 按时间倒序,取第一条
|
|
74
|
+
);
|
|
75
|
+
return entry?.value as T ?? null;
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
79
|
+
await collection.insertOne({
|
|
80
|
+
key,
|
|
81
|
+
value,
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 2. Atom 创建器
|
|
91
|
+
|
|
92
|
+
### 2.1 核心逻辑
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// src/atomCreator.ts
|
|
96
|
+
export function createPersistedAtom<T>(
|
|
97
|
+
config: AtomConfig<T>,
|
|
98
|
+
storage: Storage,
|
|
99
|
+
options?: { immediate?: boolean; debounceMs?: number }
|
|
100
|
+
): PersistedAtomWithLoading<T> {
|
|
101
|
+
// 1. 创建基础 atom
|
|
102
|
+
const baseAtom = atom<T>(config.defaultValue);
|
|
103
|
+
const loadingAtom = atom<boolean>(true);
|
|
104
|
+
|
|
105
|
+
// 2. onMount 时从 storage 加载初始值
|
|
106
|
+
baseAtom.onMount = (setValue) => {
|
|
107
|
+
storage.get<T>(config.key)
|
|
108
|
+
.then((value) => {
|
|
109
|
+
if (value !== null) setValue(value);
|
|
110
|
+
})
|
|
111
|
+
.finally(() => {
|
|
112
|
+
store.set(loadingAtom, false); // 标记加载完成
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// 3. 创建写 atom,自动持久化
|
|
117
|
+
const writeAtom = atom(
|
|
118
|
+
(get) => get(baseAtom),
|
|
119
|
+
(get, set, update) => {
|
|
120
|
+
const newValue = typeof update === 'function'
|
|
121
|
+
? update(get(baseAtom))
|
|
122
|
+
: update;
|
|
123
|
+
|
|
124
|
+
set(baseAtom, newValue); // 先更新本地
|
|
125
|
+
|
|
126
|
+
// 保存到 storage
|
|
127
|
+
if (options?.immediate) {
|
|
128
|
+
storage.set(config.key, newValue); // 立即保存
|
|
129
|
+
} else {
|
|
130
|
+
debouncedSave(config.key, newValue); // 防抖保存
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return { atom: writeAtom, loadingAtom };
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 2.2 防抖保存
|
|
140
|
+
|
|
141
|
+
使用 WeakMap 缓存每个 atom 的 debounced 函数:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
const debouncedSaveMap = new WeakMap<object, { timer: Timer; fn: Function }>();
|
|
145
|
+
|
|
146
|
+
function debounce(fn: Function, ms: number): Function {
|
|
147
|
+
let timer: Timer | null = null;
|
|
148
|
+
return (...args) => {
|
|
149
|
+
if (timer) clearTimeout(timer);
|
|
150
|
+
timer = setTimeout(() => {
|
|
151
|
+
fn(...args);
|
|
152
|
+
timer = null;
|
|
153
|
+
}, ms);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 3. Store 工厂
|
|
161
|
+
|
|
162
|
+
### 3.1 createStore
|
|
163
|
+
|
|
164
|
+
批量创建 atoms:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
// src/createStore.ts
|
|
168
|
+
export function createStore(
|
|
169
|
+
entityId: string,
|
|
170
|
+
config: AtomConfigs,
|
|
171
|
+
options?: { storage: Storage; debounceMs?: number }
|
|
172
|
+
): Store {
|
|
173
|
+
const storage = options.storage;
|
|
174
|
+
const debounceMs = options.debounceMs ?? 800;
|
|
175
|
+
|
|
176
|
+
const atoms = {};
|
|
177
|
+
const loadingAtoms = {};
|
|
178
|
+
|
|
179
|
+
for (const atomConfig of config) {
|
|
180
|
+
const result = createPersistedAtom(atomConfig, storage, {
|
|
181
|
+
immediate: atomConfig.immediate,
|
|
182
|
+
debounceMs,
|
|
183
|
+
});
|
|
184
|
+
atoms[atomConfig.key] = result.atom;
|
|
185
|
+
loadingAtoms[atomConfig.key] = result.loadingAtom;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { entityId, config, atoms, loadingAtoms };
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### 3.2 原子配置
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
interface AtomConfig<T = unknown> {
|
|
196
|
+
key: string; // 状态键名
|
|
197
|
+
defaultValue: T; // 默认值
|
|
198
|
+
type?: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
|
199
|
+
immediate?: boolean; // 是否立即保存(不用 debounce)
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`immediate: true` 用于 critical data(如 chapterOutline),避免 debounce 延迟。
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## 4. useStore Hook
|
|
208
|
+
|
|
209
|
+
### 4.1 核心实现
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
// src/useStore.ts
|
|
213
|
+
export function useStore(store: Store): UseStoreReturn {
|
|
214
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
215
|
+
|
|
216
|
+
// 订阅所有 loading atoms
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
const storeInstance = getDefaultStore();
|
|
219
|
+
const loadingAtoms = Object.values(store.loadingAtoms);
|
|
220
|
+
|
|
221
|
+
const checkLoadingStatus = () => {
|
|
222
|
+
const states = loadingAtoms.map(atom => storeInstance.get(atom));
|
|
223
|
+
setIsLoading(states.some(s => s === true));
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
checkLoadingStatus();
|
|
227
|
+
|
|
228
|
+
// 订阅变化
|
|
229
|
+
const unsubscribers = loadingAtoms.map(atom =>
|
|
230
|
+
storeInstance.sub(atom, checkLoadingStatus)
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return () => unsubscribers.forEach(unsub => unsub());
|
|
234
|
+
}, [store.loadingAtoms]);
|
|
235
|
+
|
|
236
|
+
// 为每个 atom 调用 useAtom
|
|
237
|
+
const result = {};
|
|
238
|
+
for (const config of store.config) {
|
|
239
|
+
const [value, setter] = useAtom(store.atoms[config.key]);
|
|
240
|
+
result[config.key] = value;
|
|
241
|
+
result[`set${capitalize(config.key)}`] = setter;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { ...result, isLoading };
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### 4.2 使用示例
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
const config = [
|
|
252
|
+
{ key: 'theme', defaultValue: '' },
|
|
253
|
+
{ key: 'characters', defaultValue: [] },
|
|
254
|
+
] as const;
|
|
255
|
+
|
|
256
|
+
const store = createStore('novel-123', config, { storage });
|
|
257
|
+
|
|
258
|
+
function NovelEditor() {
|
|
259
|
+
const { theme, characters, setTheme, setCharacters, isLoading } = useStore(store);
|
|
260
|
+
|
|
261
|
+
if (isLoading) return <Loading />;
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div>
|
|
265
|
+
<input value={theme} onChange={e => setTheme(e.target.value)} />
|
|
266
|
+
<CharacterList characters={characters} onUpdate={setCharacters} />
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## 5. 类型系统
|
|
275
|
+
|
|
276
|
+
### 5.1 类型导出
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
// src/types.ts
|
|
280
|
+
export interface Store {
|
|
281
|
+
entityId: string;
|
|
282
|
+
config: AtomConfigs;
|
|
283
|
+
atoms: StoreAtoms;
|
|
284
|
+
loadingAtoms: StoreLoadingAtoms;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export type UseStoreReturn = Record<string, any> & { isLoading: boolean };
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### 5.2 类型安全
|
|
291
|
+
|
|
292
|
+
虽然使用了 `as const` 和泛型,但由于 JavaScript 动态特性,返回类型使用 `Record<string, any>` 保证灵活性。
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## 6. 数据流
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
用户操作
|
|
300
|
+
│
|
|
301
|
+
▼
|
|
302
|
+
useStore Hook
|
|
303
|
+
│
|
|
304
|
+
├── useAtom(store.atoms.xxx) → 更新 React 组件
|
|
305
|
+
│
|
|
306
|
+
▼
|
|
307
|
+
writeAtom (jotai)
|
|
308
|
+
│
|
|
309
|
+
├── set(baseAtom, newValue) → 立即更新本地状态
|
|
310
|
+
│
|
|
311
|
+
▼
|
|
312
|
+
debouncedSave / storage.set()
|
|
313
|
+
│
|
|
314
|
+
▼
|
|
315
|
+
Storage Adapter (JSONL / MongoDB / Memory)
|
|
316
|
+
│
|
|
317
|
+
▼
|
|
318
|
+
持久化存储
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## 7. 与原系统对比
|
|
324
|
+
|
|
325
|
+
| 特性 | 原系统 (novel-simple-persist.ts) | 新系统 (bff-store) |
|
|
326
|
+
|------|--------------------------------|--------------------------|
|
|
327
|
+
| 配置方式 | 硬编码 ATOM_CONFIGS | 数组配置,通用 |
|
|
328
|
+
| 存储层 | API 调用 (enhancedNovelClientAPI) | 可插拔 Adapter |
|
|
329
|
+
| 单例模式 | NovelAtomsSingleton 强制单例 | 可选,不强制 |
|
|
330
|
+
| MongoDB | 通过 API | 直连 |
|
|
331
|
+
| JSONL | 无 | 原生支持 |
|
|
332
|
+
| 加载状态 | loadingAtoms | loadingAtoms |
|
|
333
|
+
| Debounce | 800ms | 可配置 |
|
package/docs/PLAN.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Plan: 提取 Jotai 状态管理为独立工具包 `bff-store`
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
当前项目 `pro_novel` 中有一套基于 jotai 的状态管理系统,分散在 `persist.ts` 和 `novel-simple-persist.ts` 中。存在以下问题:
|
|
6
|
+
- 与业务强耦合,难以跨项目复用
|
|
7
|
+
- 存储层耦合了 API(`enhancedNovelClientAPI`)
|
|
8
|
+
- 单例模式固定,难以按需使用
|
|
9
|
+
|
|
10
|
+
**目标**: 提取为独立 npm 包,配置驱动批量创建 atoms,存储层可插拔(默认 JSONL)。
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 核心 API 设计
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
// 1. 状态配置数组 - 一次定义多个状态
|
|
18
|
+
const config = [
|
|
19
|
+
{ key: 'theme', defaultValue: '' },
|
|
20
|
+
{ key: 'characters', defaultValue: [] },
|
|
21
|
+
{ key: 'chapters', defaultValue: [] },
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
// 2. 创建 store 实例
|
|
25
|
+
const store = createStore('novel-123', config, {
|
|
26
|
+
storage: jsonlStorage({ dir: './sessions' }), // 默认
|
|
27
|
+
// storage: mongodbStorage({ url: 'mongodb://...' }),
|
|
28
|
+
debounceMs: 800,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// 3. React Hook 使用
|
|
32
|
+
function NovelEditor() {
|
|
33
|
+
const { theme, characters, setTheme, setCharacters } = useStore(store);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 4. 非 React 环境直接操作 atoms
|
|
37
|
+
store.atoms.theme.set('new theme');
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 目录结构
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
bff-store/
|
|
46
|
+
├── src/
|
|
47
|
+
│ ├── index.ts # 公共 API 导出
|
|
48
|
+
│ ├── types.ts # 类型定义
|
|
49
|
+
│ ├── createStore.ts # 核心工厂函数
|
|
50
|
+
│ ├── useStore.ts # React Hook
|
|
51
|
+
│ ├── atomCreator.ts # 单个 atom 创建逻辑
|
|
52
|
+
│ └── storage/
|
|
53
|
+
│ ├── base.ts # Storage interface
|
|
54
|
+
│ ├── memory.ts # 内存存储(开发用)
|
|
55
|
+
│ ├── jsonl.ts # JSONL 文件存储(默认)
|
|
56
|
+
│ └── mongodb.ts # MongoDB 存储(可选)
|
|
57
|
+
├── docs/
|
|
58
|
+
│ └── PLAN.md # 本文档
|
|
59
|
+
├── package.json
|
|
60
|
+
└── README.md
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 实现步骤
|
|
66
|
+
|
|
67
|
+
### Step 1: 创建基础类型和接口 (`types.ts`, `storage/base.ts`)
|
|
68
|
+
|
|
69
|
+
- 定义 `AtomConfig` - 单个状态配置 `{ key, defaultValue, type?, immediate? }`
|
|
70
|
+
- 定义 `Storage` interface - `get(key)`, `set(key, value)`, `remove(key)`
|
|
71
|
+
- 定义 `StoreOptions` - 存储适配器、debounce 配置
|
|
72
|
+
|
|
73
|
+
### Step 2: 实现存储适配器 (`storage/`)
|
|
74
|
+
|
|
75
|
+
| 文件 | 职责 |
|
|
76
|
+
|------|------|
|
|
77
|
+
| `memory.ts` | 内存 Map,简单开发/测试用 |
|
|
78
|
+
| `jsonl.ts` | JSONL 文件存储,按 entityId 分文件,`sessions/{entityId}/{key}.jsonl` |
|
|
79
|
+
| `mongodb.ts` | MongoDB 存储,collection per entityId |
|
|
80
|
+
|
|
81
|
+
**JSONL 格式** (每行一条 JSON):
|
|
82
|
+
```
|
|
83
|
+
{"key":"theme","value":"科幻小说","timestamp":1704067200000}
|
|
84
|
+
{"key":"theme","value":"奇幻小说","timestamp":1704067201000}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Step 3: 实现 atom 创建器 (`atomCreator.ts`)
|
|
88
|
+
|
|
89
|
+
- `createPersistedAtom(config, storage, options)` → 返回 `{ atom, loadingAtom }`
|
|
90
|
+
- onMount 时从 storage 加载初始值
|
|
91
|
+
- 写入时 debounce 保存到 storage
|
|
92
|
+
|
|
93
|
+
### Step 4: 实现 store 工厂 (`createStore.ts`)
|
|
94
|
+
|
|
95
|
+
- 输入: `entityId`, `config[]`, `options`
|
|
96
|
+
- 批量创建 atoms,返回 `{ atoms, loadingAtoms }`
|
|
97
|
+
- 支持分片并发创建(复用现有 `createAtomsInChunks` 逻辑)
|
|
98
|
+
|
|
99
|
+
### Step 5: 实现 React Hook (`useStore.ts`)
|
|
100
|
+
|
|
101
|
+
- `useStore(store)` - 自动订阅所有 atoms
|
|
102
|
+
- 返回 `{ ...data, ...setters, isLoading }`
|
|
103
|
+
- 优化: useMemo 缓存返回值,避免不必要的 re-render
|
|
104
|
+
|
|
105
|
+
### Step 6: 导出公共 API (`index.ts`)
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
export { createStore, useStore };
|
|
109
|
+
export { jsonlStorage, mongodbStorage, memoryStorage };
|
|
110
|
+
export type { AtomConfig, Store, Storage, StoreOptions };
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 关键文件修改
|
|
116
|
+
|
|
117
|
+
| 文件 | 操作 |
|
|
118
|
+
|------|------|
|
|
119
|
+
| `src/app/store/persist.ts` | 保留基础版,工具包独立后可删除 |
|
|
120
|
+
| `src/app/store/novel-simple-persist.ts` | 逐步迁移到工具包 |
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 验证方案
|
|
125
|
+
|
|
126
|
+
1. **单元测试**: `vitest` 测试 atom 创建、存储适配器
|
|
127
|
+
2. **集成测试**: 创建 mock store,验证 CRUD + Hook 联动
|
|
128
|
+
3. **手动验证**: 在现有项目中引入工具包,替换 `novel-simple-persist.ts`
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 实现状态
|
|
133
|
+
|
|
134
|
+
✅ 已完成:
|
|
135
|
+
- [x] 项目脚手架 (package.json, tsconfig.json)
|
|
136
|
+
- [x] 类型定义 (types.ts)
|
|
137
|
+
- [x] 存储接口 (storage/base.ts)
|
|
138
|
+
- [x] 存储适配器 (memory.ts, jsonl.ts, mongodb.ts)
|
|
139
|
+
- [x] atom 创建器 (atomCreator.ts)
|
|
140
|
+
- [x] store 工厂 (createStore.ts)
|
|
141
|
+
- [x] React Hook (useStore.ts)
|
|
142
|
+
- [x] 公共 API 导出 (index.ts)
|
|
143
|
+
- [x] 构建配置和输出
|
|
144
|
+
- [x] README.md
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 下一步
|
|
149
|
+
|
|
150
|
+
1. 编写单元测试 (vitest)
|
|
151
|
+
2. 在 pro_novel 项目中引入工具包
|
|
152
|
+
3. 改造 `novel-simple-persist.ts` 使用新工具包
|
|
153
|
+
4. 考虑添加更多存储适配器 (IndexedDB, Redis 等)
|