monsqlize 1.1.8 → 1.2.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/CHANGELOG.md +3 -1
- package/lib/connect.js +99 -99
- package/lib/mongodb/common/agg-pipeline.js +4 -1
- package/lib/mongodb/queries/find-page.js +44 -4
- package/lib/multi-level-cache.js +39 -8
- package/lib/redis-cache-adapter.js +29 -0
- package/package.json +121 -121
- package/types/collection.ts +357 -357
- package/types/pagination.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 变更日志 (CHANGELOG)
|
|
2
2
|
|
|
3
3
|
> **说明**: 版本概览摘要,详细变更见 [changelogs/](./changelogs/) 目录
|
|
4
|
-
> **最后更新**: 2026-
|
|
4
|
+
> **最后更新**: 2026-04-13
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
| 版本 | 日期 | 变更摘要 | 详细 |
|
|
11
11
|
|------|------|---------|------|
|
|
12
|
+
| [v1.2.0](./changelogs/v1.2.0.md) | 2026-04-13 | 🐛 **Bug 修复 + 新功能**:`findPage` 正式支持 `projection` 投影参数(修复静默忽略问题)+ 有效投影策略自动保护游标排序字段 + 8 个测试用例 | [查看](./changelogs/v1.2.0.md) |
|
|
13
|
+
| [v1.1.9](./changelogs/v1.1.9.md) | 2026-04-02 | 🚨 **P1 Bug 修复**:MultiLevelCache L2→L1 回填 TTL 缺失(null 永久驻留 L1)+ 新增 `backfillLocalTTL` 配置 + Redis `getWithTTL` 方法 + 14 个回归测试 | [查看](./changelogs/v1.1.9.md) |
|
|
12
14
|
| [v1.1.8](./changelogs/v1.1.8.md) | 2026-03-16 | 🆕 **新功能**:Model 热重载支持(`undefine()` + `redefine()` + `_loadModels` reload 模式)+ 22个测试 (100%通过) | [查看](./changelogs/v1.1.8.md) |
|
|
13
15
|
| [v1.1.6](./changelogs/v1.1.6.md) | 2026-02-11 | 🎉 **重大功能**:精准缓存失效机制 + 🚨 upsert 缓存失效 Bug 修复 + 36个测试 (100%通过) | [查看](./changelogs/v1.1.6.md) |
|
|
14
16
|
| [v1.1.4](./changelogs/v1.1.4.md) | 2026-02-09 | 🎉 重大功能:通用函数缓存 - 52个测试 (100%通过) + 多层缓存 delPattern 修复 | [查看](./changelogs/v1.1.4.md) |
|
package/lib/connect.js
CHANGED
|
@@ -1,99 +1,99 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 数据库连接管理器
|
|
3
|
-
* 统一管理各种数据库的连接创建和实例化逻辑
|
|
4
|
-
*/
|
|
5
|
-
const Mongo = require("./mongodb");
|
|
6
|
-
|
|
7
|
-
module.exports = class ConnectionManager {
|
|
8
|
-
/**
|
|
9
|
-
* 支持的数据库类型映射
|
|
10
|
-
*/
|
|
11
|
-
static get SUPPORTED_DATABASES() {
|
|
12
|
-
return {
|
|
13
|
-
mongodb: Mongo,
|
|
14
|
-
};
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* 创建数据库实例
|
|
19
|
-
* @param {string} type - 数据库类型
|
|
20
|
-
* @param {string} databaseName - 数据库名称
|
|
21
|
-
* @param {Object} cache - 缓存实例(内存缓存)
|
|
22
|
-
* @param {Object} logger - 日志记录器
|
|
23
|
-
* @param {Object} [defaults] - 统一默认配置(如 maxTimeMS、namespace)
|
|
24
|
-
* @returns {Object} 数据库实例
|
|
25
|
-
* @throws {Error} 当数据库类型不支持或未实现时抛出错误
|
|
26
|
-
*/
|
|
27
|
-
static createInstance(type, databaseName, cache, logger, defaults) {
|
|
28
|
-
const SUPPORTED_DATABASES = this.SUPPORTED_DATABASES;
|
|
29
|
-
// 验证数据库类型是否支持
|
|
30
|
-
if (!(type in SUPPORTED_DATABASES)) {
|
|
31
|
-
const supportedTypes = Object.keys(SUPPORTED_DATABASES).join(", ");
|
|
32
|
-
throw new Error(
|
|
33
|
-
`Invalid database type: ${type}. Supported types are: ${supportedTypes}`,
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// 检查是否已实现
|
|
38
|
-
if (SUPPORTED_DATABASES[type] === null) {
|
|
39
|
-
throw new Error(`${type} support not implemented yet`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// 获取对应的数据库类
|
|
43
|
-
const DatabaseClass = SUPPORTED_DATABASES[type];
|
|
44
|
-
|
|
45
|
-
// 创建并返回实例
|
|
46
|
-
return new DatabaseClass(type, databaseName, cache, logger, defaults);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* 连接数据库
|
|
51
|
-
* @param {string} type - 数据库类型
|
|
52
|
-
* @param {string} databaseName - 数据库名称
|
|
53
|
-
* @param {Object} config - 数据库连接配置
|
|
54
|
-
* @param {Object} cache - 缓存实例
|
|
55
|
-
* @param {Object} logger - 日志记录器
|
|
56
|
-
* @param {Object} [defaults] - 统一默认配置(如 maxTimeMS、namespace)
|
|
57
|
-
* @param {Object} [poolManager] - 多连接池管理器(v1.0.8+)
|
|
58
|
-
* @returns {{accessor: Function, instance: Object}} 访问器与底层适配器实例
|
|
59
|
-
* @throws {Error} 连接失败时抛出错误
|
|
60
|
-
*/
|
|
61
|
-
static async connect(
|
|
62
|
-
type,
|
|
63
|
-
databaseName,
|
|
64
|
-
config,
|
|
65
|
-
cache,
|
|
66
|
-
logger,
|
|
67
|
-
defaults,
|
|
68
|
-
poolManager,
|
|
69
|
-
) {
|
|
70
|
-
// 创建数据库实例
|
|
71
|
-
const instance = this.createInstance(
|
|
72
|
-
type,
|
|
73
|
-
databaseName,
|
|
74
|
-
cache,
|
|
75
|
-
logger,
|
|
76
|
-
defaults,
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
// 🆕 v1.0.8: 传递 poolManager 给实例
|
|
80
|
-
if (poolManager) {
|
|
81
|
-
instance.poolManager = poolManager;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// 建立连接
|
|
85
|
-
await instance.connect(config);
|
|
86
|
-
|
|
87
|
-
// ---------- 构建访问器 ----------
|
|
88
|
-
const collection = (collectionName) =>
|
|
89
|
-
instance.collection(databaseName, collectionName);
|
|
90
|
-
const db = (databaseName) => {
|
|
91
|
-
return {
|
|
92
|
-
collection: (collectionName) =>
|
|
93
|
-
instance.collection(databaseName, collectionName),
|
|
94
|
-
};
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
return { collection, db, use: db, instance };
|
|
98
|
-
}
|
|
99
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* 数据库连接管理器
|
|
3
|
+
* 统一管理各种数据库的连接创建和实例化逻辑
|
|
4
|
+
*/
|
|
5
|
+
const Mongo = require("./mongodb");
|
|
6
|
+
|
|
7
|
+
module.exports = class ConnectionManager {
|
|
8
|
+
/**
|
|
9
|
+
* 支持的数据库类型映射
|
|
10
|
+
*/
|
|
11
|
+
static get SUPPORTED_DATABASES() {
|
|
12
|
+
return {
|
|
13
|
+
mongodb: Mongo,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 创建数据库实例
|
|
19
|
+
* @param {string} type - 数据库类型
|
|
20
|
+
* @param {string} databaseName - 数据库名称
|
|
21
|
+
* @param {Object} cache - 缓存实例(内存缓存)
|
|
22
|
+
* @param {Object} logger - 日志记录器
|
|
23
|
+
* @param {Object} [defaults] - 统一默认配置(如 maxTimeMS、namespace)
|
|
24
|
+
* @returns {Object} 数据库实例
|
|
25
|
+
* @throws {Error} 当数据库类型不支持或未实现时抛出错误
|
|
26
|
+
*/
|
|
27
|
+
static createInstance(type, databaseName, cache, logger, defaults) {
|
|
28
|
+
const SUPPORTED_DATABASES = this.SUPPORTED_DATABASES;
|
|
29
|
+
// 验证数据库类型是否支持
|
|
30
|
+
if (!(type in SUPPORTED_DATABASES)) {
|
|
31
|
+
const supportedTypes = Object.keys(SUPPORTED_DATABASES).join(", ");
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Invalid database type: ${type}. Supported types are: ${supportedTypes}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 检查是否已实现
|
|
38
|
+
if (SUPPORTED_DATABASES[type] === null) {
|
|
39
|
+
throw new Error(`${type} support not implemented yet`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 获取对应的数据库类
|
|
43
|
+
const DatabaseClass = SUPPORTED_DATABASES[type];
|
|
44
|
+
|
|
45
|
+
// 创建并返回实例
|
|
46
|
+
return new DatabaseClass(type, databaseName, cache, logger, defaults);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 连接数据库
|
|
51
|
+
* @param {string} type - 数据库类型
|
|
52
|
+
* @param {string} databaseName - 数据库名称
|
|
53
|
+
* @param {Object} config - 数据库连接配置
|
|
54
|
+
* @param {Object} cache - 缓存实例
|
|
55
|
+
* @param {Object} logger - 日志记录器
|
|
56
|
+
* @param {Object} [defaults] - 统一默认配置(如 maxTimeMS、namespace)
|
|
57
|
+
* @param {Object} [poolManager] - 多连接池管理器(v1.0.8+)
|
|
58
|
+
* @returns {{accessor: Function, instance: Object}} 访问器与底层适配器实例
|
|
59
|
+
* @throws {Error} 连接失败时抛出错误
|
|
60
|
+
*/
|
|
61
|
+
static async connect(
|
|
62
|
+
type,
|
|
63
|
+
databaseName,
|
|
64
|
+
config,
|
|
65
|
+
cache,
|
|
66
|
+
logger,
|
|
67
|
+
defaults,
|
|
68
|
+
poolManager,
|
|
69
|
+
) {
|
|
70
|
+
// 创建数据库实例
|
|
71
|
+
const instance = this.createInstance(
|
|
72
|
+
type,
|
|
73
|
+
databaseName,
|
|
74
|
+
cache,
|
|
75
|
+
logger,
|
|
76
|
+
defaults,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// 🆕 v1.0.8: 传递 poolManager 给实例
|
|
80
|
+
if (poolManager) {
|
|
81
|
+
instance.poolManager = poolManager;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 建立连接
|
|
85
|
+
await instance.connect(config);
|
|
86
|
+
|
|
87
|
+
// ---------- 构建访问器 ----------
|
|
88
|
+
const collection = (collectionName) =>
|
|
89
|
+
instance.collection(databaseName, collectionName);
|
|
90
|
+
const db = (databaseName) => {
|
|
91
|
+
return {
|
|
92
|
+
collection: (collectionName) =>
|
|
93
|
+
instance.collection(databaseName, collectionName),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return { collection, db, use: db, instance };
|
|
98
|
+
}
|
|
99
|
+
};
|
|
@@ -16,15 +16,18 @@ const { reverseSort } = require('./sort');
|
|
|
16
16
|
* @param {{a:object,s:object}|null} [params.cursor] - 解析后的游标对象
|
|
17
17
|
* @param {'after'|'before'|null} [params.direction]
|
|
18
18
|
* @param {object[]} [params.lookupPipeline] - 页内联表等追加管道
|
|
19
|
+
* @param {Record<string,any>} [params.projection] - 已计算的有效投影(调用方负责保护排序字段)
|
|
19
20
|
* @returns {object[]} 聚合管道数组
|
|
20
21
|
*/
|
|
21
|
-
function buildPagePipelineA({ query = {}, sort, limit, cursor, direction, lookupPipeline = [] }) {
|
|
22
|
+
function buildPagePipelineA({ query = {}, sort, limit, cursor, direction, lookupPipeline = [], projection }) {
|
|
22
23
|
const pipeline = [];
|
|
23
24
|
if (query && Object.keys(query).length) pipeline.push({ $match: query });
|
|
24
25
|
if (cursor) pipeline.push({ $match: { $expr: buildLexiExpr(sort, cursor.a) } });
|
|
25
26
|
pipeline.push({ $sort: sort });
|
|
26
27
|
pipeline.push({ $limit: limit + 1 });
|
|
27
28
|
if (lookupPipeline && lookupPipeline.length) pipeline.push(...lookupPipeline);
|
|
29
|
+
// 🆕 v1.2.0: 在 lookup 之后、方向恢复之前注入 $project(调用方已确保排序字段存在)
|
|
30
|
+
if (projection) pipeline.push({ $project: projection });
|
|
28
31
|
if (direction === 'before') pipeline.push({ $sort: reverseSort(sort) });
|
|
29
32
|
return pipeline;
|
|
30
33
|
}
|
|
@@ -10,9 +10,40 @@ const { buildPagePipelineA } = require('../common/agg-pipeline');
|
|
|
10
10
|
const { decodeCursor } = require('../../common/cursor');
|
|
11
11
|
const { validateLimitAfterBefore, assertCursorSortCompatible } = require('../../common/validation');
|
|
12
12
|
const { makePageResult } = require('../../common/page-result');
|
|
13
|
-
const { normalizeSort } = require('../../common/normalize');
|
|
13
|
+
const { normalizeSort, normalizeProjection } = require('../../common/normalize');
|
|
14
14
|
const { convertObjectIdStrings } = require('../../utils/objectid-converter');
|
|
15
15
|
|
|
16
|
+
// —— 有效投影计算 ——
|
|
17
|
+
/**
|
|
18
|
+
* 计算有效投影:自动保护排序字段,确保游标锚点提取(pickAnchor)不受投影影响。
|
|
19
|
+
* - 包含型投影(所有非 _id 值均为 1/true):强制追加排序字段(值为 1)
|
|
20
|
+
* - 排除型投影(含任意非 _id 字段值为 0/false):从排除列表中移除排序字段
|
|
21
|
+
* @param {Record<string,any>|undefined} projection - normalizeProjection 归一化后的投影
|
|
22
|
+
* @param {Record<string,1|-1>} sort - 稳定排序(含 _id)
|
|
23
|
+
* @returns {Record<string,any>|undefined}
|
|
24
|
+
*/
|
|
25
|
+
function buildEffectiveProjection(projection, sort) {
|
|
26
|
+
if (!projection) return undefined;
|
|
27
|
+
const sortFields = Object.keys(sort || {});
|
|
28
|
+
// 判断排除型:任意非 _id 字段值为 0 或 false
|
|
29
|
+
const isExclusion = Object.entries(projection).some(([k, v]) => k !== '_id' && (v === 0 || v === false));
|
|
30
|
+
const effective = { ...projection };
|
|
31
|
+
if (isExclusion) {
|
|
32
|
+
// 排除型:取消对排序字段的排除,确保游标可用
|
|
33
|
+
for (const k of sortFields) {
|
|
34
|
+
if (effective[k] === 0 || effective[k] === false) {
|
|
35
|
+
delete effective[k];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
// 包含型:强制包含排序字段
|
|
40
|
+
for (const k of sortFields) {
|
|
41
|
+
if (!effective[k]) effective[k] = 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return effective;
|
|
45
|
+
}
|
|
46
|
+
|
|
16
47
|
// —— Count 队列支持(高并发控制)——
|
|
17
48
|
let countQueue = null;
|
|
18
49
|
|
|
@@ -160,13 +191,17 @@ function createFindPage(ctx) {
|
|
|
160
191
|
|
|
161
192
|
async function doFindPageOne({ options, stableSort, direction, parsedCursor }) {
|
|
162
193
|
const sortForQuery = direction === 'before' ? reverseSort(stableSort) : stableSort;
|
|
194
|
+
// 🆕 v1.2.0: 计算有效投影(自动保护排序字段,确保游标锚点可提取)
|
|
195
|
+
const normalizedProjection = normalizeProjection(options.projection);
|
|
196
|
+
const effectiveProjection = buildEffectiveProjection(normalizedProjection, stableSort);
|
|
163
197
|
const pipeline = buildPagePipelineA({
|
|
164
198
|
query: options.query || {},
|
|
165
199
|
sort: sortForQuery,
|
|
166
200
|
limit: options.limit,
|
|
167
201
|
cursor: parsedCursor,
|
|
168
202
|
direction,
|
|
169
|
-
lookupPipeline: options.pipeline || []
|
|
203
|
+
lookupPipeline: options.pipeline || [],
|
|
204
|
+
projection: effectiveProjection
|
|
170
205
|
});
|
|
171
206
|
const driverOpts = {
|
|
172
207
|
maxTimeMS: options.maxTimeMS ?? defaults.maxTimeMS,
|
|
@@ -185,14 +220,15 @@ function createFindPage(ctx) {
|
|
|
185
220
|
// 如果启用流式返回
|
|
186
221
|
if (options.stream) {
|
|
187
222
|
if (options.batchSize !== undefined) driverOpts.batchSize = options.batchSize;
|
|
188
|
-
//
|
|
223
|
+
// 流式查询:不应该用 limit+1,直接使用 limit
|
|
189
224
|
const streamPipeline = buildPagePipelineA({
|
|
190
225
|
query: options.query || {},
|
|
191
226
|
sort: sortForQuery,
|
|
192
227
|
limit: options.limit, // 注意:这里不加1
|
|
193
228
|
cursor: parsedCursor,
|
|
194
229
|
direction,
|
|
195
|
-
lookupPipeline: options.pipeline || []
|
|
230
|
+
lookupPipeline: options.pipeline || [],
|
|
231
|
+
projection: normalizedProjection // 🆕 v1.2.0: 流式无需游标保护,使用原始投影
|
|
196
232
|
});
|
|
197
233
|
// 手动修改最后的 $limit 阶段,不使用 limit+1
|
|
198
234
|
const limitStageIndex = streamPipeline.findIndex(stage => stage.$limit !== undefined);
|
|
@@ -396,6 +432,10 @@ function createFindPage(ctx) {
|
|
|
396
432
|
if (query && Object.keys(query).length) p.push({ $match: query });
|
|
397
433
|
p.push({ $sort: stableSort }, { $skip: skip }, { $limit: limit + 1 });
|
|
398
434
|
if (lookupPipeline?.length) p.push(...lookupPipeline);
|
|
435
|
+
// 🆕 v1.2.0: 支持 projection(同样保护排序字段)
|
|
436
|
+
const offsetNormalizedProj = normalizeProjection(options.projection);
|
|
437
|
+
const offsetEffectiveProj = buildEffectiveProjection(offsetNormalizedProj, stableSort);
|
|
438
|
+
if (offsetEffectiveProj) p.push({ $project: offsetEffectiveProj });
|
|
399
439
|
const driverOpts = {
|
|
400
440
|
maxTimeMS: options.maxTimeMS ?? defaults.maxTimeMS,
|
|
401
441
|
allowDiskUse: options.allowDiskUse,
|
package/lib/multi-level-cache.js
CHANGED
|
@@ -16,8 +16,12 @@ class MultiLevelCache {
|
|
|
16
16
|
* @param {Object} [options.policy]
|
|
17
17
|
* @param {'both'|'local-first-async-remote'} [options.policy.writePolicy='both']
|
|
18
18
|
* @param {boolean} [options.policy.backfillLocalOnRemoteHit=true]
|
|
19
|
+
* @param {number} [options.policy.backfillLocalTTL=0] - 回填 L1 时使用的兜底 TTL(毫秒);
|
|
20
|
+
* 当 remote 支持 getWithTTL 时优先使用 L2 剩余 TTL,否则降级到此值;
|
|
21
|
+
* 0 = 不设 TTL(永不过期,向后兼容)。建议生产环境配置合理值(如 60000)
|
|
19
22
|
* @param {number} [options.remoteTimeoutMs=50] - 远端单次操作超时
|
|
20
23
|
* @param {(msg:object)=>void} [options.publish] - 可选:失效广播发布器
|
|
24
|
+
* @since 1.1.9 backfillLocalTTL 支持
|
|
21
25
|
*/
|
|
22
26
|
constructor(options = {}) {
|
|
23
27
|
const { local, remote, policy = {}, remoteTimeoutMs = 50, publish } = options;
|
|
@@ -29,6 +33,8 @@ class MultiLevelCache {
|
|
|
29
33
|
this.policy = {
|
|
30
34
|
writePolicy: policy.writePolicy || 'both',
|
|
31
35
|
backfillLocalOnRemoteHit: policy.backfillLocalOnRemoteHit !== false,
|
|
36
|
+
// 🔧 v1.1.9 修复:新增回填 TTL 兜底配置,0 = 不设 TTL(向后兼容)
|
|
37
|
+
backfillLocalTTL: typeof policy.backfillLocalTTL === 'number' ? policy.backfillLocalTTL : 0,
|
|
32
38
|
};
|
|
33
39
|
this.remoteTimeoutMs = Number(remoteTimeoutMs) || 50;
|
|
34
40
|
this.publish = typeof publish === 'function' ? publish : null;
|
|
@@ -74,13 +80,30 @@ class MultiLevelCache {
|
|
|
74
80
|
const v = await this.local.get(key);
|
|
75
81
|
if (v !== undefined) return v;
|
|
76
82
|
try {
|
|
77
|
-
|
|
83
|
+
let r;
|
|
84
|
+
// 🔧 v1.1.9 修复:backfillTTL 优先取 L2 剩余 TTL(方案A),降级到 backfillLocalTTL(方案B)
|
|
85
|
+
let backfillTTL = this.policy.backfillLocalTTL;
|
|
86
|
+
|
|
87
|
+
if (this.remote && typeof this.remote.getWithTTL === 'function') {
|
|
88
|
+
// 方案A:remote 支持 getWithTTL,单次 RTT 同时获取值与剩余 TTL
|
|
89
|
+
const meta = await this._withTimeout(this.remote.getWithTTL(key));
|
|
90
|
+
if (meta !== undefined) {
|
|
91
|
+
r = meta.value;
|
|
92
|
+
if (meta.remainingTTL > 0) backfillTTL = meta.remainingTTL;
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
// 方案B:remote 不支持 getWithTTL,使用普通 get + backfillLocalTTL 兜底
|
|
96
|
+
r = await this._withTimeout(this.remote.get(key));
|
|
97
|
+
}
|
|
98
|
+
|
|
78
99
|
if (r !== undefined && this.policy.backfillLocalOnRemoteHit) {
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
100
|
+
// 🔧 v1.1.9 补充:backfillTTL=0 时跳过 null 回填,防止无配置用户触发永久驻留 Bug
|
|
101
|
+
// - backfillTTL>0:回填所有值(含 null),TTL 保护下能正常过期
|
|
102
|
+
// - backfillTTL=0 + null:跳过——无 TTL 保护的 null 永久驻留 = Bug 本身
|
|
103
|
+
// - backfillTTL=0 + 非 null:回填(保留原有永久缓存行为,向后兼容)
|
|
104
|
+
if (backfillTTL > 0 || r !== null) {
|
|
105
|
+
try { await this.local.set(key, r, backfillTTL); } catch(_) {}
|
|
106
|
+
}
|
|
84
107
|
}
|
|
85
108
|
return r;
|
|
86
109
|
} catch(_) {
|
|
@@ -128,8 +151,16 @@ class MultiLevelCache {
|
|
|
128
151
|
try {
|
|
129
152
|
const remoteRes = await this._withTimeout(this.remote.getMany(misses));
|
|
130
153
|
if (remoteRes && typeof remoteRes === 'object') {
|
|
131
|
-
//
|
|
132
|
-
|
|
154
|
+
// 🔧 v1.1.9 修复:传入 backfillLocalTTL,防止无 TTL 永久回填(含 null 值)
|
|
155
|
+
// 🔧 v1.1.9 补充:backfillLocalTTL=0 时过滤 null,防止无配置用户触发永久驻留 Bug
|
|
156
|
+
if (this.policy.backfillLocalOnRemoteHit) {
|
|
157
|
+
const backfillData = this.policy.backfillLocalTTL > 0
|
|
158
|
+
? remoteRes
|
|
159
|
+
: Object.fromEntries(Object.entries(remoteRes).filter(([, v]) => v !== null));
|
|
160
|
+
if (Object.keys(backfillData).length > 0) {
|
|
161
|
+
this.local.setMany(backfillData, this.policy.backfillLocalTTL).catch(() => {});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
133
164
|
for (const k of misses) { if (remoteRes[k] !== undefined) out[k] = remoteRes[k]; }
|
|
134
165
|
}
|
|
135
166
|
} catch(_) { /* 降级 */ }
|
|
@@ -60,6 +60,35 @@ function createRedisCacheAdapter(redisUrlOrInstance) {
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 获取单个缓存值及剩余 TTL(毫秒),供 MultiLevelCache L2→L1 回填时携带正确 TTL
|
|
65
|
+
* 使用 pipeline(GET + PTTL)单次 RTT,避免额外网络开销
|
|
66
|
+
* @param {string} key
|
|
67
|
+
* @returns {Promise<{value: any, remainingTTL: number}|undefined>}
|
|
68
|
+
* key 不存在返回 undefined;value 为 null 时表示缓存了空结果
|
|
69
|
+
* @since 1.1.9
|
|
70
|
+
*/
|
|
71
|
+
async getWithTTL(key) {
|
|
72
|
+
try {
|
|
73
|
+
const [[, rawVal], [, pttl]] = await redis.pipeline().get(key).pttl(key).exec();
|
|
74
|
+
// PTTL = -2 表示 key 不存在
|
|
75
|
+
if (pttl === -2) return undefined;
|
|
76
|
+
let value;
|
|
77
|
+
try {
|
|
78
|
+
value = JSON.parse(rawVal);
|
|
79
|
+
} catch (_) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
value,
|
|
84
|
+
// PTTL = -1 表示永不过期,映射为 0(与 backfillLocalTTL=0 语义一致)
|
|
85
|
+
remainingTTL: pttl > 0 ? pttl : 0,
|
|
86
|
+
};
|
|
87
|
+
} catch (_) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
63
92
|
/**
|
|
64
93
|
* 设置单个缓存值
|
|
65
94
|
* @param {string} key
|