bff-store 0.1.0 → 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 +14 -1
- package/README.md +196 -63
- package/dist/cli.js +11 -9
- package/dist/index.d.mts +48 -1
- package/dist/index.d.ts +48 -1
- package/dist/index.mjs +88 -10
- package/dist/package.json +3 -6
- package/dist/server/entry.d.mts +1 -1
- package/dist/server/entry.d.ts +1 -1
- package/dist/server/entry.js +31992 -23
- package/dist/server/entry.mjs +32016 -13
- 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 +31986 -10
- package/dist/storage/mongodb-entry.mjs +32008 -8
- package/docs/BUG_FIX_MONGODB_CHILD_PROCESS.md +129 -0
- package/docs/FRONTEND_BACKEND_ISOLATION.md +150 -0
- package/docs/IMPLEMENTATION_OVERVIEW.md +231 -0
- package/docs/LOCAL_LINK_STRATEGIES.md +117 -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 +3 -6
- package/src/atomCreator.ts +10 -2
- package/src/createStore.ts +19 -1
- package/src/debouncer.ts +5 -2
- package/src/environment.ts +11 -0
- package/src/index.ts +8 -1
- package/src/nodeStore.ts +86 -0
- 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/tsconfig.json +1 -0
- package/docs/BUG_DIAGNOSIS_REMOTE_STORAGE_OPTIONS.md +0 -104
- package/docs/BUG_FIX_REMOTE_STORAGE_OPTIONS.md +0 -63
- package/docs/BUG_FIX_SESSION_2026-06-03.md +0 -171
- /package/docs/{SIDECAR_SERVER.md → BFF_SIDECAR_SERVER.md} +0 -0
|
@@ -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,含碰撞回归测试)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# BUG-08: `getStorageForRequest` 死代码
|
|
2
|
+
|
|
3
|
+
**Severity**: Medium
|
|
4
|
+
**Location**: `src/server/handlers.ts:128-146`
|
|
5
|
+
**Type**: Dead Code
|
|
6
|
+
|
|
7
|
+
## 问题
|
|
8
|
+
|
|
9
|
+
函数 `getStorageForRequest` 定义后从未被调用,与 `resolveStorage` 功能重复。
|
|
10
|
+
|
|
11
|
+
## 修复
|
|
12
|
+
|
|
13
|
+
已删除。
|
|
14
|
+
|
|
15
|
+
## 验证
|
|
16
|
+
|
|
17
|
+
- Build: ✅
|
|
18
|
+
- Tests: ✅ (69 passed)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# BUG-09: `waitForLoad` 无限等待
|
|
2
|
+
|
|
3
|
+
**Severity**: Low
|
|
4
|
+
**Location**: `src/nodeStore.ts:47-72`
|
|
5
|
+
**Type**: Infinite Wait
|
|
6
|
+
|
|
7
|
+
## 问题
|
|
8
|
+
|
|
9
|
+
`waitForLoad()` 使用 `setInterval` 轮询 loading atoms,无超时保护。若 storage 持续故障,promise 永不 resolve。
|
|
10
|
+
|
|
11
|
+
## 修复
|
|
12
|
+
|
|
13
|
+
添加 5s 超时,超时时 reject:
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
async function waitForLoad(timeoutMs: number = 5000): Promise<void> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const timeout = setTimeout(() => {
|
|
19
|
+
clearInterval(interval);
|
|
20
|
+
reject(new Error(`waitForLoad timed out after ${timeoutMs}ms`));
|
|
21
|
+
}, timeoutMs);
|
|
22
|
+
|
|
23
|
+
const interval = setInterval(() => {
|
|
24
|
+
if (!stillLoading()) {
|
|
25
|
+
clearInterval(interval);
|
|
26
|
+
clearTimeout(timeout);
|
|
27
|
+
resolve();
|
|
28
|
+
}
|
|
29
|
+
}, 10);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 验证
|
|
35
|
+
|
|
36
|
+
- Build: ✅
|
|
37
|
+
- Tests: ✅ (69 passed)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# BUG-10: `parseBody` 失败静默忽略
|
|
2
|
+
|
|
3
|
+
**Severity**: Low
|
|
4
|
+
**Location**: `src/server/handlers.ts:159`
|
|
5
|
+
**Type**: Error Swallowing
|
|
6
|
+
|
|
7
|
+
## 问题
|
|
8
|
+
|
|
9
|
+
`resolveStorage` 中 `parseBody` 失败时静默忽略,导致 backend 配置丢失无法定位问题:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
try {
|
|
13
|
+
body = await parseBody<Record<string, unknown>>(req);
|
|
14
|
+
} catch {
|
|
15
|
+
// Ignore parse errors ← 无日志
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 修复
|
|
20
|
+
|
|
21
|
+
添加 `console.warn` 记录:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
} catch {
|
|
25
|
+
console.warn('[bff-store] Failed to parse request body, using URL config only');
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 验证
|
|
30
|
+
|
|
31
|
+
- Build: ✅
|
|
32
|
+
- Tests: ✅ (69 passed)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# BUG-11: MongoDB `set` 无限积累旧版本
|
|
2
|
+
|
|
3
|
+
**Severity**: High
|
|
4
|
+
**Location**: `src/storage/mongodb.ts:63`
|
|
5
|
+
**Type**: Storage Leak
|
|
6
|
+
|
|
7
|
+
## 问题
|
|
8
|
+
|
|
9
|
+
`set` 使用 `insertOne` 每次都插入新 document,从不清理旧版本:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
await collection.insertOne({
|
|
13
|
+
key, value, timestamp: Date.now(), entityId: currentEntityId,
|
|
14
|
+
});
|
|
15
|
+
// 每次 set 都插入新行,旧版本永远存在 → collection 无限膨胀
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`remove` 使用 `deleteMany` 留下 tombstone(空 collection 仍存在)。
|
|
19
|
+
|
|
20
|
+
## 修复
|
|
21
|
+
|
|
22
|
+
改用 `updateOne` + `upsert: true` 原地更新:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
await collection.updateOne(
|
|
26
|
+
{ key, entityId: currentEntityId },
|
|
27
|
+
{ $set: { value, timestamp: Date.now() } },
|
|
28
|
+
{ upsert: true }
|
|
29
|
+
);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 验证
|
|
33
|
+
|
|
34
|
+
- Build: ✅
|
|
35
|
+
- Tests: ✅ (69 passed)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# BUG-12: `setEntityId` 不生效(闭包捕获快照值)
|
|
2
|
+
|
|
3
|
+
**Severity**: High
|
|
4
|
+
**Location**: `src/storage/adapters/remoteStorage.ts:56`
|
|
5
|
+
**Type**: Stale Closure
|
|
6
|
+
|
|
7
|
+
## 问题
|
|
8
|
+
|
|
9
|
+
`RestStorageProtocol` 构造时传入 `entityId.current`(string)的快照值,后续 `setEntityId()` 更新 `entityId.current` 但 protocol 内部的 `getEntityId()` 仍读旧值:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
// remoteStorage.ts
|
|
13
|
+
const entityId = { current: options.entityId };
|
|
14
|
+
const protocol = new RestStorageProtocol(baseUrl, entityId.current, ...); // ← 快照 string
|
|
15
|
+
|
|
16
|
+
// RestStorageProtocol.getEntityId()
|
|
17
|
+
return typeof this.entityId === 'object'
|
|
18
|
+
? this.entityId.current // 只有 string,访问 .current 返回 undefined
|
|
19
|
+
: this.entityId;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 修复
|
|
23
|
+
|
|
24
|
+
传入 `{ current }` 对象引用而非字符串快照:
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
const protocol = new RestStorageProtocol(baseUrl, entityId, ...); // ← live 引用
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`getEntityId()` 每次调用时动态读取 `this.entityId.current`。
|
|
31
|
+
|
|
32
|
+
## 验证
|
|
33
|
+
|
|
34
|
+
- Build: ✅
|
|
35
|
+
- Tests: ✅ (69 passed)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# BUG-14: `isServerRunning()` 未使用
|
|
2
|
+
|
|
3
|
+
**Severity**: Low
|
|
4
|
+
**Location**: `src/server/index.ts:41-43`
|
|
5
|
+
**Type**: Dead Code
|
|
6
|
+
|
|
7
|
+
## 问题
|
|
8
|
+
|
|
9
|
+
函数 `isServerRunning()` 定义后从未被调用。
|
|
10
|
+
|
|
11
|
+
## 修复
|
|
12
|
+
|
|
13
|
+
已删除。
|
|
14
|
+
|
|
15
|
+
## 验证
|
|
16
|
+
|
|
17
|
+
- Build: ✅
|
|
18
|
+
- Tests: ✅ (69 passed)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# BUG-15: unmount 时 pending writes 丢失
|
|
2
|
+
|
|
3
|
+
**Severity**: Medium
|
|
4
|
+
**Location**: `src/atomCreator.ts:25-41`
|
|
5
|
+
**Type**: Data Loss
|
|
6
|
+
|
|
7
|
+
## 问题
|
|
8
|
+
|
|
9
|
+
`baseAtom.onMount` 未返回 cleanup 函数,组件卸载时 pending debounced writes 仍在计时器队列中:
|
|
10
|
+
|
|
11
|
+
1. 用户快速编辑 → 触发多个 debounced writes
|
|
12
|
+
2. 组件卸载(如路由切换)→ pending writes 仍在队列
|
|
13
|
+
3. 计时器触发时,`getDefaultStore()` 在已销毁的上下文中被调用
|
|
14
|
+
4. 数据写入丢失
|
|
15
|
+
|
|
16
|
+
## 修复
|
|
17
|
+
|
|
18
|
+
`onMount` 返回 cleanup 函数取消 pending writes:
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
baseAtom.onMount = (setValue) => {
|
|
22
|
+
storage.get<T>(config.key)
|
|
23
|
+
.then((value) => { if (value != null) setValue(value); })
|
|
24
|
+
.catch((err) => { console.error(`[bff-store] Failed to load atom "${config.key}":`, err); })
|
|
25
|
+
.finally(() => { /* loadingAtom = false */ });
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
const debounceKey = `${entityId}:${config.key}`;
|
|
29
|
+
debouncerMap.cancel(debounceKey);
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 验证
|
|
35
|
+
|
|
36
|
+
- Build: ✅
|
|
37
|
+
- Tests: ✅ (69 passed)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# BUG-17: `handleHealth` 未使用参数
|
|
2
|
+
|
|
3
|
+
**Severity**: Low
|
|
4
|
+
**Location**: `src/server/handlers.ts:240`
|
|
5
|
+
**Type**: Unused Parameter
|
|
6
|
+
|
|
7
|
+
## 问题
|
|
8
|
+
|
|
9
|
+
`handleHealth` 的 `req` 参数未使用:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
async function handleHealth(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 修复
|
|
16
|
+
|
|
17
|
+
标记为 `_req` 明确表示未使用:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 验证
|
|
24
|
+
|
|
25
|
+
- Build: ✅
|
|
26
|
+
- Tests: ✅ (69 passed)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# BUG-18: `storage.get` 失败时静默忽略
|
|
2
|
+
|
|
3
|
+
**Severity**: Low
|
|
4
|
+
**Location**: `src/atomCreator.ts:25-41`
|
|
5
|
+
**Type**: Silent Error
|
|
6
|
+
|
|
7
|
+
## 问题
|
|
8
|
+
|
|
9
|
+
`baseAtom.onMount` 中 `storage.get().catch(console.error)` 吞掉加载错误,开发者无法感知数据加载失败:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
.catch(console.error) // 只打 console.error,无结构化日志
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 修复
|
|
16
|
+
|
|
17
|
+
添加包含 atom key 的错误信息:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
.catch((err) => {
|
|
21
|
+
console.error(`[bff-store] Failed to load atom "${config.key}":`, err);
|
|
22
|
+
})
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 验证
|
|
26
|
+
|
|
27
|
+
- Build: ✅
|
|
28
|
+
- Tests: ✅ (69 passed)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Bug 索引
|
|
2
|
+
|
|
3
|
+
Post-Mortem 2026-07-05 四轮审查共发现 **18 个 bug**,**15 个已修复**。
|
|
4
|
+
|
|
5
|
+
> High severity 文件名末尾标注 `.high`。
|
|
6
|
+
|
|
7
|
+
## 已修复
|
|
8
|
+
|
|
9
|
+
| # | Severity | File | Bug |
|
|
10
|
+
|---|----------|------|-----|
|
|
11
|
+
| 01 | High | [01-server-async-startup.high.md](./01-server-async-startup.high.md) | server 异步启动未等待 |
|
|
12
|
+
| 03 | Medium | [03-storage-cache-eviction.md](./03-storage-cache-eviction.md) | 缓存满时只驱逐 1 个 entry |
|
|
13
|
+
| 04 | Medium | [04-http-error-body-loss.md](./04-http-error-body-loss.md) | HTTP 错误响应 body 丢失 |
|
|
14
|
+
| 05 | Low | [05-debouncer-ms-ignored.md](./05-debouncer-ms-ignored.md) | debouncer ms 参数后续调用无效 |
|
|
15
|
+
| 07 | High | [07-jsonl-key-collision.high.md](./07-jsonl-key-collision.high.md) | JSONL key 碰撞导致数据覆盖 |
|
|
16
|
+
| 08 | Medium | [08-dead-code-getStorageForRequest.md](./08-dead-code-getStorageForRequest.md) | `getStorageForRequest` 死代码 |
|
|
17
|
+
| 09 | Low | [09-waitForLoad-infinite.md](./09-waitForLoad-infinite.md) | `waitForLoad` 无限等待 |
|
|
18
|
+
| 10 | Low | [10-parseBody-silent-failure.md](./10-parseBody-silent-failure.md) | `parseBody` 失败静默忽略 |
|
|
19
|
+
| 11 | High | [11-mongodb-storage-leak.high.md](./11-mongodb-storage-leak.high.md) | MongoDB `set` 无限积累旧版本 |
|
|
20
|
+
| 12 | High | [12-setEntityId-stale-closure.high.md](./12-setEntityId-stale-closure.high.md) | `setEntityId` 不生效(闭包捕获快照值) |
|
|
21
|
+
| 14 | Low | [14-dead-code-isServerRunning.md](./14-dead-code-isServerRunning.md) | `isServerRunning()` 未使用 |
|
|
22
|
+
| 15 | Medium | [15-unmount-data-loss.md](./15-unmount-data-loss.md) | unmount 时 pending writes 丢失 |
|
|
23
|
+
| 17 | Low | [17-unused-parameter.md](./17-unused-parameter.md) | `handleHealth` 未使用参数 |
|
|
24
|
+
| 18 | Low | [18-storage-get-silent-error.md](./18-storage-get-silent-error.md) | `storage.get` 失败时静默忽略 |
|
|
25
|
+
|
|
26
|
+
## 未修复(设计选择)
|
|
27
|
+
|
|
28
|
+
| # | Reason |
|
|
29
|
+
|---|--------|
|
|
30
|
+
| BUG-06 | module-level `debouncerMap` 通过 `entityId:key` 隔离键名,实际无跨 store 干扰 |
|
|
31
|
+
| BUG-13 | `createStorageFromTransport.get` 所有错误返回 null — 符合 storage graceful degradation 哲学 |
|
|
32
|
+
| BUG-16 | serverInitPromise 时序 — 符合 fire-and-forget 设计,`waitForServer()` 可供显式等待 |
|
|
33
|
+
| BUG-19 | GET 请求解析 body — 无害 no-op |
|
|
34
|
+
| BUG-20 | MongoDB 无复合索引 — 用户在数据库层处理 |
|
|
35
|
+
| BUG-21 | `setTimeout` unmount 后触发 — 无害 side effect |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bff-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "A jotai-based state management library with pluggable storage adapters",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
|
-
"import": "./dist/index.mjs"
|
|
12
|
-
"require": "./dist/index.js"
|
|
11
|
+
"import": "./dist/index.mjs"
|
|
13
12
|
},
|
|
14
13
|
"./jsonl": {
|
|
15
14
|
"types": "./dist/storage/jsonl-entry.d.ts",
|
|
@@ -50,13 +49,11 @@
|
|
|
50
49
|
"@types/react": "^19.2.16",
|
|
51
50
|
"jotai": "^2.6.0",
|
|
52
51
|
"jsdom": "^29.1.1",
|
|
52
|
+
"mongodb": "^6.21.0",
|
|
53
53
|
"react": "^18.2.0",
|
|
54
54
|
"react-dom": "^18.2.0",
|
|
55
55
|
"tsup": "^8.0.0",
|
|
56
56
|
"typescript": "^5.0.0",
|
|
57
57
|
"vitest": "^1.0.0"
|
|
58
|
-
},
|
|
59
|
-
"dependencies": {
|
|
60
|
-
"mongodb": "^6.21.0"
|
|
61
58
|
}
|
|
62
59
|
}
|
package/src/atomCreator.ts
CHANGED
|
@@ -30,14 +30,22 @@ export function createPersistedAtom<T>(
|
|
|
30
30
|
setValue(value);
|
|
31
31
|
}
|
|
32
32
|
})
|
|
33
|
-
.catch(
|
|
33
|
+
.catch((err) => {
|
|
34
|
+
console.error(`[bff-store] Failed to load atom "${config.key}":`, err);
|
|
35
|
+
})
|
|
34
36
|
.finally(() => {
|
|
35
|
-
// Mark as loaded
|
|
37
|
+
// Mark as loaded regardless of success or failure
|
|
36
38
|
setTimeout(() => {
|
|
37
39
|
const store = getDefaultStore();
|
|
38
40
|
store.set(loadingAtom, false);
|
|
39
41
|
}, 0);
|
|
40
42
|
});
|
|
43
|
+
|
|
44
|
+
// Cancel pending debounced write on unmount to avoid writes from a destroyed component
|
|
45
|
+
return () => {
|
|
46
|
+
const debounceKey = `${entityId}:${config.key}`;
|
|
47
|
+
debouncerMap.cancel(debounceKey);
|
|
48
|
+
};
|
|
41
49
|
};
|
|
42
50
|
|
|
43
51
|
// Create write atom with persistence
|
package/src/createStore.ts
CHANGED
|
@@ -2,6 +2,13 @@ import type { AtomConfigs, Store, StoreAtoms, StoreLoadingAtoms } from './types'
|
|
|
2
2
|
import type { StorageAdapter } from './storage/base';
|
|
3
3
|
import { createPersistedAtom } from './atomCreator';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Module-level promise for the auto-started server.
|
|
7
|
+
* Shared across all createStore calls so concurrent invocations
|
|
8
|
+
* wait on the same server instance.
|
|
9
|
+
*/
|
|
10
|
+
let serverInitPromise: Promise<unknown> | null = null;
|
|
11
|
+
|
|
5
12
|
/**
|
|
6
13
|
* Creates a store with multiple persisted atoms
|
|
7
14
|
*
|
|
@@ -36,9 +43,11 @@ export function createStore(
|
|
|
36
43
|
|
|
37
44
|
// Auto-start embedded server when using remote storage (Node.js only)
|
|
38
45
|
// In browser/Next.js environments, remoteStorage connects to an already-running BFF server
|
|
46
|
+
// serverInitPromise is module-level so concurrent createStore calls share the same promise
|
|
39
47
|
if (adapter?.name === 'remote' && typeof window === 'undefined' && typeof process !== 'undefined') {
|
|
40
48
|
import('./server').then(({ startServer }) => {
|
|
41
|
-
startServer
|
|
49
|
+
// startServer is a singleton; concurrent calls share the same promise
|
|
50
|
+
serverInitPromise = startServer().then(() => void 0).catch((err) => {
|
|
42
51
|
console.error('[bff-store] Failed to auto-start server:', err);
|
|
43
52
|
});
|
|
44
53
|
});
|
|
@@ -75,3 +84,12 @@ export function createStore(
|
|
|
75
84
|
loadingAtoms,
|
|
76
85
|
};
|
|
77
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wait for the auto-started server to be ready.
|
|
90
|
+
* Only meaningful when using remote storage in Node.js;
|
|
91
|
+
* returns a resolved promise in browser environments.
|
|
92
|
+
*/
|
|
93
|
+
export function waitForServer(): Promise<void> | undefined {
|
|
94
|
+
return serverInitPromise as Promise<void> | undefined;
|
|
95
|
+
}
|
package/src/debouncer.ts
CHANGED
|
@@ -47,8 +47,11 @@ export class DebouncerMap {
|
|
|
47
47
|
|
|
48
48
|
getDebouncer(key: string, ms?: number): Debouncer {
|
|
49
49
|
let debouncer = this.debouncers.get(key);
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const effectiveMs = ms ?? this.defaultMs;
|
|
51
|
+
|
|
52
|
+
// Create new debouncer if none exists, or if ms differs from existing one
|
|
53
|
+
if (!debouncer || debouncer.ms !== effectiveMs) {
|
|
54
|
+
debouncer = createDebouncer(effectiveMs);
|
|
52
55
|
this.debouncers.set(key, debouncer);
|
|
53
56
|
}
|
|
54
57
|
return debouncer;
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Main Export
|
|
3
3
|
// ========================================
|
|
4
4
|
|
|
5
|
-
export { createStore } from './createStore';
|
|
5
|
+
export { createStore, waitForServer } from './createStore';
|
|
6
6
|
export { useStore } from './useStore';
|
|
7
7
|
export { createPersistedAtom } from './atomCreator';
|
|
8
8
|
|
|
@@ -33,3 +33,10 @@ export { HttpTransport, createStorageFromTransport } from './storage/transport';
|
|
|
33
33
|
export { RestStorageProtocol, createStorageWithProtocol } from './storage/protocol';
|
|
34
34
|
export type { TransportAdapter } from './storage/transport';
|
|
35
35
|
export type { StorageHttpProtocol } from './storage/protocol';
|
|
36
|
+
|
|
37
|
+
// ========================================
|
|
38
|
+
// Node.js Utilities
|
|
39
|
+
// ========================================
|
|
40
|
+
|
|
41
|
+
export { isNode, isBrowser } from './environment';
|
|
42
|
+
export { createNodeStore } from './nodeStore';
|
package/src/nodeStore.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { getDefaultStore } from 'jotai';
|
|
2
|
+
import { createStore } from './createStore';
|
|
3
|
+
import type { AtomConfigs, StoreAtoms, StoreLoadingAtoms } from './types';
|
|
4
|
+
import type { StorageAdapter } from './storage/base';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a store for Node.js environments.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { jsonlStorage } from 'bff-store/jsonl';
|
|
12
|
+
* import { createNodeStore } from 'bff-store';
|
|
13
|
+
*
|
|
14
|
+
* const store = createNodeStore('entity-123', [
|
|
15
|
+
* { key: 'theme', defaultValue: 'dark' },
|
|
16
|
+
* ], {
|
|
17
|
+
* storage: jsonlStorage({ dir: './sessions' }),
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* await store.waitForLoad();
|
|
21
|
+
*
|
|
22
|
+
* const jotai = getDefaultStore();
|
|
23
|
+
* jotai.set(store.atoms.theme, 'light');
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function createNodeStore(
|
|
27
|
+
entityId: string,
|
|
28
|
+
config: AtomConfigs,
|
|
29
|
+
options: {
|
|
30
|
+
storage: StorageAdapter;
|
|
31
|
+
debounceMs?: number;
|
|
32
|
+
}
|
|
33
|
+
): {
|
|
34
|
+
/** Atoms to use with getDefaultStore().get/set */
|
|
35
|
+
atoms: StoreAtoms;
|
|
36
|
+
/** Loading state atoms */
|
|
37
|
+
loadingAtoms: StoreLoadingAtoms;
|
|
38
|
+
/**
|
|
39
|
+
* Wait for all atoms to finish loading from storage.
|
|
40
|
+
* Call this after creating the store to ensure data is ready.
|
|
41
|
+
*/
|
|
42
|
+
waitForLoad(): Promise<void>;
|
|
43
|
+
} {
|
|
44
|
+
const store = createStore(entityId, config, options);
|
|
45
|
+
const jotaiStore = getDefaultStore();
|
|
46
|
+
|
|
47
|
+
async function waitForLoad(timeoutMs: number = 5000): Promise<void> {
|
|
48
|
+
// Trigger onMount for all atoms by subscribing to them
|
|
49
|
+
// In Node.js, sub() triggers onMount (unlike get() which doesn't)
|
|
50
|
+
for (const atom of Object.values(store.atoms)) {
|
|
51
|
+
jotaiStore.sub(atom, () => {});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Now wait for all loading atoms to become false
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const loadingAtoms = Object.values(store.loadingAtoms);
|
|
57
|
+
const stillLoading = () => loadingAtoms.some((atom) => jotaiStore.get(atom));
|
|
58
|
+
|
|
59
|
+
if (!stillLoading()) {
|
|
60
|
+
resolve();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Timeout protection
|
|
65
|
+
const timeout = setTimeout(() => {
|
|
66
|
+
clearInterval(interval);
|
|
67
|
+
reject(new Error(`waitForLoad timed out after ${timeoutMs}ms`));
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
|
|
70
|
+
// Poll until all are loaded
|
|
71
|
+
const interval = setInterval(() => {
|
|
72
|
+
if (!stillLoading()) {
|
|
73
|
+
clearInterval(interval);
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
resolve();
|
|
76
|
+
}
|
|
77
|
+
}, 10);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
atoms: store.atoms,
|
|
83
|
+
loadingAtoms: store.loadingAtoms,
|
|
84
|
+
waitForLoad,
|
|
85
|
+
};
|
|
86
|
+
}
|