mm_state_store 0.0.0-stage → 1.0.1
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/README.md +331 -2
- package/example/index.js +148 -0
- package/index.js +22 -0
- package/lib/state.js +162 -0
- package/package.json +48 -4
- package/test/state.test.js +392 -0
package/README.md
CHANGED
|
@@ -1,3 +1,332 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 状态(mm_state_store)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## 说明
|
|
4
|
+
mm_state_store 是状态存储模块,用于缓存程序运行期间的状态,可随时修改和删除。
|
|
5
|
+
State 继承自 mm_cache 的 Cache,键为对象标识(如用户 ID、设备 ID),值为该对象的状态对象
|
|
6
|
+
(如账户信息、权限等),并具备过期时间、作用域隔离与 JSON 文件持久化能力。
|
|
7
|
+
状态按引用存取,不做深拷贝,外部持有的对象与缓存中是同一个对象。
|
|
8
|
+
|
|
9
|
+
## 主要状态管理
|
|
10
|
+
- 客户端设备状态:键为设备 ID,值为设备状态对象
|
|
11
|
+
- 用户登录状态:键为用户 ID,值为用户状态对象(账户信息、权限等)
|
|
12
|
+
- 应用状态:键为应用标识,值为应用状态对象
|
|
13
|
+
|
|
14
|
+
## 功能特点
|
|
15
|
+
|
|
16
|
+
- 以对象标识为键缓存状态,值必须为对象(账户信息、权限等)
|
|
17
|
+
- 键名为非空字符串,值必须是对象
|
|
18
|
+
- 支持过期时间(TTL)与剩余时间查询
|
|
19
|
+
- 写入与读取均为引用,不做深拷贝,可直接存放连接句柄等不可克隆对象
|
|
20
|
+
- 支持保存到 JSON 文件,按作用域分文件存储
|
|
21
|
+
- 基于 mm_corejs 的 Admin 管理器,按作用域缓存实例、彼此隔离
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install mm_state_store
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 快速开始
|
|
30
|
+
|
|
31
|
+
以下示例中的 `await` 需运行在 async 函数内,或使用 ESM 的顶层 await。
|
|
32
|
+
|
|
33
|
+
```javascript
|
|
34
|
+
const { State, stateAdmin } = require('mm_state_store');
|
|
35
|
+
|
|
36
|
+
// 直接使用状态类:键为用户 ID,值为该用户的状态
|
|
37
|
+
const state = new State();
|
|
38
|
+
await state.set('1001', {
|
|
39
|
+
account: { uid: '1001', nickname: '张三' },
|
|
40
|
+
power: ['user:read']
|
|
41
|
+
});
|
|
42
|
+
const user = await state.get('1001');
|
|
43
|
+
console.log(user.account.nickname); // "张三"
|
|
44
|
+
|
|
45
|
+
// 通过管理器按作用域获取实例(相同作用域返回同一实例)
|
|
46
|
+
const userState = stateAdmin('user');
|
|
47
|
+
await userState.set('1002', {
|
|
48
|
+
account: { uid: '1002', nickname: '李四' },
|
|
49
|
+
power: ['user:read']
|
|
50
|
+
});
|
|
51
|
+
console.log((await stateAdmin('user').get('1002')).account.nickname); // "李四"
|
|
52
|
+
|
|
53
|
+
await state.dispose();
|
|
54
|
+
await userState.dispose();
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
所有方法均返回 Promise,需使用 `await` 调用。
|
|
60
|
+
|
|
61
|
+
### 类方法
|
|
62
|
+
|
|
63
|
+
| 方法 | 说明 | 参数 | 返回值 |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| set | 设置指定对象的状态 | key(对象标识), value(状态对象), seconds(过期秒数,0 为永不过期) | boolean |
|
|
66
|
+
| get | 获取指定对象的状态 | key | object \| null |
|
|
67
|
+
| del | 删除指定对象的状态 | key | number(删除数量) |
|
|
68
|
+
| has | 判断指定对象的状态是否存在 | key | boolean |
|
|
69
|
+
| ttl | 获取剩余过期时间 | key | number(-1 永不过期,-2 不存在) |
|
|
70
|
+
| keys | 获取对象标识列表 | pattern(支持 * 通配符) | string[] |
|
|
71
|
+
| list | 获取全部状态的副本 | - | object |
|
|
72
|
+
| clear | 清空当前作用域的全部状态 | - | boolean |
|
|
73
|
+
| save | 写入持久化文件(需开启 persist) | - | boolean |
|
|
74
|
+
| dispose | 释放实例(含底层定时器) | - | Promise |
|
|
75
|
+
|
|
76
|
+
### 工厂函数
|
|
77
|
+
|
|
78
|
+
| 函数 | 说明 | 参数 | 返回值 |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| stateAdmin | 获取指定作用域的状态实例,不存在时创建 | scope, config | State |
|
|
81
|
+
|
|
82
|
+
`scope` 缺省时使用 `os`。`config` 为配置对象,常用:
|
|
83
|
+
|
|
84
|
+
```javascript
|
|
85
|
+
{
|
|
86
|
+
cachebase: {
|
|
87
|
+
persist: true, // 开启文件持久化,默认 false
|
|
88
|
+
dir: './data', // 持久化目录,文件名为 <scope>.json
|
|
89
|
+
auto_save: false, // 关闭自动保存,改为手动调用 save()
|
|
90
|
+
save_interval: 30000 // 自动保存间隔(毫秒)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## 使用示例
|
|
96
|
+
|
|
97
|
+
### 1. 用户状态(账户信息、权限)
|
|
98
|
+
|
|
99
|
+
```javascript
|
|
100
|
+
const { stateAdmin } = require('mm_state_store');
|
|
101
|
+
|
|
102
|
+
const userState = stateAdmin('user');
|
|
103
|
+
|
|
104
|
+
// 缓存用户状态:键为用户 ID,值为该用户的状态对象
|
|
105
|
+
await userState.set('1001', {
|
|
106
|
+
account: { uid: '1001', nickname: '张三', mobile: '13800000001' },
|
|
107
|
+
power: ['user:read', 'user:write']
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const user = await userState.get('1001');
|
|
111
|
+
console.log(user.account.nickname); // "张三"
|
|
112
|
+
console.log(user.power); // [ 'user:read', 'user:write' ]
|
|
113
|
+
|
|
114
|
+
// 更新状态:取旧状态合并后整体写回
|
|
115
|
+
await userState.set('1001', { ...user, power: ['user:read'] });
|
|
116
|
+
console.log((await userState.get('1001')).power); // [ 'user:read' ]
|
|
117
|
+
|
|
118
|
+
// 用户退出或缓存失效:删除该用户状态
|
|
119
|
+
await userState.del('1001');
|
|
120
|
+
console.log(await userState.has('1001')); // false
|
|
121
|
+
|
|
122
|
+
await userState.dispose();
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 2. 客户端设备状态(含过期时间)
|
|
126
|
+
|
|
127
|
+
```javascript
|
|
128
|
+
const { stateAdmin } = require('mm_state_store');
|
|
129
|
+
|
|
130
|
+
const deviceState = stateAdmin('device');
|
|
131
|
+
|
|
132
|
+
// 键为设备 ID,值为设备状态对象,30 分钟过期
|
|
133
|
+
await deviceState.set('d01', { platform: 'ios', online: true, last_time: Date.now() }, 1800);
|
|
134
|
+
|
|
135
|
+
// 设备离线:合并旧状态后写回
|
|
136
|
+
await deviceState.set('d01', { ...(await deviceState.get('d01')), online: false });
|
|
137
|
+
console.log((await deviceState.get('d01')).online); // false
|
|
138
|
+
|
|
139
|
+
// 查看剩余过期时间与全部设备状态
|
|
140
|
+
console.log(await deviceState.ttl('d01')); // 剩余秒数
|
|
141
|
+
console.log(await deviceState.list());
|
|
142
|
+
|
|
143
|
+
await deviceState.dispose();
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 3. 文件持久化
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
const { State } = require('mm_state_store');
|
|
150
|
+
|
|
151
|
+
const config = { scope: 'app', cachebase: { persist: true, dir: './data', auto_save: false } };
|
|
152
|
+
const state = new State(config);
|
|
153
|
+
await state.set('1001', { account: { uid: '1001', nickname: '张三' }, power: ['user:read'] });
|
|
154
|
+
console.log(await state.save()); // true,写入 ./data/app.json
|
|
155
|
+
await state.dispose();
|
|
156
|
+
|
|
157
|
+
// 重新创建实例后自动从 ./data/app.json 加载
|
|
158
|
+
const loaded = new State(config);
|
|
159
|
+
console.log((await loaded.get('1001')).account.nickname); // "张三"
|
|
160
|
+
await loaded.dispose();
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### 4. 作用域隔离与工厂配置
|
|
164
|
+
|
|
165
|
+
```javascript
|
|
166
|
+
const { stateAdmin } = require('mm_state_store');
|
|
167
|
+
|
|
168
|
+
// 首次创建时传入配置:app 作用域持久化到 ./data/app.json
|
|
169
|
+
const appState = stateAdmin('app', {
|
|
170
|
+
cachebase: { persist: true, dir: './data', auto_save: false }
|
|
171
|
+
});
|
|
172
|
+
const userState = stateAdmin('user');
|
|
173
|
+
|
|
174
|
+
await appState.set('cfg', { version: '1.0.0', debug: false });
|
|
175
|
+
|
|
176
|
+
// 不同作用域互不影响
|
|
177
|
+
console.log(await userState.has('cfg')); // false
|
|
178
|
+
console.log((await stateAdmin('app').get('cfg')).version); // "1.0.0"
|
|
179
|
+
|
|
180
|
+
// 同作用域再次调用仍复用首次创建的实例(配置不覆盖)
|
|
181
|
+
console.log(stateAdmin('app') === appState); // true
|
|
182
|
+
console.log(await appState.save()); // true
|
|
183
|
+
|
|
184
|
+
await appState.dispose();
|
|
185
|
+
await userState.dispose();
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### 5. 遍历与清空
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
const { State } = require('mm_state_store');
|
|
192
|
+
|
|
193
|
+
const state = new State({ scope: 'temp' });
|
|
194
|
+
await state.set('1001', { account: { uid: '1001', nickname: '张三' } });
|
|
195
|
+
await state.set('1002', { account: { uid: '1002', nickname: '李四' } });
|
|
196
|
+
|
|
197
|
+
console.log(await state.keys()); // [ '1001', '1002' ]
|
|
198
|
+
console.log(await state.keys('100*')); // [ '1001', '1002' ]
|
|
199
|
+
console.log(Object.keys(await state.list())); // [ '1001', '1002' ]
|
|
200
|
+
|
|
201
|
+
await state.clear();
|
|
202
|
+
console.log(await state.list()); // {}
|
|
203
|
+
|
|
204
|
+
await state.dispose();
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### 6. 登录态维护与权限校验
|
|
208
|
+
|
|
209
|
+
```javascript
|
|
210
|
+
const { stateAdmin } = require('mm_state_store');
|
|
211
|
+
|
|
212
|
+
const loginState = stateAdmin('login');
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 用户登录,写入登录态并设置 2 小时过期
|
|
216
|
+
* @param {string} user_id 用户 ID
|
|
217
|
+
* @param {object} user 用户信息
|
|
218
|
+
* @returns {Promise<void>}
|
|
219
|
+
*/
|
|
220
|
+
async function login(user_id, user) {
|
|
221
|
+
const session = {
|
|
222
|
+
account: { uid: user_id, nickname: user.nickname },
|
|
223
|
+
power: user.power,
|
|
224
|
+
login_time: Date.now()
|
|
225
|
+
};
|
|
226
|
+
await loginState.set(user_id, session, 7200);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 校验用户是否拥有指定权限
|
|
231
|
+
* @param {string} user_id 用户 ID
|
|
232
|
+
* @param {string} power 权限标识
|
|
233
|
+
* @returns {Promise<boolean>} 是否拥有权限
|
|
234
|
+
*/
|
|
235
|
+
async function checkPower(user_id, power) {
|
|
236
|
+
const session = await loginState.get(user_id);
|
|
237
|
+
if (!session) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
return session.power.includes(power);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 用户退出,删除登录态
|
|
245
|
+
* @param {string} user_id 用户 ID
|
|
246
|
+
* @returns {Promise<void>}
|
|
247
|
+
*/
|
|
248
|
+
async function logout(user_id) {
|
|
249
|
+
await loginState.del(user_id);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await login('1001', { nickname: '张三', power: ['user:read'] });
|
|
253
|
+
console.log(await checkPower('1001', 'user:read')); // true
|
|
254
|
+
console.log(await checkPower('1001', 'user:write')); // false
|
|
255
|
+
console.log(await loginState.ttl('1001')); // 约 7200
|
|
256
|
+
|
|
257
|
+
await logout('1001');
|
|
258
|
+
console.log(await loginState.has('1001')); // false
|
|
259
|
+
|
|
260
|
+
await loginState.dispose();
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### 7. 参数校验与错误处理
|
|
264
|
+
|
|
265
|
+
```javascript
|
|
266
|
+
const { State } = require('mm_state_store');
|
|
267
|
+
|
|
268
|
+
const state = new State({ scope: 'demo' });
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
await state.set('', {}); // 键名为空字符串
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.log(err.message); // "键名必须是非空字符串"
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
await state.set('1001', ['a']); // 状态值为数组
|
|
278
|
+
} catch (err) {
|
|
279
|
+
console.log(err.message); // "状态值必须是对象"
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 读取不存在的键不抛错,返回 null
|
|
283
|
+
console.log(await state.get('404')); // null
|
|
284
|
+
|
|
285
|
+
await state.dispose();
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## 注意事项
|
|
289
|
+
|
|
290
|
+
1. 键应为对象标识(如用户 ID、设备 ID),值必须是对象(不能为 `null`、数组或基础类型),否则抛出 `TypeError`。
|
|
291
|
+
2. 键名必须是非空字符串,否则抛出 `TypeError`。
|
|
292
|
+
3. 不存在或已过期的键,`get` 返回 `null`,`has` 返回 `false`。
|
|
293
|
+
4. `set` 与 `get` 均按引用传递,不做深拷贝:外部修改对象会直接影响缓存,修改 `get`/`list` 返回的对象同样会改写缓存内容,需要隔离时请自行拷贝。
|
|
294
|
+
5. `save` 仅在 `cachebase.persist` 为 `true` 时生效,否则返回 `false`;持久化文件为 `<dir>/<scope>.json`,内容为 JSON5 格式。
|
|
295
|
+
6. 默认 `auto_save` 为 `true`、`save_interval` 为 30 秒;如需精确控制落盘时机可关闭自动保存并手动 `save()`。
|
|
296
|
+
7. 底层写入使用同步 JSON 写文件,不是原子写;进程在写入瞬间被强制终止可能导致文件损坏,重要数据需自行备份。
|
|
297
|
+
8. 通过 `stateAdmin(scope, config)` 传入的配置仅首次创建时生效,同作用域后续调用复用已有实例。
|
|
298
|
+
9. 底层 CacheBase 会启动过期检查与自动保存定时器,不再使用时请调用 `dispose()` 释放,避免进程无法退出。
|
|
299
|
+
10. 持久化文件损坏或无法解析时,加载会降级为空状态并输出错误日志,不会中断实例创建,可通过重新 `set` + `save` 覆盖损坏文件。
|
|
300
|
+
|
|
301
|
+
## 依赖
|
|
302
|
+
|
|
303
|
+
- mm_corejs:提供 Admin 管理器
|
|
304
|
+
- mm_cache:提供 Cache 缓存基类(内存、文件持久化、过期时间)
|
|
305
|
+
|
|
306
|
+
## 测试
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
# 运行用例(jest,并要求 100% 行/分支/函数/语句覆盖)
|
|
310
|
+
npm test
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
用例位于 `test/state.test.js`,覆盖:导出结构、基本读写与覆盖、键名与状态值校验、引用语义、作用域缺省规则、键列表与通配查询、清空隔离、过期时间、空作用域、工厂复用与配置、持久化保存与重载、损坏文件降级。
|
|
314
|
+
|
|
315
|
+
## 示例
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
# 运行动态示例
|
|
319
|
+
npm run demo
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
示例位于 `example/index.js`,依次演示作用域工厂与用户状态、过期时间、遍历清空、文件持久化。
|
|
323
|
+
|
|
324
|
+
文档中的示例(快速开始与使用示例章节)如下:
|
|
325
|
+
|
|
326
|
+
1. 用户状态(账户信息、权限)
|
|
327
|
+
2. 客户端设备状态(含过期时间)
|
|
328
|
+
3. 文件持久化
|
|
329
|
+
4. 作用域隔离与工厂配置
|
|
330
|
+
5. 遍历与清空
|
|
331
|
+
6. 登录态维护与权限校验
|
|
332
|
+
7. 参数校验与错误处理
|
package/example/index.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mm_state_store 状态存储示例
|
|
3
|
+
* 演示 State 类与 stateAdmin 作用域工厂,状态读写、过期时间、
|
|
4
|
+
* 遍历清空与 JSON 文件持久化。
|
|
5
|
+
* 运行:node example/index.js
|
|
6
|
+
*/
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { State, stateAdmin } = require('../index.js');
|
|
11
|
+
|
|
12
|
+
/** 示例临时目录 */
|
|
13
|
+
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'mm-state-example-'));
|
|
14
|
+
|
|
15
|
+
/** 待释放的实例 */
|
|
16
|
+
const states = [];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 创建状态实例并登记
|
|
20
|
+
* @param {string} scope 作用域
|
|
21
|
+
* @param {object} [config] 配置对象
|
|
22
|
+
* @returns {State} 状态实例
|
|
23
|
+
*/
|
|
24
|
+
function createState(scope, config) {
|
|
25
|
+
const state = new State({ scope, ...(config || {}) });
|
|
26
|
+
states.push(state);
|
|
27
|
+
return state;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 演示作用域工厂与状态读写
|
|
32
|
+
* @returns {Promise<void>}
|
|
33
|
+
*/
|
|
34
|
+
async function demoScope() {
|
|
35
|
+
console.log('\n=== 1. 作用域工厂与用户状态 ===');
|
|
36
|
+
|
|
37
|
+
const user_state = stateAdmin('user');
|
|
38
|
+
states.push(user_state);
|
|
39
|
+
|
|
40
|
+
// 键为用户 ID,值为该用户的状态对象
|
|
41
|
+
await user_state.set('1001', {
|
|
42
|
+
account: { uid: '1001', nickname: '张三', mobile: '13800000001' },
|
|
43
|
+
power: ['user:read', 'user:write']
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const user = await user_state.get('1001');
|
|
47
|
+
console.log('用户昵称:', user.account.nickname);
|
|
48
|
+
console.log('用户权限:', user.power);
|
|
49
|
+
console.log('是否已缓存:', await user_state.has('1001'));
|
|
50
|
+
|
|
51
|
+
// 更新状态:取旧状态合并后整体写回
|
|
52
|
+
await user_state.set('1001', { ...user, power: ['user:read'] });
|
|
53
|
+
console.log('更新后权限:', (await user_state.get('1001')).power);
|
|
54
|
+
|
|
55
|
+
console.log('同作用域复用实例:', stateAdmin('user') === user_state);
|
|
56
|
+
|
|
57
|
+
const session_state = stateAdmin('session');
|
|
58
|
+
states.push(session_state);
|
|
59
|
+
console.log('不同作用域隔离:', (await session_state.has('1001')) === false);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 演示过期时间
|
|
64
|
+
* @returns {Promise<void>}
|
|
65
|
+
*/
|
|
66
|
+
async function demoExpire() {
|
|
67
|
+
console.log('\n=== 2. 过期时间 ===');
|
|
68
|
+
|
|
69
|
+
const device_state = stateAdmin('device');
|
|
70
|
+
states.push(device_state);
|
|
71
|
+
|
|
72
|
+
// 键为设备 ID,值为设备状态对象,30 秒过期
|
|
73
|
+
await device_state.set('d01', { platform: 'ios', online: true }, 30);
|
|
74
|
+
console.log('剩余时间(秒):', await device_state.ttl('d01'));
|
|
75
|
+
|
|
76
|
+
await device_state.set('d02', { platform: 'android', online: true }, 0.01);
|
|
77
|
+
console.log('短 TTL 剩余(秒):', await device_state.ttl('d02'));
|
|
78
|
+
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
80
|
+
console.log('过期后读取 d02:', await device_state.get('d02'));
|
|
81
|
+
console.log('过期后 ttl(d02):', await device_state.ttl('d02'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 演示遍历与清空
|
|
86
|
+
* @returns {Promise<void>}
|
|
87
|
+
*/
|
|
88
|
+
async function demoList() {
|
|
89
|
+
console.log('\n=== 3. 遍历与清空 ===');
|
|
90
|
+
|
|
91
|
+
const cache_state = createState('cache');
|
|
92
|
+
|
|
93
|
+
await cache_state.set('1001', { account: { uid: '1001', nickname: '张三' } });
|
|
94
|
+
await cache_state.set('1002', { account: { uid: '1002', nickname: '李四' } });
|
|
95
|
+
|
|
96
|
+
console.log('全部标识:', await cache_state.keys());
|
|
97
|
+
console.log('通配 100*:', await cache_state.keys('100*'));
|
|
98
|
+
console.log('状态表:', JSON.stringify(await cache_state.list()));
|
|
99
|
+
|
|
100
|
+
await cache_state.clear();
|
|
101
|
+
console.log('清空后列表:', await cache_state.list());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 演示文件持久化
|
|
106
|
+
* @returns {Promise<void>}
|
|
107
|
+
*/
|
|
108
|
+
async function demoPersist() {
|
|
109
|
+
console.log('\n=== 4. 文件持久化 ===');
|
|
110
|
+
|
|
111
|
+
const scope = 'app';
|
|
112
|
+
const config = { cachebase: { persist: true, dir: TMP_DIR, auto_save: false } };
|
|
113
|
+
|
|
114
|
+
const app_state = createState(scope, config);
|
|
115
|
+
await app_state.set('1001', {
|
|
116
|
+
account: { uid: '1001', nickname: '张三' },
|
|
117
|
+
power: ['user:read']
|
|
118
|
+
});
|
|
119
|
+
console.log('保存结果:', await app_state.save());
|
|
120
|
+
|
|
121
|
+
// 落盘内容即下次启动时自动加载的数据(JSON5 格式)
|
|
122
|
+
const file = path.join(TMP_DIR, `${scope}.json`);
|
|
123
|
+
console.log('持久化文件:', file);
|
|
124
|
+
console.log('文件内容:', fs.readFileSync(file, 'utf8'));
|
|
125
|
+
console.log('创建同名作用域实例时会自动加载该文件');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 主流程
|
|
130
|
+
* @returns {Promise<void>}
|
|
131
|
+
*/
|
|
132
|
+
async function main() {
|
|
133
|
+
await demoScope();
|
|
134
|
+
await demoExpire();
|
|
135
|
+
await demoList();
|
|
136
|
+
await demoPersist();
|
|
137
|
+
|
|
138
|
+
for (const state of states) {
|
|
139
|
+
await state.dispose();
|
|
140
|
+
}
|
|
141
|
+
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
|
142
|
+
console.log('\n示例执行完成');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
main().catch((err) => {
|
|
146
|
+
console.error(err);
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
});
|
package/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const Admin = require('mm_corejs').Admin;
|
|
2
|
+
const State = require('./lib/state.js');
|
|
3
|
+
|
|
4
|
+
const admin = new Admin();
|
|
5
|
+
admin.setClass(State);
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
State,
|
|
9
|
+
admin,
|
|
10
|
+
/**
|
|
11
|
+
* 获取指定作用域的状态存储实例,不存在时创建
|
|
12
|
+
* @param {string} [scope] 作用域名称,缺省时使用 os
|
|
13
|
+
* @param {object} [config] 配置对象,如 { cachebase: { persist: true, dir: './cache' } }
|
|
14
|
+
* @returns {State} 状态存储实例
|
|
15
|
+
*/
|
|
16
|
+
stateAdmin(scope, config) {
|
|
17
|
+
if (!scope) {
|
|
18
|
+
scope = 'os';
|
|
19
|
+
}
|
|
20
|
+
return admin.get(scope, { ...(config || {}), scope });
|
|
21
|
+
}
|
|
22
|
+
};
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const { Cache } = require('mm_cache');
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SCOPE = 'os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 状态存储类,以对象标识为键缓存状态,支持过期时间与持久化。
|
|
7
|
+
* 存取均为引用,不做深拷贝,可直接存放连接句柄等不可克隆对象。
|
|
8
|
+
* @class
|
|
9
|
+
* @augments {Cache}
|
|
10
|
+
*/
|
|
11
|
+
class State extends Cache {
|
|
12
|
+
/**
|
|
13
|
+
* 构造函数
|
|
14
|
+
* @param {object} [config] 配置对象
|
|
15
|
+
* @param {string} [config.scope] 作用域名称,缺省时使用 os
|
|
16
|
+
* @param {string} [config.way] 缓存方式,默认 cachebase(纯内存,可开启持久化)
|
|
17
|
+
* @param {object} [config.cachebase] 持久化配置,如 { persist: true, dir: './cache', save_interval: 30000 }
|
|
18
|
+
*/
|
|
19
|
+
constructor(config = {}) {
|
|
20
|
+
const scope = config.scope || config.name || DEFAULT_SCOPE;
|
|
21
|
+
const cachebase_config = { ...(config.cachebase || {}), scope };
|
|
22
|
+
super({
|
|
23
|
+
way: 'cachebase',
|
|
24
|
+
...config,
|
|
25
|
+
scope,
|
|
26
|
+
cachebase: cachebase_config
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 校验键名,必须是非空字符串
|
|
32
|
+
* @param {*} key 对象标识
|
|
33
|
+
* @returns {void}
|
|
34
|
+
* @throws {TypeError} 键名不是非空字符串时抛出
|
|
35
|
+
*/
|
|
36
|
+
_checkKey(key) {
|
|
37
|
+
if (!key || typeof key !== 'string') {
|
|
38
|
+
throw new TypeError('键名必须是非空字符串');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 校验状态值,必须是对象
|
|
44
|
+
* @param {*} value 状态值
|
|
45
|
+
* @returns {void}
|
|
46
|
+
* @throws {TypeError} 状态值不是对象时抛出
|
|
47
|
+
*/
|
|
48
|
+
_checkValue(value) {
|
|
49
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
50
|
+
throw new TypeError('状态值必须是对象');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 设置指定对象的状态,按引用保存,不做深拷贝
|
|
56
|
+
* @param {string} key 对象标识
|
|
57
|
+
* @param {object} value 状态对象
|
|
58
|
+
* @param {number} [seconds] 过期时间(秒),0 表示永不过期
|
|
59
|
+
* @returns {Promise<boolean>} 是否设置成功
|
|
60
|
+
*/
|
|
61
|
+
async set(key, value, seconds = 0) {
|
|
62
|
+
this._checkKey(key);
|
|
63
|
+
this._checkValue(value);
|
|
64
|
+
return await super.set(key, value, seconds);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 获取指定对象的状态,返回缓存中的同一引用
|
|
69
|
+
* @param {string} key 对象标识
|
|
70
|
+
* @returns {Promise<object|null>} 状态对象,不存在或已过期时返回 null
|
|
71
|
+
*/
|
|
72
|
+
async get(key) {
|
|
73
|
+
this._checkKey(key);
|
|
74
|
+
return await super.get(key);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 删除指定对象的状态
|
|
79
|
+
* @param {string} key 对象标识
|
|
80
|
+
* @returns {Promise<number>} 删除数量
|
|
81
|
+
*/
|
|
82
|
+
async del(key) {
|
|
83
|
+
this._checkKey(key);
|
|
84
|
+
return await super.del(key);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 判断指定对象的状态是否存在
|
|
89
|
+
* @param {string} key 对象标识
|
|
90
|
+
* @returns {Promise<boolean>} 是否存在
|
|
91
|
+
*/
|
|
92
|
+
async has(key) {
|
|
93
|
+
this._checkKey(key);
|
|
94
|
+
return Boolean(await super.has(key));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 获取指定对象状态的剩余过期时间
|
|
99
|
+
* @param {string} key 对象标识
|
|
100
|
+
* @returns {Promise<number>} 剩余秒数,-1 表示永不过期,-2 表示不存在
|
|
101
|
+
*/
|
|
102
|
+
async ttl(key) {
|
|
103
|
+
this._checkKey(key);
|
|
104
|
+
return await super.ttl(key);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 获取当前作用域下的全部对象标识
|
|
109
|
+
* @param {string} [pattern] 匹配模式,支持 * 通配符
|
|
110
|
+
* @returns {Promise<string[]>} 对象标识数组
|
|
111
|
+
*/
|
|
112
|
+
async keys(pattern = '*') {
|
|
113
|
+
const prefix = `${this.scope}:`;
|
|
114
|
+
const keys = await super.keys(pattern);
|
|
115
|
+
return keys.map((key) => (key.startsWith(prefix) ? key.slice(prefix.length) : key));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 获取当前作用域下的全部状态(值为缓存中的引用)
|
|
120
|
+
* @returns {Promise<object>} 以对象标识为键的状态表
|
|
121
|
+
*/
|
|
122
|
+
async list() {
|
|
123
|
+
const keys = await this.keys();
|
|
124
|
+
const result = {};
|
|
125
|
+
for (const key of keys) {
|
|
126
|
+
result[key] = await this.get(key);
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 清空当前作用域下的全部状态
|
|
133
|
+
* @returns {Promise<boolean>} 是否清空成功
|
|
134
|
+
*/
|
|
135
|
+
async clear() {
|
|
136
|
+
const keys = await this.keys();
|
|
137
|
+
for (const key of keys) {
|
|
138
|
+
await super.del(key);
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 将当前作用域的数据写入持久化文件,仅在启用 persist 时生效
|
|
145
|
+
* @returns {Promise<boolean>} 是否执行了保存
|
|
146
|
+
*/
|
|
147
|
+
async save() {
|
|
148
|
+
if (!this._client) {
|
|
149
|
+
await this.connect();
|
|
150
|
+
}
|
|
151
|
+
if (!this._client || typeof this._client.save !== 'function') {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (!this._client.config || !this._client.config.persist) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
this._client.save();
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = State;
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mm_state_store",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "状态存储模块:以对象标识为键缓存状态对象,支持过期时间与持久化",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://gitee.com/qiuwenwu91/mm_state_store.git"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"mm_mod",
|
|
12
|
+
"mm_infra",
|
|
13
|
+
"mm_data",
|
|
14
|
+
"state",
|
|
15
|
+
"状态",
|
|
16
|
+
"缓存"
|
|
17
|
+
],
|
|
18
|
+
"layer": "infra",
|
|
19
|
+
"group": "data",
|
|
20
|
+
"author": "邱文武",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18.0.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "jest --coverage",
|
|
27
|
+
"demo": "node example/index.js"
|
|
28
|
+
},
|
|
29
|
+
"jest": {
|
|
30
|
+
"collectCoverageFrom": [
|
|
31
|
+
"index.js",
|
|
32
|
+
"lib/**/*.js"
|
|
33
|
+
],
|
|
34
|
+
"coverageThreshold": {
|
|
35
|
+
"global": {
|
|
36
|
+
"lines": 100,
|
|
37
|
+
"branches": 100,
|
|
38
|
+
"functions": 100,
|
|
39
|
+
"statements": 100
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"mm_corejs": "^1.1.4",
|
|
45
|
+
"mm_cache": "^1.5.5"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"jest": "^30.5.2"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { Cache } = require('mm_cache');
|
|
7
|
+
const { admin: cachebase_admin } = require('mm_cachebase');
|
|
8
|
+
const mod = require('../index');
|
|
9
|
+
const State = require('../lib/state');
|
|
10
|
+
|
|
11
|
+
/** 用例数据:用户状态 */
|
|
12
|
+
const user_1001 = {
|
|
13
|
+
account: { uid: '1001', nickname: '张三', mobile: '13800000001' },
|
|
14
|
+
power: ['user:read', 'user:write']
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** 用例数据:用户状态(覆盖后) */
|
|
18
|
+
const user_1001_new = {
|
|
19
|
+
account: { uid: '1001', nickname: '张三丰', mobile: '13800000001' },
|
|
20
|
+
power: ['user:read']
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** 用例数据:设备状态 */
|
|
24
|
+
const device_d01 = { platform: 'ios', online: true };
|
|
25
|
+
|
|
26
|
+
/** 临时目录,用于持久化用例 */
|
|
27
|
+
let tmp_dir;
|
|
28
|
+
|
|
29
|
+
/** 待释放的实例,避免定时器阻止进程退出 */
|
|
30
|
+
const states = [];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 创建状态实例并登记,便于统一释放
|
|
34
|
+
* @param {string} scope 作用域
|
|
35
|
+
* @param {object} [config] 配置对象
|
|
36
|
+
* @returns {State} 状态实例
|
|
37
|
+
*/
|
|
38
|
+
function createState(scope, config) {
|
|
39
|
+
const state = new State({ scope, ...(config || {}) });
|
|
40
|
+
states.push(state);
|
|
41
|
+
return state;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 延时指定毫秒
|
|
46
|
+
* @param {number} ms 毫秒数
|
|
47
|
+
* @returns {Promise<void>}
|
|
48
|
+
*/
|
|
49
|
+
function sleep(ms) {
|
|
50
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeAll(() => {
|
|
54
|
+
tmp_dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mm-state-'));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
for (const state of states) {
|
|
59
|
+
await state.dispose();
|
|
60
|
+
}
|
|
61
|
+
cachebase_admin.clear();
|
|
62
|
+
fs.rmSync(tmp_dir, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('mm_state_store 模块入口', () => {
|
|
66
|
+
it('导出 State 类、admin 与 stateAdmin 函数', () => {
|
|
67
|
+
expect(mod.State).toBe(State);
|
|
68
|
+
expect(mod.admin).toBeDefined();
|
|
69
|
+
expect(typeof mod.stateAdmin).toBe('function');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('State 继承 Cache,默认缓存方式为 cachebase', () => {
|
|
73
|
+
const state = createState('export_state');
|
|
74
|
+
expect(state).toBeInstanceOf(Cache);
|
|
75
|
+
expect(state.getCacheWay()).toBe('cachebase');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('未提供 scope 时使用 os', () => {
|
|
79
|
+
const state = new State();
|
|
80
|
+
states.push(state);
|
|
81
|
+
expect(state.scope).toBe('os');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('未提供 scope 时回退到 name', () => {
|
|
85
|
+
const state = new State({ name: 'named_state' });
|
|
86
|
+
states.push(state);
|
|
87
|
+
expect(state.scope).toBe('named_state');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('stateAdmin 缺省作用域为 os 并复用实例', () => {
|
|
91
|
+
const state = mod.stateAdmin();
|
|
92
|
+
states.push(state);
|
|
93
|
+
expect(state.scope).toBe('os');
|
|
94
|
+
expect(mod.stateAdmin()).toBe(state);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('stateAdmin 按作用域复用实例,不同作用域彼此隔离', () => {
|
|
98
|
+
const state = mod.stateAdmin('factory_state');
|
|
99
|
+
const other = mod.stateAdmin('factory_other');
|
|
100
|
+
states.push(state, other);
|
|
101
|
+
|
|
102
|
+
expect(state).toBeInstanceOf(State);
|
|
103
|
+
expect(mod.stateAdmin('factory_state')).toBe(state);
|
|
104
|
+
expect(other).not.toBe(state);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('基本读写', () => {
|
|
109
|
+
it('set 后可 get,has 为 true', async () => {
|
|
110
|
+
const state = createState('basic_state');
|
|
111
|
+
|
|
112
|
+
expect(await state.set('1001', user_1001)).toBe(true);
|
|
113
|
+
expect(await state.get('1001')).toEqual(user_1001);
|
|
114
|
+
expect(await state.has('1001')).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('不存在的键 get 返回 null,has 返回 false', async () => {
|
|
118
|
+
const state = createState('basic_missing');
|
|
119
|
+
|
|
120
|
+
expect(await state.get('1002')).toBeNull();
|
|
121
|
+
expect(await state.has('1002')).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('允许覆盖已有状态', async () => {
|
|
125
|
+
const state = createState('basic_overwrite');
|
|
126
|
+
await state.set('1001', user_1001);
|
|
127
|
+
|
|
128
|
+
expect(await state.set('1001', user_1001_new)).toBe(true);
|
|
129
|
+
expect(await state.get('1001')).toEqual(user_1001_new);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('del 返回删除数量', async () => {
|
|
133
|
+
const state = createState('basic_del');
|
|
134
|
+
await state.set('1001', user_1001);
|
|
135
|
+
|
|
136
|
+
expect(await state.del('1001')).toBe(1);
|
|
137
|
+
expect(await state.del('1001')).toBe(0);
|
|
138
|
+
expect(await state.has('1001')).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('参数校验', () => {
|
|
143
|
+
it('键名必须是非空字符串', async () => {
|
|
144
|
+
const state = createState('validate_key');
|
|
145
|
+
|
|
146
|
+
await expect(state.set('', {})).rejects.toThrow('键名必须是非空字符串');
|
|
147
|
+
await expect(state.set(123, {})).rejects.toThrow('键名必须是非空字符串');
|
|
148
|
+
await expect(state.get(null)).rejects.toThrow('键名必须是非空字符串');
|
|
149
|
+
await expect(state.del(undefined)).rejects.toThrow('键名必须是非空字符串');
|
|
150
|
+
await expect(state.has(0)).rejects.toThrow('键名必须是非空字符串');
|
|
151
|
+
await expect(state.ttl(undefined)).rejects.toThrow('键名必须是非空字符串');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('状态值必须是对象', async () => {
|
|
155
|
+
const state = createState('validate_value');
|
|
156
|
+
|
|
157
|
+
await expect(state.set('1001', null)).rejects.toThrow('状态值必须是对象');
|
|
158
|
+
await expect(state.set('1001', undefined)).rejects.toThrow('状态值必须是对象');
|
|
159
|
+
await expect(state.set('1001', 1)).rejects.toThrow('状态值必须是对象');
|
|
160
|
+
await expect(state.set('1001', 'ios')).rejects.toThrow('状态值必须是对象');
|
|
161
|
+
await expect(state.set('1001', true)).rejects.toThrow('状态值必须是对象');
|
|
162
|
+
await expect(state.set('1001', [1, 2])).rejects.toThrow('状态值必须是对象');
|
|
163
|
+
expect(await state.has('1001')).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('允许设置空对象', async () => {
|
|
167
|
+
const state = createState('validate_empty');
|
|
168
|
+
expect(await state.set('1001', {})).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('引用语义', () => {
|
|
173
|
+
it('写入后修改外部对象会同步反映到缓存', async () => {
|
|
174
|
+
const state = createState('ref_set');
|
|
175
|
+
const source = { account: { uid: '3001', nickname: '王五' }, power: ['user:read'] };
|
|
176
|
+
|
|
177
|
+
await state.set('3001', source);
|
|
178
|
+
source.account.nickname = '改动后';
|
|
179
|
+
source.power.push('user:admin');
|
|
180
|
+
|
|
181
|
+
const fetched = await state.get('3001');
|
|
182
|
+
expect(fetched).toBe(source);
|
|
183
|
+
expect(fetched.account.nickname).toBe('改动后');
|
|
184
|
+
expect(fetched.power).toEqual(['user:read', 'user:admin']);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('get 返回缓存中的同一引用', async () => {
|
|
188
|
+
const state = createState('ref_get');
|
|
189
|
+
const source = { account: { uid: '3001', nickname: '王五' } };
|
|
190
|
+
|
|
191
|
+
await state.set('3001', source);
|
|
192
|
+
|
|
193
|
+
expect(await state.get('3001')).toBe(source);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('可存放含不可克隆句柄的对象', async () => {
|
|
197
|
+
const state = createState('ref_handle');
|
|
198
|
+
const handle = { ws: { readyState: 1 }, queue: Promise.resolve() };
|
|
199
|
+
|
|
200
|
+
expect(await state.set('c1', handle)).toBe(true);
|
|
201
|
+
|
|
202
|
+
const fetched = await state.get('c1');
|
|
203
|
+
expect(fetched.ws).toBe(handle.ws);
|
|
204
|
+
expect(fetched.queue).toBe(handle.queue);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('list 返回的状态值为缓存中的引用', async () => {
|
|
208
|
+
const state = createState('ref_list');
|
|
209
|
+
const source = { account: { uid: '3001', nickname: '王五' } };
|
|
210
|
+
await state.set('3001', source);
|
|
211
|
+
|
|
212
|
+
const all_states = await state.list();
|
|
213
|
+
expect(all_states['3001']).toBe(source);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('底层返回 undefined 时原样返回', async () => {
|
|
217
|
+
const state = createState('clone_abnormal');
|
|
218
|
+
await state.set('1001', user_1001);
|
|
219
|
+
|
|
220
|
+
const client_get = state._client.get;
|
|
221
|
+
state._client.get = async () => undefined;
|
|
222
|
+
expect(await state.get('1001')).toBeUndefined();
|
|
223
|
+
state._client.get = client_get;
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('键列表与作用域', () => {
|
|
228
|
+
it('keys 返回不含作用域前缀的键名,支持通配符', async () => {
|
|
229
|
+
const state = createState('list_state');
|
|
230
|
+
await state.set('1001', user_1001);
|
|
231
|
+
await state.set('d01', device_d01);
|
|
232
|
+
|
|
233
|
+
expect((await state.keys()).sort()).toEqual(['1001', 'd01']);
|
|
234
|
+
expect(await state.keys('100*')).toEqual(['1001']);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('list 返回键值状态表', async () => {
|
|
238
|
+
const state = createState('list_all');
|
|
239
|
+
await state.set('1001', user_1001);
|
|
240
|
+
await state.set('d01', device_d01);
|
|
241
|
+
|
|
242
|
+
expect(await state.list()).toEqual({ 1001: user_1001, d01: device_d01 });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('底层返回无前缀的键时原样保留', async () => {
|
|
246
|
+
const state = createState('raw_key_state');
|
|
247
|
+
await state.set('1001', user_1001);
|
|
248
|
+
|
|
249
|
+
const client_keys = state._client.keys;
|
|
250
|
+
state._client.keys = async () => ['raw_key_state:1001', 'raw_key'];
|
|
251
|
+
expect(await state.keys()).toEqual(['1001', 'raw_key']);
|
|
252
|
+
state._client.keys = client_keys;
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('空作用域的 keys 为数组、list 为空对象、ttl 为 -2', async () => {
|
|
256
|
+
const state = createState('empty_state');
|
|
257
|
+
|
|
258
|
+
expect(await state.keys()).toEqual([]);
|
|
259
|
+
expect(await state.list()).toEqual({});
|
|
260
|
+
expect(await state.ttl('1001')).toBe(-2);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('clear 仅清空当前作用域', async () => {
|
|
264
|
+
const state_a = createState('clear_a');
|
|
265
|
+
const state_b = createState('clear_b');
|
|
266
|
+
await state_a.set('1001', user_1001);
|
|
267
|
+
await state_b.set('1001', user_1001);
|
|
268
|
+
|
|
269
|
+
expect(await state_a.clear()).toBe(true);
|
|
270
|
+
expect(await state_a.list()).toEqual({});
|
|
271
|
+
expect(await state_b.keys()).toEqual(['1001']);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('不同作用域的状态互不影响', async () => {
|
|
275
|
+
const app_state = createState('app_scope');
|
|
276
|
+
const user_state = createState('user_scope');
|
|
277
|
+
await app_state.set('cfg', { version: '1.0.0' });
|
|
278
|
+
|
|
279
|
+
expect(await user_state.has('cfg')).toBe(false);
|
|
280
|
+
expect(await app_state.get('cfg')).toEqual({ version: '1.0.0' });
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('过期时间', () => {
|
|
285
|
+
it('永不过期返回 -1,过期后不可读且不在 keys 中', async () => {
|
|
286
|
+
const state = createState('ttl_state');
|
|
287
|
+
|
|
288
|
+
await state.set('1001', user_1001);
|
|
289
|
+
expect(await state.ttl('1001')).toBe(-1);
|
|
290
|
+
|
|
291
|
+
await state.set('1002', user_1001, 0.05);
|
|
292
|
+
expect(await state.ttl('1002')).toBe(1);
|
|
293
|
+
|
|
294
|
+
await sleep(80);
|
|
295
|
+
expect(await state.get('1002')).toBeNull();
|
|
296
|
+
expect(await state.has('1002')).toBe(false);
|
|
297
|
+
expect(await state.ttl('1002')).toBe(-2);
|
|
298
|
+
expect(await state.keys()).toEqual(['1001']);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('持久化', () => {
|
|
303
|
+
it('未启用 persist 时 save 返回 false', async () => {
|
|
304
|
+
const state = createState('memory_state');
|
|
305
|
+
await state.set('1001', user_1001);
|
|
306
|
+
|
|
307
|
+
expect(await state.save()).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('未连接即调用 save 时先连接,未启用 persist 返回 false', async () => {
|
|
311
|
+
const state = createState('fresh_save');
|
|
312
|
+
expect(await state.save()).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('save 的底层分支行为', async () => {
|
|
316
|
+
const state = createState('stub_save');
|
|
317
|
+
await state.set('1001', user_1001);
|
|
318
|
+
const client = state._client;
|
|
319
|
+
|
|
320
|
+
state._client = { config: { persist: true } };
|
|
321
|
+
expect(await state.save()).toBe(false);
|
|
322
|
+
|
|
323
|
+
state._client = { save: () => true, config: null };
|
|
324
|
+
expect(await state.save()).toBe(false);
|
|
325
|
+
|
|
326
|
+
let saved_count = 0;
|
|
327
|
+
state._client = {
|
|
328
|
+
save: () => {
|
|
329
|
+
saved_count += 1;
|
|
330
|
+
},
|
|
331
|
+
config: { persist: true }
|
|
332
|
+
};
|
|
333
|
+
expect(await state.save()).toBe(true);
|
|
334
|
+
expect(saved_count).toBe(1);
|
|
335
|
+
|
|
336
|
+
state._client = client;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('保存落盘后可重新加载', async () => {
|
|
340
|
+
const scope = 'persist_state';
|
|
341
|
+
const config = { cachebase: { persist: true, dir: tmp_dir, auto_save: false } };
|
|
342
|
+
|
|
343
|
+
const state = createState(scope, config);
|
|
344
|
+
await state.set('1001', user_1001);
|
|
345
|
+
expect(await state.save()).toBe(true);
|
|
346
|
+
expect(fs.existsSync(path.join(tmp_dir, `${scope}.json`))).toBe(true);
|
|
347
|
+
|
|
348
|
+
await state.dispose();
|
|
349
|
+
cachebase_admin.clear();
|
|
350
|
+
|
|
351
|
+
const loaded = createState(scope, config);
|
|
352
|
+
expect(await loaded.get('1001')).toEqual(user_1001);
|
|
353
|
+
expect(await loaded.save()).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('同作用域再次调用 stateAdmin 时沿用首次配置', async () => {
|
|
357
|
+
const scope = 'factory_config';
|
|
358
|
+
const config = { cachebase: { persist: true, dir: tmp_dir, auto_save: false } };
|
|
359
|
+
const state = mod.stateAdmin(scope, config);
|
|
360
|
+
states.push(state);
|
|
361
|
+
await state.set('1001', user_1001);
|
|
362
|
+
|
|
363
|
+
const again = mod.stateAdmin(scope, { cachebase: { persist: false } });
|
|
364
|
+
expect(again).toBe(state);
|
|
365
|
+
expect(await again.get('1001')).toEqual(user_1001);
|
|
366
|
+
expect(await again.save()).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('持久化文件损坏时降级为空状态并可通过保存覆盖', async () => {
|
|
370
|
+
const scope = 'corrupt_state';
|
|
371
|
+
const config = { cachebase: { persist: true, dir: tmp_dir, auto_save: false } };
|
|
372
|
+
const file = path.join(tmp_dir, `${scope}.json`);
|
|
373
|
+
fs.writeFileSync(file, '{ 这不是合法的 JSON', 'utf8');
|
|
374
|
+
|
|
375
|
+
// 损坏文件的加载错误属于预期行为,屏蔽底层错误日志
|
|
376
|
+
const error_spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
377
|
+
try {
|
|
378
|
+
const state = createState(scope, config);
|
|
379
|
+
expect(await state.list()).toEqual({});
|
|
380
|
+
expect(await state.set('1001', user_1001)).toBe(true);
|
|
381
|
+
expect(await state.save()).toBe(true);
|
|
382
|
+
|
|
383
|
+
await state.dispose();
|
|
384
|
+
cachebase_admin.clear();
|
|
385
|
+
|
|
386
|
+
const reloaded = createState(scope, config);
|
|
387
|
+
expect(await reloaded.get('1001')).toEqual(user_1001);
|
|
388
|
+
} finally {
|
|
389
|
+
error_spy.mockRestore();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
});
|