monsqlize 1.1.9 → 1.2.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/CHANGELOG.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # 变更日志 (CHANGELOG)
2
2
 
3
3
  > **说明**: 版本概览摘要,详细变更见 [changelogs/](./changelogs/) 目录
4
- > **最后更新**: 2026-04-02
4
+ > **最后更新**: 2026-04-13
5
5
 
6
6
  ---
7
7
 
@@ -9,6 +9,8 @@
9
9
 
10
10
  | 版本 | 日期 | 变更摘要 | 详细 |
11
11
  |------|------|---------|------|
12
+ | [v1.2.1](./changelogs/v1.2.1.md) | 2026-04-13 | 🐛 **Bug 修复**:`msq.model()` 实例缓存 + 索引去重 + 死代码清理 + `types/monsqlize.ts` 补全 `model()` / `collection()` 类型 | [查看](./changelogs/v1.2.1.md) |
13
+ | [v1.2.0](./changelogs/v1.2.0.md) | 2026-04-13 | 🐛 **Bug 修复 + 新功能**:`findPage` 正式支持 `projection` 投影参数(修复静默忽略问题)+ 有效投影策略自动保护游标排序字段 + 8 个测试用例 | [查看](./changelogs/v1.2.0.md) |
12
14
  | [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) |
13
15
  | [v1.1.8](./changelogs/v1.1.8.md) | 2026-03-16 | 🆕 **新功能**:Model 热重载支持(`undefine()` + `redefine()` + `_loadModels` reload 模式)+ 22个测试 (100%通过) | [查看](./changelogs/v1.1.8.md) |
14
16
  | [v1.1.6](./changelogs/v1.1.6.md) | 2026-02-11 | 🎉 **重大功能**:精准缓存失效机制 + 🚨 upsert 缓存失效 Bug 修复 + 36个测试 (100%通过) | [查看](./changelogs/v1.1.6.md) |
package/lib/index.js CHANGED
@@ -678,7 +678,10 @@ module.exports = class {
678
678
  }
679
679
 
680
680
  /**
681
- * 获取 Model 实例
681
+ * 获取 Model 实例(缓存复用)
682
+ *
683
+ * 同一 collectionName 多次调用返回同一实例。
684
+ * Model.redefine() / Model.undefine() 后自动失效,close() 后全部清空。
682
685
  *
683
686
  * @param {string} collectionName - 集合名称
684
687
  * @returns {ModelInstance} Model 实例
@@ -710,8 +713,19 @@ module.exports = class {
710
713
  throw err;
711
714
  }
712
715
 
713
- // 检查 Model 是否已定义
714
716
  const Model = require("./model");
717
+
718
+ // 缓存命中 + redefine 失效检查
719
+ if (this._modelInstances && this._modelInstances.has(collectionName)) {
720
+ if (!Model._redefinedNames.has(collectionName)) {
721
+ return this._modelInstances.get(collectionName);
722
+ }
723
+ // redefine 后需要重建实例
724
+ this._modelInstances.delete(collectionName);
725
+ Model._redefinedNames.delete(collectionName);
726
+ }
727
+
728
+ // 检查 Model 是否已定义
715
729
  if (!Model.has(collectionName)) {
716
730
  const err = new Error(
717
731
  `Model '${collectionName}' is not defined. Call Model.define() first.`,
@@ -726,9 +740,13 @@ module.exports = class {
726
740
  // 获取 collection 实例
727
741
  const collection = this.dbInstance.collection(collectionName);
728
742
 
729
- // 创建 ModelInstance
730
- const ModelInstanceClass = require("./model").ModelInstance;
731
- return new ModelInstanceClass(collection, modelDef.definition, this);
743
+ // 创建 ModelInstance 并缓存
744
+ const instance = new Model.ModelInstance(collection, modelDef.definition, this);
745
+
746
+ if (!this._modelInstances) this._modelInstances = new Map();
747
+ this._modelInstances.set(collectionName, instance);
748
+
749
+ return instance;
732
750
  }
733
751
 
734
752
  /**
@@ -1007,6 +1025,12 @@ module.exports = class {
1007
1025
  this.dbInstance = null;
1008
1026
  this._connecting = null;
1009
1027
 
1028
+ // 清理 ModelInstance 缓存
1029
+ if (this._modelInstances) {
1030
+ this._modelInstances.clear();
1031
+ this._modelInstances = null;
1032
+ }
1033
+
1010
1034
  return null;
1011
1035
  }
1012
1036
  };
@@ -58,6 +58,14 @@ class Model {
58
58
  */
59
59
  static _registry = new Map();
60
60
 
61
+ /**
62
+ * 已被 redefine 的 collectionName 集合(用于缓存失效通知)
63
+ * @private
64
+ * @type {Set<string>}
65
+ * @since 1.2.1
66
+ */
67
+ static _redefinedNames = new Set();
68
+
61
69
  /**
62
70
  * 定义并注册 Model
63
71
  *
@@ -396,6 +404,8 @@ class Model {
396
404
  * @since 1.1.7
397
405
  */
