bff-store 0.1.1 → 0.1.2

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.
Files changed (45) hide show
  1. package/.claude/settings.local.json +5 -1
  2. package/README.md +172 -68
  3. package/dist/cli.js +11 -9
  4. package/dist/index.d.mts +7 -1
  5. package/dist/index.d.ts +7 -1
  6. package/dist/index.mjs +47 -12
  7. package/dist/package.json +1 -1
  8. package/dist/server/entry.d.mts +1 -1
  9. package/dist/server/entry.d.ts +1 -1
  10. package/dist/server/entry.js +11 -9
  11. package/dist/server/entry.mjs +11 -9
  12. package/dist/{server-V7WCW4ZB.mjs → server-2WMLXQ23.mjs} +11 -9
  13. package/dist/storage/jsonl-entry.js +1 -1
  14. package/dist/storage/jsonl-entry.mjs +1 -1
  15. package/dist/storage/mongodb-entry.js +5 -6
  16. package/dist/storage/mongodb-entry.mjs +5 -6
  17. package/docs/IMPLEMENTATION_OVERVIEW.md +231 -0
  18. package/docs/bugs/01-server-async-startup.high.md +34 -0
  19. package/docs/bugs/03-storage-cache-eviction.md +40 -0
  20. package/docs/bugs/04-http-error-body-loss.md +38 -0
  21. package/docs/bugs/05-debouncer-ms-ignored.md +41 -0
  22. package/docs/bugs/07-jsonl-key-collision.high.md +31 -0
  23. package/docs/bugs/08-dead-code-getStorageForRequest.md +18 -0
  24. package/docs/bugs/09-waitForLoad-infinite.md +37 -0
  25. package/docs/bugs/10-parseBody-silent-failure.md +32 -0
  26. package/docs/bugs/11-mongodb-storage-leak.high.md +35 -0
  27. package/docs/bugs/12-setEntityId-stale-closure.high.md +35 -0
  28. package/docs/bugs/14-dead-code-isServerRunning.md +18 -0
  29. package/docs/bugs/15-unmount-data-loss.md +37 -0
  30. package/docs/bugs/17-unused-parameter.md +26 -0
  31. package/docs/bugs/18-storage-get-silent-error.md +28 -0
  32. package/docs/bugs/README.md +35 -0
  33. package/package.json +1 -4
  34. package/src/atomCreator.ts +10 -2
  35. package/src/createStore.ts +19 -1
  36. package/src/debouncer.ts +5 -2
  37. package/src/index.ts +1 -1
  38. package/src/nodeStore.ts +9 -2
  39. package/src/server/handlers.ts +7 -24
  40. package/src/server/index.ts +0 -4
  41. package/src/storage/adapters/remoteStorage.ts +2 -1
  42. package/src/storage/jsonl.ts +3 -1
  43. package/src/storage/mongodb.ts +6 -6
  44. package/src/storage/transport.ts +24 -3
  45. package/tests/storage/jsonl.test.ts +18 -2
@@ -62,7 +62,7 @@ declare function createStorageHandlers(options: StorageHandlersOptions): {
62
62
  handleDelete: (req: IncomingMessage, res: ServerResponse, params?: Record<string, string>) => Promise<void>;
63
63
  handleBatchGet: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
64
64
  handleBatchSet: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
65
- handleHealth: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
65
+ handleHealth: (_req: IncomingMessage, res: ServerResponse) => Promise<void>;
66
66
  };
67
67
 
