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.
- package/.claude/settings.local.json +5 -1
- package/README.md +172 -68
- package/dist/cli.js +11 -9
- package/dist/index.d.mts +7 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.mjs +47 -12
- package/dist/package.json +1 -1
- package/dist/server/entry.d.mts +1 -1
- package/dist/server/entry.d.ts +1 -1
- package/dist/server/entry.js +11 -9
- package/dist/server/entry.mjs +11 -9
- package/dist/{server-V7WCW4ZB.mjs → server-2WMLXQ23.mjs} +11 -9
- package/dist/storage/jsonl-entry.js +1 -1
- package/dist/storage/jsonl-entry.mjs +1 -1
- package/dist/storage/mongodb-entry.js +5 -6
- package/dist/storage/mongodb-entry.mjs +5 -6
- package/docs/IMPLEMENTATION_OVERVIEW.md +231 -0
- package/docs/bugs/01-server-async-startup.high.md +34 -0
- package/docs/bugs/03-storage-cache-eviction.md +40 -0
- package/docs/bugs/04-http-error-body-loss.md +38 -0
- package/docs/bugs/05-debouncer-ms-ignored.md +41 -0
- package/docs/bugs/07-jsonl-key-collision.high.md +31 -0
- package/docs/bugs/08-dead-code-getStorageForRequest.md +18 -0
- package/docs/bugs/09-waitForLoad-infinite.md +37 -0
- package/docs/bugs/10-parseBody-silent-failure.md +32 -0
- package/docs/bugs/11-mongodb-storage-leak.high.md +35 -0
- package/docs/bugs/12-setEntityId-stale-closure.high.md +35 -0
- package/docs/bugs/14-dead-code-isServerRunning.md +18 -0
- package/docs/bugs/15-unmount-data-loss.md +37 -0
- package/docs/bugs/17-unused-parameter.md +26 -0
- package/docs/bugs/18-storage-get-silent-error.md +28 -0
- package/docs/bugs/README.md +35 -0
- package/package.json +1 -4
- package/src/atomCreator.ts +10 -2
- package/src/createStore.ts +19 -1
- package/src/debouncer.ts +5 -2
- package/src/index.ts +1 -1
- package/src/nodeStore.ts +9 -2
- package/src/server/handlers.ts +7 -24
- package/src/server/index.ts +0 -4
- package/src/storage/adapters/remoteStorage.ts +2 -1
- package/src/storage/jsonl.ts +3 -1
- package/src/storage/mongodb.ts +6 -6
- package/src/storage/transport.ts +24 -3
- package/tests/storage/jsonl.test.ts +18 -2
package/dist/server/entry.d.mts
CHANGED
|
@@ -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: (
|
|
65
|
+
handleHealth: (_req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
/**
|
package/dist/server/entry.d.ts
CHANGED
|
@@ -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: (
|
|
65
|
+
handleHealth: (_req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
/**
|
package/dist/server/entry.js
CHANGED
|
@@ -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.
|
|
32039
|
-
key,
|
|
32040
|
-
value,
|
|
32041
|
-
|
|
32042
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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" }));
|
package/dist/server/entry.mjs
CHANGED
|
@@ -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.
|
|
32034
|
-
key,
|
|
32035
|
-
value,
|
|
32036
|
-
|
|
32037
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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.
|
|
33
|
-
key,
|
|
34
|
-
value,
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
32034
|
-
key,
|
|
32035
|
-
value,
|
|
32036
|
-
|
|
32037
|
-
|
|
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.
|
|
32031
|
-
key,
|
|
32032
|
-
value,
|
|
32033
|
-
|
|
32034
|
-
|
|
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,含碰撞回归测试)
|