@hile/micro-dynamic-configs 1.0.3 → 2.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 +85 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/server.d.ts +1 -4
- package/dist/server.js +13 -54
- package/package.json +4 -4
- package/SKILL.md +0 -232
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @hile/micro-dynamic-configs
|
|
2
|
+
|
|
3
|
+
Dynamic configuration server for @hile/micro. Stores config in Redis, validates with Zod, and pushes changes to subscribers via micro's built-in pub/sub.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { Application } from '@hile/micro';
|
|
9
|
+
import { MicroDynamicConfigsServer } from '@hile/micro-dynamic-configs';
|
|
10
|
+
import Redis from 'ioredis';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
|
|
13
|
+
const schema = z.object({
|
|
14
|
+
name: z.string().default(''),
|
|
15
|
+
port: z.number().default(8080),
|
|
16
|
+
debug: z.boolean().default(false),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const app = new Application({
|
|
20
|
+
namespace: 'config-svc',
|
|
21
|
+
registry: { host: '127.0.0.1', port: 6379 },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const redis = new Redis({ host: '127.0.0.1', port: 6379 });
|
|
25
|
+
|
|
26
|
+
const configs = new MicroDynamicConfigsServer({
|
|
27
|
+
app,
|
|
28
|
+
redis,
|
|
29
|
+
schema,
|
|
30
|
+
redis_key: 'my-app:config',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await configs.initialize();
|
|
34
|
+
|
|
35
|
+
// Read current value
|
|
36
|
+
console.log(configs.value); // { name: '', port: 8080, debug: false }
|
|
37
|
+
|
|
38
|
+
// Update and persist — subscribers receive push
|
|
39
|
+
await configs.save({ name: 'production', port: 9090 });
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Topic Convention
|
|
43
|
+
|
|
44
|
+
Each schema field publishes to a separate topic:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
{namespace}:{field}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For example, with namespace `config-svc` and schema fields `name`, `port`, `debug`:
|
|
51
|
+
|
|
52
|
+
- `config-svc:name`
|
|
53
|
+
- `config-svc:port`
|
|
54
|
+
- `config-svc:debug`
|
|
55
|
+
|
|
56
|
+
## Subscribing
|
|
57
|
+
|
|
58
|
+
Use `app.subscribe()` directly on any micro Application:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const values: Record<string, any> = {};
|
|
62
|
+
const unsub = await app.subscribe('config-svc:name', (v) => values.name = v);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Local Events
|
|
66
|
+
|
|
67
|
+
The server emits `change:{field}` events locally:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
configs.on('change:name', (newValue, oldValue) => {
|
|
71
|
+
console.log(`name changed from ${oldValue} to ${newValue}`);
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Persistence
|
|
76
|
+
|
|
77
|
+
Config is persisted to Redis on every `save()`. On `initialize()`, the server loads the last saved state from Redis. Schema defaults apply when no value exists.
|
|
78
|
+
|
|
79
|
+
## Teardown
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const teardown = await configs.initialize();
|
|
83
|
+
// Later:
|
|
84
|
+
await teardown(); // unpublishes all topics and cleans up listeners
|
|
85
|
+
```
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/server.d.ts
CHANGED
|
@@ -4,21 +4,18 @@ import { Redis } from "ioredis";
|
|
|
4
4
|
import { EventEmitter } from 'node:events';
|
|
5
5
|
export declare class MicroDynamicConfigsServer<T extends Application, Z extends ZodObject<ZodRawShape>> extends EventEmitter {
|
|
6
6
|
private _value;
|
|
7
|
-
private readonly stacks;
|
|
8
7
|
private readonly app;
|
|
9
8
|
private readonly schema;
|
|
10
9
|
private readonly redis;
|
|
11
10
|
private readonly redis_key;
|
|
11
|
+
private readonly publishers;
|
|
12
12
|
constructor(options: {
|
|
13
13
|
app: T;
|
|
14
14
|
redis: Redis;
|
|
15
15
|
schema: Z;
|
|
16
16
|
redis_key: string;
|
|
17
17
|
});
|
|
18
|
-
private onClientDisconnect;
|
|
19
18
|
get value(): z.core.output<Z>;
|
|
20
19
|
initialize(): Promise<() => Promise<void>>;
|
|
21
|
-
private registerSubscribe;
|
|
22
|
-
private registerUnsubscribe;
|
|
23
20
|
save(value: Partial<z.infer<Z>>): Promise<number>;
|
|
24
21
|
}
|
package/dist/server.js
CHANGED
|
@@ -2,11 +2,11 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
export class MicroDynamicConfigsServer extends EventEmitter {
|
|
4
4
|
_value;
|
|
5
|
-
stacks = new Map();
|
|
6
5
|
app;
|
|
7
6
|
schema;
|
|
8
7
|
redis;
|
|
9
8
|
redis_key;
|
|
9
|
+
publishers = new Map();
|
|
10
10
|
constructor(options) {
|
|
11
11
|
super();
|
|
12
12
|
this.app = options.app;
|
|
@@ -14,32 +14,7 @@ export class MicroDynamicConfigsServer extends EventEmitter {
|
|
|
14
14
|
this.redis = options.redis;
|
|
15
15
|
this.redis_key = options.redis_key;
|
|
16
16
|
this._value = this.schema.parse({});
|
|
17
|
-
for (const key of Object.keys(this.schema.shape)) {
|
|
18
|
-
this.stacks.set(key, new Set());
|
|
19
|
-
this.on('change:' + key, (newValue, oldValue) => {
|
|
20
|
-
if (this.stacks.has(key)) {
|
|
21
|
-
const pool = this.stacks.get(key);
|
|
22
|
-
for (const client of pool) {
|
|
23
|
-
if (this.app.clients.has(client)) {
|
|
24
|
-
this.app.clients.get(client).push('/-/dynamic-configs/change', {
|
|
25
|
-
key, newValue, oldValue,
|
|
26
|
-
namespace: this.app.namespace,
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
this.registerSubscribe();
|
|
34
|
-
this.registerUnsubscribe();
|
|
35
|
-
this.app.events.on('disconnect', this.onClientDisconnect);
|
|
36
17
|
}
|
|
37
|
-
onClientDisconnect = (client) => {
|
|
38
|
-
const key = client.host + ':' + client.port;
|
|
39
|
-
for (const [, pool] of this.stacks) {
|
|
40
|
-
pool.delete(key);
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
18
|
get value() {
|
|
44
19
|
return this._value;
|
|
45
20
|
}
|
|
@@ -50,38 +25,19 @@ export class MicroDynamicConfigsServer extends EventEmitter {
|
|
|
50
25
|
this._value = this.schema.parse(JSON.parse(value));
|
|
51
26
|
}
|
|
52
27
|
}
|
|
28
|
+
const keys = Object.keys(this.schema.shape);
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
const publisher = await this.app.publish(`${this.app.namespace}:${key}`, this._value[key]);
|
|
31
|
+
this.publishers.set(key, publisher);
|
|
32
|
+
}
|
|
53
33
|
return async () => {
|
|
34
|
+
for (const publisher of this.publishers.values()) {
|
|
35
|
+
await publisher.unpublish();
|
|
36
|
+
}
|
|
37
|
+
this.publishers.clear();
|
|
54
38
|
this.removeAllListeners();
|
|
55
|
-
this.app.events.off('disconnect', this.onClientDisconnect);
|
|
56
|
-
this.stacks.clear();
|
|
57
39
|
};
|
|
58
40
|
}
|
|
59
|
-
registerSubscribe() {
|
|
60
|
-
this.app.register('/-/dynamic-configs/subscribe', async ({ data, client }) => {
|
|
61
|
-
const out = {};
|
|
62
|
-
for (const key of data) {
|
|
63
|
-
if (this.stacks.has(key)) {
|
|
64
|
-
this.stacks.get(key).add(client.host + ':' + client.port);
|
|
65
|
-
out[key] = this._value[key];
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return out;
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
registerUnsubscribe() {
|
|
72
|
-
this.app.register('/-/dynamic-configs/unsubscribe', async ({ data, client }) => {
|
|
73
|
-
for (const key of data) {
|
|
74
|
-
if (this.stacks.has(key)) {
|
|
75
|
-
const pool = this.stacks.get(key);
|
|
76
|
-
const _key = client.host + ':' + client.port;
|
|
77
|
-
if (pool.has(_key)) {
|
|
78
|
-
pool.delete(_key);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return Date.now();
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
41
|
async save(value) {
|
|
86
42
|
const keys = Object.keys(value);
|
|
87
43
|
if (!keys.length)
|
|
@@ -112,6 +68,9 @@ export class MicroDynamicConfigsServer extends EventEmitter {
|
|
|
112
68
|
this._value[key] = parsed;
|
|
113
69
|
}
|
|
114
70
|
for (const { key, parsed: newValue, oldValue } of entries) {
|
|
71
|
+
if (this.publishers.has(key)) {
|
|
72
|
+
await this.publishers.get(key).update(newValue);
|
|
73
|
+
}
|
|
115
74
|
this.emit('change:' + key, newValue, oldValue);
|
|
116
75
|
}
|
|
117
76
|
return entries.length;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hile/micro-dynamic-configs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
"vitest": "^4.0.18"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@hile/ioredis": "^
|
|
26
|
-
"@hile/micro": "^
|
|
25
|
+
"@hile/ioredis": "^2.0.0",
|
|
26
|
+
"@hile/micro": "^2.0.1",
|
|
27
27
|
"ioredis": "^5.10.0",
|
|
28
28
|
"zod": "^4.4.3"
|
|
29
29
|
},
|
|
30
|
-
"gitHead": "
|
|
30
|
+
"gitHead": "8e0fd1f78b5a8abd21218d1f596ada2533a0c8e7"
|
|
31
31
|
}
|
package/SKILL.md
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: micro-dynamic-configs
|
|
3
|
-
description: Code generation and contribution rules for @hile/micro-dynamic-configs. Use when editing this package or when the user asks about dynamic config patterns or API.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# @hile/micro-dynamic-configs
|
|
7
|
-
|
|
8
|
-
面向 AI 编码模型与维护者的代码生成规范。阅读后应能正确使用本库编写符合架构规则的代码。
|
|
9
|
-
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
## 1. 架构总览
|
|
13
|
-
|
|
14
|
-
三个角色,通过 `@hile/micro` 的 WebSocket 通信:
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
APP_3 (Publisher) APP_1 (Config Server) APP_2 (Subscriber)
|
|
18
|
-
│ │ │
|
|
19
|
-
│ save({ key: val }) │ │
|
|
20
|
-
├──────────────────────────► │ │
|
|
21
|
-
│ │ push /-/dynamic-configs/ │
|
|
22
|
-
│ │ change { key, newValue } │
|
|
23
|
-
│ ├─────────────────────────────►│
|
|
24
|
-
│ │ │
|
|
25
|
-
│ │◄── subscribe(fields) ───────┤
|
|
26
|
-
│ │───── initial values ────────►│
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
- **Config Server** (`MicroDynamicConfigsServer`) — 持有配置数据,管理订阅者列表,推送变更
|
|
30
|
-
- **Subscriber** (`MicroDynamicConfigClients`) — 订阅某个 namespace 的配置字段,接收实时推送
|
|
31
|
-
- **Publisher** — 调用 `server.save()` 修改配置,数据流向是:validate → Redis 持久化 → 内存更新 → 推送通知
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
## 2. 类型签名
|
|
36
|
-
|
|
37
|
-
### MicroDynamicConfigsServer
|
|
38
|
-
|
|
39
|
-
```typescript
|
|
40
|
-
class MicroDynamicConfigsServer<
|
|
41
|
-
T extends Application,
|
|
42
|
-
Z extends ZodObject<ZodRawShape>
|
|
43
|
-
> extends EventEmitter
|
|
44
|
-
|
|
45
|
-
constructor(options: {
|
|
46
|
-
app: T // Application 实例
|
|
47
|
-
redis: Redis // ioredis 实例
|
|
48
|
-
schema: Z // Zod schema 定义配置结构
|
|
49
|
-
redis_key: string // Redis 存储键名
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
// 从 Redis 加载持久化数据,返回 teardown 函数
|
|
53
|
-
initialize(): Promise<() => void>
|
|
54
|
-
|
|
55
|
-
// 持久化并推送变更
|
|
56
|
-
save(value: Partial<z.infer<Z>>): Promise<number>
|
|
57
|
-
|
|
58
|
-
// 当前配置快照(只读)
|
|
59
|
-
get value(): z.infer<Z>
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
### MicroDynamicConfigClients
|
|
63
|
-
|
|
64
|
-
```typescript
|
|
65
|
-
class MicroDynamicConfigClients
|
|
66
|
-
|
|
67
|
-
constructor(app: Application)
|
|
68
|
-
|
|
69
|
-
subscribe<T extends Record<string, any>>(
|
|
70
|
-
namespace: string,
|
|
71
|
-
fields: (keyof T)[]
|
|
72
|
-
): Promise<T>
|
|
73
|
-
|
|
74
|
-
close(): Promise<void>
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
### DynamicConfigClient (通常不直接使用)
|
|
78
|
-
|
|
79
|
-
```typescript
|
|
80
|
-
class DynamicConfigClient<T extends Record<string, any>>
|
|
81
|
-
|
|
82
|
-
getValue(): Promise<T>
|
|
83
|
-
close(): Promise<void>
|
|
84
|
-
setValue<K extends keyof T>(k: K, v: T[K]): this // 由 push handler 调用
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
---
|
|
88
|
-
|
|
89
|
-
## 3. 通信协议
|
|
90
|
-
|
|
91
|
-
### 路由端点
|
|
92
|
-
|
|
93
|
-
| 方向 | 路径 | 数据 | 返回 |
|
|
94
|
-
|------|------|------|------|
|
|
95
|
-
| Subscriber → Server | `/-/dynamic-configs/subscribe` | `string[]` (字段名) | `Record<string, any>` (字段值) |
|
|
96
|
-
| Subscriber → Server | `/-/dynamic-configs/unsubscribe` | `string[]` (字段名) | `number` (timestamp) |
|
|
97
|
-
| Server → Subscriber | `/-/dynamic-configs/change` (push) | `{ key, newValue, oldValue, namespace }` | — |
|
|
98
|
-
|
|
99
|
-
### 订阅流程
|
|
100
|
-
|
|
101
|
-
1. 客户端通过 Registry 发现目标 namespace 的 `host:port`
|
|
102
|
-
2. 建立 WebSocket 连接
|
|
103
|
-
3. 发送 subscribe 请求,服务端注册并返回初始值
|
|
104
|
-
4. 服务端数据变更时,遍历 `stacks` 中该字段的订阅者,逐个 push
|
|
105
|
-
5. 客户端收到 push 后更新本地缓存
|
|
106
|
-
|
|
107
|
-
### 退订与断连
|
|
108
|
-
|
|
109
|
-
- 显式退订:调用 `close()` → 发送 unsubscribe 请求
|
|
110
|
-
- 被动断连:WebSocket close → 服务端 `onClientDisconnect` 清理所有 `stacks`
|
|
111
|
-
- 客户端断连后 `_status = 0`,下次 `getValue()` 重新走完整订阅流程
|
|
112
|
-
|
|
113
|
-
---
|
|
114
|
-
|
|
115
|
-
## 4. 代码生成强制规则
|
|
116
|
-
|
|
117
|
-
### 4.1 save() 必须分两轮执行
|
|
118
|
-
|
|
119
|
-
```typescript
|
|
120
|
-
// ✓ 正确:先 validate 再 apply
|
|
121
|
-
// Pass 1: validate & diff
|
|
122
|
-
const entries = [];
|
|
123
|
-
for (const key of keys) {
|
|
124
|
-
const parsed = schema.parse(value[key]);
|
|
125
|
-
if (!deepEqual(old, parsed)) entries.push({ key, parsed });
|
|
126
|
-
}
|
|
127
|
-
// Pass 2: persist → memory → emit
|
|
128
|
-
await redis.set(key, JSON.stringify(next));
|
|
129
|
-
update memory;
|
|
130
|
-
emit events;
|
|
131
|
-
|
|
132
|
-
// ✗ 禁止:validate 和 mutate 混在一轮
|
|
133
|
-
for (const key of keys) {
|
|
134
|
-
this._value[key] = parsed; // ❌ 后续字段失败时 _value 已脏
|
|
135
|
-
}
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
### 4.2 客户端状态机
|
|
139
|
-
|
|
140
|
-
```
|
|
141
|
-
_status: 0 (uninit) ──getValue()──→ 1 (loading)
|
|
142
|
-
↑ │
|
|
143
|
-
│ subscribe()
|
|
144
|
-
│ │
|
|
145
|
-
│ ┌────┴────┐
|
|
146
|
-
│ fail │ │ success
|
|
147
|
-
│ ┌────┘ └────┐
|
|
148
|
-
│ ↓ ↓
|
|
149
|
-
│ 0 (retry) 2 (ready)
|
|
150
|
-
│ │
|
|
151
|
-
│ disconnect │
|
|
152
|
-
│ ────────→ 0
|
|
153
|
-
│ close()
|
|
154
|
-
└────────────────────────────────── closed
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
- `close()` 设 `_closed = true`,pending subscribe 完成时检测标记并 dispose 连接
|
|
158
|
-
- `close()` 后 `getValue()` 直接 reject
|
|
159
|
-
|
|
160
|
-
### 4.3 生命周期
|
|
161
|
-
|
|
162
|
-
```typescript
|
|
163
|
-
// 服务端初始化
|
|
164
|
-
const teardown = await server.initialize();
|
|
165
|
-
// ... 运行 ...
|
|
166
|
-
teardown(); // 清理所有监听器和订阅
|
|
167
|
-
|
|
168
|
-
// 客户端创建
|
|
169
|
-
const configs = new MicroDynamicConfigClients(app);
|
|
170
|
-
const value = await configs.subscribe("ns", ["key1"]);
|
|
171
|
-
// ... 使用 ...
|
|
172
|
-
await configs.close(); // 清理所有订阅连接
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
---
|
|
176
|
-
|
|
177
|
-
## 5. 反模式(禁止)
|
|
178
|
-
|
|
179
|
-
### 5.1 save() 中字段校验失败后保留脏状态
|
|
180
|
-
|
|
181
|
-
```typescript
|
|
182
|
-
// ✗
|
|
183
|
-
for (const key of keys) {
|
|
184
|
-
this._value[key] = parsed; // 后续失败 → 脏数据
|
|
185
|
-
}
|
|
186
|
-
await redis.set(...);
|
|
187
|
-
|
|
188
|
-
// ✓ 先全部校验,再统一写入
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
### 5.2 close() 前不检查 pending subscribe
|
|
192
|
-
|
|
193
|
-
```typescript
|
|
194
|
-
// ✗ close() 不设标记,pending subscribe 完成导致 client 复活
|
|
195
|
-
async close() {
|
|
196
|
-
await this.unsubscribe();
|
|
197
|
-
this._client = undefined;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// ✓ close() 先设 _closed 标记
|
|
201
|
-
async close() {
|
|
202
|
-
this._closed = true;
|
|
203
|
-
await this.unsubscribe();
|
|
204
|
-
this._client = undefined;
|
|
205
|
-
}
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
### 5.3 多个 MicroDynamicConfigClients 实例共享同一 app
|
|
209
|
-
|
|
210
|
-
rou3 `dispatch()` 只执行第一个匹配的 handler,第二个实例的 `/-/dynamic-configs/change` 不会触发。一个 app 只应创建一个 `MicroDynamicConfigClients` 实例。
|
|
211
|
-
|
|
212
|
-
### 5.4 在 push handler 中做耗时的同步操作
|
|
213
|
-
|
|
214
|
-
push handler 在 message 调度线程中执行,不应包含耗时操作。`setValue()` 仅是内存写入,保持轻量。
|
|
215
|
-
|
|
216
|
-
---
|
|
217
|
-
|
|
218
|
-
## 6. 边界条件清单
|
|
219
|
-
|
|
220
|
-
- [ ] `save()` 传入空对象 `{}` — 返回 0,无操作
|
|
221
|
-
- [ ] `save()` 传入 schema 中不存在的字段 — 跳过,不报错
|
|
222
|
-
- [ ] `save()` 中单个字段校验失败 — 整个操作 reject,`_value` 不变
|
|
223
|
-
- [ ] `save()` 中所有字段值与当前一致 — 返回 0,不写 Redis 不推送
|
|
224
|
-
- [ ] `save()` Redis 写入失败 — throw,`_value` 不变
|
|
225
|
-
- [ ] `initialize()` 时 Redis 无数据 — 使用 schema 默认值
|
|
226
|
-
- [ ] `close()` 在 subscribe 完成前调用 — `_closed` 标记防止复活,新连接被 dispose
|
|
227
|
-
- [ ] `close()` 后调用 `getValue()` — 直接 reject
|
|
228
|
-
- [ ] 订阅不存在的字段 — 服务器静默跳过,不返回该字段
|
|
229
|
-
- [ ] 同一 namespace 重复 subscribe — 复用已有 client,`fields` 不会合并
|
|
230
|
-
- [ ] 断连后重新 subscribe — `_status = 0` → 全量重新拉取
|
|
231
|
-
- [ ] 服务端推送时订阅者已断连 — `has(client)` 检查跳过,等待 `onClientDisconnect` 清理
|
|
232
|
-
- [ ] 多个字段同时变更 — 一轮 `save()` 中多次 `change:key` 事件,订阅者逐个收到
|