68
68
  /**
@@ -62,7 +62,7 @@ declare function createStorageHandlers(options: StorageHandlersOptions): {
62
62
  handleDelete: (req: IncomingMessage, res: ServerResponse, params?: Record<string, string>) => Promise<void>;
63
63
  handleBatchGet: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
64
64
  handleBatchSet: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
65
- handleHealth: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
65
+ handleHealth: (_req: IncomingMessage, res: ServerResponse) => Promise<void>;
66
66
  };
67
67
 
68
68
  /**
@@ -32035,12 +32035,11 @@ async function mongodbStorage(options) {
32035
32035
  },
32036
32036
  async set(key, value) {
32037
32037
  const collection = getCollection(currentEntityId);
32038
- await collection.insertOne({
32039
- key,
32040
- value,
32041
- timestamp: Date.now(),
32042
- entityId: currentEntityId
32043
- });
32038
+ await collection.updateOne(
32039
+ { key, entityId: currentEntityId },
32040
+ { $set: { value, timestamp: Date.now() } },
32041
+ { upsert: true }
32042
+ );
32044
32043
  },
32045
32044
  async remove(key) {
32046
32045
  const collection = getCollection(currentEntityId);
@@ -32143,7 +32142,7 @@ function jsonlStorage(options) {
32143
32142
  const baseDir = options?.dir ?? "./sessions";
32144
32143
  let entityId = "default";
32145
32144
  function getFilePath(eId, key) {
32146
- const safeKey = key.replace(/[^a-zA-Z0-9_]/g, "_");
32145
+ const safeKey = encodeURIComponent(key);
32147
32146
  return path.join(baseDir, eId, `${safeKey}.jsonl`);
32148
32147
  }
32149
32148
  const storage = {
@@ -32291,7 +32290,7 @@ async function getCachedStorage(config, entityId) {
32291
32290
  }
32292
32291
  return entry.adapter.storage;
32293
32292
  }
32294
- if (storageCache.size >= MAX_CACHE_SIZE) {
32293
+ while (storageCache.size >= MAX_CACHE_SIZE && storageCache.size > 0) {
32295
32294
  let oldestKey = null;
32296
32295
  let oldestTime = Infinity;
32297
32296
  for (const [k, entry] of storageCache.entries()) {
@@ -32302,6 +32301,8 @@ async function getCachedStorage(config, entityId) {
32302
32301
  }
32303
32302
  if (oldestKey) {
32304
32303
  storageCache.delete(oldestKey);
32304
+ } else {
32305
+ break;
32305
32306
  }
32306
32307
  }
32307
32308
  let adapter;
@@ -32334,6 +32335,7 @@ function createStorageHandlers(options) {
32334
32335
  try {
32335
32336
  body = await parseBody(req);
32336
32337
  } catch {
32338
+ console.warn("[bff-store] Failed to parse request body, using URL config only");
32337
32339
  }
32338
32340
  }
32339
32341
  const config = body ? {
@@ -32409,7 +32411,7 @@ function createStorageHandlers(options) {
32409
32411
  res.writeHead(200);
32410
32412
  res.end(JSON.stringify({ success: true }));
32411
32413
  }
32412
- async function handleHealth(req, res) {
32414
+ async function handleHealth(_req, res) {
32413
32415
  res.setHeader("Content-Type", "application/json");
32414
32416
  res.writeHead(200);
32415
32417
  res.end(JSON.stringify({ status: "ok" }));
@@ -32030,12 +32030,11 @@ async function mongodbStorage(options) {
32030
32030
  },
32031
32031
  async set(key, value) {
32032
32032
  const collection = getCollection(currentEntityId);
32033
- await collection.insertOne({
32034
- key,
32035
- value,
32036
- timestamp: Date.now(),
32037
- entityId: currentEntityId
32038
- });
32033
+ await collection.updateOne(
32034
+ { key, entityId: currentEntityId },
32035
+ { $set: { value, timestamp: Date.now() } },
32036
+ { upsert: true }
32037
+ );
32039
32038
  },
32040
32039
  async remove(key) {
32041
32040
  const collection = getCollection(currentEntityId);
@@ -32138,7 +32137,7 @@ function jsonlStorage(options) {
32138
32137
  const baseDir = options?.dir ?? "./sessions";
32139
32138
  let entityId = "default";
32140
32139
  function getFilePath(eId, key) {
32141
- const safeKey = key.replace(/[^a-zA-Z0-9_]/g, "_");
32140
+ const safeKey = encodeURIComponent(key);
32142
32141
  return path.join(baseDir, eId, `${safeKey}.jsonl`);
32143
32142
  }
32144
32143
  const storage = {
@@ -32286,7 +32285,7 @@ async function getCachedStorage(config, entityId) {
32286
32285
  }
32287
32286
  return entry.adapter.storage;
32288
32287
  }
32289
- if (storageCache.size >= MAX_CACHE_SIZE) {
32288
+ while (storageCache.size >= MAX_CACHE_SIZE && storageCache.size > 0) {
32290
32289
  let oldestKey = null;
32291
32290
  let oldestTime = Infinity;
32292
32291
  for (const [k, entry] of storageCache.entries()) {
@@ -32297,6 +32296,8 @@ async function getCachedStorage(config, entityId) {
32297
32296
  }
32298
32297
  if (oldestKey) {
32299
32298
  storageCache.delete(oldestKey);
32299
+ } else {
32300
+ break;
32300
32301
  }
32301
32302
  }
32302
32303
  let adapter;
@@ -32329,6 +32330,7 @@ function createStorageHandlers(options) {
32329
32330
  try {
32330
32331
  body = await parseBody(req);
32331
32332
  } catch {
32333
+ console.warn("[bff-store] Failed to parse request body, using URL config only");
32332
32334
  }
32333
32335
  }
32334
32336
  const config = body ? {
@@ -32404,7 +32406,7 @@ function createStorageHandlers(options) {
32404
32406
  res.writeHead(200);
32405
32407
  res.end(JSON.stringify({ success: true }));
32406
32408
  }
32407
- async function handleHealth(req, res) {
32409
+ async function handleHealth(_req, res) {
32408
32410
  res.setHeader("Content-Type", "application/json");
32409
32411
  res.writeHead(200);
32410
32412
  res.end(JSON.stringify({ status: "ok" }));
@@ -29,12 +29,11 @@ async function mongodbStorage(options) {
29
29
  },
30
30
  async set(key, value) {
31
31
  const collection = getCollection(currentEntityId);
32
- await collection.insertOne({
33
- key,
34
- value,
35
- timestamp: Date.now(),
36
- entityId: currentEntityId
37
- });
32
+ await collection.updateOne(
33
+ { key, entityId: currentEntityId },
34
+ { $set: { value, timestamp: Date.now() } },
35
+ { upsert: true }
36
+ );
38
37
  },
39
38
  async remove(key) {
40
39
  const collection = getCollection(currentEntityId);
@@ -137,7 +136,7 @@ function jsonlStorage(options) {
137
136
  const baseDir = options?.dir ?? "./sessions";
138
137
  let entityId = "default";
139
138
  function getFilePath(eId, key) {
140
- const safeKey = key.replace(/[^a-zA-Z0-9_]/g, "_");
139
+ const safeKey = encodeURIComponent(key);
141
140
  return path.join(baseDir, eId, `${safeKey}.jsonl`);
142
141
  }
143
142
  const storage = {
@@ -285,7 +284,7 @@ async function getCachedStorage(config, entityId) {
285
284
  }
286
285
  return entry.adapter.storage;
287
286
  }
288
- if (storageCache.size >= MAX_CACHE_SIZE) {
287
+ while (storageCache.size >= MAX_CACHE_SIZE && storageCache.size > 0) {
289
288
  let oldestKey = null;
290
289
  let oldestTime = Infinity;
291
290
  for (const [k, entry] of storageCache.entries()) {
@@ -296,6 +295,8 @@ async function getCachedStorage(config, entityId) {
296
295
  }
297
296
  if (oldestKey) {
298
297
  storageCache.delete(oldestKey);
298
+ } else {
299
+ break;
299
300
  }
300
301
  }
301
302
  let adapter;
@@ -328,6 +329,7 @@ function createStorageHandlers(options) {
328
329
  try {
329
330
  body = await parseBody(req);
330
331
  } catch {
332
+ console.warn("[bff-store] Failed to parse request body, using URL config only");
331
333
  }
332
334
  }
333
335
  const config = body ? {
@@ -403,7 +405,7 @@ function createStorageHandlers(options) {
403
405
  res.writeHead(200);
404
406
  res.end(JSON.stringify({ success: true }));
405
407
  }
406
- async function handleHealth(req, res) {
408
+ async function handleHealth(_req, res) {
407
409
  res.setHeader("Content-Type", "application/json");
408
410
  res.writeHead(200);
409
411
  res.end(JSON.stringify({ status: "ok" }));
@@ -42,7 +42,7 @@ function jsonlStorage(options) {
42
42
  const baseDir = options?.dir ?? "./sessions";
43
43
  let entityId = "default";
44
44
  function getFilePath(eId, key) {
45
- const safeKey = key.replace(/[^a-zA-Z0-9_]/g, "_");
45
+ const safeKey = encodeURIComponent(key);
46
46
  return path.join(baseDir, eId, `${safeKey}.jsonl`);
47
47
  }
48
48
  const storage = {
@@ -5,7 +5,7 @@ function jsonlStorage(options) {
5
5
  const baseDir = options?.dir ?? "./sessions";
6
6
  let entityId = "default";
7
7
  function getFilePath(eId, key) {
8
- const safeKey = key.replace(/[^a-zA-Z0-9_]/g, "_");
8
+ const safeKey = encodeURIComponent(key);
9
9
  return path.join(baseDir, eId, `${safeKey}.jsonl`);
10
10
  }
11
11
  const storage = {
@@ -32030,12 +32030,11 @@ async function mongodbStorage(options) {
32030
32030
  },
32031
32031
  async set(key, value) {
32032
32032
  const collection = getCollection(currentEntityId);
32033
- await collection.insertOne({
32034
- key,
32035
- value,
32036
- timestamp: Date.now(),
32037
- entityId: currentEntityId
32038
- });
32033
+ await collection.updateOne(
32034
+ { key, entityId: currentEntityId },
32035
+ { $set: { value, timestamp: Date.now() } },
32036
+ { upsert: true }
32037
+ );
32039
32038
  },
32040
32039
  async remove(key) {
32041
32040
  const collection = getCollection(currentEntityId);
@@ -32027,12 +32027,11 @@ async function mongodbStorage(options) {
32027
32027
  },
32028
32028
  async set(key, value) {
32029
32029
  const collection = getCollection(currentEntityId);
32030
- await collection.insertOne({
32031
- key,
32032
- value,
32033
- timestamp: Date.now(),
32034
- entityId: currentEntityId
32035
- });
32030
+ await collection.updateOne(
32031
+ { key, entityId: currentEntityId },
32032
+ { $set: { value, timestamp: Date.now() } },
32033
+ { upsert: true }
32034
+ );
32036
32035
  },
32037
32036
  async remove(key) {
32038
32037
  const collection = getCollection(currentEntityId);
@@ -0,0 +1,231 @@
1
+ # 实现大纲 / Implementation Overview
2
+
3
+ > 本文档反映 2026-07-05 post-mortem 修复后的实现状态。
4
+
5
+ ---
6
+
7
+ ## 架构概览
8
+
9
+ ```
10
+ ┌─────────────────────────────────────────────────────────┐
11
+ │ Frontend / React │
12
+ │ createStore() → useStore() → atoms (jotai) │
13
+ └────────────────────────┬────────────────────────────────┘
14
+ │ storage API (HTTP or direct)
15
+ ┌────────────────────────▼────────────────────────────────┐
16
+ │ BFF Server (Node.js sidecar) │
17
+ │ Router → Handlers → Storage Cache │
18
+ └────────────────────────┬────────────────────────────────┘
19
+ │ storage adapter
20
+ ┌────────────────────────▼────────────────────────────────┐
21
+ │ Storage Backend │
22
+ │ jsonl / mongodb / memory │
23
+ └─────────────────────────────────────────────────────────┘
24
+ ```
25
+
26
+ 三层分离:
27
+ - **前端层**:React 组件通过 `useStore` 消费 jotai atom
28
+ - **BFF 层**:Node.js sidecar server,代理存储请求,支持多租户
29
+ - **存储层**:可插拔后端(JSONL 文件、MongoDB、内存)
30
+
31
+ ---
32
+
33
+ ## 模块映射表
34
+
35
+ | 模块 | 文件 | 职责 |
36
+ |------|------|------|
37
+ | `createStore` | `src/createStore.ts` | 创建多 atom 持久化 store |
38
+ | `waitForServer` | `src/createStore.ts` | 等待 auto-start server 就绪 |
39
+ | `createPersistedAtom` | `src/atomCreator.ts` | 单个 atom + loading 状态 |
40
+ | `useStore` | `src/useStore.ts` | React hook,消费 store |
41
+ | `debouncer` | `src/debouncer.ts` | 写冲突防抖(800ms default,ms 不一致时替换实例) |
42
+ | `remoteStorage` | `src/storage/adapters/remoteStorage.ts` | 客户端 HTTP 适配器 |
43
+ | `HttpTransport` | `src/storage/transport.ts` | HTTP GET/POST/DELETE 原语(含错误详情提取) |
44
+ | `RestStorageProtocol` | `src/storage/protocol.ts` | REST 风格协议封装(支持 live entityId 引用) |
45
+ | `startServer` | `src/server/index.ts` | BFF 服务端单例启动 |
46
+ | `createStorageHandlers` | `src/server/handlers.ts` | 请求 → storage 分发 + 缓存 + 过期清理 |
47
+ | `Router` | `src/server/router.ts` | 路径 → handler 匹配(:key 参数) |
48
+ | `EntityIdCache` | `src/server/entityIdCache.ts` | JSONL 多租户隔离 |
49
+ | `jsonlStorage` | `src/storage/jsonl.ts` | 文件落盘 `{dir}/{entityId}/{encodeURIComponent(key)}.jsonl` |
50
+ | `mongodbStorage` | `src/storage/mongodb.ts` | MongoDB 落盘 `state_{entityId}` collection,upsert 模式 |
51
+ | `memoryStorage` | `src/storage/memory.ts` | 内存存储(测试/开发) |
52
+ | `createNodeStore` | `src/nodeStore.ts` | Node.js 环境专用(非 React),含 5s 超时 waitForLoad |
53
+
54
+ ---
55
+
56
+ ## 数据流详解
57
+
58
+ ### 1. 初始化流程
59
+
60
+ ```typescript
61
+ const store = createStore('entity-A', config, { storage: adapter });
62
+
63
+ // 内部执行
64
+ adapter.setEntityId('entity-A') // 设置租户 ID
65
+ for (atomConfig of config) {
66
+ createPersistedAtom(atomConfig, entityId, storage, { debounceMs })
67
+ // → 创建 baseAtom(初始值 defaultValue)
68
+ // → 创建 loadingAtom(初始值 true)
69
+ // → baseAtom.onMount = (setValue, unmount) => {
70
+ // storage.get(key).then(setValue).catch(console.error)
71
+ // return () => debouncerMap.cancel(`${entityId}:${key}`) // unmount cleanup
72
+ // }
73
+ }
74
+
75
+ // remote 模式下 auto-start server(fire-and-forget)
76
+ import('./server').then(({ startServer }) => {
77
+ serverInitPromise = startServer().then(() => void 0);
78
+ });
79
+ ```
80
+
81
+ ### 2. 读取流程(React 组件)
82
+
83
+ ```
84
+ useStore(store)
85
+ → buildStoreResult(store)
86
+ → useAtom(atom) for each config[key]
87
+ → baseAtom.get() // 同步返回 jotai store 中的当前值
88
+ ```
89
+
90
+ **注意**:`storage.get()` 只在 `onMount` 时调用一次,结果写入 baseAtom。后续读取直接走 jotai store 内部状态,不涉及 storage。
91
+
92
+ ### 3. 写入流程(带防抖 + unmount 取消)
93
+
94
+ ```
95
+ setTheme('dark')
96
+ → writeAtom.write(dark)
97
+ → baseAtom.set(dark) // 本地立即更新,UI 同步响应
98
+ → debouncer.debounce(key, saveFn, 800ms)
99
+ → storage.set(key, dark) // 延迟写入后端
100
+ // unmount 时:debouncerMap.cancel(`${entityId}:${key}`) 取消 pending 写入
101
+ ```
102
+
103
+ - `immediate: true` 的 atom 跳过防抖,直接 `storage.set()`
104
+ - 防抖 key 格式:`${entityId}:${config.key}`,避免跨 store 干扰
105
+ - **ms 不一致时**:替换已有 debouncer 实例,确保延迟时间正确
106
+
107
+ ### 4. 远程写入(remoteStorage 路径)
108
+
109
+ ```
110
+ setTheme('dark')
111
+ → writeAtom.write(dark)
112
+ → baseAtom.set(dark)
113
+ → debouncer.debounce(...)
114
+ → storage.set(key, value)
115
+ → HttpTransport.post('/storage/set/:key', { value, ...backendConfig })
116
+ → BFF server: POST /storage/set/:key
117
+ → handlers.handleSet()
118
+ → getCachedStorage(config, entityId)
119
+ → storage.set(key, value)
120
+ ```
121
+
122
+ ### 5. Node.js 服务端(BFF)
123
+
124
+ ```
125
+ http.createServer()
126
+ → Router.handle(req, res)
127
+ → matched route: /storage/get/:key
128
+ → handlers.handleGet(req, res, { key })
129
+ → resolveStorage(req) // 解析 body + url config
130
+ → storage.get(key)
131
+ → res.json({ value })
132
+ ```
133
+
134
+ ---
135
+
136
+ ## 多租户隔离策略
137
+
138
+ ### Server 端
139
+
140
+ - **JSONL**:每个 `entityId` 对应独立目录 `{jsonlDir}/{entityId}/`
141
+ - **MongoDB**:每个 `entityId` 对应独立 collection `state_{entityId}`
142
+ - **Storage Cache**:`Map<cacheKey, { adapter, lastUsed }>`,TTL 5min,**full LRU 驱逐(满时全部驱逐直到有空间)**
143
+
144
+ ### Client 端
145
+
146
+ - `remoteStorage.setEntityId(id)` 更新 `entityId.current`(live 引用,protocol 层每次请求实时读取)
147
+ - 后续所有请求在 protocol 层拼入 query param `?entityId=xxx`
148
+
149
+ ---
150
+
151
+ ## 关键设计决策
152
+
153
+ ### 为什么 atom 初始值用 defaultValue 而非从 storage 加载?
154
+
155
+ jotai 要求 atom 必须有同步初始值。`createPersistedAtom` 创建时传入 `defaultValue`,然后通过 `onMount` 异步从 storage 覆盖。
156
+
157
+ ```typescript
158
+ const baseAtom = atom(config.defaultValue); // 同步默认值
159
+ baseAtom.onMount = (setValue) => {
160
+ storage.get(key)
161
+ .then((value) => { if (value != null) setValue(value); })
162
+ .catch((err) => { console.error(`[bff-store] Failed to load atom "${key}":`, err); })
163
+ .finally(() => { /* loadingAtom = false */ });
164
+ };
165
+ ```
166
+
167
+ ### 为什么需要 loadingAtom?
168
+
169
+ `onMount` 是异步的,React 组件可能在数据加载完成前就开始 render。`loadingAtom` 跟踪所有 atom 的加载状态,合并为 `useStore` 返回的 `isLoading` 布尔值。
170
+
171
+ ### 防抖 DebouncerMap 为什么用 module-level 单例?
172
+
173
+ 所有 atom 共享同一个 `DebouncerMap` 实例,确保同一 `entityId:key` 的多次写入只触发一次延迟保存。ms 不一致时替换已有实例。
174
+
175
+ ### unmount 时为什么要 cancel pending writes?
176
+
177
+ 组件卸载后,写入回调可能仍在计时器队列中,若不取消会导致:
178
+ 1. 写入操作在组件销毁后执行
179
+ 2. `getDefaultStore()` 在已销毁的组件上下文中被调用
180
+
181
+ ---
182
+
183
+ ## 路由表(服务端)
184
+
185
+ | Method | Path | Handler |
186
+ |--------|------|---------|
187
+ | GET | `/storage/get/:key` | `handleGet` |
188
+ | POST | `/storage/set/:key` | `handleSet` |
189
+ | DELETE | `/storage/delete/:key` | `handleDelete` |
190
+ | POST | `/storage/batch-get` | `handleBatchGet` |
191
+ | POST | `/storage/batch-set` | `handleBatchSet` |
192
+ | GET | `/health` | `handleHealth` |
193
+
194
+ ---
195
+
196
+ ## 已修复 Bug 清单(Post-Mortem 2026-07-05)
197
+
198
+ | # | Severity | Location | Bug | Fix |
199
+ |---|----------|----------|-----|-----|
200
+ | 1 | High | `createStore.ts` | server 启动 fire-and-forget | 引入 `serverInitPromise` + `waitForServer()` |
201
+ | 3 | Medium | `handlers.ts` | 缓存满时只驱逐 1 个 entry | 改为 `while` 循环直到有空间 |
202
+ | 4 | Medium | `transport.ts` | HTTP 错误 body 丢失 | `res.clone().json()` 读取错误详情 |
203
+ | 5 | Low | `debouncer.ts` | ms 不一致时忽略新值 | ms 不同时替换已有 debouncer |
204
+ | 7 | High | `jsonl.ts` | `.` `-` 等分隔符替换为 `_` 导致 key 碰撞 | 改用 `encodeURIComponent(key)` |
205
+ | 8 | Medium | `handlers.ts` | `getStorageForRequest` 死代码 | 已删除 |
206
+ | 9 | Low | `nodeStore.ts` | `waitForLoad` 无限等待 | 添加 5s 超时 reject |
207
+ | 10 | Low | `handlers.ts` | `parseBody` 失败静默忽略 | 改为 `console.warn` |
208
+ | 11 | High | `mongodb.ts` | `insertOne` 无限积累旧版本 | 改用 `updateOne` + `upsert: true` |
209
+ | 12 | High | `remoteStorage.ts` | `setEntityId` 不生效(闭包捕获快照) | 传入对象引用 `{ current }` |
210
+ | 14 | Low | `server/index.ts` | `isServerRunning()` 未使用 | 已删除 |
211
+ | 15 | Medium | `atomCreator.ts` | unmount 时 pending writes 丢失 | `onMount` 返回 cleanup 调用 `cancel()` |
212
+ | 17 | Low | `handlers.ts` | `handleHealth` 参数未使用 | `req` → `_req` |
213
+ | 18 | Low | `atomCreator.ts` | storage.get 失败静默忽略 | 添加 `console.error` 记录 |
214
+
215
+ ### 未修复(设计选择)
216
+
217
+ | # | Reason |
218
+ |---|--------|
219
+ | BUG-6 | module-level `debouncerMap` 通过 `entityId:key` 隔离键名,实际无跨 store 干扰 |
220
+ | BUG-13 | `createStorageFromTransport.get` 所有错误返回 null — 符合 storage graceful degradation 哲学 |
221
+
222
+ ---
223
+
224
+ ## 配置文件
225
+
226
+ | 文件 | 用途 |
227
+ |------|------|
228
+ | `tsup.config.ts` | 多入口打包(index, jsonl-entry, mongodb-entry, server/entry, cli) |
229
+ | `tsconfig.json` | TypeScript 配置 |
230
+ | `vitest.config.ts` | 测试配置 |
231
+ | `package.json` | 导出字段:`import` → `dist/index.mjs` |
@@ -0,0 +1,34 @@
1
+ # BUG-01: server 异步启动未等待
2
+
3
+ **Severity**: High
4
+ **Location**: `src/createStore.ts:47-53`
5
+ **Type**: Async Fire-and-Forget
6
+
7
+ ## 问题
8
+
9
+ `createStore` 中对 `remote` 存储类型自动启动 server 时,使用 `import().then()` 的 fire-and-forget 模式:
10
+
11
+ ```typescript
12
+ import('./server').then(({ startServer }) => {
13
+ serverInitPromise = startServer().then(() => void 0).catch(...);
14
+ });
15
+ ```
16
+
17
+ `createStore` 是同步函数,立即返回,但 server 可能尚未启动完成,导致后续 remote storage 操作立即失败。
18
+
19
+ ## 修复
20
+
21
+ 引入 module-level `serverInitPromise` 追踪启动状态,并导出 `waitForServer()` 供调用者等待:
22
+
23
+ ```typescript
24
+ let serverInitPromise: Promise<unknown> | null = null;
25
+
26
+ export function waitForServer(): Promise<void> | undefined {
27
+ return serverInitPromise as Promise<void> | undefined;
28
+ }
29
+ ```
30
+
31
+ ## 验证
32
+
33
+ - Build: ✅
34
+ - Tests: ✅ (69 passed)
@@ -0,0 +1,40 @@
1
+ # BUG-03: storage cache 驱逐不充分
2
+
3
+ **Severity**: Medium
4
+ **Location**: `src/server/handlers.ts:85-96`
5
+ **Type**: Cache Eviction
6
+
7
+ ## 问题
8
+
9
+ 缓存满时(`MAX_CACHE_SIZE=10`)只驱逐 1 个最旧 entry,但新 adapter 创建后会再次超过限制:
10
+
11
+ ```typescript
12
+ if (storageCache.size >= MAX_CACHE_SIZE) {
13
+ storageCache.delete(oldestKey); // 只删 1 个
14
+ }
15
+ // 再次 check: size 可能仍 >= MAX_CACHE_SIZE
16
+ ```
17
+
18
+ ## 修复
19
+
20
+ 改为 `while` 循环,直到有空间:
21
+
22
+ ```typescript
23
+ while (storageCache.size >= MAX_CACHE_SIZE && storageCache.size > 0) {
24
+ let oldestKey: string | null = null;
25
+ let oldestTime = Infinity;
26
+ for (const [k, entry] of storageCache.entries()) {
27
+ if (entry.lastUsed < oldestTime) {
28
+ oldestTime = entry.lastUsed;
29
+ oldestKey = k;
30
+ }
31
+ }
32
+ if (oldestKey) storageCache.delete(oldestKey);
33
+ else break; // 防止无限循环
34
+ }
35
+ ```
36
+
37
+ ## 验证
38
+
39
+ - Build: ✅
40
+ - Tests: ✅ (69 passed)
@@ -0,0 +1,38 @@
1
+ # BUG-04: HTTP 错误响应 body 丢失
2
+
3
+ **Severity**: Medium
4
+ **Location**: `src/storage/transport.ts:22-26`
5
+ **Type**: Error Info Loss
6
+
7
+ ## 问题
8
+
9
+ `HttpTransport` 的 GET/POST/DELETE 在 HTTP 错误时只返回 `statusText`,丢失 server 返回的错误详情:
10
+
11
+ ```typescript
12
+ if (!res.ok) {
13
+ throw new Error(`GET ${url} failed: ${res.statusText}`);
14
+ // 若 server 返回 { "error": "entity not found" },此处只有 "Not Found"
15
+ }
16
+ ```
17
+
18
+ ## 修复
19
+
20
+ 尝试读取响应 body 作为错误详情:
21
+
22
+ ```typescript
23
+ if (!res.ok) {
24
+ let detail = res.statusText;
25
+ try {
26
+ const body = await res.clone().json();
27
+ detail = body?.error ?? body?.message ?? detail;
28
+ } catch {
29
+ // body is not JSON or unreadable
30
+ }
31
+ throw new Error(`GET ${url} failed: ${detail}`);
32
+ }
33
+ ```
34
+
35
+ ## 验证
36
+
37
+ - Build: ✅
38
+ - Tests: ✅ (69 passed)
@@ -0,0 +1,41 @@
1
+ # BUG-05: debouncer ms 参数后续调用无效
2
+
3
+ **Severity**: Low
4
+ **Location**: `src/debouncer.ts:48-55`
5
+ **Type**: Logic Error
6
+
7
+ ## 问题
8
+
9
+ `DebouncerMap.getDebouncer()` 在 key 已存在时忽略传入的 `ms` 参数:
10
+
11
+ ```typescript
12
+ getDebouncer(key: string, ms?: number): Debouncer {
13
+ let debouncer = this.debouncers.get(key);
14
+ if (!debouncer) {
15
+ debouncer = createDebouncer(ms ?? this.defaultMs);
16
+ this.debouncers.set(key, debouncer);
17
+ }
18
+ return debouncer; // 后续传入不同 ms 被忽略
19
+ }
20
+ ```
21
+
22
+ ## 修复
23
+
24
+ ms 不同时替换已有 debouncer 实例:
25
+
26
+ ```typescript
27
+ getDebouncer(key: string, ms?: number): Debouncer {
28
+ const effectiveMs = ms ?? this.defaultMs;
29
+ let debouncer = this.debouncers.get(key);
30
+ if (!debouncer || debouncer.ms !== effectiveMs) {
31
+ debouncer = createDebouncer(effectiveMs);
32
+ this.debouncers.set(key, debouncer);
33
+ }
34
+ return debouncer;
35
+ }
36
+ ```
37
+
38
+ ## 验证
39
+
40
+ - Build: ✅
41
+ - Tests: ✅ (10 debouncer tests passed)
@@ -0,0 +1,31 @@
1
+ # BUG-07: JSONL key 碰撞导致数据覆盖
2
+
3
+ **Severity**: High
4
+ **Location**: `src/storage/jsonl.ts:32`
5
+ **Type**: Key Collision
6
+
7
+ ## 问题
8
+
9
+ `getFilePath` 将所有非字母数字字符替换为 `_`:
10
+
11
+ ```typescript
12
+ const safeKey = key.replace(/[^a-zA-Z0-9_]/g, '_');
13
+ // "user.name" → "user_name"
14
+ // "user-name" → "user_name" ← 碰撞!
15
+ ```
16
+
17
+ ## 修复
18
+
19
+ 改用 `encodeURIComponent` 生成碰撞安全的文件名:
20
+
21
+ ```typescript
22
+ const safeKey = encodeURIComponent(key);
23
+ // "user.name" → "user.name" (实际 "user%2Ename")
24
+ // "user-name" → "user-name"
25
+ // "user name" → "user%20name"
26
+ ```
27
+
28
+ ## 验证
29
+
30
+ - Build: ✅
31
+ - Tests: ✅ (11 jsonl tests passed,含碰撞回归测试)