398
406
  static undefine(collectionName) {
407
+ // 标记需要缓存失效(MonSQLize.model() 检查此标记)
408
+ this._redefinedNames.add(collectionName);
399
409
  return this._registry.delete(collectionName);
400
410
  }
401
411
 
@@ -434,6 +444,8 @@ class Model {
434
444
  static redefine(collectionName, definition) {
435
445
  this.undefine(collectionName);
436
446
  this.define(collectionName, definition);
447
+ // 标记需要缓存失效(MonSQLize.model() 检查此标记)
448
+ this._redefinedNames.add(collectionName);
437
449
  }
438
450
 
439
451
  /**
@@ -443,6 +455,7 @@ class Model {
443
455
  */
444
456
  static _clear() {
445
457
  this._registry.clear();
458
+ this._redefinedNames.clear();
446
459
  }
447
460
  }
448
461
 
@@ -662,38 +675,6 @@ class ModelInstance {
662
675
  }
663
676
  }
664
677
 
665
- /**
666
- * 创建索引
667
- *
668
- * @private
669
- * @returns {Promise<void>}
670
- */
671
- async _createIndexes() {
672
- if (!Array.isArray(this.indexes) || this.indexes.length === 0) {
673
- return;
674
- }
675
-
676
- try {
677
- // 使用 createIndexes 批量创建索引
678
- await this.collection.createIndexes(this.indexes);
679
-
680
- if (this.msq && this.msq.logger) {
681
- this.msq.logger.info(
682
- `[Model] Created ${this.indexes.length} index(es) for ${this.collection.collectionName}`,
683
- );
684
- }
685
- } catch (err) {
686
- // 索引创建失败仅记录警告
687
- if (this.msq && this.msq.logger) {
688
- this.msq.logger.warn(
689
- `[Model] Failed to create indexes for ${this.collection.collectionName}:`,
690
- err.message,
691
- );
692
- }
693
- throw err;
694
- }
695
- }
696
-
697
678
  /**
698
679
  * 将实例方法注入到文档对象
699
680
  *
@@ -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
- // 流式查询:��应该用 limit+1,直接使用 limit
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "monsqlize",
3
- "version": "1.1.9",
3
+ "version": "1.2.1",
4
4
  "description": "A lightweight MongoDB ORM with multi-level caching, transaction support, distributed features, Saga distributed transactions, unified expression system with 122 operators, and universal function caching (100% MongoDB support)",
5
5
  "main": "lib/index.js",
6
6
  "module": "index.mjs",
@@ -4,12 +4,13 @@
4
4
  */
5
5
 
6
6
  import type { TransactionOptions } from './options';
7
- import type { DbAccessor, HealthView } from './collection';
7
+ import type { DbAccessor, HealthView, Collection } from './collection';
8
8
  import type { CacheLike } from './cache';
9
9
  import type { Transaction } from './transaction';
10
10
  import type { Lock, LockOptions } from './lock';
11
11
  import type { ExpressionFunction } from './base';
12
12
  import type { MetaInfo } from './pagination';
13
+ import type { ModelInstance } from './model';
13
14
 
14
15
  /**
15
16
  * MonSQLize 主类
@@ -46,6 +47,29 @@ export interface MonSQLize {
46
47
  */
47
48
  health(): Promise<HealthView>;
48
49
 
50
+ /**
51
+ * 获取 Model 实例(缓存复用)
52
+ *
53
+ * 同一 collectionName 多次调用返回同一实例。
54
+ * Model.redefine() / Model.undefine() 后自动失效,close() 后全部清空。
55
+ *
56
+ * @param collectionName - 已注册的集合名称
57
+ * @returns ModelInstance 实例
58
+ * @throws 数据库未连接(NOT_CONNECTED)
59
+ * @throws Model 未定义(MODEL_NOT_DEFINED)
60
+ * @since 1.0.3
61
+ */
62
+ model(collectionName: string): ModelInstance;
63
+
64
+ /**
65
+ * 获取原始集合实例
66
+ *
67
+ * @param collectionName - 集合名称
68
+ * @returns Collection 实例
69
+ * @throws 数据库未连接(NOT_CONNECTED)
70
+ */
71
+ collection(collectionName: string): Collection;
72
+
49
73
 
50
74
  // ============================================================================
51
75
  // 事件系统
@@ -61,6 +61,9 @@ export interface TotalsOptions {
61
61
  * 深度分页(统一版)选项
62
62
  */
63
63
  export interface FindPageOptions extends FindOptions {
64
+ /**
65
+ * 附加聚合管道(在 projection 之前执行,仅对当页数据生效)
66
+ */
64
67
  pipeline?: object[];
65
68
  after?: string;
66
69
  before?: string;
@@ -77,6 +80,8 @@ export interface FindPageOptions extends FindOptions {
77
80
  offsetJump?: OffsetJumpOptions; // 小范围 offset 兜底
78
81
  totals?: TotalsOptions; // 总数/总页数配置
79
82
  meta?: boolean | MetaOptions; // 返回耗时元信息
83
+ // 注:projection 继承自 FindOptions,findPage 自 v1.2.0 起正式支持。
84
+ // 排序字段会被自动保留以确保游标正确生成。
80
85
  }
81
86
 
82
87
  /**