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
@@ -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.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",
@@ -30,14 +30,22 @@ export function createPersistedAtom<T>(
30
30
  setValue(value);
31
31
  }
32
32
  })
33
- .catch(console.error)
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
@@ -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().catch((err) => {
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
- if (!debouncer) {
51
- debouncer = createDebouncer(ms ?? this.defaultMs);
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);
@@ -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 if cache is full
85
- if (storageCache.size >= MAX_CACHE_SIZE) {
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(req: IncomingMessage, res: ServerResponse): Promise<void> {
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' }));
@@ -38,10 +38,6 @@ const defaultOptions: ServerOptions = {
38
38
  jsonlDir: './data',
39
39
  };
40
40
 
41
- function isServerRunning(): boolean {
42
- return serverInstance !== null;
43
- }
44
-
45
41
  /**
46
42
  * Start the embedded API server (singleton).
47
43
  *
@@ -53,7 +53,8 @@ export function remoteStorage(options: RemoteStorageOptions = {}): StorageAdapte
53
53
  jsonlDir: options.jsonlDir,
54
54
  };
55
55
 
56
- const protocol = options.protocol ?? new RestStorageProtocol(baseUrl, entityId.current, backendConfig);
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 = {
@@ -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
- const safeKey = key.replace(/[^a-zA-Z0-9_]/g, '_');
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
 
@@ -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
- await collection.insertOne({
64
- key,
65
- value,
66
- timestamp: Date.now(),
67
- entityId: currentEntityId,
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> {
@@ -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
- throw new Error(`GET ${url} failed: ${res.statusText}`);
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
- throw new Error(`POST ${url} failed: ${res.statusText}`);
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
- throw new Error(`DELETE ${url} failed: ${res.statusText}`);
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 sanitize key in filename', async () => {
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
- const filePath = path.join(testDir, 'entity1', 'key_with_spaces.jsonl');
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');