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
|
@@ -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,10 +1,7 @@
|
|
|
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
|
-
"publishConfig": {
|
|
6
|
-
"access": "public"
|
|
7
|
-
},
|
|
8
5
|
"main": "dist/index.js",
|
|
9
6
|
"module": "dist/index.mjs",
|
|
10
7
|
"types": "dist/index.d.ts",
|
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
|
|
package/src/nodeStore.ts
CHANGED
|
@@ -44,7 +44,7 @@ export function createNodeStore(
|
|
|
44
44
|
const store = createStore(entityId, config, options);
|
|
45
45
|
const jotaiStore = getDefaultStore();
|
|
46
46
|
|
|
47
|
-
async function waitForLoad(): Promise<void> {
|
|
47
|
+
async function waitForLoad(timeoutMs: number = 5000): Promise<void> {
|
|
48
48
|
// Trigger onMount for all atoms by subscribing to them
|
|
49
49
|
// In Node.js, sub() triggers onMount (unlike get() which doesn't)
|
|
50
50
|
for (const atom of Object.values(store.atoms)) {
|
|
@@ -52,7 +52,7 @@ export function createNodeStore(
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// Now wait for all loading atoms to become false
|
|
55
|
-
return new Promise((resolve) => {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
56
|
const loadingAtoms = Object.values(store.loadingAtoms);
|
|
57
57
|
const stillLoading = () => loadingAtoms.some((atom) => jotaiStore.get(atom));
|
|
58
58
|
|
|
@@ -61,10 +61,17 @@ export function createNodeStore(
|
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// Timeout protection
|
|
65
|
+
const timeout = setTimeout(() => {
|
|
66
|
+
clearInterval(interval);
|
|
67
|
+
reject(new Error(`waitForLoad timed out after ${timeoutMs}ms`));
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
|
|
64
70
|
// Poll until all are loaded
|
|
65
71
|
const interval = setInterval(() => {
|
|
66
72
|
if (!stillLoading()) {
|
|
67
73
|
clearInterval(interval);
|
|
74
|
+
clearTimeout(timeout);
|
|
68
75
|
resolve();
|
|
69
76
|
}
|
|
70
77
|
}, 10);
|
package/src/server/handlers.ts
CHANGED
|
@@ -81,8 +81,8 @@ async function getCachedStorage(config: BackendConfig, entityId?: string): Promi
|
|
|
81
81
|
return entry.adapter.storage;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
// Evict oldest
|
|
85
|
-
|
|
84
|
+
// Evict oldest entries until there's space for a new one
|
|
85
|
+
while (storageCache.size >= MAX_CACHE_SIZE && storageCache.size > 0) {
|
|
86
86
|
let oldestKey: string | null = null;
|
|
87
87
|
let oldestTime = Infinity;
|
|
88
88
|
for (const [k, entry] of storageCache.entries()) {
|
|
@@ -93,6 +93,8 @@ async function getCachedStorage(config: BackendConfig, entityId?: string): Promi
|
|
|
93
93
|
}
|
|
94
94
|
if (oldestKey) {
|
|
95
95
|
storageCache.delete(oldestKey);
|
|
96
|
+
} else {
|
|
97
|
+
break; // should not happen, but guard against infinite loop
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -123,26 +125,6 @@ async function getCachedStorage(config: BackendConfig, entityId?: string): Promi
|
|
|
123
125
|
return adapter.storage;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
async function getStorageForRequest(
|
|
127
|
-
req: IncomingMessage,
|
|
128
|
-
entityId?: string
|
|
129
|
-
): Promise<Storage> {
|
|
130
|
-
const urlConfig = getBackendConfig(req);
|
|
131
|
-
const body = await parseBody<BackendConfig>(req);
|
|
132
|
-
|
|
133
|
-
const config: BackendConfig = {
|
|
134
|
-
backend: body.backend ?? urlConfig.backend,
|
|
135
|
-
mongoUrl: body.mongoUrl ?? urlConfig.mongoUrl,
|
|
136
|
-
mongoDb: body.mongoDb ?? urlConfig.mongoDb,
|
|
137
|
-
jsonlDir: body.jsonlDir ?? urlConfig.jsonlDir,
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
// Periodic cleanup of expired cache entries
|
|
141
|
-
cleanExpiredCache();
|
|
142
|
-
|
|
143
|
-
return getCachedStorage(config, entityId);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
128
|
export function createStorageHandlers(options: StorageHandlersOptions) {
|
|
147
129
|
const { getStorage } = options;
|
|
148
130
|
|
|
@@ -157,7 +139,8 @@ export function createStorageHandlers(options: StorageHandlersOptions) {
|
|
|
157
139
|
try {
|
|
158
140
|
body = await parseBody<Record<string, unknown>>(req);
|
|
159
141
|
} catch {
|
|
160
|
-
// Ignore parse errors
|
|
142
|
+
// Ignore parse errors - fall back to urlConfig only
|
|
143
|
+
console.warn('[bff-store] Failed to parse request body, using URL config only');
|
|
161
144
|
}
|
|
162
145
|
}
|
|
163
146
|
|
|
@@ -254,7 +237,7 @@ export function createStorageHandlers(options: StorageHandlersOptions) {
|
|
|
254
237
|
res.end(JSON.stringify({ success: true }));
|
|
255
238
|
}
|
|
256
239
|
|
|
257
|
-
async function handleHealth(
|
|
240
|
+
async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
258
241
|
res.setHeader('Content-Type', 'application/json');
|
|
259
242
|
res.writeHead(200);
|
|
260
243
|
res.end(JSON.stringify({ status: 'ok' }));
|
package/src/server/index.ts
CHANGED
|
@@ -53,7 +53,8 @@ export function remoteStorage(options: RemoteStorageOptions = {}): StorageAdapte
|
|
|
53
53
|
jsonlDir: options.jsonlDir,
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// Pass the entityId object ref so getEntityId() reads the live value on each request
|
|
57
|
+
const protocol = options.protocol ?? new RestStorageProtocol(baseUrl, entityId, backendConfig);
|
|
57
58
|
const storage = createStorageWithProtocol(transport, protocol);
|
|
58
59
|
|
|
59
60
|
const adapter: StorageAdapter = {
|
package/src/storage/jsonl.ts
CHANGED
|
@@ -29,7 +29,9 @@ export function jsonlStorage(options?: JsonlStorageOptions): JsonlStorageInstanc
|
|
|
29
29
|
let entityId: string = 'default';
|
|
30
30
|
|
|
31
31
|
function getFilePath(eId: string, key: string): string {
|
|
32
|
-
|
|
32
|
+
// Use encodeURIComponent to avoid key collision:
|
|
33
|
+
// "user.name", "user-name", "user%2Ename" all produce distinct filenames
|
|
34
|
+
const safeKey = encodeURIComponent(key);
|
|
33
35
|
return path.join(baseDir, eId, `${safeKey}.jsonl`);
|
|
34
36
|
}
|
|
35
37
|
|
package/src/storage/mongodb.ts
CHANGED
|
@@ -60,12 +60,12 @@ export async function mongodbStorage(
|
|
|
60
60
|
async set<T>(key: string, value: T): Promise<void> {
|
|
61
61
|
const collection = getCollection(currentEntityId);
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
timestamp: Date.now(),
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
// Use upsert to avoid accumulating duplicate entries over time
|
|
64
|
+
await collection.updateOne(
|
|
65
|
+
{ key, entityId: currentEntityId },
|
|
66
|
+
{ $set: { value, timestamp: Date.now() } },
|
|
67
|
+
{ upsert: true }
|
|
68
|
+
);
|
|
69
69
|
},
|
|
70
70
|
|
|
71
71
|
async remove(key: string): Promise<void> {
|
package/src/storage/transport.ts
CHANGED
|
@@ -21,7 +21,14 @@ export class HttpTransport implements TransportAdapter {
|
|
|
21
21
|
async get<T>(url: string): Promise<T> {
|
|
22
22
|
const res = await fetch(url);
|
|
23
23
|
if (!res.ok) {
|
|
24
|
-
|
|
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, use statusText only
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`GET ${url} failed: ${detail}`);
|
|
25
32
|
}
|
|
26
33
|
return res.json();
|
|
27
34
|
}
|
|
@@ -33,7 +40,14 @@ export class HttpTransport implements TransportAdapter {
|
|
|
33
40
|
body: JSON.stringify(body),
|
|
34
41
|
});
|
|
35
42
|
if (!res.ok) {
|
|
36
|
-
|
|
43
|
+
let detail = res.statusText;
|
|
44
|
+
try {
|
|
45
|
+
const errBody = await res.clone().json();
|
|
46
|
+
detail = errBody?.error ?? errBody?.message ?? detail;
|
|
47
|
+
} catch {
|
|
48
|
+
// body is not JSON or unreadable, use statusText only
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`POST ${url} failed: ${detail}`);
|
|
37
51
|
}
|
|
38
52
|
return res.json();
|
|
39
53
|
}
|
|
@@ -41,7 +55,14 @@ export class HttpTransport implements TransportAdapter {
|
|
|
41
55
|
async delete(url: string): Promise<void> {
|
|
42
56
|
const res = await fetch(url, { method: 'DELETE' });
|
|
43
57
|
if (!res.ok) {
|
|
44
|
-
|
|
58
|
+
let detail = res.statusText;
|
|
59
|
+
try {
|
|
60
|
+
const errBody = await res.clone().json();
|
|
61
|
+
detail = errBody?.error ?? errBody?.message ?? detail;
|
|
62
|
+
} catch {
|
|
63
|
+
// body is not JSON or unreadable, use statusText only
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`DELETE ${url} failed: ${detail}`);
|
|
45
66
|
}
|
|
46
67
|
}
|
|
47
68
|
}
|
|
@@ -79,16 +79,32 @@ describe('jsonlStorage', () => {
|
|
|
79
79
|
expect(await adapter2.storage.get('key')).toBe('value2');
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it('should
|
|
82
|
+
it('should encode key in filename to avoid collision', async () => {
|
|
83
83
|
const adapter = jsonlStorage({ dir: testDir });
|
|
84
84
|
adapter.setEntityId('entity1');
|
|
85
85
|
|
|
86
86
|
await adapter.storage.set('key with spaces', 'value');
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
// encodeURIComponent converts " " → "%20", producing a collision-safe filename
|
|
89
|
+
const filePath = path.join(testDir, 'entity1', 'key%20with%20spaces.jsonl');
|
|
89
90
|
expect(fs.existsSync(filePath)).toBe(true);
|
|
90
91
|
});
|
|
91
92
|
|
|
93
|
+
it('should not collide on keys with dots and hyphens', async () => {
|
|
94
|
+
const adapter = jsonlStorage({ dir: testDir });
|
|
95
|
+
adapter.setEntityId('entity1');
|
|
96
|
+
|
|
97
|
+
await adapter.storage.set('user.name', 'value1');
|
|
98
|
+
await adapter.storage.set('user-name', 'value2');
|
|
99
|
+
|
|
100
|
+
const filePath1 = path.join(testDir, 'entity1', 'user.name.jsonl');
|
|
101
|
+
const filePath2 = path.join(testDir, 'entity1', 'user-name.jsonl');
|
|
102
|
+
expect(fs.existsSync(filePath1)).toBe(true);
|
|
103
|
+
expect(fs.existsSync(filePath2)).toBe(true);
|
|
104
|
+
expect(await adapter.storage.get('user.name')).toBe('value1');
|
|
105
|
+
expect(await adapter.storage.get('user-name')).toBe('value2');
|
|
106
|
+
});
|
|
107
|
+
|
|
92
108
|
it('should handle multiple types', async () => {
|
|
93
109
|
const adapter = jsonlStorage({ dir: testDir });
|
|
94
110
|
adapter.setEntityId('entity1');
|