bff-store 0.1.0
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 +45 -0
- package/CONTEXT.md +53 -0
- package/README.md +223 -0
- package/dist/cli.js +32577 -0
- package/dist/index.d.mts +232 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.mjs +430 -0
- package/dist/package.json +62 -0
- package/dist/server/entry.d.mts +94 -0
- package/dist/server/entry.d.ts +94 -0
- package/dist/server/entry.js +573 -0
- package/dist/server/entry.mjs +533 -0
- package/dist/server-V7WCW4ZB.mjs +530 -0
- package/dist/storage/jsonl-entry.d.mts +42 -0
- package/dist/storage/jsonl-entry.d.ts +42 -0
- package/dist/storage/jsonl-entry.js +112 -0
- package/dist/storage/jsonl-entry.mjs +74 -0
- package/dist/storage/mongodb-entry.d.mts +40 -0
- package/dist/storage/mongodb-entry.d.ts +40 -0
- package/dist/storage/mongodb-entry.js +114 -0
- package/dist/storage/mongodb-entry.mjs +86 -0
- package/docs/BUG_DIAGNOSIS_REMOTE_STORAGE_OPTIONS.md +104 -0
- package/docs/BUG_FIX_REMOTE_STORAGE_OPTIONS.md +63 -0
- package/docs/BUG_FIX_SESSION_2026-06-03.md +171 -0
- package/docs/IMPLEMENTATION.md +333 -0
- package/docs/PLAN.md +153 -0
- package/docs/REMOTE_STORAGE_CONFIG.md +125 -0
- package/docs/SIDECAR_SERVER.md +184 -0
- package/package.json +62 -0
- package/scripts/adapt-dist-package.js +33 -0
- package/src/atomCreator.ts +76 -0
- package/src/createStore.ts +77 -0
- package/src/debouncer.ts +84 -0
- package/src/index.ts +35 -0
- package/src/server/cli.ts +62 -0
- package/src/server/entityIdCache.ts +57 -0
- package/src/server/entry.ts +12 -0
- package/src/server/handlers.ts +271 -0
- package/src/server/index.ts +182 -0
- package/src/server/router.ts +74 -0
- package/src/server.ts +5 -0
- package/src/storage/adapters/remoteStorage.ts +70 -0
- package/src/storage/base.ts +28 -0
- package/src/storage/index.ts +9 -0
- package/src/storage/jsonl-entry.ts +9 -0
- package/src/storage/jsonl.ts +111 -0
- package/src/storage/memory.ts +49 -0
- package/src/storage/mongodb-entry.ts +9 -0
- package/src/storage/mongodb.ts +132 -0
- package/src/storage/protocol.ts +170 -0
- package/src/storage/transport.ts +95 -0
- package/src/types.ts +76 -0
- package/src/useStore.ts +83 -0
- package/tests/atomCreator.test.ts +153 -0
- package/tests/createStore.test.ts +126 -0
- package/tests/debouncer.test.ts +125 -0
- package/tests/server.test.ts +158 -0
- package/tests/storage/jsonl.test.ts +132 -0
- package/tests/storage/memory.test.ts +101 -0
- package/tests/storage/mongodb.test.ts +40 -0
- package/tests/storage/remoteStorage.test.ts +126 -0
- package/tests/useStore.test.tsx +147 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +53 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Remote Storage 客户端配置存储后端
|
|
2
|
+
|
|
3
|
+
## 背景
|
|
4
|
+
|
|
5
|
+
用户希望在客户端通过 `remoteStorage()` 配置使用 jsonl 还是 mongodb,以及各自的参数。
|
|
6
|
+
|
|
7
|
+
## 设计
|
|
8
|
+
|
|
9
|
+
### 客户端 API
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
// 使用 MongoDB
|
|
13
|
+
const adapter = remoteStorage({
|
|
14
|
+
backend: 'mongodb',
|
|
15
|
+
mongoUrl: 'mongodb://user:pass@host:27017',
|
|
16
|
+
database: 'myapp'
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// 使用 JSONL
|
|
20
|
+
const adapter = remoteStorage({
|
|
21
|
+
backend: 'jsonl',
|
|
22
|
+
jsonlDir: '/tmp/my-app-data'
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// 默认 (不指定 backend,走 BFF 默认配置)
|
|
26
|
+
const adapter = remoteStorage()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 请求流程
|
|
30
|
+
|
|
31
|
+
1. 客户端发起请求,带 `backend` 类型和配置
|
|
32
|
+
2. BFF 根据 `backend` 路由到 mongodb 或 jsonl adapter
|
|
33
|
+
3. 每次请求都传配置参数
|
|
34
|
+
|
|
35
|
+
### BFF 行为
|
|
36
|
+
|
|
37
|
+
BFF 根据客户端请求中的 `backend` 类型,动态调用对应 adapter。BFF 不预建连接。
|
|
38
|
+
|
|
39
|
+
## 实现
|
|
40
|
+
|
|
41
|
+
### 1. `src/storage/adapters/remoteStorage.ts`
|
|
42
|
+
|
|
43
|
+
更新 `RemoteStorageOptions` 类型:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
export interface RemoteStorageOptions {
|
|
47
|
+
baseUrl?: string;
|
|
48
|
+
entityId?: string;
|
|
49
|
+
// 新增: 存储后端配置
|
|
50
|
+
backend?: 'mongodb' | 'jsonl';
|
|
51
|
+
mongoUrl?: string;
|
|
52
|
+
mongoDb?: string;
|
|
53
|
+
jsonlDir?: string;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
更新 `remoteStorage()` 函数,将 backend 配置放入请求中。
|
|
58
|
+
|
|
59
|
+
### 2. `src/storage/protocol.ts`
|
|
60
|
+
|
|
61
|
+
更新 `RestStorageProtocol`,在请求中附带 backend 配置:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
buildGetUrl(key: string): string {
|
|
65
|
+
const url = `${this.baseUrl}/storage/get/${encodeURIComponent(key)}`;
|
|
66
|
+
// 附带 backend 配置
|
|
67
|
+
const params = new URLSearchParams();
|
|
68
|
+
if (this.entityId) params.set('entityId', this.entityId);
|
|
69
|
+
if (this.backend) params.set('backend', this.backend);
|
|
70
|
+
if (this.backend === 'mongodb' && this.mongoUrl) {
|
|
71
|
+
params.set('mongoUrl', this.mongoUrl);
|
|
72
|
+
params.set('mongoDb', this.mongoDb || 'jotai_state_store');
|
|
73
|
+
}
|
|
74
|
+
if (this.backend === 'jsonl' && this.jsonlDir) {
|
|
75
|
+
params.set('jsonlDir', this.jsonlDir);
|
|
76
|
+
}
|
|
77
|
+
// ...
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 3. `src/server/handlers.ts`
|
|
82
|
+
|
|
83
|
+
更新 handlers,从请求中解析 backend 配置,动态创建对应 adapter:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
async handleSet(req, res) {
|
|
87
|
+
const { key } = req.params;
|
|
88
|
+
const { value, backend, mongoUrl, mongoDb, jsonlDir } = await parseBody(req);
|
|
89
|
+
|
|
90
|
+
let storage;
|
|
91
|
+
if (backend === 'mongodb') {
|
|
92
|
+
const adapter = await mongodbStorage({ url: mongoUrl, database: mongoDb });
|
|
93
|
+
storage = adapter.storage;
|
|
94
|
+
} else if (backend === 'jsonl') {
|
|
95
|
+
const adapter = jsonlStorage({ dir: jsonlDir });
|
|
96
|
+
storage = adapter.storage;
|
|
97
|
+
} else {
|
|
98
|
+
// 使用默认 storage
|
|
99
|
+
storage = getDefaultStorage();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await storage.set(key, value);
|
|
103
|
+
// ...
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 4. `src/server/router.ts`
|
|
108
|
+
|
|
109
|
+
更新 router 传递 backend 相关参数。
|
|
110
|
+
|
|
111
|
+
## 文件修改清单
|
|
112
|
+
|
|
113
|
+
| 文件 | 修改内容 |
|
|
114
|
+
|------|----------|
|
|
115
|
+
| `src/storage/adapters/remoteStorage.ts` | 添加 backend 配置字段 |
|
|
116
|
+
| `src/storage/protocol.ts` | 附带 backend 配置到请求 URL |
|
|
117
|
+
| `src/server/handlers.ts` | 解析 backend 并动态路由 |
|
|
118
|
+
| `src/server/router.ts` | 传递 backend 参数 |
|
|
119
|
+
|
|
120
|
+
## 验证
|
|
121
|
+
|
|
122
|
+
1. 构建后检查主 bundle 不含 mongodb/jsonl 代码
|
|
123
|
+
2. 测试 `remoteStorage({ backend: 'mongodb', ... })` 能正常工作
|
|
124
|
+
3. 测试 `remoteStorage({ backend: 'jsonl', ... })` 能正常工作
|
|
125
|
+
4. 测试默认 `remoteStorage()` 仍能正常工作
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Embedded Sidecar API Server (BFF)
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Provides a lightweight HTTP server that acts as a BFF (Backend for Frontend) proxy for storage backends (JSONL/MongoDB). Designed for use in browser/Next.js environments where direct file system or database access isn't available.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
┌─────────────────────────────────────────────────────────┐
|
|
11
|
+
│ Main Application │
|
|
12
|
+
│ (Next.js/Node.js) │
|
|
13
|
+
│ │
|
|
14
|
+
│ useStore() ───► remoteStorageAdapter ──► localhost:3847 │
|
|
15
|
+
│ (HTTP) │ │
|
|
16
|
+
└──────────────────────────────────────────┼───────────────┘
|
|
17
|
+
│
|
|
18
|
+
▼
|
|
19
|
+
┌────────────────────────┐
|
|
20
|
+
│ Embedded API Server │
|
|
21
|
+
│ (BFF Layer) │
|
|
22
|
+
│ │
|
|
23
|
+
│ /storage/get/:key │
|
|
24
|
+
│ /storage/set/:key │
|
|
25
|
+
│ /storage/delete/:key │
|
|
26
|
+
│ /storage/batch-get │
|
|
27
|
+
│ /storage/batch-set │
|
|
28
|
+
└────────────────────────┘
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Auto-Start Behavior
|
|
32
|
+
|
|
33
|
+
The server starts **automatically** when using `remoteStorage()`:
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { createStore, useStore, remoteStorage } from 'bff-store';
|
|
37
|
+
|
|
38
|
+
// Server auto-starts on first createStore call
|
|
39
|
+
const store = createStore('user-1', config, {
|
|
40
|
+
storage: remoteStorage()
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Singleton pattern**: Only one server instance runs. Subsequent `createStore` calls reuse the existing server.
|
|
45
|
+
|
|
46
|
+
## API Endpoints
|
|
47
|
+
|
|
48
|
+
| Method | Endpoint | Body | Response |
|
|
49
|
+
|--------|----------|------|----------|
|
|
50
|
+
| GET | `/storage/get/:key?entityId=x` | - | `{ value: T }` |
|
|
51
|
+
| POST | `/storage/set/:key?entityId=x` | `{ value: T }` | `{ success: true }` |
|
|
52
|
+
| DELETE | `/storage/delete/:key?entityId=x` | - | `{ success: true }` |
|
|
53
|
+
| POST | `/storage/batch-get?entityId=x` | `{ keys: string[] }` | `{ entries: {...} }` |
|
|
54
|
+
| POST | `/storage/batch-set?entityId=x` | `{ entries: {...} }` | `{ success: true }` |
|
|
55
|
+
| GET | `/health` | - | `{ status: 'ok' }` |
|
|
56
|
+
|
|
57
|
+
## Server Options
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
interface ServerOptions {
|
|
61
|
+
port?: number; // Default: 3847
|
|
62
|
+
host?: string; // Default: localhost
|
|
63
|
+
backend: 'jsonl' | 'mongodb';
|
|
64
|
+
jsonlDir?: string; // For JSONL backend (default: ./data)
|
|
65
|
+
mongoUrl?: string; // For MongoDB backend (required)
|
|
66
|
+
mongoDb?: string; // For MongoDB backend (default: jotai_state_store)
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Programmatic Usage
|
|
71
|
+
|
|
72
|
+
### Auto-Start (Recommended)
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import { createStore, remoteStorage } from 'bff-store';
|
|
76
|
+
|
|
77
|
+
// Server auto-starts with defaults (JSONL, port 3847, ./data dir)
|
|
78
|
+
const store = createStore('user-1', config, {
|
|
79
|
+
storage: remoteStorage()
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Customize Server Before First Use
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
import { startServer, createStore, remoteStorage } from 'bff-store';
|
|
87
|
+
|
|
88
|
+
// Start with custom settings
|
|
89
|
+
await startServer({
|
|
90
|
+
backend: 'mongodb',
|
|
91
|
+
mongoUrl: 'mongodb://localhost:27017',
|
|
92
|
+
port: 5000,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Now all remoteStorage() calls use this server
|
|
96
|
+
const store = createStore('user-1', config, {
|
|
97
|
+
storage: remoteStorage()
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Using startServer Directly
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { startServer } from 'bff-store';
|
|
105
|
+
|
|
106
|
+
const server = await startServer({
|
|
107
|
+
backend: 'jsonl',
|
|
108
|
+
port: 3847,
|
|
109
|
+
jsonlDir: './data',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Server runs until Ctrl+C
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Remote Storage Adapter
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { remoteStorage } from 'bff-store';
|
|
119
|
+
|
|
120
|
+
// Default: http://localhost:3847
|
|
121
|
+
const adapter = remoteStorage();
|
|
122
|
+
|
|
123
|
+
// Custom server URL
|
|
124
|
+
const adapter = remoteStorage({ baseUrl: 'http://custom:9999' });
|
|
125
|
+
|
|
126
|
+
// With default entityId
|
|
127
|
+
const adapter = remoteStorage({ entityId: 'user-123' });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Graceful Shutdown
|
|
131
|
+
|
|
132
|
+
The server handles `SIGINT` and `SIGTERM` signals:
|
|
133
|
+
- Stops accepting new connections
|
|
134
|
+
- Closes existing connections
|
|
135
|
+
- Exits cleanly after 5 seconds max
|
|
136
|
+
|
|
137
|
+
## File Structure
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
src/server/
|
|
141
|
+
├── index.ts # Main entry, singleton manager, startServer()
|
|
142
|
+
├── router.ts # HTTP pattern-based router
|
|
143
|
+
├── handlers.ts # Storage operation handlers
|
|
144
|
+
└── entityIdCache.ts # Multi-tenant storage cache
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Router (`router.ts`)
|
|
148
|
+
|
|
149
|
+
Pattern-based HTTP router with named parameter extraction.
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
router.get('/storage/get/:key', handler); // :key is a named param
|
|
153
|
+
router.post('/storage/set/:key', handler);
|
|
154
|
+
router.delete('/storage/delete/:key', handler);
|
|
155
|
+
router.post('/storage/batch-get', handler);
|
|
156
|
+
router.post('/storage/batch-set', handler);
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### EntityIdCache (`entityIdCache.ts`)
|
|
160
|
+
|
|
161
|
+
Caches JSONL storage adapters per `entityId` to avoid recreation:
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
const cache = new EntityIdCache({ dir: './data' });
|
|
165
|
+
const storage = cache.getStorage('user-123'); // Gets or creates
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Handlers (`handlers.ts`)
|
|
169
|
+
|
|
170
|
+
Request handlers for storage operations. Each handler reads `entityId` from URL query params:
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
const handlers = createStorageHandlers({
|
|
174
|
+
getStorage: (entityId?: string) => entityIdCache.getStorage(entityId),
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Testing
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
npm test -- tests/server.test.ts
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Server tests create a temporary directory for JSONL storage, start the server on port 3849, and verify all endpoints work correctly.
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bff-store",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A jotai-based state management library with pluggable storage adapters",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./jsonl": {
|
|
15
|
+
"types": "./dist/storage/jsonl-entry.d.ts",
|
|
16
|
+
"import": "./dist/storage/jsonl-entry.mjs",
|
|
17
|
+
"require": "./dist/storage/jsonl-entry.js"
|
|
18
|
+
},
|
|
19
|
+
"./mongodb": {
|
|
20
|
+
"types": "./dist/storage/mongodb-entry.d.ts",
|
|
21
|
+
"import": "./dist/storage/mongodb-entry.mjs",
|
|
22
|
+
"require": "./dist/storage/mongodb-entry.js"
|
|
23
|
+
},
|
|
24
|
+
"./server": {
|
|
25
|
+
"types": "./dist/server/entry.d.ts",
|
|
26
|
+
"import": "./dist/server/entry.mjs",
|
|
27
|
+
"require": "./dist/server/entry.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup && cp package.json dist/package.json && node scripts/adapt-dist-package.js && for f in dist/*.d.mts; do cp \"$f\" \"${f%.d.mts}.d.ts\"; done",
|
|
32
|
+
"dev": "tsup --watch",
|
|
33
|
+
"test": "vitest",
|
|
34
|
+
"start": "node dist/server/cli.js"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"jotai",
|
|
38
|
+
"state-management",
|
|
39
|
+
"react",
|
|
40
|
+
"storage"
|
|
41
|
+
],
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"jotai": ">=2.0.0",
|
|
44
|
+
"react": ">=18.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@testing-library/dom": "^10.4.1",
|
|
48
|
+
"@testing-library/react": "^16.3.2",
|
|
49
|
+
"@types/node": "^20.0.0",
|
|
50
|
+
"@types/react": "^19.2.16",
|
|
51
|
+
"jotai": "^2.6.0",
|
|
52
|
+
"jsdom": "^29.1.1",
|
|
53
|
+
"react": "^18.2.0",
|
|
54
|
+
"react-dom": "^18.2.0",
|
|
55
|
+
"tsup": "^8.0.0",
|
|
56
|
+
"typescript": "^5.0.0",
|
|
57
|
+
"vitest": "^1.0.0"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"mongodb": "^6.21.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const distPkgPath = path.resolve(__dirname, '..', 'dist', 'package.json');
|
|
5
|
+
|
|
6
|
+
if (!fs.existsSync(distPkgPath)) {
|
|
7
|
+
console.error('dist/package.json not found — skipping path adaptation');
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const pkg = JSON.parse(fs.readFileSync(distPkgPath, 'utf-8'));
|
|
12
|
+
|
|
13
|
+
// Strip ./dist/ prefix from top-level entry fields
|
|
14
|
+
for (const field of ['main', 'module', 'types']) {
|
|
15
|
+
if (typeof pkg[field] === 'string') {
|
|
16
|
+
pkg[field] = pkg[field].replace(/^dist\//, '');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Strip ./dist/ prefix from exports map
|
|
21
|
+
if (pkg.exports) {
|
|
22
|
+
for (const key of Object.keys(pkg.exports)) {
|
|
23
|
+
const entry = pkg.exports[key];
|
|
24
|
+
for (const cond of ['types', 'import', 'require', 'default']) {
|
|
25
|
+
if (typeof entry[cond] === 'string') {
|
|
26
|
+
entry[cond] = entry[cond].replace(/^\.\/dist\//, './');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fs.writeFileSync(distPkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
33
|
+
console.log('dist/package.json paths adapted for local resolution');
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { atom, getDefaultStore } from 'jotai';
|
|
2
|
+
import type { AtomConfig, PersistedAtomWithLoading } from './types';
|
|
3
|
+
import type { Storage } from './storage/base';
|
|
4
|
+
import { DebouncerMap } from './debouncer';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Module-level debouncer map for all persisted atoms.
|
|
8
|
+
* Each unique key (atom config key) gets its own debouncer.
|
|
9
|
+
*/
|
|
10
|
+
const debouncerMap = new DebouncerMap();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a persisted atom with loading state tracking
|
|
14
|
+
*/
|
|
15
|
+
export function createPersistedAtom<T>(
|
|
16
|
+
config: AtomConfig<T>,
|
|
17
|
+
entityId: string,
|
|
18
|
+
storage: Storage,
|
|
19
|
+
options?: { immediate?: boolean; debounceMs?: number }
|
|
20
|
+
): PersistedAtomWithLoading<T> {
|
|
21
|
+
const baseAtom = atom<T>(config.defaultValue);
|
|
22
|
+
const loadingAtom = atom<boolean>(true);
|
|
23
|
+
|
|
24
|
+
// Load initial value on mount
|
|
25
|
+
baseAtom.onMount = (setValue) => {
|
|
26
|
+
storage
|
|
27
|
+
.get<T>(config.key)
|
|
28
|
+
.then((value) => {
|
|
29
|
+
if (value !== null && value !== undefined) {
|
|
30
|
+
setValue(value);
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
.catch(console.error)
|
|
34
|
+
.finally(() => {
|
|
35
|
+
// Mark as loaded
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
const store = getDefaultStore();
|
|
38
|
+
store.set(loadingAtom, false);
|
|
39
|
+
}, 0);
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Create write atom with persistence
|
|
44
|
+
const writeAtom = atom(
|
|
45
|
+
(get) => get(baseAtom),
|
|
46
|
+
(get, set, update: T | ((prev: T) => T)) => {
|
|
47
|
+
const newValue =
|
|
48
|
+
typeof update === 'function'
|
|
49
|
+
? (update as (prev: T) => T)(get(baseAtom))
|
|
50
|
+
: update;
|
|
51
|
+
|
|
52
|
+
// Update local state first
|
|
53
|
+
set(baseAtom, newValue);
|
|
54
|
+
|
|
55
|
+
// Save to storage
|
|
56
|
+
if (options?.immediate) {
|
|
57
|
+
// Immediate save for critical data
|
|
58
|
+
storage.set(config.key, newValue).catch(console.error);
|
|
59
|
+
} else {
|
|
60
|
+
// Debounced save for normal data
|
|
61
|
+
// Use entityId:key as the debounce key to avoid cross-store interference
|
|
62
|
+
const debounceKey = `${entityId}:${config.key}`;
|
|
63
|
+
const debounceMs = options?.debounceMs ?? 800;
|
|
64
|
+
const saveFn = () => {
|
|
65
|
+
storage.set(config.key, newValue).catch(console.error);
|
|
66
|
+
};
|
|
67
|
+
debouncerMap.debounce(debounceKey, saveFn, debounceMs);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
atom: writeAtom,
|
|
74
|
+
loadingAtom,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { AtomConfigs, Store, StoreAtoms, StoreLoadingAtoms } from './types';
|
|
2
|
+
import type { StorageAdapter } from './storage/base';
|
|
3
|
+
import { createPersistedAtom } from './atomCreator';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a store with multiple persisted atoms
|
|
7
|
+
*
|
|
8
|
+
* @param entityId - Unique identifier for this store instance
|
|
9
|
+
* @param config - Array of atom configurations
|
|
10
|
+
* @param options - Store options including storage adapter
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const config = [
|
|
15
|
+
* { key: 'theme', defaultValue: '' },
|
|
16
|
+
* { key: 'characters', defaultValue: [] },
|
|
17
|
+
* ] as const;
|
|
18
|
+
*
|
|
19
|
+
* const adapter = jsonlStorage({ dir: './sessions' });
|
|
20
|
+
*
|
|
21
|
+
* const store = createStore('novel-123', config, {
|
|
22
|
+
* storage: adapter,
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function createStore(
|
|
27
|
+
entityId: string,
|
|
28
|
+
config: AtomConfigs,
|
|
29
|
+
options?: {
|
|
30
|
+
storage: StorageAdapter;
|
|
31
|
+
debounceMs?: number;
|
|
32
|
+
}
|
|
33
|
+
): Store {
|
|
34
|
+
const adapter = options?.storage;
|
|
35
|
+
const debounceMs = options?.debounceMs ?? 800;
|
|
36
|
+
|
|
37
|
+
// Auto-start embedded server when using remote storage (Node.js only)
|
|
38
|
+
// In browser/Next.js environments, remoteStorage connects to an already-running BFF server
|
|
39
|
+
if (adapter?.name === 'remote' && typeof window === 'undefined' && typeof process !== 'undefined') {
|
|
40
|
+
import('./server').then(({ startServer }) => {
|
|
41
|
+
startServer().catch((err) => {
|
|
42
|
+
console.error('[bff-store] Failed to auto-start server:', err);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!adapter) {
|
|
48
|
+
throw new Error('Storage adapter is required');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Initialize adapter with entityId if it supports it
|
|
52
|
+
if ('setEntityId' in adapter && typeof adapter.setEntityId === 'function') {
|
|
53
|
+
adapter.setEntityId(entityId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const storage = adapter.storage;
|
|
57
|
+
|
|
58
|
+
const atoms: StoreAtoms = {};
|
|
59
|
+
const loadingAtoms: StoreLoadingAtoms = {};
|
|
60
|
+
|
|
61
|
+
// Create atoms synchronously for immediate availability
|
|
62
|
+
for (const atomConfig of config) {
|
|
63
|
+
const result = createPersistedAtom(atomConfig, entityId, storage, {
|
|
64
|
+
immediate: atomConfig.immediate,
|
|
65
|
+
debounceMs,
|
|
66
|
+
});
|
|
67
|
+
atoms[atomConfig.key] = result.atom;
|
|
68
|
+
loadingAtoms[atomConfig.key] = result.loadingAtom;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
entityId,
|
|
73
|
+
config,
|
|
74
|
+
atoms,
|
|
75
|
+
loadingAtoms,
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/debouncer.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debouncer - manages debounced function execution
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates timer state for delayed function calls.
|
|
5
|
+
* Each Debouncer instance manages one debounce pipeline.
|
|
6
|
+
*/
|
|
7
|
+
export interface Debouncer {
|
|
8
|
+
readonly ms: number;
|
|
9
|
+
run(fn: () => void): void;
|
|
10
|
+
cancel(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createDebouncer(ms: number): Debouncer {
|
|
14
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
get ms() {
|
|
18
|
+
return ms;
|
|
19
|
+
},
|
|
20
|
+
run(fn) {
|
|
21
|
+
if (timer) clearTimeout(timer);
|
|
22
|
+
timer = setTimeout(() => {
|
|
23
|
+
fn();
|
|
24
|
+
timer = null;
|
|
25
|
+
}, ms);
|
|
26
|
+
},
|
|
27
|
+
cancel() {
|
|
28
|
+
if (timer) {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
timer = null;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Factory for creating debounced save functions per key.
|
|
38
|
+
* Maintains a map of debouncers, one per unique key.
|
|
39
|
+
*/
|
|
40
|
+
export class DebouncerMap {
|
|
41
|
+
private debouncers = new Map<string, Debouncer>();
|
|
42
|
+
private readonly defaultMs: number;
|
|
43
|
+
|
|
44
|
+
constructor(defaultMs: number = 800) {
|
|
45
|
+
this.defaultMs = defaultMs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getDebouncer(key: string, ms?: number): Debouncer {
|
|
49
|
+
let debouncer = this.debouncers.get(key);
|
|
50
|
+
if (!debouncer) {
|
|
51
|
+
debouncer = createDebouncer(ms ?? this.defaultMs);
|
|
52
|
+
this.debouncers.set(key, debouncer);
|
|
53
|
+
}
|
|
54
|
+
return debouncer;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Execute a function with debounce for a given key.
|
|
59
|
+
* Subsequent calls for the same key reset the timer.
|
|
60
|
+
*/
|
|
61
|
+
debounce(key: string, fn: () => void, ms?: number): void {
|
|
62
|
+
const debouncer = this.getDebouncer(key, ms);
|
|
63
|
+
debouncer.run(fn);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cancel pending debounced call for a key
|
|
68
|
+
*/
|
|
69
|
+
cancel(key: string): void {
|
|
70
|
+
const debouncer = this.debouncers.get(key);
|
|
71
|
+
if (debouncer) {
|
|
72
|
+
debouncer.cancel();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Cancel all pending debounced calls
|
|
78
|
+
*/
|
|
79
|
+
cancelAll(): void {
|
|
80
|
+
for (const debouncer of this.debouncers.values()) {
|
|
81
|
+
debouncer.cancel();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ========================================
|
|
2
|
+
// Main Export
|
|
3
|
+
// ========================================
|
|
4
|
+
|
|
5
|
+
export { createStore } from './createStore';
|
|
6
|
+
export { useStore } from './useStore';
|
|
7
|
+
export { createPersistedAtom } from './atomCreator';
|
|
8
|
+
|
|
9
|
+
// ========================================
|
|
10
|
+
// Types
|
|
11
|
+
// ========================================
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
AtomConfig,
|
|
15
|
+
AtomConfigs,
|
|
16
|
+
AtomType,
|
|
17
|
+
Store,
|
|
18
|
+
StorageOptions,
|
|
19
|
+
MemoryStorageOptions,
|
|
20
|
+
UseStoreReturn,
|
|
21
|
+
} from './types';
|
|
22
|
+
|
|
23
|
+
// ========================================
|
|
24
|
+
// Storage Adapters
|
|
25
|
+
// ========================================
|
|
26
|
+
|
|
27
|
+
export { memoryStorage, createMemoryStorage } from './storage/memory';
|
|
28
|
+
export { remoteStorage, createRemoteStorage } from './storage/adapters/remoteStorage';
|
|
29
|
+
export type { Storage, StorageAdapter, StorageFactory, AsyncStorageFactory } from './storage/base';
|
|
30
|
+
|
|
31
|
+
// Transport & Protocol
|
|
32
|
+
export { HttpTransport, createStorageFromTransport } from './storage/transport';
|
|
33
|
+
export { RestStorageProtocol, createStorageWithProtocol } from './storage/protocol';
|
|
34
|
+
export type { TransportAdapter } from './storage/transport';
|
|
35
|
+
export type { StorageHttpProtocol } from './storage/protocol';
|