@classytic/mongokit 3.12.0 → 3.13.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 +126 -4
- package/dist/{PaginationEngine-BDaAsyPn.mjs → PaginationEngine-CfQRM1yz.mjs} +16 -16
- package/dist/Repository-d5gWSROf.mjs +3603 -0
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/adapter/index.d.mts +190 -0
- package/dist/adapter/index.mjs +2 -0
- package/dist/adapter-DTDMnUOH.mjs +158 -0
- package/dist/ai/index.d.mts +1 -1
- package/dist/ai/index.mjs +1 -1
- package/dist/better-auth/index.d.mts +149 -0
- package/dist/better-auth/index.mjs +81 -0
- package/dist/field-selection-Br3WPOEb.mjs +99 -0
- package/dist/{index-D9cORR-o.d.mts → index-BbHaKwi5.d.mts} +33 -13
- package/dist/index.d.mts +690 -30
- package/dist/index.mjs +240 -17
- package/dist/{logger-8-VS1za6.mjs → logger-DprijP5h.mjs} +1 -5
- package/dist/{mongooseToJsonSchema-B187ufOP.mjs → mongooseToJsonSchema-BYbG894y.mjs} +1 -1
- package/dist/{mongooseToJsonSchema-rtkdbBhf.d.mts → mongooseToJsonSchema-BgRAaXX-.d.mts} +2 -1
- package/dist/pagination/PaginationEngine.d.mts +2 -2
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/plugins/index.d.mts +2 -2
- package/dist/plugins/index.mjs +2 -2
- package/dist/query/primitives/coercion.d.mts +1 -1
- package/dist/query/primitives/coercion.mjs +1 -1
- package/dist/query/primitives/indexes.d.mts +1 -1
- package/dist/{types-DXscZnLu.d.mts → types-BbaPZleN.d.mts} +371 -112
- package/dist/{update-CDxPtpsk.mjs → update-C-3viiw1.mjs} +112 -28
- package/dist/utils/index.d.mts +2 -91
- package/dist/utils/index.mjs +5 -5
- package/dist/validation-chain.plugin-BLVp_Pw2.mjs +2399 -0
- package/dist/{validation-chain.plugin-CH-xPoKM.d.mts → validation-chain.plugin-CAPYCj_T.d.mts} +212 -80
- package/package.json +20 -3
- package/dist/field-selection-dI0m4RBs.mjs +0 -221
- package/dist/validation-chain.plugin-BicIgzzt.mjs +0 -4970
- /package/dist/{coercion-oM8G4b6m.d.mts → coercion-TRMmR49h.d.mts} +0 -0
- /package/dist/{id-resolution-e4BT9zAV.mjs → id-resolution-B0Odvvou.mjs} +0 -0
- /package/dist/{indexes-C26GNZRU.d.mts → indexes-CgOF7Wjd.d.mts} +0 -0
|
@@ -1,4970 +0,0 @@
|
|
|
1
|
-
import { _ as upsert, a as count, b as LookupBuilder, c as getByQuery, d as deleteById, f as deleteByQuery, h as createMany, l as getOrCreate, m as create, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, x as compileFilterToMongo, y as distinct } from "./update-CDxPtpsk.mjs";
|
|
2
|
-
import { n as debug, r as warn } from "./logger-8-VS1za6.mjs";
|
|
3
|
-
import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-B00pI7dV.mjs";
|
|
4
|
-
import { t as PaginationEngine } from "./PaginationEngine-BDaAsyPn.mjs";
|
|
5
|
-
import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
|
|
6
|
-
import { r as isValidIdForType, t as getSchemaIdType } from "./id-resolution-e4BT9zAV.mjs";
|
|
7
|
-
import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-dI0m4RBs.mjs";
|
|
8
|
-
import { isFilter } from "@classytic/repo-core/filter";
|
|
9
|
-
import mongoose from "mongoose";
|
|
10
|
-
import { HOOK_PRIORITY } from "@classytic/repo-core/hooks";
|
|
11
|
-
import { RepositoryBase, validatePluginOrder } from "@classytic/repo-core/repository";
|
|
12
|
-
import { compileUpdateSpecToMongo, isUpdateSpec } from "@classytic/repo-core/update";
|
|
13
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
|
-
//#region src/operations.ts
|
|
15
|
-
/**
|
|
16
|
-
* The registry. Order intentionally groups by category for readability.
|
|
17
|
-
* Adding a new RepositoryOperation requires exactly one entry here, after
|
|
18
|
-
* which every bundled plugin auto-classifies it correctly.
|
|
19
|
-
*/
|
|
20
|
-
const OP_REGISTRY = {
|
|
21
|
-
create: {
|
|
22
|
-
policyKey: "data",
|
|
23
|
-
mutates: true,
|
|
24
|
-
hasIdContext: false
|
|
25
|
-
},
|
|
26
|
-
update: {
|
|
27
|
-
policyKey: "query",
|
|
28
|
-
mutates: true,
|
|
29
|
-
hasIdContext: true
|
|
30
|
-
},
|
|
31
|
-
findOneAndUpdate: {
|
|
32
|
-
policyKey: "query",
|
|
33
|
-
mutates: true,
|
|
34
|
-
hasIdContext: false
|
|
35
|
-
},
|
|
36
|
-
delete: {
|
|
37
|
-
policyKey: "query",
|
|
38
|
-
mutates: true,
|
|
39
|
-
hasIdContext: true
|
|
40
|
-
},
|
|
41
|
-
restore: {
|
|
42
|
-
policyKey: "query",
|
|
43
|
-
mutates: true,
|
|
44
|
-
hasIdContext: true
|
|
45
|
-
},
|
|
46
|
-
createMany: {
|
|
47
|
-
policyKey: "dataArray",
|
|
48
|
-
mutates: true,
|
|
49
|
-
hasIdContext: false
|
|
50
|
-
},
|
|
51
|
-
updateMany: {
|
|
52
|
-
policyKey: "query",
|
|
53
|
-
mutates: true,
|
|
54
|
-
hasIdContext: false
|
|
55
|
-
},
|
|
56
|
-
deleteMany: {
|
|
57
|
-
policyKey: "query",
|
|
58
|
-
mutates: true,
|
|
59
|
-
hasIdContext: false
|
|
60
|
-
},
|
|
61
|
-
bulkWrite: {
|
|
62
|
-
policyKey: "operations",
|
|
63
|
-
mutates: true,
|
|
64
|
-
hasIdContext: false
|
|
65
|
-
},
|
|
66
|
-
getById: {
|
|
67
|
-
policyKey: "query",
|
|
68
|
-
mutates: false,
|
|
69
|
-
hasIdContext: true
|
|
70
|
-
},
|
|
71
|
-
getByQuery: {
|
|
72
|
-
policyKey: "query",
|
|
73
|
-
mutates: false,
|
|
74
|
-
hasIdContext: false
|
|
75
|
-
},
|
|
76
|
-
getOne: {
|
|
77
|
-
policyKey: "query",
|
|
78
|
-
mutates: false,
|
|
79
|
-
hasIdContext: false
|
|
80
|
-
},
|
|
81
|
-
findAll: {
|
|
82
|
-
policyKey: "query",
|
|
83
|
-
mutates: false,
|
|
84
|
-
hasIdContext: false
|
|
85
|
-
},
|
|
86
|
-
getOrCreate: {
|
|
87
|
-
policyKey: "query",
|
|
88
|
-
mutates: false,
|
|
89
|
-
hasIdContext: false
|
|
90
|
-
},
|
|
91
|
-
count: {
|
|
92
|
-
policyKey: "query",
|
|
93
|
-
mutates: false,
|
|
94
|
-
hasIdContext: false
|
|
95
|
-
},
|
|
96
|
-
exists: {
|
|
97
|
-
policyKey: "query",
|
|
98
|
-
mutates: false,
|
|
99
|
-
hasIdContext: false
|
|
100
|
-
},
|
|
101
|
-
distinct: {
|
|
102
|
-
policyKey: "query",
|
|
103
|
-
mutates: false,
|
|
104
|
-
hasIdContext: false
|
|
105
|
-
},
|
|
106
|
-
aggregate: {
|
|
107
|
-
policyKey: "query",
|
|
108
|
-
mutates: false,
|
|
109
|
-
hasIdContext: false
|
|
110
|
-
},
|
|
111
|
-
aggregatePipeline: {
|
|
112
|
-
policyKey: "query",
|
|
113
|
-
mutates: false,
|
|
114
|
-
hasIdContext: false
|
|
115
|
-
},
|
|
116
|
-
getAll: {
|
|
117
|
-
policyKey: "filters",
|
|
118
|
-
mutates: false,
|
|
119
|
-
hasIdContext: false
|
|
120
|
-
},
|
|
121
|
-
aggregatePaginate: {
|
|
122
|
-
policyKey: "filters",
|
|
123
|
-
mutates: false,
|
|
124
|
-
hasIdContext: false
|
|
125
|
-
},
|
|
126
|
-
aggregatePipelinePaginate: {
|
|
127
|
-
policyKey: "filters",
|
|
128
|
-
mutates: false,
|
|
129
|
-
hasIdContext: false
|
|
130
|
-
},
|
|
131
|
-
lookupPopulate: {
|
|
132
|
-
policyKey: "filters",
|
|
133
|
-
mutates: false,
|
|
134
|
-
hasIdContext: false
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
/** All known repository operations, in registry order. */
|
|
138
|
-
const ALL_OPERATIONS = Object.keys(OP_REGISTRY);
|
|
139
|
-
/** Operations that mutate the database. Drives audit + cache invalidation lists. */
|
|
140
|
-
const MUTATING_OPERATIONS = ALL_OPERATIONS.filter((op) => OP_REGISTRY[op].mutates);
|
|
141
|
-
/** Operations that don't mutate. Drives default cacheable ops. */
|
|
142
|
-
const READ_OPERATIONS = ALL_OPERATIONS.filter((op) => !OP_REGISTRY[op].mutates);
|
|
143
|
-
/** Filter ops by their policy injection key. */
|
|
144
|
-
function operationsByPolicyKey(key) {
|
|
145
|
-
return ALL_OPERATIONS.filter((op) => OP_REGISTRY[op].policyKey === key);
|
|
146
|
-
}
|
|
147
|
-
//#endregion
|
|
148
|
-
//#region src/plugins/aggregate-helpers.plugin.ts
|
|
149
|
-
/**
|
|
150
|
-
* Aggregate helpers plugin
|
|
151
|
-
*
|
|
152
|
-
* @example
|
|
153
|
-
* const repo = new Repository(Model, [
|
|
154
|
-
* methodRegistryPlugin(),
|
|
155
|
-
* aggregateHelpersPlugin(),
|
|
156
|
-
* ]);
|
|
157
|
-
*
|
|
158
|
-
* const groups = await repo.groupBy('category');
|
|
159
|
-
* const total = await repo.sum('amount', { status: 'completed' });
|
|
160
|
-
*/
|
|
161
|
-
function aggregateHelpersPlugin() {
|
|
162
|
-
return {
|
|
163
|
-
name: "aggregate-helpers",
|
|
164
|
-
apply(repo) {
|
|
165
|
-
if (!repo.registerMethod) throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
|
|
166
|
-
/**
|
|
167
|
-
* Group by field
|
|
168
|
-
*/
|
|
169
|
-
repo.registerMethod("groupBy", async function(field, options = {}) {
|
|
170
|
-
const pipeline = [{ $group: {
|
|
171
|
-
_id: `$${field}`,
|
|
172
|
-
count: { $sum: 1 }
|
|
173
|
-
} }, { $sort: { count: -1 } }];
|
|
174
|
-
if (options.limit) pipeline.push({ $limit: options.limit });
|
|
175
|
-
return this.aggregatePipeline(pipeline, options);
|
|
176
|
-
});
|
|
177
|
-
const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
|
|
178
|
-
const pipeline = [{ $match: query }, { $group: {
|
|
179
|
-
_id: null,
|
|
180
|
-
[resultKey]: { [operator]: `$${field}` }
|
|
181
|
-
} }];
|
|
182
|
-
return (await this.aggregatePipeline(pipeline, options))[0]?.[resultKey] || 0;
|
|
183
|
-
};
|
|
184
|
-
/**
|
|
185
|
-
* Sum field values
|
|
186
|
-
*/
|
|
187
|
-
repo.registerMethod("sum", async function(field, query = {}, options = {}) {
|
|
188
|
-
return aggregateOperation.call(this, field, "$sum", "total", query, options);
|
|
189
|
-
});
|
|
190
|
-
/**
|
|
191
|
-
* Average field values
|
|
192
|
-
*/
|
|
193
|
-
repo.registerMethod("average", async function(field, query = {}, options = {}) {
|
|
194
|
-
return aggregateOperation.call(this, field, "$avg", "avg", query, options);
|
|
195
|
-
});
|
|
196
|
-
/**
|
|
197
|
-
* Get minimum value
|
|
198
|
-
*/
|
|
199
|
-
repo.registerMethod("min", async function(field, query = {}, options = {}) {
|
|
200
|
-
return aggregateOperation.call(this, field, "$min", "min", query, options);
|
|
201
|
-
});
|
|
202
|
-
/**
|
|
203
|
-
* Get maximum value
|
|
204
|
-
*/
|
|
205
|
-
repo.registerMethod("max", async function(field, query = {}, options = {}) {
|
|
206
|
-
return aggregateOperation.call(this, field, "$max", "max", query, options);
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
//#endregion
|
|
212
|
-
//#region src/plugins/audit-log.plugin.ts
|
|
213
|
-
/**
|
|
214
|
-
* Audit log plugin that logs all repository operations
|
|
215
|
-
*
|
|
216
|
-
* @example
|
|
217
|
-
* const repo = new Repository(Model, [auditLogPlugin(console)]);
|
|
218
|
-
*/
|
|
219
|
-
function auditLogPlugin(logger) {
|
|
220
|
-
return {
|
|
221
|
-
name: "auditLog",
|
|
222
|
-
apply(repo) {
|
|
223
|
-
repo.on("after:create", ({ context, result }) => {
|
|
224
|
-
const idKey = repo.idField || "_id";
|
|
225
|
-
logger?.info?.("Document created", {
|
|
226
|
-
model: context.model || repo.model,
|
|
227
|
-
id: result?.[idKey],
|
|
228
|
-
userId: context.user?._id || context.user?.id,
|
|
229
|
-
organizationId: context.organizationId
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
repo.on("after:update", ({ context, result }) => {
|
|
233
|
-
logger?.info?.("Document updated", {
|
|
234
|
-
model: context.model || repo.model,
|
|
235
|
-
id: context.id || result?.[repo.idField || "_id"],
|
|
236
|
-
userId: context.user?._id || context.user?.id,
|
|
237
|
-
organizationId: context.organizationId
|
|
238
|
-
});
|
|
239
|
-
});
|
|
240
|
-
repo.on("after:findOneAndUpdate", ({ context, result }) => {
|
|
241
|
-
if (!result) return;
|
|
242
|
-
logger?.info?.("Document upserted/updated (findOneAndUpdate)", {
|
|
243
|
-
model: context.model || repo.model,
|
|
244
|
-
id: result?.[repo.idField || "_id"],
|
|
245
|
-
userId: context.user?._id || context.user?.id,
|
|
246
|
-
organizationId: context.organizationId
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
repo.on("error:findOneAndUpdate", ({ context, error }) => {
|
|
250
|
-
logger?.error?.("findOneAndUpdate failed", {
|
|
251
|
-
model: context.model || repo.model,
|
|
252
|
-
error: error.message,
|
|
253
|
-
userId: context.user?._id || context.user?.id
|
|
254
|
-
});
|
|
255
|
-
});
|
|
256
|
-
repo.on("after:delete", ({ context }) => {
|
|
257
|
-
logger?.info?.("Document deleted", {
|
|
258
|
-
model: context.model || repo.model,
|
|
259
|
-
id: context.id,
|
|
260
|
-
userId: context.user?._id || context.user?.id,
|
|
261
|
-
organizationId: context.organizationId
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
repo.on("error:create", ({ context, error }) => {
|
|
265
|
-
logger?.error?.("Create failed", {
|
|
266
|
-
model: context.model || repo.model,
|
|
267
|
-
error: error.message,
|
|
268
|
-
userId: context.user?._id || context.user?.id
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
repo.on("error:update", ({ context, error }) => {
|
|
272
|
-
logger?.error?.("Update failed", {
|
|
273
|
-
model: context.model || repo.model,
|
|
274
|
-
id: context.id,
|
|
275
|
-
error: error.message,
|
|
276
|
-
userId: context.user?._id || context.user?.id
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
repo.on("error:delete", ({ context, error }) => {
|
|
280
|
-
logger?.error?.("Delete failed", {
|
|
281
|
-
model: context.model || repo.model,
|
|
282
|
-
id: context.id,
|
|
283
|
-
error: error.message,
|
|
284
|
-
userId: context.user?._id || context.user?.id
|
|
285
|
-
});
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
//#endregion
|
|
291
|
-
//#region src/plugins/audit-trail.plugin.ts
|
|
292
|
-
/**
|
|
293
|
-
* Audit Trail Plugin
|
|
294
|
-
*
|
|
295
|
-
* Persists operation audit entries to a MongoDB collection.
|
|
296
|
-
* Fire-and-forget: writes happen async and never block or fail the main operation.
|
|
297
|
-
*
|
|
298
|
-
* Features:
|
|
299
|
-
* - Tracks create, update, delete operations
|
|
300
|
-
* - Field-level change tracking (before/after diff on updates)
|
|
301
|
-
* - TTL auto-cleanup via MongoDB TTL index
|
|
302
|
-
* - Custom metadata per entry (IP, user-agent, etc.)
|
|
303
|
-
* - Shared `audit_trails` collection across all models
|
|
304
|
-
*
|
|
305
|
-
* @example
|
|
306
|
-
* ```typescript
|
|
307
|
-
* const repo = new Repository(Job, [
|
|
308
|
-
* auditTrailPlugin({
|
|
309
|
-
* operations: ['create', 'update', 'delete'],
|
|
310
|
-
* trackChanges: true,
|
|
311
|
-
* ttlDays: 90,
|
|
312
|
-
* metadata: (context) => ({
|
|
313
|
-
* ip: context.req?.ip,
|
|
314
|
-
* }),
|
|
315
|
-
* }),
|
|
316
|
-
* ]);
|
|
317
|
-
* ```
|
|
318
|
-
*/
|
|
319
|
-
const modelCache = /* @__PURE__ */ new Map();
|
|
320
|
-
function getAuditModel(collectionName, ttlDays) {
|
|
321
|
-
const existing = modelCache.get(collectionName);
|
|
322
|
-
if (existing) return existing;
|
|
323
|
-
const schema = new mongoose.Schema({
|
|
324
|
-
model: {
|
|
325
|
-
type: String,
|
|
326
|
-
required: true,
|
|
327
|
-
index: true
|
|
328
|
-
},
|
|
329
|
-
operation: {
|
|
330
|
-
type: String,
|
|
331
|
-
required: true,
|
|
332
|
-
enum: [
|
|
333
|
-
"create",
|
|
334
|
-
"update",
|
|
335
|
-
"findOneAndUpdate",
|
|
336
|
-
"delete"
|
|
337
|
-
]
|
|
338
|
-
},
|
|
339
|
-
documentId: {
|
|
340
|
-
type: mongoose.Schema.Types.Mixed,
|
|
341
|
-
required: true,
|
|
342
|
-
index: true
|
|
343
|
-
},
|
|
344
|
-
userId: {
|
|
345
|
-
type: mongoose.Schema.Types.Mixed,
|
|
346
|
-
index: true
|
|
347
|
-
},
|
|
348
|
-
orgId: {
|
|
349
|
-
type: mongoose.Schema.Types.Mixed,
|
|
350
|
-
index: true
|
|
351
|
-
},
|
|
352
|
-
changes: { type: mongoose.Schema.Types.Mixed },
|
|
353
|
-
document: { type: mongoose.Schema.Types.Mixed },
|
|
354
|
-
metadata: { type: mongoose.Schema.Types.Mixed },
|
|
355
|
-
timestamp: {
|
|
356
|
-
type: Date,
|
|
357
|
-
default: Date.now,
|
|
358
|
-
index: true
|
|
359
|
-
}
|
|
360
|
-
}, {
|
|
361
|
-
collection: collectionName,
|
|
362
|
-
versionKey: false
|
|
363
|
-
});
|
|
364
|
-
schema.index({
|
|
365
|
-
model: 1,
|
|
366
|
-
documentId: 1,
|
|
367
|
-
timestamp: -1
|
|
368
|
-
});
|
|
369
|
-
schema.index({
|
|
370
|
-
orgId: 1,
|
|
371
|
-
userId: 1,
|
|
372
|
-
timestamp: -1
|
|
373
|
-
});
|
|
374
|
-
if (ttlDays !== void 0 && ttlDays > 0) {
|
|
375
|
-
const ttlSeconds = ttlDays * 24 * 60 * 60;
|
|
376
|
-
schema.index({ timestamp: 1 }, { expireAfterSeconds: ttlSeconds });
|
|
377
|
-
}
|
|
378
|
-
const modelName = `AuditTrail_${collectionName}`;
|
|
379
|
-
const model = mongoose.models[modelName] || mongoose.model(modelName, schema);
|
|
380
|
-
modelCache.set(collectionName, model);
|
|
381
|
-
return model;
|
|
382
|
-
}
|
|
383
|
-
/** Compute field-level diff between previous and updated document */
|
|
384
|
-
function computeChanges(prev, next, excludeFields) {
|
|
385
|
-
const changes = {};
|
|
386
|
-
const exclude = new Set(excludeFields);
|
|
387
|
-
for (const key of Object.keys(next)) {
|
|
388
|
-
if (exclude.has(key)) continue;
|
|
389
|
-
if (key === "_id" || key === "__v" || key === "updatedAt") continue;
|
|
390
|
-
const prevVal = prev[key];
|
|
391
|
-
const nextVal = next[key];
|
|
392
|
-
if (!deepEqual(prevVal, nextVal)) changes[key] = {
|
|
393
|
-
from: prevVal,
|
|
394
|
-
to: nextVal
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
return Object.keys(changes).length > 0 ? changes : void 0;
|
|
398
|
-
}
|
|
399
|
-
/** Simple deep equality check for audit diffing */
|
|
400
|
-
function deepEqual(a, b) {
|
|
401
|
-
if (a === b) return true;
|
|
402
|
-
if (a == null && b == null) return true;
|
|
403
|
-
if (a == null || b == null) return false;
|
|
404
|
-
if (typeof a === "object" && typeof b === "object") {
|
|
405
|
-
const aStr = a.toString?.();
|
|
406
|
-
const bStr = b.toString?.();
|
|
407
|
-
if (aStr && bStr && aStr === bStr) return true;
|
|
408
|
-
}
|
|
409
|
-
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
410
|
-
try {
|
|
411
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
412
|
-
} catch {
|
|
413
|
-
return false;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
/** Extract user ID from context */
|
|
417
|
-
function getUserId(context) {
|
|
418
|
-
return context.user?._id || context.user?.id;
|
|
419
|
-
}
|
|
420
|
-
/** Fire-and-forget: write audit entry, never throw */
|
|
421
|
-
function writeAudit(AuditModel, entry) {
|
|
422
|
-
Promise.resolve().then(() => {
|
|
423
|
-
AuditModel.create({
|
|
424
|
-
...entry,
|
|
425
|
-
timestamp: /* @__PURE__ */ new Date()
|
|
426
|
-
}).catch((err) => {
|
|
427
|
-
warn(`[auditTrailPlugin] Failed to write audit entry: ${err.message}`);
|
|
428
|
-
});
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
const snapshots = /* @__PURE__ */ new WeakMap();
|
|
432
|
-
function auditTrailPlugin(options = {}) {
|
|
433
|
-
const { operations = [
|
|
434
|
-
"create",
|
|
435
|
-
"update",
|
|
436
|
-
"delete"
|
|
437
|
-
], trackChanges = true, trackDocument = false, ttlDays, collectionName = "audit_trails", metadata, excludeFields = [] } = options;
|
|
438
|
-
const opsSet = new Set(operations);
|
|
439
|
-
return {
|
|
440
|
-
name: "auditTrail",
|
|
441
|
-
apply(repo) {
|
|
442
|
-
const AuditModel = getAuditModel(collectionName, ttlDays);
|
|
443
|
-
if (opsSet.has("create")) repo.on("after:create", ({ context, result }) => {
|
|
444
|
-
const doc = toPlainObject(result);
|
|
445
|
-
const idKey = repo.idField || "_id";
|
|
446
|
-
writeAudit(AuditModel, {
|
|
447
|
-
model: context.model || repo.model,
|
|
448
|
-
operation: "create",
|
|
449
|
-
documentId: doc?.[idKey],
|
|
450
|
-
userId: getUserId(context),
|
|
451
|
-
orgId: context.organizationId,
|
|
452
|
-
document: trackDocument ? sanitizeDoc(doc, excludeFields) : void 0,
|
|
453
|
-
metadata: metadata?.(context)
|
|
454
|
-
});
|
|
455
|
-
});
|
|
456
|
-
if (opsSet.has("update")) {
|
|
457
|
-
if (trackChanges) repo.on("before:update", async (context) => {
|
|
458
|
-
if (!context.id) return;
|
|
459
|
-
try {
|
|
460
|
-
const prev = await repo.Model.findById(context.id).lean();
|
|
461
|
-
if (prev) snapshots.set(context, prev);
|
|
462
|
-
} catch (err) {
|
|
463
|
-
warn(`[auditTrailPlugin] Failed to snapshot before update: ${err.message}`);
|
|
464
|
-
}
|
|
465
|
-
});
|
|
466
|
-
repo.on("after:update", ({ context, result }) => {
|
|
467
|
-
const doc = result;
|
|
468
|
-
let changes;
|
|
469
|
-
if (trackChanges) {
|
|
470
|
-
const prev = snapshots.get(context);
|
|
471
|
-
if (prev && context.data) changes = computeChanges(prev, context.data, excludeFields);
|
|
472
|
-
snapshots.delete(context);
|
|
473
|
-
}
|
|
474
|
-
writeAudit(AuditModel, {
|
|
475
|
-
model: context.model || repo.model,
|
|
476
|
-
operation: "update",
|
|
477
|
-
documentId: context.id || doc?.[repo.idField || "_id"],
|
|
478
|
-
userId: getUserId(context),
|
|
479
|
-
orgId: context.organizationId,
|
|
480
|
-
changes,
|
|
481
|
-
metadata: metadata?.(context)
|
|
482
|
-
});
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
if (opsSet.has("findOneAndUpdate")) repo.on("after:findOneAndUpdate", ({ context, result }) => {
|
|
486
|
-
if (!result) return;
|
|
487
|
-
const doc = result;
|
|
488
|
-
const idKey = repo.idField || "_id";
|
|
489
|
-
writeAudit(AuditModel, {
|
|
490
|
-
model: context.model || repo.model,
|
|
491
|
-
operation: "findOneAndUpdate",
|
|
492
|
-
documentId: doc?.[idKey],
|
|
493
|
-
userId: getUserId(context),
|
|
494
|
-
orgId: context.organizationId,
|
|
495
|
-
metadata: metadata?.(context)
|
|
496
|
-
});
|
|
497
|
-
});
|
|
498
|
-
if (opsSet.has("delete")) repo.on("after:delete", ({ context }) => {
|
|
499
|
-
writeAudit(AuditModel, {
|
|
500
|
-
model: context.model || repo.model,
|
|
501
|
-
operation: "delete",
|
|
502
|
-
documentId: context.id,
|
|
503
|
-
userId: getUserId(context),
|
|
504
|
-
orgId: context.organizationId,
|
|
505
|
-
metadata: metadata?.(context)
|
|
506
|
-
});
|
|
507
|
-
});
|
|
508
|
-
if (typeof repo.registerMethod === "function")
|
|
509
|
-
/**
|
|
510
|
-
* Get audit trail for a specific document
|
|
511
|
-
*/
|
|
512
|
-
repo.registerMethod("getAuditTrail", async function(documentId, queryOptions = {}) {
|
|
513
|
-
const { page = 1, limit = 20, operation } = queryOptions;
|
|
514
|
-
const skip = (page - 1) * limit;
|
|
515
|
-
const filter = {
|
|
516
|
-
model: this.model,
|
|
517
|
-
documentId
|
|
518
|
-
};
|
|
519
|
-
if (operation) filter.operation = operation;
|
|
520
|
-
const [docs, total] = await Promise.all([AuditModel.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), AuditModel.countDocuments(filter)]);
|
|
521
|
-
return {
|
|
522
|
-
docs,
|
|
523
|
-
page,
|
|
524
|
-
limit,
|
|
525
|
-
total,
|
|
526
|
-
pages: Math.ceil(total / limit),
|
|
527
|
-
hasNext: page < Math.ceil(total / limit),
|
|
528
|
-
hasPrev: page > 1
|
|
529
|
-
};
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
/** Convert Mongoose document to plain object */
|
|
535
|
-
function toPlainObject(doc) {
|
|
536
|
-
if (!doc) return {};
|
|
537
|
-
if (typeof doc.toObject === "function") return doc.toObject();
|
|
538
|
-
return doc;
|
|
539
|
-
}
|
|
540
|
-
/** Remove excluded fields from a document snapshot */
|
|
541
|
-
function sanitizeDoc(doc, excludeFields) {
|
|
542
|
-
if (excludeFields.length === 0) return doc;
|
|
543
|
-
const result = { ...doc };
|
|
544
|
-
for (const field of excludeFields) delete result[field];
|
|
545
|
-
return result;
|
|
546
|
-
}
|
|
547
|
-
/**
|
|
548
|
-
* Standalone audit trail query utility.
|
|
549
|
-
* Use this to query audits across all models — e.g., admin dashboards, audit APIs.
|
|
550
|
-
*
|
|
551
|
-
* @example
|
|
552
|
-
* ```typescript
|
|
553
|
-
* import { AuditTrailQuery } from '@classytic/mongokit';
|
|
554
|
-
*
|
|
555
|
-
* const auditQuery = new AuditTrailQuery(); // defaults to 'audit_trails' collection
|
|
556
|
-
*
|
|
557
|
-
* // All audits for an org
|
|
558
|
-
* const orgAudits = await auditQuery.query({ orgId: '...' });
|
|
559
|
-
*
|
|
560
|
-
* // All updates by a user
|
|
561
|
-
* const userUpdates = await auditQuery.query({
|
|
562
|
-
* userId: '...',
|
|
563
|
-
* operation: 'update',
|
|
564
|
-
* });
|
|
565
|
-
*
|
|
566
|
-
* // All audits for a specific document
|
|
567
|
-
* const docHistory = await auditQuery.query({
|
|
568
|
-
* model: 'Job',
|
|
569
|
-
* documentId: '...',
|
|
570
|
-
* });
|
|
571
|
-
*
|
|
572
|
-
* // Date range
|
|
573
|
-
* const recent = await auditQuery.query({
|
|
574
|
-
* from: new Date('2025-01-01'),
|
|
575
|
-
* to: new Date(),
|
|
576
|
-
* page: 1,
|
|
577
|
-
* limit: 50,
|
|
578
|
-
* });
|
|
579
|
-
*
|
|
580
|
-
* // Direct model access for custom queries
|
|
581
|
-
* const model = auditQuery.getModel();
|
|
582
|
-
* const count = await model.countDocuments({ operation: 'delete' });
|
|
583
|
-
* ```
|
|
584
|
-
*/
|
|
585
|
-
var AuditTrailQuery = class {
|
|
586
|
-
model;
|
|
587
|
-
constructor(collectionName = "audit_trails", ttlDays) {
|
|
588
|
-
this.model = getAuditModel(collectionName, ttlDays);
|
|
589
|
-
}
|
|
590
|
-
/**
|
|
591
|
-
* Get the underlying Mongoose model for custom queries
|
|
592
|
-
*/
|
|
593
|
-
getModel() {
|
|
594
|
-
return this.model;
|
|
595
|
-
}
|
|
596
|
-
/**
|
|
597
|
-
* Query audit entries with filters and pagination
|
|
598
|
-
*/
|
|
599
|
-
async query(options = {}) {
|
|
600
|
-
const { page = 1, limit = 20 } = options;
|
|
601
|
-
const skip = (page - 1) * limit;
|
|
602
|
-
const filter = {};
|
|
603
|
-
if (options.model) filter.model = options.model;
|
|
604
|
-
if (options.documentId) filter.documentId = options.documentId;
|
|
605
|
-
if (options.userId) filter.userId = options.userId;
|
|
606
|
-
if (options.orgId) filter.orgId = options.orgId;
|
|
607
|
-
if (options.operation) filter.operation = options.operation;
|
|
608
|
-
if (options.from || options.to) {
|
|
609
|
-
const dateFilter = {};
|
|
610
|
-
if (options.from) dateFilter.$gte = options.from;
|
|
611
|
-
if (options.to) dateFilter.$lte = options.to;
|
|
612
|
-
filter.timestamp = dateFilter;
|
|
613
|
-
}
|
|
614
|
-
const [docs, total] = await Promise.all([this.model.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), this.model.countDocuments(filter)]);
|
|
615
|
-
const pages = Math.ceil(total / limit);
|
|
616
|
-
return {
|
|
617
|
-
docs,
|
|
618
|
-
page,
|
|
619
|
-
limit,
|
|
620
|
-
total,
|
|
621
|
-
pages,
|
|
622
|
-
hasNext: page < pages,
|
|
623
|
-
hasPrev: page > 1
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
/**
|
|
627
|
-
* Get audit trail for a specific document
|
|
628
|
-
*/
|
|
629
|
-
async getDocumentTrail(model, documentId, options = {}) {
|
|
630
|
-
return this.query({
|
|
631
|
-
model,
|
|
632
|
-
documentId,
|
|
633
|
-
...options
|
|
634
|
-
});
|
|
635
|
-
}
|
|
636
|
-
/**
|
|
637
|
-
* Get all audits for a user
|
|
638
|
-
*/
|
|
639
|
-
async getUserTrail(userId, options = {}) {
|
|
640
|
-
return this.query({
|
|
641
|
-
userId,
|
|
642
|
-
...options
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
/**
|
|
646
|
-
* Get all audits for an organization
|
|
647
|
-
*/
|
|
648
|
-
async getOrgTrail(orgId, options = {}) {
|
|
649
|
-
return this.query({
|
|
650
|
-
orgId,
|
|
651
|
-
...options
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
};
|
|
655
|
-
//#endregion
|
|
656
|
-
//#region src/plugins/batch-operations.plugin.ts
|
|
657
|
-
/**
|
|
658
|
-
* Batch operations plugin.
|
|
659
|
-
*
|
|
660
|
-
* @example
|
|
661
|
-
* const repo = new Repository(Model, [
|
|
662
|
-
* methodRegistryPlugin(),
|
|
663
|
-
* batchOperationsPlugin(),
|
|
664
|
-
* ]);
|
|
665
|
-
*
|
|
666
|
-
* // updateMany + deleteMany are primitives on Repository — no plugin needed.
|
|
667
|
-
* await repo.updateMany({ status: 'pending' }, { status: 'active' });
|
|
668
|
-
* await repo.deleteMany({ status: 'deleted' });
|
|
669
|
-
*
|
|
670
|
-
* // bulkWrite is what this plugin contributes.
|
|
671
|
-
* await repo.bulkWrite([...ops]);
|
|
672
|
-
*/
|
|
673
|
-
function batchOperationsPlugin() {
|
|
674
|
-
return {
|
|
675
|
-
name: "batch-operations",
|
|
676
|
-
apply(repo) {
|
|
677
|
-
if (!repo.registerMethod) throw new Error("batchOperationsPlugin requires methodRegistryPlugin");
|
|
678
|
-
/**
|
|
679
|
-
* Execute heterogeneous bulk write operations in a single database call.
|
|
680
|
-
*
|
|
681
|
-
* Supports insertOne, updateOne, updateMany, deleteOne, deleteMany, and replaceOne
|
|
682
|
-
* operations mixed together for maximum efficiency.
|
|
683
|
-
*
|
|
684
|
-
* @example
|
|
685
|
-
* await repo.bulkWrite([
|
|
686
|
-
* { insertOne: { document: { name: 'New Item', price: 10 } } },
|
|
687
|
-
* { updateOne: { filter: { _id: id1 }, update: { $inc: { views: 1 } } } },
|
|
688
|
-
* { updateMany: { filter: { status: 'draft' }, update: { $set: { status: 'published' } } } },
|
|
689
|
-
* { deleteOne: { filter: { _id: id2 } } },
|
|
690
|
-
* ]);
|
|
691
|
-
*/
|
|
692
|
-
repo.registerMethod("bulkWrite", async function(operations, options = {}) {
|
|
693
|
-
const context = await this._buildContext("bulkWrite", {
|
|
694
|
-
operations,
|
|
695
|
-
...options
|
|
696
|
-
});
|
|
697
|
-
try {
|
|
698
|
-
const finalOps = context.operations || operations;
|
|
699
|
-
if (!finalOps || finalOps.length === 0) throw createError(400, "bulkWrite requires at least one operation");
|
|
700
|
-
const result = await this.Model.bulkWrite(finalOps, {
|
|
701
|
-
ordered: options.ordered ?? true,
|
|
702
|
-
session: options.session
|
|
703
|
-
});
|
|
704
|
-
const bulkResult = {
|
|
705
|
-
ok: result.ok,
|
|
706
|
-
insertedCount: result.insertedCount,
|
|
707
|
-
upsertedCount: result.upsertedCount,
|
|
708
|
-
matchedCount: result.matchedCount,
|
|
709
|
-
modifiedCount: result.modifiedCount,
|
|
710
|
-
deletedCount: result.deletedCount,
|
|
711
|
-
insertedIds: result.insertedIds,
|
|
712
|
-
upsertedIds: result.upsertedIds
|
|
713
|
-
};
|
|
714
|
-
await this.emitAsync("after:bulkWrite", {
|
|
715
|
-
context,
|
|
716
|
-
result: bulkResult
|
|
717
|
-
});
|
|
718
|
-
return bulkResult;
|
|
719
|
-
} catch (error) {
|
|
720
|
-
this.emit("error:bulkWrite", {
|
|
721
|
-
context,
|
|
722
|
-
error
|
|
723
|
-
});
|
|
724
|
-
throw this._handleError(error);
|
|
725
|
-
}
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
//#endregion
|
|
731
|
-
//#region src/actions/aggregate-ir/normalize.ts
|
|
732
|
-
/**
|
|
733
|
-
* Normalize `AggRequest['groupBy']` into a readonly string array.
|
|
734
|
-
* Returns `[]` for scalar aggregation (no groupBy). Downstream
|
|
735
|
-
* compilers treat `[]` uniformly — one `$group: { _id: null, ... }`
|
|
736
|
-
* stage, no per-key projection.
|
|
737
|
-
*/
|
|
738
|
-
function normalizeGroupBy(groupBy) {
|
|
739
|
-
if (!groupBy) return [];
|
|
740
|
-
if (typeof groupBy === "string") return [groupBy];
|
|
741
|
-
return groupBy;
|
|
742
|
-
}
|
|
743
|
-
/**
|
|
744
|
-
* Fail loud on an empty measures bag — there's nothing to compute and
|
|
745
|
-
* the caller's code path is almost certainly a wiring bug (conditional
|
|
746
|
-
* collapsed, key renamed, etc.). Silently returning `{ rows: [] }`
|
|
747
|
-
* would mask it.
|
|
748
|
-
*/
|
|
749
|
-
function validateMeasures(measures) {
|
|
750
|
-
if (!measures || Object.keys(measures).length === 0) throw new Error("mongokit/aggregate: AggRequest requires at least one measure — empty measures bag is a wiring bug");
|
|
751
|
-
}
|
|
752
|
-
//#endregion
|
|
753
|
-
//#region src/actions/aggregate-ir/measure.ts
|
|
754
|
-
function compileMeasure(measure) {
|
|
755
|
-
switch (measure.op) {
|
|
756
|
-
case "count":
|
|
757
|
-
if (!measure.field || measure.field === "*") return { groupExpr: { $sum: 1 } };
|
|
758
|
-
return { groupExpr: { $sum: { $cond: [
|
|
759
|
-
{ $ne: [`$${measure.field}`, null] },
|
|
760
|
-
1,
|
|
761
|
-
0
|
|
762
|
-
] } } };
|
|
763
|
-
case "countDistinct": return {
|
|
764
|
-
groupExpr: { $addToSet: `$${measure.field}` },
|
|
765
|
-
projectExpr: { $size: { $ifNull: ["$$CURRENT", []] } }
|
|
766
|
-
};
|
|
767
|
-
case "sum": return { groupExpr: { $sum: `$${measure.field}` } };
|
|
768
|
-
case "avg": return { groupExpr: { $avg: `$${measure.field}` } };
|
|
769
|
-
case "min": return { groupExpr: { $min: `$${measure.field}` } };
|
|
770
|
-
case "max": return { groupExpr: { $max: `$${measure.field}` } };
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
//#endregion
|
|
774
|
-
//#region src/actions/aggregate-ir/pipeline.ts
|
|
775
|
-
function buildAggPipeline(req) {
|
|
776
|
-
validateMeasures(req.measures);
|
|
777
|
-
const groupCols = normalizeGroupBy(req.groupBy);
|
|
778
|
-
const stages = [];
|
|
779
|
-
if (req.filter) {
|
|
780
|
-
const match = compileFilterToMongo(req.filter);
|
|
781
|
-
if (Object.keys(match).length > 0) stages.push({ $match: match });
|
|
782
|
-
}
|
|
783
|
-
const groupStage = { _id: groupCols.length === 0 ? null : groupCols.reduce((acc, field) => {
|
|
784
|
-
acc[field] = `$${field}`;
|
|
785
|
-
return acc;
|
|
786
|
-
}, {}) };
|
|
787
|
-
const distinctSetAliases = [];
|
|
788
|
-
for (const [alias, measure] of Object.entries(req.measures)) {
|
|
789
|
-
groupStage[alias] = compileMeasure(measure).groupExpr;
|
|
790
|
-
if (measure.op === "countDistinct") distinctSetAliases.push(alias);
|
|
791
|
-
}
|
|
792
|
-
stages.push({ $group: groupStage });
|
|
793
|
-
if (distinctSetAliases.length > 0) {
|
|
794
|
-
const addFields = {};
|
|
795
|
-
for (const alias of distinctSetAliases) addFields[alias] = { $size: { $ifNull: [`$${alias}`, []] } };
|
|
796
|
-
stages.push({ $addFields: addFields });
|
|
797
|
-
}
|
|
798
|
-
const projection = { _id: 0 };
|
|
799
|
-
for (const field of groupCols) projection[field] = `$_id.${field}`;
|
|
800
|
-
for (const alias of Object.keys(req.measures)) projection[alias] = 1;
|
|
801
|
-
stages.push({ $project: projection });
|
|
802
|
-
if (req.having) {
|
|
803
|
-
const match = compileFilterToMongo(req.having);
|
|
804
|
-
if (Object.keys(match).length > 0) stages.push({ $match: match });
|
|
805
|
-
}
|
|
806
|
-
const prePaginationIndex = stages.length;
|
|
807
|
-
if (req.sort) stages.push({ $sort: req.sort });
|
|
808
|
-
if (typeof req.offset === "number" && req.offset > 0) stages.push({ $skip: req.offset });
|
|
809
|
-
if (typeof req.limit === "number") stages.push({ $limit: req.limit });
|
|
810
|
-
return {
|
|
811
|
-
pipeline: stages,
|
|
812
|
-
prePaginationIndex
|
|
813
|
-
};
|
|
814
|
-
}
|
|
815
|
-
//#endregion
|
|
816
|
-
//#region src/actions/aggregate-ir/count.ts
|
|
817
|
-
async function countAggGroups(Model, req, options = {}) {
|
|
818
|
-
const session = options.session;
|
|
819
|
-
const groupCols = normalizeGroupBy(req.groupBy);
|
|
820
|
-
if (req.having) {
|
|
821
|
-
const { pipeline, prePaginationIndex } = buildAggPipeline(req);
|
|
822
|
-
const finalPipeline = [...pipeline.slice(0, prePaginationIndex), { $count: "n" }];
|
|
823
|
-
const aggregation = Model.aggregate(finalPipeline);
|
|
824
|
-
if (session) aggregation.session(session);
|
|
825
|
-
const [row] = await aggregation.exec();
|
|
826
|
-
return row?.n ?? 0;
|
|
827
|
-
}
|
|
828
|
-
const match = req.filter ? compileFilterToMongo(req.filter) : {};
|
|
829
|
-
if (groupCols.length === 0) {
|
|
830
|
-
const pipeline = [];
|
|
831
|
-
if (Object.keys(match).length > 0) pipeline.push({ $match: match });
|
|
832
|
-
pipeline.push({ $limit: 1 });
|
|
833
|
-
pipeline.push({ $count: "n" });
|
|
834
|
-
const aggregation = Model.aggregate(pipeline);
|
|
835
|
-
if (session) aggregation.session(session);
|
|
836
|
-
const [row] = await aggregation.exec();
|
|
837
|
-
return row?.n ?? 0;
|
|
838
|
-
}
|
|
839
|
-
const groupId = groupCols.reduce((acc, field) => {
|
|
840
|
-
acc[field] = `$${field}`;
|
|
841
|
-
return acc;
|
|
842
|
-
}, {});
|
|
843
|
-
const pipeline = [];
|
|
844
|
-
if (Object.keys(match).length > 0) pipeline.push({ $match: match });
|
|
845
|
-
pipeline.push({ $group: { _id: groupId } });
|
|
846
|
-
pipeline.push({ $count: "n" });
|
|
847
|
-
const aggregation = Model.aggregate(pipeline);
|
|
848
|
-
if (session) aggregation.session(session);
|
|
849
|
-
const [row] = await aggregation.exec();
|
|
850
|
-
return row?.n ?? 0;
|
|
851
|
-
}
|
|
852
|
-
//#endregion
|
|
853
|
-
//#region src/actions/aggregate-ir/execute.ts
|
|
854
|
-
async function executeAgg(Model, req, options = {}) {
|
|
855
|
-
const session = options.session;
|
|
856
|
-
const { pipeline } = buildAggPipeline(req);
|
|
857
|
-
const aggregation = Model.aggregate(pipeline);
|
|
858
|
-
if (session) aggregation.session(session);
|
|
859
|
-
return await aggregation.exec();
|
|
860
|
-
}
|
|
861
|
-
//#endregion
|
|
862
|
-
//#region src/query/AggregationBuilder.ts
|
|
863
|
-
/**
|
|
864
|
-
* Normalize SortSpec to MongoDB's strict format (1 | -1)
|
|
865
|
-
* Converts 'asc' -> 1, 'desc' -> -1
|
|
866
|
-
*/
|
|
867
|
-
function normalizeSortSpec(sortSpec) {
|
|
868
|
-
const normalized = {};
|
|
869
|
-
for (const [field, order] of Object.entries(sortSpec)) if (order === "asc") normalized[field] = 1;
|
|
870
|
-
else if (order === "desc") normalized[field] = -1;
|
|
871
|
-
else normalized[field] = order;
|
|
872
|
-
return normalized;
|
|
873
|
-
}
|
|
874
|
-
/**
|
|
875
|
-
* Fluent builder for MongoDB aggregation pipelines
|
|
876
|
-
* Optimized for complex queries at scale
|
|
877
|
-
*/
|
|
878
|
-
var AggregationBuilder = class AggregationBuilder {
|
|
879
|
-
pipeline = [];
|
|
880
|
-
_diskUse = false;
|
|
881
|
-
/**
|
|
882
|
-
* Get the current pipeline
|
|
883
|
-
*/
|
|
884
|
-
get() {
|
|
885
|
-
return [...this.pipeline];
|
|
886
|
-
}
|
|
887
|
-
/**
|
|
888
|
-
* Build and return the final pipeline
|
|
889
|
-
*/
|
|
890
|
-
build() {
|
|
891
|
-
return this.get();
|
|
892
|
-
}
|
|
893
|
-
/**
|
|
894
|
-
* Build pipeline with execution options (allowDiskUse, etc.)
|
|
895
|
-
*/
|
|
896
|
-
plan() {
|
|
897
|
-
return {
|
|
898
|
-
pipeline: this.get(),
|
|
899
|
-
allowDiskUse: this._diskUse
|
|
900
|
-
};
|
|
901
|
-
}
|
|
902
|
-
/**
|
|
903
|
-
* Build and execute the pipeline against a model
|
|
904
|
-
*
|
|
905
|
-
* @example
|
|
906
|
-
* ```typescript
|
|
907
|
-
* const results = await new AggregationBuilder()
|
|
908
|
-
* .match({ status: 'active' })
|
|
909
|
-
* .allowDiskUse()
|
|
910
|
-
* .exec(MyModel);
|
|
911
|
-
* ```
|
|
912
|
-
*/
|
|
913
|
-
async exec(model, session) {
|
|
914
|
-
const agg = model.aggregate(this.build());
|
|
915
|
-
if (this._diskUse) agg.allowDiskUse(true);
|
|
916
|
-
if (session) agg.session(session);
|
|
917
|
-
return agg.exec();
|
|
918
|
-
}
|
|
919
|
-
/**
|
|
920
|
-
* Reset the pipeline
|
|
921
|
-
*/
|
|
922
|
-
reset() {
|
|
923
|
-
this.pipeline = [];
|
|
924
|
-
this._diskUse = false;
|
|
925
|
-
return this;
|
|
926
|
-
}
|
|
927
|
-
/**
|
|
928
|
-
* Add a raw pipeline stage
|
|
929
|
-
*/
|
|
930
|
-
addStage(stage) {
|
|
931
|
-
this.pipeline.push(stage);
|
|
932
|
-
return this;
|
|
933
|
-
}
|
|
934
|
-
/**
|
|
935
|
-
* Add multiple raw pipeline stages
|
|
936
|
-
*/
|
|
937
|
-
addStages(stages) {
|
|
938
|
-
this.pipeline.push(...stages);
|
|
939
|
-
return this;
|
|
940
|
-
}
|
|
941
|
-
/**
|
|
942
|
-
* $match - Filter documents
|
|
943
|
-
* IMPORTANT: Place $match as early as possible for performance
|
|
944
|
-
*/
|
|
945
|
-
match(query) {
|
|
946
|
-
this.pipeline.push({ $match: query });
|
|
947
|
-
return this;
|
|
948
|
-
}
|
|
949
|
-
/**
|
|
950
|
-
* $project - Include/exclude fields or compute new fields
|
|
951
|
-
*/
|
|
952
|
-
project(projection) {
|
|
953
|
-
this.pipeline.push({ $project: projection });
|
|
954
|
-
return this;
|
|
955
|
-
}
|
|
956
|
-
/**
|
|
957
|
-
* $group - Group documents and compute aggregations
|
|
958
|
-
*
|
|
959
|
-
* @example
|
|
960
|
-
* ```typescript
|
|
961
|
-
* .group({
|
|
962
|
-
* _id: '$department',
|
|
963
|
-
* count: { $sum: 1 },
|
|
964
|
-
* avgSalary: { $avg: '$salary' }
|
|
965
|
-
* })
|
|
966
|
-
* ```
|
|
967
|
-
*/
|
|
968
|
-
group(groupSpec) {
|
|
969
|
-
this.pipeline.push({ $group: groupSpec });
|
|
970
|
-
return this;
|
|
971
|
-
}
|
|
972
|
-
/**
|
|
973
|
-
* $sort - Sort documents
|
|
974
|
-
*/
|
|
975
|
-
sort(sortSpec) {
|
|
976
|
-
if (typeof sortSpec === "string") {
|
|
977
|
-
const order = sortSpec.startsWith("-") ? -1 : 1;
|
|
978
|
-
const field = sortSpec.startsWith("-") ? sortSpec.substring(1) : sortSpec;
|
|
979
|
-
this.pipeline.push({ $sort: { [field]: order } });
|
|
980
|
-
} else this.pipeline.push({ $sort: normalizeSortSpec(sortSpec) });
|
|
981
|
-
return this;
|
|
982
|
-
}
|
|
983
|
-
/**
|
|
984
|
-
* $limit - Limit number of documents
|
|
985
|
-
*/
|
|
986
|
-
limit(count) {
|
|
987
|
-
this.pipeline.push({ $limit: count });
|
|
988
|
-
return this;
|
|
989
|
-
}
|
|
990
|
-
/**
|
|
991
|
-
* $skip - Skip documents
|
|
992
|
-
*/
|
|
993
|
-
skip(count) {
|
|
994
|
-
this.pipeline.push({ $skip: count });
|
|
995
|
-
return this;
|
|
996
|
-
}
|
|
997
|
-
/**
|
|
998
|
-
* $unwind - Deconstruct array field
|
|
999
|
-
*/
|
|
1000
|
-
unwind(path, preserveNullAndEmptyArrays = false) {
|
|
1001
|
-
this.pipeline.push({ $unwind: {
|
|
1002
|
-
path: path.startsWith("$") ? path : `$${path}`,
|
|
1003
|
-
preserveNullAndEmptyArrays
|
|
1004
|
-
} });
|
|
1005
|
-
return this;
|
|
1006
|
-
}
|
|
1007
|
-
/**
|
|
1008
|
-
* $addFields - Add new fields or replace existing fields
|
|
1009
|
-
*/
|
|
1010
|
-
addFields(fields) {
|
|
1011
|
-
this.pipeline.push({ $addFields: fields });
|
|
1012
|
-
return this;
|
|
1013
|
-
}
|
|
1014
|
-
/**
|
|
1015
|
-
* $set - Alias for $addFields
|
|
1016
|
-
*/
|
|
1017
|
-
set(fields) {
|
|
1018
|
-
return this.addFields(fields);
|
|
1019
|
-
}
|
|
1020
|
-
/**
|
|
1021
|
-
* $unset - Remove fields
|
|
1022
|
-
*/
|
|
1023
|
-
unset(fields) {
|
|
1024
|
-
this.pipeline.push({ $unset: fields });
|
|
1025
|
-
return this;
|
|
1026
|
-
}
|
|
1027
|
-
/**
|
|
1028
|
-
* $replaceRoot - Replace the root document
|
|
1029
|
-
*/
|
|
1030
|
-
replaceRoot(newRoot) {
|
|
1031
|
-
this.pipeline.push({ $replaceRoot: { newRoot: typeof newRoot === "string" ? `$${newRoot}` : newRoot } });
|
|
1032
|
-
return this;
|
|
1033
|
-
}
|
|
1034
|
-
/**
|
|
1035
|
-
* $lookup - Join with another collection (simple form)
|
|
1036
|
-
*
|
|
1037
|
-
* @param from - Collection to join with
|
|
1038
|
-
* @param localField - Field from source collection
|
|
1039
|
-
* @param foreignField - Field from target collection
|
|
1040
|
-
* @param as - Output field name
|
|
1041
|
-
* @param single - Unwrap array to single object
|
|
1042
|
-
*
|
|
1043
|
-
* @example
|
|
1044
|
-
* ```typescript
|
|
1045
|
-
* // Join employees with departments by slug
|
|
1046
|
-
* .lookup('departments', 'deptSlug', 'slug', 'department', true)
|
|
1047
|
-
* ```
|
|
1048
|
-
*/
|
|
1049
|
-
lookup(from, localField, foreignField, as, single) {
|
|
1050
|
-
const stages = new LookupBuilder(from).localField(localField).foreignField(foreignField).as(as || from).single(single || false).build();
|
|
1051
|
-
this.pipeline.push(...stages);
|
|
1052
|
-
return this;
|
|
1053
|
-
}
|
|
1054
|
-
/**
|
|
1055
|
-
* $lookup - Join with another collection (advanced form with pipeline)
|
|
1056
|
-
*
|
|
1057
|
-
* @example
|
|
1058
|
-
* ```typescript
|
|
1059
|
-
* .lookupWithPipeline({
|
|
1060
|
-
* from: 'products',
|
|
1061
|
-
* localField: 'productIds',
|
|
1062
|
-
* foreignField: 'sku',
|
|
1063
|
-
* as: 'products',
|
|
1064
|
-
* pipeline: [
|
|
1065
|
-
* { $match: { status: 'active' } },
|
|
1066
|
-
* { $project: { name: 1, price: 1 } }
|
|
1067
|
-
* ]
|
|
1068
|
-
* })
|
|
1069
|
-
* ```
|
|
1070
|
-
*/
|
|
1071
|
-
lookupWithPipeline(options) {
|
|
1072
|
-
const builder = new LookupBuilder(options.from).localField(options.localField).foreignField(options.foreignField);
|
|
1073
|
-
if (options.as) builder.as(options.as);
|
|
1074
|
-
if (options.single) builder.single(options.single);
|
|
1075
|
-
if (options.pipeline) builder.pipeline(options.pipeline);
|
|
1076
|
-
if (options.let) builder.let(options.let);
|
|
1077
|
-
this.pipeline.push(...builder.build());
|
|
1078
|
-
return this;
|
|
1079
|
-
}
|
|
1080
|
-
/**
|
|
1081
|
-
* Multiple lookups at once
|
|
1082
|
-
*
|
|
1083
|
-
* @example
|
|
1084
|
-
* ```typescript
|
|
1085
|
-
* .multiLookup([
|
|
1086
|
-
* { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
|
|
1087
|
-
* { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
|
|
1088
|
-
* ])
|
|
1089
|
-
* ```
|
|
1090
|
-
*/
|
|
1091
|
-
multiLookup(lookups) {
|
|
1092
|
-
const stages = LookupBuilder.multiple(lookups);
|
|
1093
|
-
this.pipeline.push(...stages);
|
|
1094
|
-
return this;
|
|
1095
|
-
}
|
|
1096
|
-
/**
|
|
1097
|
-
* $facet - Process multiple aggregation pipelines in a single stage
|
|
1098
|
-
* Useful for computing multiple aggregations in parallel
|
|
1099
|
-
*
|
|
1100
|
-
* @example
|
|
1101
|
-
* ```typescript
|
|
1102
|
-
* .facet({
|
|
1103
|
-
* totalCount: [{ $count: 'count' }],
|
|
1104
|
-
* avgPrice: [{ $group: { _id: null, avg: { $avg: '$price' } } }],
|
|
1105
|
-
* topProducts: [{ $sort: { sales: -1 } }, { $limit: 10 }]
|
|
1106
|
-
* })
|
|
1107
|
-
* ```
|
|
1108
|
-
*/
|
|
1109
|
-
facet(facets) {
|
|
1110
|
-
this.pipeline.push({ $facet: facets });
|
|
1111
|
-
return this;
|
|
1112
|
-
}
|
|
1113
|
-
/**
|
|
1114
|
-
* $bucket - Categorize documents into buckets
|
|
1115
|
-
*
|
|
1116
|
-
* @example
|
|
1117
|
-
* ```typescript
|
|
1118
|
-
* .bucket({
|
|
1119
|
-
* groupBy: '$price',
|
|
1120
|
-
* boundaries: [0, 50, 100, 200],
|
|
1121
|
-
* default: 'Other',
|
|
1122
|
-
* output: {
|
|
1123
|
-
* count: { $sum: 1 },
|
|
1124
|
-
* products: { $push: '$name' }
|
|
1125
|
-
* }
|
|
1126
|
-
* })
|
|
1127
|
-
* ```
|
|
1128
|
-
*/
|
|
1129
|
-
bucket(options) {
|
|
1130
|
-
this.pipeline.push({ $bucket: options });
|
|
1131
|
-
return this;
|
|
1132
|
-
}
|
|
1133
|
-
/**
|
|
1134
|
-
* $bucketAuto - Automatically determine bucket boundaries
|
|
1135
|
-
*/
|
|
1136
|
-
bucketAuto(options) {
|
|
1137
|
-
this.pipeline.push({ $bucketAuto: options });
|
|
1138
|
-
return this;
|
|
1139
|
-
}
|
|
1140
|
-
/**
|
|
1141
|
-
* $setWindowFields - Perform window functions (MongoDB 5.0+)
|
|
1142
|
-
* Useful for rankings, running totals, moving averages
|
|
1143
|
-
*
|
|
1144
|
-
* @example
|
|
1145
|
-
* ```typescript
|
|
1146
|
-
* .setWindowFields({
|
|
1147
|
-
* partitionBy: '$department',
|
|
1148
|
-
* sortBy: { salary: -1 },
|
|
1149
|
-
* output: {
|
|
1150
|
-
* rank: { $rank: {} },
|
|
1151
|
-
* runningTotal: { $sum: '$salary', window: { documents: ['unbounded', 'current'] } }
|
|
1152
|
-
* }
|
|
1153
|
-
* })
|
|
1154
|
-
* ```
|
|
1155
|
-
*/
|
|
1156
|
-
setWindowFields(options) {
|
|
1157
|
-
const normalizedOptions = {
|
|
1158
|
-
...options,
|
|
1159
|
-
sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
|
|
1160
|
-
};
|
|
1161
|
-
this.pipeline.push({ $setWindowFields: normalizedOptions });
|
|
1162
|
-
return this;
|
|
1163
|
-
}
|
|
1164
|
-
/**
|
|
1165
|
-
* $unionWith - Combine results from multiple collections (MongoDB 4.4+)
|
|
1166
|
-
*
|
|
1167
|
-
* @example
|
|
1168
|
-
* ```typescript
|
|
1169
|
-
* .unionWith({
|
|
1170
|
-
* coll: 'archivedOrders',
|
|
1171
|
-
* pipeline: [{ $match: { year: 2024 } }]
|
|
1172
|
-
* })
|
|
1173
|
-
* ```
|
|
1174
|
-
*/
|
|
1175
|
-
unionWith(options) {
|
|
1176
|
-
this.pipeline.push({ $unionWith: options });
|
|
1177
|
-
return this;
|
|
1178
|
-
}
|
|
1179
|
-
/**
|
|
1180
|
-
* $densify - Fill gaps in data (MongoDB 5.1+)
|
|
1181
|
-
* Useful for time series data with missing points
|
|
1182
|
-
*/
|
|
1183
|
-
densify(options) {
|
|
1184
|
-
this.pipeline.push({ $densify: options });
|
|
1185
|
-
return this;
|
|
1186
|
-
}
|
|
1187
|
-
/**
|
|
1188
|
-
* $fill - Fill null or missing field values (MongoDB 5.3+)
|
|
1189
|
-
*/
|
|
1190
|
-
fill(options) {
|
|
1191
|
-
const normalizedOptions = {
|
|
1192
|
-
...options,
|
|
1193
|
-
sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
|
|
1194
|
-
};
|
|
1195
|
-
this.pipeline.push({ $fill: normalizedOptions });
|
|
1196
|
-
return this;
|
|
1197
|
-
}
|
|
1198
|
-
/**
|
|
1199
|
-
* Enable allowDiskUse for large aggregations that exceed 100MB memory limit
|
|
1200
|
-
*
|
|
1201
|
-
* @example
|
|
1202
|
-
* ```typescript
|
|
1203
|
-
* const results = await new AggregationBuilder()
|
|
1204
|
-
* .match({ status: 'active' })
|
|
1205
|
-
* .group({ _id: '$category', total: { $sum: '$amount' } })
|
|
1206
|
-
* .allowDiskUse()
|
|
1207
|
-
* .exec(Model);
|
|
1208
|
-
* ```
|
|
1209
|
-
*/
|
|
1210
|
-
allowDiskUse(enable = true) {
|
|
1211
|
-
this._diskUse = enable;
|
|
1212
|
-
return this;
|
|
1213
|
-
}
|
|
1214
|
-
/**
|
|
1215
|
-
* Paginate - Add skip and limit for offset-based pagination
|
|
1216
|
-
*/
|
|
1217
|
-
paginate(page, limit) {
|
|
1218
|
-
const skip = (page - 1) * limit;
|
|
1219
|
-
return this.skip(skip).limit(limit);
|
|
1220
|
-
}
|
|
1221
|
-
/**
|
|
1222
|
-
* Count total documents (useful with $facet for pagination metadata)
|
|
1223
|
-
*/
|
|
1224
|
-
count(outputField = "count") {
|
|
1225
|
-
this.pipeline.push({ $count: outputField });
|
|
1226
|
-
return this;
|
|
1227
|
-
}
|
|
1228
|
-
/**
|
|
1229
|
-
* Sample - Randomly select N documents
|
|
1230
|
-
*/
|
|
1231
|
-
sample(size) {
|
|
1232
|
-
this.pipeline.push({ $sample: { size } });
|
|
1233
|
-
return this;
|
|
1234
|
-
}
|
|
1235
|
-
/**
|
|
1236
|
-
* Out - Write results to a collection
|
|
1237
|
-
*/
|
|
1238
|
-
out(collection) {
|
|
1239
|
-
this.pipeline.push({ $out: collection });
|
|
1240
|
-
return this;
|
|
1241
|
-
}
|
|
1242
|
-
/**
|
|
1243
|
-
* Merge - Merge results into a collection
|
|
1244
|
-
*/
|
|
1245
|
-
merge(options) {
|
|
1246
|
-
this.pipeline.push({ $merge: typeof options === "string" ? { into: options } : options });
|
|
1247
|
-
return this;
|
|
1248
|
-
}
|
|
1249
|
-
/**
|
|
1250
|
-
* GeoNear - Perform geospatial queries
|
|
1251
|
-
*/
|
|
1252
|
-
geoNear(options) {
|
|
1253
|
-
this.pipeline.push({ $geoNear: options });
|
|
1254
|
-
return this;
|
|
1255
|
-
}
|
|
1256
|
-
/**
|
|
1257
|
-
* GraphLookup - Perform recursive search (graph traversal)
|
|
1258
|
-
*/
|
|
1259
|
-
graphLookup(options) {
|
|
1260
|
-
this.pipeline.push({ $graphLookup: options });
|
|
1261
|
-
return this;
|
|
1262
|
-
}
|
|
1263
|
-
/**
|
|
1264
|
-
* $search - Atlas Search full-text search (Atlas only)
|
|
1265
|
-
*
|
|
1266
|
-
* @example
|
|
1267
|
-
* ```typescript
|
|
1268
|
-
* .search({
|
|
1269
|
-
* index: 'default',
|
|
1270
|
-
* text: {
|
|
1271
|
-
* query: 'laptop computer',
|
|
1272
|
-
* path: ['title', 'description'],
|
|
1273
|
-
* fuzzy: { maxEdits: 2 }
|
|
1274
|
-
* }
|
|
1275
|
-
* })
|
|
1276
|
-
* ```
|
|
1277
|
-
*/
|
|
1278
|
-
search(options) {
|
|
1279
|
-
this.pipeline.push({ $search: options });
|
|
1280
|
-
return this;
|
|
1281
|
-
}
|
|
1282
|
-
/**
|
|
1283
|
-
* $searchMeta - Get Atlas Search metadata (Atlas only)
|
|
1284
|
-
*/
|
|
1285
|
-
searchMeta(options) {
|
|
1286
|
-
this.pipeline.push({ $searchMeta: options });
|
|
1287
|
-
return this;
|
|
1288
|
-
}
|
|
1289
|
-
/**
|
|
1290
|
-
* $vectorSearch - Semantic similarity search using vector embeddings (Atlas only)
|
|
1291
|
-
*
|
|
1292
|
-
* Requires an Atlas Vector Search index on the target field.
|
|
1293
|
-
* Must be the first stage in the pipeline.
|
|
1294
|
-
*
|
|
1295
|
-
* @example
|
|
1296
|
-
* ```typescript
|
|
1297
|
-
* const results = await new AggregationBuilder()
|
|
1298
|
-
* .vectorSearch({
|
|
1299
|
-
* index: 'vector_index',
|
|
1300
|
-
* path: 'embedding',
|
|
1301
|
-
* queryVector: await getEmbedding('running shoes'),
|
|
1302
|
-
* limit: 10,
|
|
1303
|
-
* numCandidates: 100,
|
|
1304
|
-
* filter: { category: 'footwear' }
|
|
1305
|
-
* })
|
|
1306
|
-
* .project({ embedding: 0, score: { $meta: 'vectorSearchScore' } })
|
|
1307
|
-
* .exec(ProductModel);
|
|
1308
|
-
* ```
|
|
1309
|
-
*/
|
|
1310
|
-
vectorSearch(options) {
|
|
1311
|
-
if (this.pipeline.length > 0) throw new Error("[mongokit] $vectorSearch must be the first stage in the pipeline");
|
|
1312
|
-
const rawCandidates = options.numCandidates ?? Math.max(options.limit * 10, 100);
|
|
1313
|
-
const numCandidates = Math.min(Math.max(rawCandidates, options.limit), 1e4);
|
|
1314
|
-
this.pipeline.push({ $vectorSearch: {
|
|
1315
|
-
index: options.index,
|
|
1316
|
-
path: options.path,
|
|
1317
|
-
queryVector: options.queryVector,
|
|
1318
|
-
numCandidates,
|
|
1319
|
-
limit: options.limit,
|
|
1320
|
-
...options.filter && { filter: options.filter },
|
|
1321
|
-
...options.exact && { exact: options.exact }
|
|
1322
|
-
} });
|
|
1323
|
-
return this;
|
|
1324
|
-
}
|
|
1325
|
-
/**
|
|
1326
|
-
* Add vectorSearchScore as a field after $vectorSearch
|
|
1327
|
-
* Convenience for `.addFields({ score: { $meta: 'vectorSearchScore' } })`
|
|
1328
|
-
*/
|
|
1329
|
-
withVectorScore(fieldName = "score") {
|
|
1330
|
-
return this.addFields({ [fieldName]: { $meta: "vectorSearchScore" } });
|
|
1331
|
-
}
|
|
1332
|
-
/**
|
|
1333
|
-
* Create a builder from an existing pipeline
|
|
1334
|
-
*/
|
|
1335
|
-
static from(pipeline) {
|
|
1336
|
-
const builder = new AggregationBuilder();
|
|
1337
|
-
builder.pipeline = [...pipeline];
|
|
1338
|
-
return builder;
|
|
1339
|
-
}
|
|
1340
|
-
/**
|
|
1341
|
-
* Create a builder with initial match stage
|
|
1342
|
-
*/
|
|
1343
|
-
static startWith(query) {
|
|
1344
|
-
return new AggregationBuilder().match(query);
|
|
1345
|
-
}
|
|
1346
|
-
};
|
|
1347
|
-
/**
|
|
1348
|
-
* Optimized Aggregation Patterns for Scale
|
|
1349
|
-
*
|
|
1350
|
-
* 1. **Early Filtering** - Always place $match as early as possible:
|
|
1351
|
-
* ```typescript
|
|
1352
|
-
* new AggregationBuilder()
|
|
1353
|
-
* .match({ status: 'active' }) // ✅ Filter first
|
|
1354
|
-
* .lookup(...) // Then join
|
|
1355
|
-
* .sort(...)
|
|
1356
|
-
* ```
|
|
1357
|
-
*
|
|
1358
|
-
* 2. **Index Usage** - Ensure indexes on:
|
|
1359
|
-
* - Fields in $match
|
|
1360
|
-
* - Fields in $sort (especially with $limit)
|
|
1361
|
-
* - Fields in $lookup (both local and foreign)
|
|
1362
|
-
*
|
|
1363
|
-
* 3. **Projection** - Remove unnecessary fields early:
|
|
1364
|
-
* ```typescript
|
|
1365
|
-
* .project({ password: 0, internalNotes: 0 }) // Remove before joins
|
|
1366
|
-
* .lookup(...)
|
|
1367
|
-
* ```
|
|
1368
|
-
*
|
|
1369
|
-
* 4. **Faceted Pagination** - Get count and data in one query:
|
|
1370
|
-
* ```typescript
|
|
1371
|
-
* .facet({
|
|
1372
|
-
* metadata: [{ $count: 'total' }],
|
|
1373
|
-
* data: [{ $skip: skip }, { $limit: limit }]
|
|
1374
|
-
* })
|
|
1375
|
-
* ```
|
|
1376
|
-
*
|
|
1377
|
-
* 5. **allowDiskUse** - For large datasets:
|
|
1378
|
-
* ```typescript
|
|
1379
|
-
* new AggregationBuilder()
|
|
1380
|
-
* .match({ status: 'active' })
|
|
1381
|
-
* .allowDiskUse()
|
|
1382
|
-
* .exec(Model)
|
|
1383
|
-
* ```
|
|
1384
|
-
*
|
|
1385
|
-
* 6. **Vector Search** - Semantic similarity (Atlas only):
|
|
1386
|
-
* ```typescript
|
|
1387
|
-
* new AggregationBuilder()
|
|
1388
|
-
* .vectorSearch({ index: 'vec_idx', path: 'embedding', queryVector: vec, limit: 10 })
|
|
1389
|
-
* .withVectorScore()
|
|
1390
|
-
* .exec(Model)
|
|
1391
|
-
* ```
|
|
1392
|
-
*/
|
|
1393
|
-
//#endregion
|
|
1394
|
-
//#region src/transaction.ts
|
|
1395
|
-
/**
|
|
1396
|
-
* Run a callback inside a MongoDB transaction on the given connection.
|
|
1397
|
-
*
|
|
1398
|
-
* - Starts a session, runs `session.withTransaction(callback)` (which auto-retries
|
|
1399
|
-
* on `TransientTransactionError` and `UnknownTransactionCommitResult`), and
|
|
1400
|
-
* always ends the session in `finally`.
|
|
1401
|
-
* - When `allowFallback` is true and the deployment doesn't support transactions
|
|
1402
|
-
* (e.g. standalone MongoDB in dev), the callback runs once without a
|
|
1403
|
-
* transaction on the same session. `onFallback` is invoked with the original
|
|
1404
|
-
* error so hosts can log the degradation.
|
|
1405
|
-
*
|
|
1406
|
-
* @example
|
|
1407
|
-
* ```ts
|
|
1408
|
-
* import mongoose from 'mongoose';
|
|
1409
|
-
* import { withTransaction } from '@classytic/mongokit';
|
|
1410
|
-
*
|
|
1411
|
-
* await withTransaction(mongoose.connection, async (session) => {
|
|
1412
|
-
* const order = await orderRepo.create(data, { session });
|
|
1413
|
-
* await inventoryRepo.decrement(order.items, { session });
|
|
1414
|
-
* return order;
|
|
1415
|
-
* });
|
|
1416
|
-
* ```
|
|
1417
|
-
*/
|
|
1418
|
-
async function withTransaction(connection, callback, options = {}) {
|
|
1419
|
-
const session = await connection.startSession();
|
|
1420
|
-
try {
|
|
1421
|
-
return await session.withTransaction(() => callback(session), options.transactionOptions);
|
|
1422
|
-
} catch (error) {
|
|
1423
|
-
const err = error;
|
|
1424
|
-
if (options.allowFallback && isTransactionUnsupported(err)) {
|
|
1425
|
-
options.onFallback?.(err);
|
|
1426
|
-
return await callback(session);
|
|
1427
|
-
}
|
|
1428
|
-
throw err;
|
|
1429
|
-
} finally {
|
|
1430
|
-
await session.endSession();
|
|
1431
|
-
}
|
|
1432
|
-
}
|
|
1433
|
-
/**
|
|
1434
|
-
* Detect whether an error indicates the MongoDB deployment does not support
|
|
1435
|
-
* multi-document transactions (standalone server, older topology, etc.).
|
|
1436
|
-
*
|
|
1437
|
-
* Checks MongoDB error codes first — 263 (standalone) and 20 (unsupported
|
|
1438
|
-
* topology) — with a message-matching fallback for edge cases surfaced by
|
|
1439
|
-
* older driver versions.
|
|
1440
|
-
*/
|
|
1441
|
-
function isTransactionUnsupported(error) {
|
|
1442
|
-
const code = error.code;
|
|
1443
|
-
if (code === 263 || code === 20) return true;
|
|
1444
|
-
const message = (error.message || "").toLowerCase();
|
|
1445
|
-
return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("transaction is not supported");
|
|
1446
|
-
}
|
|
1447
|
-
//#endregion
|
|
1448
|
-
//#region src/tx-bound.ts
|
|
1449
|
-
/**
|
|
1450
|
-
* Map of session-aware method name → positional index of the `{ session }`
|
|
1451
|
-
* options bag. Keep this list in sync with the method signatures in
|
|
1452
|
-
* `Repository.ts` and the plugin method definitions. Unknown plugin
|
|
1453
|
-
* methods pass through unwrapped (callers use the standalone helper).
|
|
1454
|
-
*/
|
|
1455
|
-
const SESSION_OPTIONS_INDEX = Object.freeze({
|
|
1456
|
-
create: 1,
|
|
1457
|
-
update: 2,
|
|
1458
|
-
delete: 1,
|
|
1459
|
-
getById: 1,
|
|
1460
|
-
getAll: 1,
|
|
1461
|
-
createMany: 1,
|
|
1462
|
-
findAll: 1,
|
|
1463
|
-
getOrCreate: 2,
|
|
1464
|
-
count: 1,
|
|
1465
|
-
exists: 1,
|
|
1466
|
-
getByQuery: 1,
|
|
1467
|
-
getOne: 1,
|
|
1468
|
-
findOneAndUpdate: 2,
|
|
1469
|
-
distinct: 2,
|
|
1470
|
-
aggregate: 1,
|
|
1471
|
-
aggregatePaginate: 0,
|
|
1472
|
-
lookupPopulate: 0,
|
|
1473
|
-
upsert: 2,
|
|
1474
|
-
increment: 3,
|
|
1475
|
-
decrement: 3,
|
|
1476
|
-
multiplyField: 3,
|
|
1477
|
-
setMin: 3,
|
|
1478
|
-
setMax: 3,
|
|
1479
|
-
pushToArray: 3,
|
|
1480
|
-
pullFromArray: 3,
|
|
1481
|
-
addToSet: 3,
|
|
1482
|
-
setField: 3,
|
|
1483
|
-
unsetField: 2,
|
|
1484
|
-
renameField: 3,
|
|
1485
|
-
atomicUpdate: 2,
|
|
1486
|
-
updateMany: 2,
|
|
1487
|
-
deleteMany: 1,
|
|
1488
|
-
bulkWrite: 1,
|
|
1489
|
-
restore: 1,
|
|
1490
|
-
getDeleted: 1,
|
|
1491
|
-
addSubdocument: 3,
|
|
1492
|
-
getSubdocument: 3,
|
|
1493
|
-
updateSubdocument: 4,
|
|
1494
|
-
deleteSubdocument: 3,
|
|
1495
|
-
groupBy: 1,
|
|
1496
|
-
sum: 2,
|
|
1497
|
-
average: 2,
|
|
1498
|
-
min: 2,
|
|
1499
|
-
max: 2
|
|
1500
|
-
});
|
|
1501
|
-
/**
|
|
1502
|
-
* Build a session-threaded proxy over `outer`. The returned object has the
|
|
1503
|
-
* same method signatures, but every CRUD call auto-injects the supplied
|
|
1504
|
-
* session into the options bag. Non-CRUD properties (Model, modelName,
|
|
1505
|
-
* hook API, utility helpers) pass through.
|
|
1506
|
-
*/
|
|
1507
|
-
function createTxBoundRepo(outer, session) {
|
|
1508
|
-
return new Proxy(outer, { get(target, prop, receiver) {
|
|
1509
|
-
if (prop === "withTransaction") return () => {
|
|
1510
|
-
throw new Error("[mongokit] Nested withTransaction is not supported on a tx-bound repository. Reuse the outer `txRepo` directly, or collapse the nesting.");
|
|
1511
|
-
};
|
|
1512
|
-
const value = Reflect.get(target, prop, receiver);
|
|
1513
|
-
if (typeof value !== "function") return value;
|
|
1514
|
-
if (typeof prop === "symbol") return value.bind(target);
|
|
1515
|
-
if (prop.startsWith("_")) return value.bind(target);
|
|
1516
|
-
const optionsIndex = SESSION_OPTIONS_INDEX[prop];
|
|
1517
|
-
if (optionsIndex === void 0) return value.bind(target);
|
|
1518
|
-
return function txBoundMethod(...args) {
|
|
1519
|
-
while (args.length <= optionsIndex) args.push(void 0);
|
|
1520
|
-
const current = args[optionsIndex];
|
|
1521
|
-
if (current === void 0) args[optionsIndex] = { session };
|
|
1522
|
-
else if (typeof current === "object" && current !== null && !Array.isArray(current)) args[optionsIndex] = {
|
|
1523
|
-
...current,
|
|
1524
|
-
session
|
|
1525
|
-
};
|
|
1526
|
-
else return value.apply(target, args);
|
|
1527
|
-
return value.apply(target, args);
|
|
1528
|
-
};
|
|
1529
|
-
} });
|
|
1530
|
-
}
|
|
1531
|
-
//#endregion
|
|
1532
|
-
//#region src/Repository.ts
|
|
1533
|
-
function ensureLookupProjectionIncludesCursorFields(projection, sort) {
|
|
1534
|
-
if (!projection || !sort) return projection;
|
|
1535
|
-
if (!Object.values(projection).some((value) => value === 1)) return projection;
|
|
1536
|
-
const nextProjection = { ...projection };
|
|
1537
|
-
for (const field of [...Object.keys(sort), "_id"]) nextProjection[field] = 1;
|
|
1538
|
-
return nextProjection;
|
|
1539
|
-
}
|
|
1540
|
-
/**
|
|
1541
|
-
* Plugin phase priorities (lower = runs first).
|
|
1542
|
-
*
|
|
1543
|
-
* Re-exported from `@classytic/repo-core/hooks` so mongokit 3.10 callers see
|
|
1544
|
-
* the same constant whether they import from mongokit or directly from
|
|
1545
|
-
* repo-core — priorities are identical (POLICY=100, CACHE=200,
|
|
1546
|
-
* OBSERVABILITY=300, DEFAULT=500) and the two refs are reference-equal.
|
|
1547
|
-
*/
|
|
1548
|
-
const HOOK_PRIORITY$1 = HOOK_PRIORITY;
|
|
1549
|
-
/**
|
|
1550
|
-
* Production-grade repository for MongoDB.
|
|
1551
|
-
*
|
|
1552
|
-
* Extends `@classytic/repo-core/repository`'s `RepositoryBase` for the
|
|
1553
|
-
* driver-agnostic hook + plugin plumbing (context builder, priority-sorted
|
|
1554
|
-
* event engine, plugin-order validator) while layering every Mongo-specific
|
|
1555
|
-
* concern — populate, aggregate, lookup, keyset + cursor pagination, $near
|
|
1556
|
-
* rewriting — on top.
|
|
1557
|
-
*
|
|
1558
|
-
* Public surface is byte-stable with mongokit 3.9 (the `_hooks` property is
|
|
1559
|
-
* preserved as a live read-through getter; all CRUD, event, and plugin method
|
|
1560
|
-
* signatures unchanged). 3.10 is an internal re-plumbing release; consumers
|
|
1561
|
-
* upgrading from 3.9 need no code changes.
|
|
1562
|
-
*/
|
|
1563
|
-
var Repository = class extends RepositoryBase {
|
|
1564
|
-
Model;
|
|
1565
|
-
/**
|
|
1566
|
-
* Mongoose model name. Duplicated on the instance for BC — arc / catalog /
|
|
1567
|
-
* user code read `repo.model`. `RepositoryBase.modelName` points to the
|
|
1568
|
-
* same string; prefer `modelName` in new code.
|
|
1569
|
-
*/
|
|
1570
|
-
model;
|
|
1571
|
-
_pagination;
|
|
1572
|
-
idField;
|
|
1573
|
-
searchMode;
|
|
1574
|
-
searchFields;
|
|
1575
|
-
_hasTextIndex = null;
|
|
1576
|
-
constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
|
|
1577
|
-
super({
|
|
1578
|
-
name: Model.modelName,
|
|
1579
|
-
hooks: options.hooks ?? "async",
|
|
1580
|
-
pluginOrderChecks: "off"
|
|
1581
|
-
});
|
|
1582
|
-
validatePluginOrder(plugins, Model.modelName, options.pluginOrderChecks ?? "warn", (m) => {
|
|
1583
|
-
warn(m);
|
|
1584
|
-
});
|
|
1585
|
-
this.Model = Model;
|
|
1586
|
-
this.model = Model.modelName;
|
|
1587
|
-
this._pagination = new PaginationEngine(Model, paginationConfig);
|
|
1588
|
-
this.idField = options.idField ?? "_id";
|
|
1589
|
-
this.searchMode = options.searchMode ?? "text";
|
|
1590
|
-
this.searchFields = options.searchFields;
|
|
1591
|
-
if (this.searchMode === "regex" && (!this.searchFields || this.searchFields.length === 0)) warn(`[mongokit] Repository "${this.model}" configured with searchMode: 'regex' but no searchFields provided. getAll({ search }) will throw until searchFields is set.`);
|
|
1592
|
-
for (const plugin of plugins) this.use(plugin);
|
|
1593
|
-
}
|
|
1594
|
-
/**
|
|
1595
|
-
* Live read-through view of the hook engine's listener registry.
|
|
1596
|
-
*
|
|
1597
|
-
* Preserved as a public field on the Repository for back-compat with
|
|
1598
|
-
* mongokit ≤3.9 — existing tests and observability code pattern-match
|
|
1599
|
-
* `repo._hooks.get('before:getAll')` / `repo._hooks.size`. Each access
|
|
1600
|
-
* returns a fresh snapshot of the underlying HookEngine state.
|
|
1601
|
-
*
|
|
1602
|
-
* Read-only. To register listeners use `repo.on(event, listener, { priority })`.
|
|
1603
|
-
*/
|
|
1604
|
-
get _hooks() {
|
|
1605
|
-
return this.hooks.listeners();
|
|
1606
|
-
}
|
|
1607
|
-
/**
|
|
1608
|
-
* Fire-and-forget emit aliased through RepositoryBase's async path so
|
|
1609
|
-
* error hooks converted to/from sync mode still route uniformly. Mongokit
|
|
1610
|
-
* ≤3.9 made this a private method on its subclass; the base class exposes
|
|
1611
|
-
* it publicly and wraps it in `_emitHook` below for internal use.
|
|
1612
|
-
*/
|
|
1613
|
-
async _emitHook(event, data) {
|
|
1614
|
-
await this.hooks.emitAccordingToMode(event, data);
|
|
1615
|
-
}
|
|
1616
|
-
async _emitErrorHook(event, data) {
|
|
1617
|
-
try {
|
|
1618
|
-
await this._emitHook(event, data);
|
|
1619
|
-
} catch (hookError) {
|
|
1620
|
-
warn(`[${this.model}] Error hook '${event}' threw: ${hookError instanceof Error ? hookError.message : String(hookError)}`);
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
/**
|
|
1624
|
-
* Run a Repository operation under the standard envelope:
|
|
1625
|
-
* - invoke `fn`, emit `after:<op>` with `{ context, result }` on success
|
|
1626
|
-
* - emit `error:<op>` and rethrow via `_handleError` on failure
|
|
1627
|
-
*
|
|
1628
|
-
* Methods with branched in-try logic that emit `after:*` from multiple
|
|
1629
|
-
* paths (`getById`, `update`, `delete`, `deleteMany`, `aggregatePaginate`,
|
|
1630
|
-
* `lookupPopulate`) intentionally keep their inline try/catch rather than
|
|
1631
|
-
* being restructured to return a single result through this helper.
|
|
1632
|
-
*/
|
|
1633
|
-
async _runOp(op, context, fn) {
|
|
1634
|
-
try {
|
|
1635
|
-
const result = await fn();
|
|
1636
|
-
await this._emitHook(`after:${op}`, {
|
|
1637
|
-
context,
|
|
1638
|
-
result
|
|
1639
|
-
});
|
|
1640
|
-
return result;
|
|
1641
|
-
} catch (error) {
|
|
1642
|
-
await this._emitErrorHook(`error:${op}`, {
|
|
1643
|
-
context,
|
|
1644
|
-
error
|
|
1645
|
-
});
|
|
1646
|
-
throw this._handleError(error);
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
/**
|
|
1650
|
-
* Create single document
|
|
1651
|
-
*/
|
|
1652
|
-
async create(data, options = {}) {
|
|
1653
|
-
const context = await this._buildContext("create", {
|
|
1654
|
-
data,
|
|
1655
|
-
...options
|
|
1656
|
-
});
|
|
1657
|
-
return this._runOp("create", context, () => create(this.Model, context.data || data, options));
|
|
1658
|
-
}
|
|
1659
|
-
/**
|
|
1660
|
-
* Create multiple documents
|
|
1661
|
-
*/
|
|
1662
|
-
async createMany(dataArray, options = {}) {
|
|
1663
|
-
const context = await this._buildContext("createMany", {
|
|
1664
|
-
dataArray,
|
|
1665
|
-
...options
|
|
1666
|
-
});
|
|
1667
|
-
return this._runOp("createMany", context, () => createMany(this.Model, context.dataArray || dataArray, options));
|
|
1668
|
-
}
|
|
1669
|
-
/**
|
|
1670
|
-
* Get document by ID
|
|
1671
|
-
*/
|
|
1672
|
-
async getById(id, options = {}) {
|
|
1673
|
-
const populateSpec = options.populateOptions || options.populate;
|
|
1674
|
-
const context = await this._buildContext("getById", {
|
|
1675
|
-
id,
|
|
1676
|
-
...options,
|
|
1677
|
-
populate: populateSpec
|
|
1678
|
-
});
|
|
1679
|
-
if (context._cacheHit) {
|
|
1680
|
-
const cachedResult = context._cachedResult;
|
|
1681
|
-
await this._emitHook("after:getById", {
|
|
1682
|
-
context,
|
|
1683
|
-
result: cachedResult,
|
|
1684
|
-
fromCache: true
|
|
1685
|
-
});
|
|
1686
|
-
return cachedResult;
|
|
1687
|
-
}
|
|
1688
|
-
try {
|
|
1689
|
-
const effectiveIdField = options.idField ?? this.idField;
|
|
1690
|
-
if (effectiveIdField !== "_id") {
|
|
1691
|
-
const result = await getByQuery(this.Model, {
|
|
1692
|
-
[effectiveIdField]: id,
|
|
1693
|
-
...context.query || {}
|
|
1694
|
-
}, context);
|
|
1695
|
-
await this._emitHook("after:getById", {
|
|
1696
|
-
context,
|
|
1697
|
-
result
|
|
1698
|
-
});
|
|
1699
|
-
return result;
|
|
1700
|
-
}
|
|
1701
|
-
const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
|
|
1702
|
-
if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
|
|
1703
|
-
if (wantsThrow) throw createError(404, "Document not found");
|
|
1704
|
-
return null;
|
|
1705
|
-
}
|
|
1706
|
-
const result = await getById(this.Model, id, context);
|
|
1707
|
-
if (!result && wantsThrow) throw createError(404, "Document not found");
|
|
1708
|
-
await this._emitHook("after:getById", {
|
|
1709
|
-
context,
|
|
1710
|
-
result
|
|
1711
|
-
});
|
|
1712
|
-
return result;
|
|
1713
|
-
} catch (error) {
|
|
1714
|
-
await this._emitErrorHook("error:getById", {
|
|
1715
|
-
context,
|
|
1716
|
-
error
|
|
1717
|
-
});
|
|
1718
|
-
throw this._handleError(error);
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
/**
|
|
1722
|
-
* Get single document by query
|
|
1723
|
-
*/
|
|
1724
|
-
async getByQuery(query, options = {}) {
|
|
1725
|
-
const populateSpec = options.populateOptions || options.populate;
|
|
1726
|
-
const context = await this._buildContext("getByQuery", {
|
|
1727
|
-
query,
|
|
1728
|
-
...options,
|
|
1729
|
-
populate: populateSpec
|
|
1730
|
-
});
|
|
1731
|
-
if (context._cacheHit) {
|
|
1732
|
-
const cachedResult = context._cachedResult;
|
|
1733
|
-
await this._emitHook("after:getByQuery", {
|
|
1734
|
-
context,
|
|
1735
|
-
result: cachedResult,
|
|
1736
|
-
fromCache: true
|
|
1737
|
-
});
|
|
1738
|
-
return cachedResult;
|
|
1739
|
-
}
|
|
1740
|
-
const finalQuery = context.query || query;
|
|
1741
|
-
return this._runOp("getByQuery", context, () => getByQuery(this.Model, finalQuery, context));
|
|
1742
|
-
}
|
|
1743
|
-
/**
|
|
1744
|
-
* Get single document by arbitrary filter.
|
|
1745
|
-
* Unlike getByQuery, this method is designed for Arc/controller use where
|
|
1746
|
-
* compound filters (org scope + policy + id) are pre-built.
|
|
1747
|
-
*
|
|
1748
|
-
* @example
|
|
1749
|
-
* const product = await repo.getOne({ slug: 'laptop', organizationId: 'org_1' });
|
|
1750
|
-
*/
|
|
1751
|
-
async getOne(query, options = {}) {
|
|
1752
|
-
const populateSpec = options.populateOptions || options.populate;
|
|
1753
|
-
const context = await this._buildContext("getOne", {
|
|
1754
|
-
query,
|
|
1755
|
-
...options,
|
|
1756
|
-
populate: populateSpec
|
|
1757
|
-
});
|
|
1758
|
-
if (context._cacheHit) {
|
|
1759
|
-
const cachedResult = context._cachedResult;
|
|
1760
|
-
await this._emitHook("after:getOne", {
|
|
1761
|
-
context,
|
|
1762
|
-
result: cachedResult,
|
|
1763
|
-
fromCache: true
|
|
1764
|
-
});
|
|
1765
|
-
return cachedResult;
|
|
1766
|
-
}
|
|
1767
|
-
const finalQuery = context.query || query;
|
|
1768
|
-
return this._runOp("getOne", context, () => getByQuery(this.Model, finalQuery, context));
|
|
1769
|
-
}
|
|
1770
|
-
/**
|
|
1771
|
-
* Fetch ALL documents matching filters without pagination.
|
|
1772
|
-
* Use for background jobs, exports, batch processing where you need every doc.
|
|
1773
|
-
*
|
|
1774
|
-
* @example
|
|
1775
|
-
* const all = await repo.findAll({ status: 'active' });
|
|
1776
|
-
* const allLean = await repo.findAll({}, { select: 'name email', lean: true });
|
|
1777
|
-
*/
|
|
1778
|
-
async findAll(filters = {}, options = {}) {
|
|
1779
|
-
const context = await this._buildContext("findAll", {
|
|
1780
|
-
query: filters,
|
|
1781
|
-
...options
|
|
1782
|
-
});
|
|
1783
|
-
const resolvedFilters = context.query ?? filters;
|
|
1784
|
-
return this._runOp("findAll", context, async () => {
|
|
1785
|
-
const query = this.Model.find(resolvedFilters);
|
|
1786
|
-
const sortSpec = context.sort || options.sort;
|
|
1787
|
-
if (sortSpec) query.sort(this._parseSort(sortSpec));
|
|
1788
|
-
const selectSpec = context.select || options.select;
|
|
1789
|
-
if (selectSpec) query.select(selectSpec);
|
|
1790
|
-
if (options.populate || context.populate) query.populate(this._parsePopulate(context.populate || options.populate));
|
|
1791
|
-
if (context.lean ?? options.lean ?? true) query.lean();
|
|
1792
|
-
if (options.session) query.session(options.session);
|
|
1793
|
-
if (options.readPreference) query.read(options.readPreference);
|
|
1794
|
-
return await query.exec();
|
|
1795
|
-
});
|
|
1796
|
-
}
|
|
1797
|
-
/**
|
|
1798
|
-
* Unified pagination - auto-detects offset vs keyset based on params
|
|
1799
|
-
*
|
|
1800
|
-
* Auto-detection logic:
|
|
1801
|
-
* - If params has 'cursor' or 'after' → uses keyset pagination (stream)
|
|
1802
|
-
* - If params has 'pagination' or 'page' → uses offset pagination (paginate)
|
|
1803
|
-
* - Else → defaults to offset pagination with page=1
|
|
1804
|
-
*
|
|
1805
|
-
* @example
|
|
1806
|
-
* // Offset pagination (page-based)
|
|
1807
|
-
* await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
|
|
1808
|
-
* await repo.getAll({ pagination: { page: 2, limit: 20 } });
|
|
1809
|
-
*
|
|
1810
|
-
* // Keyset pagination (cursor-based)
|
|
1811
|
-
* await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
|
|
1812
|
-
* await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
|
|
1813
|
-
*
|
|
1814
|
-
* // Simple query (defaults to page 1)
|
|
1815
|
-
* await repo.getAll({ filters: { status: 'active' } });
|
|
1816
|
-
*
|
|
1817
|
-
* // Skip cache for fresh data
|
|
1818
|
-
* await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
|
|
1819
|
-
*/
|
|
1820
|
-
async getAll(params = {}, options = {}) {
|
|
1821
|
-
const normalizedParams = {
|
|
1822
|
-
...params,
|
|
1823
|
-
page: params.page ?? params.pagination?.page,
|
|
1824
|
-
limit: params.limit ?? params.pagination?.limit
|
|
1825
|
-
};
|
|
1826
|
-
const context = await this._buildContext("getAll", {
|
|
1827
|
-
...normalizedParams,
|
|
1828
|
-
...options
|
|
1829
|
-
});
|
|
1830
|
-
if (context._cacheHit) {
|
|
1831
|
-
const cachedResult = context._cachedResult;
|
|
1832
|
-
await this._emitHook("after:getAll", {
|
|
1833
|
-
context,
|
|
1834
|
-
result: cachedResult,
|
|
1835
|
-
fromCache: true
|
|
1836
|
-
});
|
|
1837
|
-
return cachedResult;
|
|
1838
|
-
}
|
|
1839
|
-
if (params.noPagination) return this.findAll(context.filters ?? params.filters ?? {}, {
|
|
1840
|
-
...options,
|
|
1841
|
-
sort: context.sort ?? params.sort
|
|
1842
|
-
});
|
|
1843
|
-
const filters = context.filters ?? params.filters ?? {};
|
|
1844
|
-
const search = context.search;
|
|
1845
|
-
const isNearQuery = hasNearOperator(filters);
|
|
1846
|
-
const sort = this._resolveSort(context, params, isNearQuery);
|
|
1847
|
-
const limit = context.limit ?? params.limit ?? params.pagination?.limit ?? this._pagination.config.defaultLimit;
|
|
1848
|
-
const page = context.page ?? params.pagination?.page ?? params.page;
|
|
1849
|
-
const after = context.after ?? params.cursor ?? params.after;
|
|
1850
|
-
const mode = context.mode ?? params.mode;
|
|
1851
|
-
const useKeyset = this._detectPaginationMode(mode, page, after, sort, context, params);
|
|
1852
|
-
const query = this._buildSearchQuery(filters, search);
|
|
1853
|
-
const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
|
|
1854
|
-
const paginationOptions = {
|
|
1855
|
-
filters: query,
|
|
1856
|
-
sort: isNearQuery ? void 0 : this._parseSort(sort),
|
|
1857
|
-
limit,
|
|
1858
|
-
populate: this._parsePopulate(populateSpec),
|
|
1859
|
-
select: context.select || options.select,
|
|
1860
|
-
lean: context.lean ?? options.lean ?? true,
|
|
1861
|
-
session: options.session,
|
|
1862
|
-
hint: context.hint ?? params.hint,
|
|
1863
|
-
maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
|
|
1864
|
-
readPreference: context.readPreference ?? options.readPreference ?? params.readPreference,
|
|
1865
|
-
collation: context.collation ?? params.collation
|
|
1866
|
-
};
|
|
1867
|
-
const lookups = context.lookups ?? params.lookups;
|
|
1868
|
-
if (lookups && lookups.length > 0) return this._runOp("getAll", context, async () => {
|
|
1869
|
-
return await this.lookupPopulate({
|
|
1870
|
-
filters: query,
|
|
1871
|
-
lookups,
|
|
1872
|
-
sort: paginationOptions.sort,
|
|
1873
|
-
page: useKeyset ? void 0 : page || 1,
|
|
1874
|
-
after: useKeyset ? after : void 0,
|
|
1875
|
-
limit,
|
|
1876
|
-
select: paginationOptions.select,
|
|
1877
|
-
session: options.session,
|
|
1878
|
-
readPreference: paginationOptions.readPreference,
|
|
1879
|
-
collation: paginationOptions.collation,
|
|
1880
|
-
countStrategy: context.countStrategy ?? params.countStrategy
|
|
1881
|
-
});
|
|
1882
|
-
});
|
|
1883
|
-
return this._runOp("getAll", context, async () => {
|
|
1884
|
-
let result;
|
|
1885
|
-
if (useKeyset) result = await this._pagination.stream({
|
|
1886
|
-
...paginationOptions,
|
|
1887
|
-
sort: paginationOptions.sort,
|
|
1888
|
-
after
|
|
1889
|
-
});
|
|
1890
|
-
else {
|
|
1891
|
-
let countFilters;
|
|
1892
|
-
let forcedCountStrategy;
|
|
1893
|
-
if (isNearQuery) {
|
|
1894
|
-
const rewritten = rewriteNearForCount(query);
|
|
1895
|
-
if (rewritten) countFilters = rewritten;
|
|
1896
|
-
else forcedCountStrategy = "none";
|
|
1897
|
-
}
|
|
1898
|
-
result = await this._pagination.paginate({
|
|
1899
|
-
...paginationOptions,
|
|
1900
|
-
page: page || 1,
|
|
1901
|
-
countFilters,
|
|
1902
|
-
countStrategy: context.countStrategy ?? params.countStrategy ?? forcedCountStrategy
|
|
1903
|
-
});
|
|
1904
|
-
}
|
|
1905
|
-
return result;
|
|
1906
|
-
});
|
|
1907
|
-
}
|
|
1908
|
-
/**
|
|
1909
|
-
* Get or create document
|
|
1910
|
-
* Routes through hook system for policy enforcement (multi-tenant, soft-delete)
|
|
1911
|
-
*/
|
|
1912
|
-
async getOrCreate(query, createData, options = {}) {
|
|
1913
|
-
const context = await this._buildContext("getOrCreate", {
|
|
1914
|
-
query,
|
|
1915
|
-
data: createData,
|
|
1916
|
-
...options
|
|
1917
|
-
});
|
|
1918
|
-
return this._runOp("getOrCreate", context, () => {
|
|
1919
|
-
const finalQuery = context.query || query;
|
|
1920
|
-
const finalData = context.data || createData;
|
|
1921
|
-
return getOrCreate(this.Model, finalQuery, finalData, options);
|
|
1922
|
-
});
|
|
1923
|
-
}
|
|
1924
|
-
/**
|
|
1925
|
-
* Count documents
|
|
1926
|
-
* Routes through hook system for policy enforcement (multi-tenant, soft-delete)
|
|
1927
|
-
*/
|
|
1928
|
-
async count(query = {}, options = {}) {
|
|
1929
|
-
const context = await this._buildContext("count", {
|
|
1930
|
-
query,
|
|
1931
|
-
...options
|
|
1932
|
-
});
|
|
1933
|
-
return this._runOp("count", context, () => count(this.Model, context.query || query, options));
|
|
1934
|
-
}
|
|
1935
|
-
/**
|
|
1936
|
-
* Check if document exists
|
|
1937
|
-
* Routes through hook system for policy enforcement (multi-tenant, soft-delete)
|
|
1938
|
-
*/
|
|
1939
|
-
async exists(query, options = {}) {
|
|
1940
|
-
const context = await this._buildContext("exists", {
|
|
1941
|
-
query,
|
|
1942
|
-
...options
|
|
1943
|
-
});
|
|
1944
|
-
return this._runOp("exists", context, () => exists(this.Model, context.query || query, options));
|
|
1945
|
-
}
|
|
1946
|
-
/**
|
|
1947
|
-
* Update document by ID
|
|
1948
|
-
*/
|
|
1949
|
-
async update(id, data, options = {}) {
|
|
1950
|
-
const context = await this._buildContext("update", {
|
|
1951
|
-
id,
|
|
1952
|
-
data,
|
|
1953
|
-
...options
|
|
1954
|
-
});
|
|
1955
|
-
try {
|
|
1956
|
-
const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
|
|
1957
|
-
const effectiveIdField = options.idField ?? this.idField;
|
|
1958
|
-
if (effectiveIdField === "_id") {
|
|
1959
|
-
if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
|
|
1960
|
-
if (wantsThrow) throw createError(404, "Document not found");
|
|
1961
|
-
await this._emitHook("after:update", {
|
|
1962
|
-
context,
|
|
1963
|
-
result: null
|
|
1964
|
-
});
|
|
1965
|
-
return null;
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
let result;
|
|
1969
|
-
if (effectiveIdField !== "_id") result = await updateByQuery(this.Model, {
|
|
1970
|
-
[effectiveIdField]: id,
|
|
1971
|
-
...context.query || {}
|
|
1972
|
-
}, context.data || data, context);
|
|
1973
|
-
else result = await update(this.Model, id, context.data || data, context);
|
|
1974
|
-
if (!result && wantsThrow) throw createError(404, "Document not found");
|
|
1975
|
-
await this._emitHook("after:update", {
|
|
1976
|
-
context,
|
|
1977
|
-
result
|
|
1978
|
-
});
|
|
1979
|
-
return result;
|
|
1980
|
-
} catch (error) {
|
|
1981
|
-
await this._emitErrorHook("error:update", {
|
|
1982
|
-
context,
|
|
1983
|
-
error
|
|
1984
|
-
});
|
|
1985
|
-
throw this._handleError(error);
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
/**
|
|
1989
|
-
* Atomic findOneAndUpdate (compare-and-set primitive).
|
|
1990
|
-
*
|
|
1991
|
-
* Single round-trip match-and-mutate for outbox relays, distributed locks,
|
|
1992
|
-
* and workflow semaphores. Goes through the full hook pipeline so
|
|
1993
|
-
* multi-tenant scope, soft-delete, audit, and cache plugins all apply.
|
|
1994
|
-
*
|
|
1995
|
-
* Returns the matched document (post-update by default) or `null` when no
|
|
1996
|
-
* document matches and `upsert` is false.
|
|
1997
|
-
*
|
|
1998
|
-
* @example FIFO claim-lease for an outbox worker
|
|
1999
|
-
* ```ts
|
|
2000
|
-
* const claimed = await outboxRepo.findOneAndUpdate(
|
|
2001
|
-
* { status: 'pending', leaseExpiresAt: { $lt: new Date() } },
|
|
2002
|
-
* { $set: { status: 'processing', leaseExpiresAt: leaseUntil, leasedBy: workerId } },
|
|
2003
|
-
* { sort: { createdAt: 1 }, returnDocument: 'after' },
|
|
2004
|
-
* );
|
|
2005
|
-
* if (!claimed) return; // queue empty
|
|
2006
|
-
* ```
|
|
2007
|
-
*
|
|
2008
|
-
* @example CAS upsert
|
|
2009
|
-
* ```ts
|
|
2010
|
-
* const lock = await locksRepo.findOneAndUpdate(
|
|
2011
|
-
* { _id: lockKey, ownerId: { $in: [null, workerId] } },
|
|
2012
|
-
* { $set: { ownerId: workerId, expiresAt: leaseUntil } },
|
|
2013
|
-
* { upsert: true },
|
|
2014
|
-
* );
|
|
2015
|
-
* ```
|
|
2016
|
-
*/
|
|
2017
|
-
async findOneAndUpdate(filter, update, options = {}) {
|
|
2018
|
-
const normalizedUpdate = isUpdateSpec(update) ? compileUpdateSpecToMongo(update) : update;
|
|
2019
|
-
const context = await this._buildContext("findOneAndUpdate", {
|
|
2020
|
-
query: filter,
|
|
2021
|
-
data: normalizedUpdate,
|
|
2022
|
-
...options
|
|
2023
|
-
});
|
|
2024
|
-
return this._runOp("findOneAndUpdate", context, async () => {
|
|
2025
|
-
const finalQuery = context.query || filter;
|
|
2026
|
-
const finalUpdate = context.data || normalizedUpdate;
|
|
2027
|
-
return await findOneAndUpdate(this.Model, finalQuery, finalUpdate, {
|
|
2028
|
-
...options,
|
|
2029
|
-
sort: options.sort,
|
|
2030
|
-
returnDocument: options.returnDocument ?? "after",
|
|
2031
|
-
upsert: options.upsert ?? false,
|
|
2032
|
-
session: options.session
|
|
2033
|
-
});
|
|
2034
|
-
});
|
|
2035
|
-
}
|
|
2036
|
-
/**
|
|
2037
|
-
* Delete a document by ID.
|
|
2038
|
-
*
|
|
2039
|
-
* By default the behavior is decided by the plugin stack:
|
|
2040
|
-
* - With `softDeletePlugin` wired → soft delete (sets `deletedAt`)
|
|
2041
|
-
* - Without the plugin → physical delete
|
|
2042
|
-
*
|
|
2043
|
-
* Pass `mode: 'hard'` to force physical deletion regardless of plugins.
|
|
2044
|
-
* All policy hooks (multi-tenant scope, audit trails, cache invalidation,
|
|
2045
|
-
* cascade) still fire — only the soft-delete interception is skipped. This
|
|
2046
|
-
* is the GDPR / admin-cleanup path.
|
|
2047
|
-
*
|
|
2048
|
-
* @example
|
|
2049
|
-
* ```ts
|
|
2050
|
-
* // Respects softDeletePlugin — soft if wired
|
|
2051
|
-
* await userRepo.delete(userId);
|
|
2052
|
-
*
|
|
2053
|
-
* // GDPR erasure — physical delete, audit hooks still fire
|
|
2054
|
-
* await userRepo.delete(userId, { mode: 'hard', organizationId: 'org_123' });
|
|
2055
|
-
* ```
|
|
2056
|
-
*/
|
|
2057
|
-
async delete(id, options = {}) {
|
|
2058
|
-
const context = await this._buildContext("delete", {
|
|
2059
|
-
id,
|
|
2060
|
-
...options,
|
|
2061
|
-
...options.mode ? { deleteMode: options.mode } : {}
|
|
2062
|
-
});
|
|
2063
|
-
try {
|
|
2064
|
-
if (context.softDeleted) {
|
|
2065
|
-
const result = {
|
|
2066
|
-
success: true,
|
|
2067
|
-
message: "Soft deleted successfully",
|
|
2068
|
-
id: String(id),
|
|
2069
|
-
soft: true
|
|
2070
|
-
};
|
|
2071
|
-
await this._emitHook("after:delete", {
|
|
2072
|
-
context,
|
|
2073
|
-
result
|
|
2074
|
-
});
|
|
2075
|
-
return result;
|
|
2076
|
-
}
|
|
2077
|
-
const effectiveIdField = options.idField ?? this.idField;
|
|
2078
|
-
const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
|
|
2079
|
-
if (effectiveIdField === "_id") {
|
|
2080
|
-
if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
|
|
2081
|
-
if (wantsThrow) throw createError(404, "Document not found");
|
|
2082
|
-
const result = {
|
|
2083
|
-
success: false,
|
|
2084
|
-
message: "Document not found",
|
|
2085
|
-
id: String(id)
|
|
2086
|
-
};
|
|
2087
|
-
await this._emitHook("after:delete", {
|
|
2088
|
-
context,
|
|
2089
|
-
result
|
|
2090
|
-
});
|
|
2091
|
-
return result;
|
|
2092
|
-
}
|
|
2093
|
-
}
|
|
2094
|
-
const deleteQuery = effectiveIdField !== "_id" ? {
|
|
2095
|
-
[effectiveIdField]: id,
|
|
2096
|
-
...context.query || {}
|
|
2097
|
-
} : void 0;
|
|
2098
|
-
const result = deleteQuery ? await deleteByQuery(this.Model, deleteQuery, { session: options.session }) : await deleteById(this.Model, id, {
|
|
2099
|
-
session: options.session,
|
|
2100
|
-
query: context.query
|
|
2101
|
-
});
|
|
2102
|
-
if (!result.success && wantsThrow) throw createError(404, "Document not found");
|
|
2103
|
-
await this._emitHook("after:delete", {
|
|
2104
|
-
context,
|
|
2105
|
-
result
|
|
2106
|
-
});
|
|
2107
|
-
return result;
|
|
2108
|
-
} catch (error) {
|
|
2109
|
-
await this._emitErrorHook("error:delete", {
|
|
2110
|
-
context,
|
|
2111
|
-
error
|
|
2112
|
-
});
|
|
2113
|
-
throw this._handleError(error);
|
|
2114
|
-
}
|
|
2115
|
-
}
|
|
2116
|
-
/**
|
|
2117
|
-
* Update every document matching the filter.
|
|
2118
|
-
*
|
|
2119
|
-
* Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 so
|
|
2120
|
-
* the surface matches sqlitekit's always-available `updateMany`. Consumers
|
|
2121
|
-
* who forget to wire `batchOperationsPlugin` no longer get a runtime
|
|
2122
|
-
* `TypeError: repo.updateMany is not a function` — the method is always
|
|
2123
|
-
* here.
|
|
2124
|
-
*
|
|
2125
|
-
* Accepts three `update` forms via `UpdateInput`:
|
|
2126
|
-
* - Portable `UpdateSpec` from `@classytic/repo-core/update` (compiled to
|
|
2127
|
-
* `$set`/`$unset`/`$inc`/`$setOnInsert` before hooks run).
|
|
2128
|
-
* - Raw Mongo operator record (`{ $set, $inc, ... }`) — passed through.
|
|
2129
|
-
* - Mongo aggregation pipeline (`[...]`) — requires `updatePipeline: true`
|
|
2130
|
-
* to guard against accidental pipeline updates.
|
|
2131
|
-
*
|
|
2132
|
-
* Refuses empty filters (defense-in-depth against mass-update accidents)
|
|
2133
|
-
* both before and after policy hooks inject tenant scope.
|
|
2134
|
-
*/
|
|
2135
|
-
async updateMany(filter, update, options = {}) {
|
|
2136
|
-
const normalizedData = isUpdateSpec(update) ? compileUpdateSpecToMongo(update) : update;
|
|
2137
|
-
const context = await this._buildContext("updateMany", {
|
|
2138
|
-
query: filter,
|
|
2139
|
-
data: normalizedData,
|
|
2140
|
-
...options
|
|
2141
|
-
});
|
|
2142
|
-
return this._runOp("updateMany", context, async () => {
|
|
2143
|
-
const finalQuery = context.query || filter;
|
|
2144
|
-
if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "updateMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass updates.");
|
|
2145
|
-
if (Array.isArray(normalizedData) && options.updatePipeline !== true) throw createError(400, "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates.");
|
|
2146
|
-
const finalData = context.data || normalizedData;
|
|
2147
|
-
return await this.Model.updateMany(finalQuery, finalData, {
|
|
2148
|
-
runValidators: true,
|
|
2149
|
-
session: options.session,
|
|
2150
|
-
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
2151
|
-
}).exec();
|
|
2152
|
-
});
|
|
2153
|
-
}
|
|
2154
|
-
/**
|
|
2155
|
-
* Delete every document matching the filter.
|
|
2156
|
-
*
|
|
2157
|
-
* Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 for
|
|
2158
|
-
* the same reason as `updateMany` — sqlitekit parity and no silent "method
|
|
2159
|
-
* is undefined" footgun when the batch plugin isn't wired.
|
|
2160
|
-
*
|
|
2161
|
-
* Behavior mirrors `delete()`: defaults to soft delete when
|
|
2162
|
-
* `softDeletePlugin` is wired, physical delete otherwise. Pass
|
|
2163
|
-
* `{ mode: 'hard' }` to bypass soft-delete — multi-tenant scoping, audit,
|
|
2164
|
-
* and cache hooks still fire.
|
|
2165
|
-
*
|
|
2166
|
-
* Rejects empty filters up front AND after policy hooks, so a policy-plugin
|
|
2167
|
-
* bug that zeroes out the query can't be exploited to wipe a collection.
|
|
2168
|
-
*/
|
|
2169
|
-
async deleteMany(filter, options = {}) {
|
|
2170
|
-
if (!filter || Object.keys(filter).length === 0) throw createError(400, "deleteMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass deletes.");
|
|
2171
|
-
const mode = options.mode;
|
|
2172
|
-
const context = await this._buildContext("deleteMany", {
|
|
2173
|
-
query: filter,
|
|
2174
|
-
...options,
|
|
2175
|
-
...mode ? { deleteMode: mode } : {}
|
|
2176
|
-
});
|
|
2177
|
-
try {
|
|
2178
|
-
if (context.softDeleted) {
|
|
2179
|
-
const result = {
|
|
2180
|
-
acknowledged: true,
|
|
2181
|
-
deletedCount: context.softDeletedCount ?? 0,
|
|
2182
|
-
soft: true
|
|
2183
|
-
};
|
|
2184
|
-
await this._emitHook("after:deleteMany", {
|
|
2185
|
-
context,
|
|
2186
|
-
result
|
|
2187
|
-
});
|
|
2188
|
-
return result;
|
|
2189
|
-
}
|
|
2190
|
-
const finalQuery = context.query || filter;
|
|
2191
|
-
if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter after policy hooks.");
|
|
2192
|
-
const result = await this.Model.deleteMany(finalQuery, { session: options.session }).exec();
|
|
2193
|
-
await this._emitHook("after:deleteMany", {
|
|
2194
|
-
context,
|
|
2195
|
-
result
|
|
2196
|
-
});
|
|
2197
|
-
return result;
|
|
2198
|
-
} catch (error) {
|
|
2199
|
-
await this._emitErrorHook("error:deleteMany", {
|
|
2200
|
-
context,
|
|
2201
|
-
error
|
|
2202
|
-
});
|
|
2203
|
-
throw this._handleError(error);
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
/**
|
|
2207
|
-
* Kit-native aggregation pipeline — takes a MongoDB stage array and
|
|
2208
|
-
* returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
|
|
2209
|
-
* `$facet`, `$graphLookup`, and any other mongo-specific power
|
|
2210
|
-
* features that don't translate to SQL.
|
|
2211
|
-
*
|
|
2212
|
-
* For backend-portable aggregations (filter + group + measures +
|
|
2213
|
-
* sort + limit) use `aggregate(req: AggRequest)` instead — it
|
|
2214
|
-
* compiles the repo-core IR to the equivalent pipeline internally
|
|
2215
|
-
* and produces the same row shape sqlitekit emits.
|
|
2216
|
-
*
|
|
2217
|
-
* Routes through the hook system for policy enforcement
|
|
2218
|
-
* (multi-tenant, soft-delete).
|
|
2219
|
-
*
|
|
2220
|
-
* @param pipeline - MongoDB aggregation stage array
|
|
2221
|
-
* @param options - Aggregation options including governance controls
|
|
2222
|
-
*/
|
|
2223
|
-
async aggregatePipeline(pipeline, options = {}) {
|
|
2224
|
-
const context = await this._buildContext("aggregatePipeline", {
|
|
2225
|
-
pipeline,
|
|
2226
|
-
...options
|
|
2227
|
-
});
|
|
2228
|
-
const maxStages = options.maxPipelineStages;
|
|
2229
|
-
if (maxStages && pipeline.length > maxStages) throw createError(400, `Aggregation pipeline exceeds maximum allowed stages (${pipeline.length} > ${maxStages})`);
|
|
2230
|
-
return this._runOp("aggregatePipeline", context, async () => {
|
|
2231
|
-
const finalPipeline = [...pipeline];
|
|
2232
|
-
if (context.query && Object.keys(context.query).length > 0) finalPipeline.unshift({ $match: context.query });
|
|
2233
|
-
const aggregation = this.Model.aggregate(finalPipeline);
|
|
2234
|
-
if (options.session) aggregation.session(options.session);
|
|
2235
|
-
if (options.allowDiskUse) aggregation.allowDiskUse(true);
|
|
2236
|
-
if (options.readPreference) aggregation.read(options.readPreference);
|
|
2237
|
-
if (options.maxTimeMS) aggregation.option({ maxTimeMS: options.maxTimeMS });
|
|
2238
|
-
if (options.comment) aggregation.option({ comment: options.comment });
|
|
2239
|
-
if (options.readConcern) aggregation.option({ readConcern: options.readConcern });
|
|
2240
|
-
if (options.collation) aggregation.collation(options.collation);
|
|
2241
|
-
return await aggregation.exec();
|
|
2242
|
-
});
|
|
2243
|
-
}
|
|
2244
|
-
/**
|
|
2245
|
-
* Paginated kit-native aggregation pipeline. Same mongo-specific
|
|
2246
|
-
* scope as `aggregatePipeline` — reach for this when you need
|
|
2247
|
-
* `$lookup` / `$unwind` / `$facet` alongside pagination.
|
|
2248
|
-
*
|
|
2249
|
-
* Prefer `aggregatePaginate(req: AggPaginationRequest)` for
|
|
2250
|
-
* cross-backend dashboard code; that method compiles the portable
|
|
2251
|
-
* IR to an equivalent pipeline.
|
|
2252
|
-
*
|
|
2253
|
-
* Policy hooks (multi-tenant, soft-delete) inject context.filters
|
|
2254
|
-
* which are prepended as a `$match` stage.
|
|
2255
|
-
*/
|
|
2256
|
-
async aggregatePipelinePaginate(options = {}) {
|
|
2257
|
-
const context = await this._buildContext("aggregatePipelinePaginate", options);
|
|
2258
|
-
const finalPipeline = [...context.pipeline || options.pipeline || []];
|
|
2259
|
-
if (context.filters && Object.keys(context.filters).length > 0) finalPipeline.unshift({ $match: context.filters });
|
|
2260
|
-
const aggOptions = {
|
|
2261
|
-
...context,
|
|
2262
|
-
pipeline: finalPipeline
|
|
2263
|
-
};
|
|
2264
|
-
return this._runOp("aggregatePipelinePaginate", context, () => this._pagination.aggregatePaginate(aggOptions));
|
|
2265
|
-
}
|
|
2266
|
-
/**
|
|
2267
|
-
* Portable aggregation. Compiles the repo-core `AggRequest` IR to
|
|
2268
|
-
* `[{$match}, {$group}, {$addFields}, {$project}, {$match}, {$sort},
|
|
2269
|
-
* {$skip}, {$limit}]` against this repo's collection. Output rows
|
|
2270
|
-
* carry one key per `groupBy` column plus one key per measure alias
|
|
2271
|
-
* — identical shape to sqlitekit's `aggregate(req)`, so dashboards
|
|
2272
|
-
* work unchanged across backends.
|
|
2273
|
-
*
|
|
2274
|
-
* Without `groupBy`: returns a single-row result with just the
|
|
2275
|
-
* measures (scalar aggregation). Pass
|
|
2276
|
-
* `{ measures: { total: { op: 'sum', field: 'amount' } } }` for a
|
|
2277
|
-
* simple summary.
|
|
2278
|
-
*
|
|
2279
|
-
* For mongo-specific power features (`$lookup`, `$unwind`, `$facet`,
|
|
2280
|
-
* `$graphLookup`, window operators) reach for `aggregatePipeline`
|
|
2281
|
-
* — that's the kit-native escape hatch.
|
|
2282
|
-
*
|
|
2283
|
-
* Routes through the hook system for policy enforcement
|
|
2284
|
-
* (multi-tenant, soft-delete) — plugins inject their scope into
|
|
2285
|
-
* `context.filter`, which is merged into the pre-aggregate `$match`
|
|
2286
|
-
* before compilation.
|
|
2287
|
-
*/
|
|
2288
|
-
async aggregate(req) {
|
|
2289
|
-
const context = await this._buildContext("aggregate", { aggRequest: req });
|
|
2290
|
-
return this._runOp("aggregate", context, async () => {
|
|
2291
|
-
const finalReq = this._injectPolicyScopeIntoAgg(req, context);
|
|
2292
|
-
return { rows: await executeAgg(this.Model, finalReq, { session: context.session }) };
|
|
2293
|
-
});
|
|
2294
|
-
}
|
|
2295
|
-
/**
|
|
2296
|
-
* Offset-paginated portable aggregation. Same IR as `aggregate`,
|
|
2297
|
-
* wrapped in the standard `OffsetPaginationResultCore` envelope so
|
|
2298
|
-
* UI code paginates aggregated dashboards with the same primitives
|
|
2299
|
-
* as raw document lists.
|
|
2300
|
-
*
|
|
2301
|
-
* `countStrategy: 'none'` skips the second round-trip that computes
|
|
2302
|
-
* `total`; the envelope reports `total: 0`, `pages: 0`, and derives
|
|
2303
|
-
* `hasNext` from a `LIMIT N+1` peek on the data pipeline.
|
|
2304
|
-
*/
|
|
2305
|
-
async aggregatePaginate(req) {
|
|
2306
|
-
const context = await this._buildContext("aggregatePaginate", { aggRequest: req });
|
|
2307
|
-
const page = Math.max(1, req.page ?? 1);
|
|
2308
|
-
const limit = Math.max(1, Math.min(req.limit ?? 20, 1e3));
|
|
2309
|
-
const countStrategy = req.countStrategy ?? "exact";
|
|
2310
|
-
const offset = (page - 1) * limit;
|
|
2311
|
-
const session = context.session;
|
|
2312
|
-
try {
|
|
2313
|
-
const finalReq = this._injectPolicyScopeIntoAgg(req, context);
|
|
2314
|
-
if (countStrategy === "none") {
|
|
2315
|
-
const peek = await executeAgg(this.Model, {
|
|
2316
|
-
...finalReq,
|
|
2317
|
-
limit: limit + 1,
|
|
2318
|
-
offset
|
|
2319
|
-
}, session ? { session } : {});
|
|
2320
|
-
const hasNext = peek.length > limit;
|
|
2321
|
-
const result = {
|
|
2322
|
-
method: "offset",
|
|
2323
|
-
docs: hasNext ? peek.slice(0, limit) : peek,
|
|
2324
|
-
page,
|
|
2325
|
-
limit,
|
|
2326
|
-
total: 0,
|
|
2327
|
-
pages: 0,
|
|
2328
|
-
hasNext,
|
|
2329
|
-
hasPrev: page > 1
|
|
2330
|
-
};
|
|
2331
|
-
await this._emitHook("after:aggregatePaginate", {
|
|
2332
|
-
context,
|
|
2333
|
-
result
|
|
2334
|
-
});
|
|
2335
|
-
return result;
|
|
2336
|
-
}
|
|
2337
|
-
const [docs, total] = await Promise.all([executeAgg(this.Model, {
|
|
2338
|
-
...finalReq,
|
|
2339
|
-
limit,
|
|
2340
|
-
offset
|
|
2341
|
-
}, session ? { session } : {}), countAggGroups(this.Model, finalReq, session ? { session } : {})]);
|
|
2342
|
-
const result = {
|
|
2343
|
-
method: "offset",
|
|
2344
|
-
docs,
|
|
2345
|
-
page,
|
|
2346
|
-
limit,
|
|
2347
|
-
total,
|
|
2348
|
-
pages: Math.max(1, Math.ceil(total / limit)),
|
|
2349
|
-
hasNext: page * limit < total,
|
|
2350
|
-
hasPrev: page > 1
|
|
2351
|
-
};
|
|
2352
|
-
await this._emitHook("after:aggregatePaginate", {
|
|
2353
|
-
context,
|
|
2354
|
-
result
|
|
2355
|
-
});
|
|
2356
|
-
return result;
|
|
2357
|
-
} catch (error) {
|
|
2358
|
-
await this._emitErrorHook("error:aggregatePaginate", {
|
|
2359
|
-
context,
|
|
2360
|
-
error
|
|
2361
|
-
});
|
|
2362
|
-
throw this._handleError(error);
|
|
2363
|
-
}
|
|
2364
|
-
}
|
|
2365
|
-
/**
|
|
2366
|
-
* Merge policy-hook-injected filters (multi-tenant scope,
|
|
2367
|
-
* soft-delete) into the pre-aggregate `filter` slot of the request.
|
|
2368
|
-
* Plugins speak mongo-query shape (`context.query` / `context.filters`);
|
|
2369
|
-
* the caller's `req.filter` may be either Filter IR or a mongo query.
|
|
2370
|
-
*
|
|
2371
|
-
* Each part is compiled through `compileFilterToMongo` BEFORE the
|
|
2372
|
-
* `$and` merge — otherwise an IR node like `eq('status', 'paid')`
|
|
2373
|
-
* (`{ op, field, value }`) gets nested inside `$and` as a literal,
|
|
2374
|
-
* `compileFilterToMongo` of the merged value finds no top-level `op`
|
|
2375
|
-
* (so `isFilter` is false), and Mongo receives the IR object as a
|
|
2376
|
-
* raw query clause that matches no real document. Symptom: aggregates
|
|
2377
|
-
* silently return 0 rows whenever a callerpasses IR + a policy
|
|
2378
|
-
* plugin is installed.
|
|
2379
|
-
*/
|
|
2380
|
-
_injectPolicyScopeIntoAgg(req, context) {
|
|
2381
|
-
const scopeCandidates = [];
|
|
2382
|
-
for (const candidate of [context.filters, context.query]) if (candidate && typeof candidate === "object" && Object.keys(candidate).length > 0) scopeCandidates.push(candidate);
|
|
2383
|
-
if (scopeCandidates.length === 0) return req;
|
|
2384
|
-
const parts = [];
|
|
2385
|
-
for (const c of scopeCandidates) {
|
|
2386
|
-
const compiled = compileFilterToMongo(c);
|
|
2387
|
-
if (Object.keys(compiled).length > 0) parts.push(compiled);
|
|
2388
|
-
}
|
|
2389
|
-
if (req.filter) {
|
|
2390
|
-
const compiled = compileFilterToMongo(req.filter);
|
|
2391
|
-
if (Object.keys(compiled).length > 0) parts.push(compiled);
|
|
2392
|
-
}
|
|
2393
|
-
if (parts.length === 0) return req;
|
|
2394
|
-
const merged = parts.length === 1 ? parts[0] : { $and: parts };
|
|
2395
|
-
return {
|
|
2396
|
-
...req,
|
|
2397
|
-
filter: merged
|
|
2398
|
-
};
|
|
2399
|
-
}
|
|
2400
|
-
/**
|
|
2401
|
-
* Get distinct values
|
|
2402
|
-
* Routes through hook system for policy enforcement (multi-tenant, soft-delete)
|
|
2403
|
-
*/
|
|
2404
|
-
async distinct(field, query = {}, options = {}) {
|
|
2405
|
-
const context = await this._buildContext("distinct", {
|
|
2406
|
-
query,
|
|
2407
|
-
...options
|
|
2408
|
-
});
|
|
2409
|
-
return this._runOp("distinct", context, () => {
|
|
2410
|
-
const finalQuery = context.query || query;
|
|
2411
|
-
const readPreference = context.readPreference ?? options.readPreference;
|
|
2412
|
-
return distinct(this.Model, field, finalQuery, {
|
|
2413
|
-
session: options.session,
|
|
2414
|
-
readPreference
|
|
2415
|
-
});
|
|
2416
|
-
});
|
|
2417
|
-
}
|
|
2418
|
-
/**
|
|
2419
|
-
* Query with custom field lookups ($lookup)
|
|
2420
|
-
* Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
|
|
2421
|
-
*
|
|
2422
|
-
* @example
|
|
2423
|
-
* ```typescript
|
|
2424
|
-
* // Join employees with departments using slug instead of ObjectId
|
|
2425
|
-
* const employees = await employeeRepo.lookupPopulate({
|
|
2426
|
-
* filters: { status: 'active' },
|
|
2427
|
-
* lookups: [
|
|
2428
|
-
* {
|
|
2429
|
-
* from: 'departments',
|
|
2430
|
-
* localField: 'departmentSlug',
|
|
2431
|
-
* foreignField: 'slug',
|
|
2432
|
-
* as: 'department',
|
|
2433
|
-
* single: true
|
|
2434
|
-
* }
|
|
2435
|
-
* ],
|
|
2436
|
-
* sort: '-createdAt',
|
|
2437
|
-
* page: 1,
|
|
2438
|
-
* limit: 50
|
|
2439
|
-
* });
|
|
2440
|
-
* ```
|
|
2441
|
-
*/
|
|
2442
|
-
async lookupPopulate(options) {
|
|
2443
|
-
const context = await this._buildContext("lookupPopulate", options);
|
|
2444
|
-
try {
|
|
2445
|
-
const MAX_LOOKUPS = 10;
|
|
2446
|
-
const lookups = context.lookups ?? options.lookups;
|
|
2447
|
-
if (lookups.length > MAX_LOOKUPS) throw createError(400, `Too many lookups (${lookups.length}). Maximum is ${MAX_LOOKUPS}.`);
|
|
2448
|
-
const filters = context.filters ?? options.filters;
|
|
2449
|
-
const sort = context.sort ?? options.sort;
|
|
2450
|
-
const limit = context.limit ?? options.limit ?? this._pagination.config.defaultLimit ?? 20;
|
|
2451
|
-
const readPref = context.readPreference ?? options.readPreference;
|
|
2452
|
-
const session = context.session ?? options.session;
|
|
2453
|
-
const collation = context.collation ?? options.collation;
|
|
2454
|
-
const after = context.after ?? options.after;
|
|
2455
|
-
const pageFromContext = context.page ?? options.page;
|
|
2456
|
-
const isKeyset = !!after || !pageFromContext && !!sort;
|
|
2457
|
-
const countStrategy = context.countStrategy ?? options.countStrategy ?? "exact";
|
|
2458
|
-
const selectSpec = context.select ?? options.select;
|
|
2459
|
-
let projection;
|
|
2460
|
-
if (selectSpec) {
|
|
2461
|
-
if (typeof selectSpec === "string") {
|
|
2462
|
-
projection = {};
|
|
2463
|
-
for (const field of selectSpec.split(",").map((f) => f.trim())) if (field.startsWith("-")) projection[field.substring(1)] = 0;
|
|
2464
|
-
else projection[field] = 1;
|
|
2465
|
-
} else if (Array.isArray(selectSpec)) {
|
|
2466
|
-
projection = {};
|
|
2467
|
-
for (const field of selectSpec) if (field.startsWith("-")) projection[field.substring(1)] = 0;
|
|
2468
|
-
else projection[field] = 1;
|
|
2469
|
-
} else projection = { ...selectSpec };
|
|
2470
|
-
if (projection) {
|
|
2471
|
-
if (Object.values(projection).some((v) => v === 1)) for (const lookup of lookups) {
|
|
2472
|
-
const asField = lookup.as || lookup.from;
|
|
2473
|
-
if (!(asField in projection)) projection[asField] = 1;
|
|
2474
|
-
}
|
|
2475
|
-
}
|
|
2476
|
-
}
|
|
2477
|
-
const appendLookupStages = (pipeline) => {
|
|
2478
|
-
pipeline.push(...LookupBuilder.multiple(lookups));
|
|
2479
|
-
for (const lookup of lookups) if (lookup.single) {
|
|
2480
|
-
const asField = lookup.as || lookup.from;
|
|
2481
|
-
pipeline.push({ $addFields: { [asField]: { $ifNull: [`$${asField}`, null] } } });
|
|
2482
|
-
}
|
|
2483
|
-
const finalProjection = ensureLookupProjectionIncludesCursorFields(projection, isKeyset && sort ? this._parseSort(sort) : void 0);
|
|
2484
|
-
if (finalProjection) pipeline.push({ $project: finalProjection });
|
|
2485
|
-
};
|
|
2486
|
-
if (isKeyset && sort) {
|
|
2487
|
-
const parsedSort = this._parseSort(sort);
|
|
2488
|
-
const { validateKeysetSort } = await import("./sort-CrZjTK25.mjs").then((n) => n.n);
|
|
2489
|
-
const { encodeCursor, resolveCursorFilter } = await import("./cursor-J84rR6wG.mjs").then((n) => n.t);
|
|
2490
|
-
const { getPrimaryField } = await import("./sort-CrZjTK25.mjs").then((n) => n.n);
|
|
2491
|
-
const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
|
|
2492
|
-
const cursorVersion = this._pagination.config.cursorVersion ?? 1;
|
|
2493
|
-
const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
|
|
2494
|
-
const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }, minCursorVersion) : { ...filters || {} };
|
|
2495
|
-
if (projection) {
|
|
2496
|
-
if (Object.values(projection).some((v) => v === 1)) {
|
|
2497
|
-
for (const sortField of Object.keys(normalizedSort)) if (!(sortField in projection)) projection[sortField] = 1;
|
|
2498
|
-
}
|
|
2499
|
-
}
|
|
2500
|
-
const pipeline = [];
|
|
2501
|
-
if (Object.keys(matchFilters).length > 0) pipeline.push({ $match: matchFilters });
|
|
2502
|
-
pipeline.push({ $sort: normalizedSort });
|
|
2503
|
-
pipeline.push({ $limit: limit + 1 });
|
|
2504
|
-
appendLookupStages(pipeline);
|
|
2505
|
-
const aggregation = this.Model.aggregate(pipeline).session(session || null);
|
|
2506
|
-
if (collation) aggregation.collation(collation);
|
|
2507
|
-
if (readPref) aggregation.read(readPref);
|
|
2508
|
-
const docs = await aggregation;
|
|
2509
|
-
const hasMore = docs.length > limit;
|
|
2510
|
-
if (hasMore) docs.pop();
|
|
2511
|
-
const primaryField = getPrimaryField(normalizedSort);
|
|
2512
|
-
const result = {
|
|
2513
|
-
method: "keyset",
|
|
2514
|
-
docs,
|
|
2515
|
-
limit,
|
|
2516
|
-
hasMore,
|
|
2517
|
-
next: hasMore && docs.length > 0 ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, cursorVersion) : null
|
|
2518
|
-
};
|
|
2519
|
-
await this._emitHook("after:lookupPopulate", {
|
|
2520
|
-
context,
|
|
2521
|
-
result
|
|
2522
|
-
});
|
|
2523
|
-
return result;
|
|
2524
|
-
}
|
|
2525
|
-
const page = pageFromContext ?? 1;
|
|
2526
|
-
const skip = (page - 1) * limit;
|
|
2527
|
-
if (skip > 1e4) warn(`[mongokit] Large offset (${skip}) in lookupPopulate. Consider using keyset pagination: getAll({ sort, after, limit, lookups })`);
|
|
2528
|
-
const dataPipeline = [];
|
|
2529
|
-
if (filters && Object.keys(filters).length > 0) dataPipeline.push({ $match: filters });
|
|
2530
|
-
if (sort) dataPipeline.push({ $sort: this._parseSort(sort) });
|
|
2531
|
-
if (countStrategy === "none") {
|
|
2532
|
-
dataPipeline.push({ $skip: skip }, { $limit: limit + 1 });
|
|
2533
|
-
appendLookupStages(dataPipeline);
|
|
2534
|
-
const aggregation = this.Model.aggregate(dataPipeline).session(session || null);
|
|
2535
|
-
if (collation) aggregation.collation(collation);
|
|
2536
|
-
if (readPref) aggregation.read(readPref);
|
|
2537
|
-
const docs = await aggregation;
|
|
2538
|
-
const hasNext = docs.length > limit;
|
|
2539
|
-
if (hasNext) docs.pop();
|
|
2540
|
-
const result = {
|
|
2541
|
-
method: "offset",
|
|
2542
|
-
docs,
|
|
2543
|
-
page,
|
|
2544
|
-
limit,
|
|
2545
|
-
total: 0,
|
|
2546
|
-
pages: 0,
|
|
2547
|
-
hasNext,
|
|
2548
|
-
hasPrev: page > 1
|
|
2549
|
-
};
|
|
2550
|
-
await this._emitHook("after:lookupPopulate", {
|
|
2551
|
-
context,
|
|
2552
|
-
result
|
|
2553
|
-
});
|
|
2554
|
-
return result;
|
|
2555
|
-
}
|
|
2556
|
-
dataPipeline.push({ $skip: skip }, { $limit: limit });
|
|
2557
|
-
appendLookupStages(dataPipeline);
|
|
2558
|
-
const countPipeline = [];
|
|
2559
|
-
if (filters && Object.keys(filters).length > 0) countPipeline.push({ $match: filters });
|
|
2560
|
-
countPipeline.push({ $count: "total" });
|
|
2561
|
-
const pipeline = [{ $facet: {
|
|
2562
|
-
metadata: countPipeline,
|
|
2563
|
-
data: dataPipeline
|
|
2564
|
-
} }];
|
|
2565
|
-
const aggregation = this.Model.aggregate(pipeline).session(session || null);
|
|
2566
|
-
if (collation) aggregation.collation(collation);
|
|
2567
|
-
if (readPref) aggregation.read(readPref);
|
|
2568
|
-
const facetResult = (await aggregation)[0] || {
|
|
2569
|
-
metadata: [],
|
|
2570
|
-
data: []
|
|
2571
|
-
};
|
|
2572
|
-
const total = facetResult.metadata[0]?.total || 0;
|
|
2573
|
-
const result = {
|
|
2574
|
-
method: "offset",
|
|
2575
|
-
docs: facetResult.data || [],
|
|
2576
|
-
page,
|
|
2577
|
-
limit,
|
|
2578
|
-
total,
|
|
2579
|
-
pages: Math.max(1, Math.ceil(total / limit)),
|
|
2580
|
-
hasNext: page * limit < total,
|
|
2581
|
-
hasPrev: page > 1
|
|
2582
|
-
};
|
|
2583
|
-
await this._emitHook("after:lookupPopulate", {
|
|
2584
|
-
context,
|
|
2585
|
-
result
|
|
2586
|
-
});
|
|
2587
|
-
return result;
|
|
2588
|
-
} catch (error) {
|
|
2589
|
-
await this._emitErrorHook("error:lookupPopulate", {
|
|
2590
|
-
context,
|
|
2591
|
-
error
|
|
2592
|
-
});
|
|
2593
|
-
throw this._handleError(error);
|
|
2594
|
-
}
|
|
2595
|
-
}
|
|
2596
|
-
/**
|
|
2597
|
-
* Create an aggregation builder for this model
|
|
2598
|
-
* Useful for building complex custom aggregations
|
|
2599
|
-
*
|
|
2600
|
-
* @example
|
|
2601
|
-
* ```typescript
|
|
2602
|
-
* const pipeline = repo.buildAggregation()
|
|
2603
|
-
* .match({ status: 'active' })
|
|
2604
|
-
* .lookup('departments', 'deptSlug', 'slug', 'department', true)
|
|
2605
|
-
* .group({ _id: '$department', count: { $sum: 1 } })
|
|
2606
|
-
* .sort({ count: -1 })
|
|
2607
|
-
* .build();
|
|
2608
|
-
*
|
|
2609
|
-
* const results = await repo.Model.aggregate(pipeline);
|
|
2610
|
-
* ```
|
|
2611
|
-
*/
|
|
2612
|
-
buildAggregation() {
|
|
2613
|
-
return new AggregationBuilder();
|
|
2614
|
-
}
|
|
2615
|
-
/**
|
|
2616
|
-
* Create a lookup builder
|
|
2617
|
-
* Useful for building $lookup stages independently
|
|
2618
|
-
*
|
|
2619
|
-
* @example
|
|
2620
|
-
* ```typescript
|
|
2621
|
-
* const lookupStages = repo.buildLookup('departments')
|
|
2622
|
-
* .localField('deptSlug')
|
|
2623
|
-
* .foreignField('slug')
|
|
2624
|
-
* .as('department')
|
|
2625
|
-
* .single()
|
|
2626
|
-
* .build();
|
|
2627
|
-
*
|
|
2628
|
-
* const pipeline = [
|
|
2629
|
-
* { $match: { status: 'active' } },
|
|
2630
|
-
* ...lookupStages
|
|
2631
|
-
* ];
|
|
2632
|
-
* ```
|
|
2633
|
-
*/
|
|
2634
|
-
buildLookup(from) {
|
|
2635
|
-
return new LookupBuilder(from);
|
|
2636
|
-
}
|
|
2637
|
-
/**
|
|
2638
|
-
* Execute callback within a MongoDB transaction with automatic retry on
|
|
2639
|
-
* transient failures.
|
|
2640
|
-
*
|
|
2641
|
-
* **BREAKING (3.10):** the callback now receives a **tx-bound repository**,
|
|
2642
|
-
* not a raw `ClientSession`. This matches repo-core's `StandardRepo.withTransaction`
|
|
2643
|
-
* contract that every kit (sqlitekit, pgkit, prismakit) already implements
|
|
2644
|
-
* — so cross-kit plugins and apps are portable. Session threading happens
|
|
2645
|
-
* transparently: every CRUD call on `txRepo` auto-injects the session into
|
|
2646
|
-
* its options bag, including plugin-added methods (`upsert`, `increment`,
|
|
2647
|
-
* `updateMany`, `restore`, ...). Nested `txRepo.withTransaction(...)` throws.
|
|
2648
|
-
*
|
|
2649
|
-
* For raw-session cross-repo workflows — multiple repositories coordinating
|
|
2650
|
-
* writes — use the standalone `withTransaction(connection, fn)` export
|
|
2651
|
-
* from `@classytic/mongokit`. That helper remains session-based by design.
|
|
2652
|
-
*
|
|
2653
|
-
* @param callback - Receives a tx-bound `Repository<TDoc>` — call its
|
|
2654
|
-
* methods directly, no need to pass `{ session }` manually
|
|
2655
|
-
* @param options.allowFallback - Run without transaction on standalone MongoDB (default: false)
|
|
2656
|
-
* @param options.onFallback - Called when falling back to non-transactional execution
|
|
2657
|
-
* @param options.transactionOptions - MongoDB driver transaction options (readConcern, writeConcern, etc.)
|
|
2658
|
-
*
|
|
2659
|
-
* @example
|
|
2660
|
-
* ```typescript
|
|
2661
|
-
* const order = await repo.withTransaction(async (txRepo) => {
|
|
2662
|
-
* const created = await txRepo.create({ total: 100 });
|
|
2663
|
-
* await txRepo.update(created._id, { confirmed: true });
|
|
2664
|
-
* return created;
|
|
2665
|
-
* });
|
|
2666
|
-
*
|
|
2667
|
-
* // With fallback for standalone/dev environments
|
|
2668
|
-
* await repo.withTransaction(
|
|
2669
|
-
* async (txRepo) => {
|
|
2670
|
-
* await txRepo.create(doc);
|
|
2671
|
-
* },
|
|
2672
|
-
* {
|
|
2673
|
-
* allowFallback: true,
|
|
2674
|
-
* onFallback: (err) => logger.warn('Running without transaction', err),
|
|
2675
|
-
* },
|
|
2676
|
-
* );
|
|
2677
|
-
*
|
|
2678
|
-
* // Cross-repo with explicit session:
|
|
2679
|
-
* import { withTransaction } from '@classytic/mongokit';
|
|
2680
|
-
* await withTransaction(mongoose.connection, async (session) => {
|
|
2681
|
-
* const order = await orderRepo.create(data, { session });
|
|
2682
|
-
* await inventoryRepo.decrement(..., { session });
|
|
2683
|
-
* });
|
|
2684
|
-
* ```
|
|
2685
|
-
*/
|
|
2686
|
-
async withTransaction(callback, options = {}) {
|
|
2687
|
-
return withTransaction(this.Model.db, async (session) => {
|
|
2688
|
-
return callback(createTxBoundRepo(this, session));
|
|
2689
|
-
}, options);
|
|
2690
|
-
}
|
|
2691
|
-
/**
|
|
2692
|
-
* Execute custom query with event emission
|
|
2693
|
-
*/
|
|
2694
|
-
async _executeQuery(buildQuery) {
|
|
2695
|
-
const operation = buildQuery.name || "custom";
|
|
2696
|
-
const context = await this._buildContext(operation, {});
|
|
2697
|
-
return this._runOp(operation, context, () => buildQuery(this.Model));
|
|
2698
|
-
}
|
|
2699
|
-
/**
|
|
2700
|
-
* Build operation context and run before-hooks (sorted by priority).
|
|
2701
|
-
*
|
|
2702
|
-
* Narrows `RepositoryBase._buildContext`'s return type to mongokit's
|
|
2703
|
-
* richer `RepositoryContext` (which carries `softDeleted`, `_cacheHit`,
|
|
2704
|
-
* populate / readPreference / session, etc.). Runtime is inherited from
|
|
2705
|
-
* the base class — this override is purely a type-narrowing shim.
|
|
2706
|
-
*
|
|
2707
|
-
* Hook execution order is deterministic:
|
|
2708
|
-
* 1. POLICY (100) — tenant isolation, soft-delete filtering, validation
|
|
2709
|
-
* 2. CACHE (200) — cache lookup (after policy filters are injected)
|
|
2710
|
-
* 3. OBSERVABILITY (300) — audit logging, metrics
|
|
2711
|
-
* 4. DEFAULT (500) — user-registered hooks
|
|
2712
|
-
*/
|
|
2713
|
-
async _buildContext(operation, options) {
|
|
2714
|
-
const normalized = this._normalizeFilterSlots(options);
|
|
2715
|
-
return await super._buildContext(operation, normalized);
|
|
2716
|
-
}
|
|
2717
|
-
/**
|
|
2718
|
-
* Walk the well-known filter-carrying keys (`query`, `filters`,
|
|
2719
|
-
* `having`) and route any Filter IR through `compileFilterToMongo`.
|
|
2720
|
-
* Plain Mongo queries pass through untouched — `isFilter` is the
|
|
2721
|
-
* discriminator. Returns a shallow clone; never mutates input.
|
|
2722
|
-
*/
|
|
2723
|
-
_normalizeFilterSlots(options) {
|
|
2724
|
-
let cloned = null;
|
|
2725
|
-
for (const key of [
|
|
2726
|
-
"query",
|
|
2727
|
-
"filters",
|
|
2728
|
-
"having"
|
|
2729
|
-
]) {
|
|
2730
|
-
const value = options[key];
|
|
2731
|
-
if (value === void 0 || value === null) continue;
|
|
2732
|
-
if (!isFilter(value)) continue;
|
|
2733
|
-
cloned ??= { ...options };
|
|
2734
|
-
cloned[key] = compileFilterToMongo(value);
|
|
2735
|
-
}
|
|
2736
|
-
return cloned ?? options;
|
|
2737
|
-
}
|
|
2738
|
-
/**
|
|
2739
|
-
* Resolve the effective sort for getAll. Handles $near conflict detection
|
|
2740
|
-
* (warns + drops explicit sort when $near is in filters).
|
|
2741
|
-
*/
|
|
2742
|
-
_resolveSort(context, params, isNearQuery) {
|
|
2743
|
-
const explicitSort = context.sort ?? params.sort;
|
|
2744
|
-
if (isNearQuery) {
|
|
2745
|
-
if (explicitSort !== void 0) warn(`[mongokit] Repository "${this.model}" dropping explicit sort (${JSON.stringify(explicitSort)}) because the filter contains $near / $nearSphere. MongoDB forbids explicit sort with $near. Use [withinRadius] instead of [near] if you need a custom sort.`);
|
|
2746
|
-
return;
|
|
2747
|
-
}
|
|
2748
|
-
return explicitSort ?? "-createdAt";
|
|
2749
|
-
}
|
|
2750
|
-
/**
|
|
2751
|
-
* Detect whether to use keyset (cursor) or offset (page) pagination.
|
|
2752
|
-
*/
|
|
2753
|
-
_detectPaginationMode(mode, page, after, sort, context, params) {
|
|
2754
|
-
if (mode) return mode === "keyset";
|
|
2755
|
-
return !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
|
|
2756
|
-
}
|
|
2757
|
-
/**
|
|
2758
|
-
* Build the MongoDB query filter with search merged in. Handles text
|
|
2759
|
-
* search ($text), regex search ($or of $regex), and the search-resolver
|
|
2760
|
-
* plugin contract (search already cleared by a before:getAll hook).
|
|
2761
|
-
*/
|
|
2762
|
-
_buildSearchQuery(filters, search) {
|
|
2763
|
-
const query = { ...filters };
|
|
2764
|
-
if (!search) return query;
|
|
2765
|
-
if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
|
|
2766
|
-
let effectiveMode = this.searchMode === "regex" ? "regex" : "text";
|
|
2767
|
-
if (this.searchMode === "auto") effectiveMode = this._hasTextIndex ? "text" : "regex";
|
|
2768
|
-
if (effectiveMode === "regex") {
|
|
2769
|
-
if (!this.searchFields || this.searchFields.length === 0) throw createError(400, `Repository "${this.model}" configured with searchMode: '${this.searchMode}' but no searchFields provided.`);
|
|
2770
|
-
const escaped = String(search).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2771
|
-
const orConds = this.searchFields.map((field) => ({ [field]: {
|
|
2772
|
-
$regex: escaped,
|
|
2773
|
-
$options: "i"
|
|
2774
|
-
} }));
|
|
2775
|
-
if (Array.isArray(query.$or)) {
|
|
2776
|
-
const existingOr = query.$or;
|
|
2777
|
-
delete query.$or;
|
|
2778
|
-
query.$and = [
|
|
2779
|
-
...Array.isArray(query.$and) ? query.$and : [],
|
|
2780
|
-
{ $or: existingOr },
|
|
2781
|
-
{ $or: orConds }
|
|
2782
|
-
];
|
|
2783
|
-
} else query.$or = orConds;
|
|
2784
|
-
} else if (this._hasTextIndex) query.$text = { $search: search };
|
|
2785
|
-
else {
|
|
2786
|
-
const modelName = String(this.model);
|
|
2787
|
-
throw createError(400, `No text index found for ${modelName}. Cannot perform text search. Configure ${modelName}'s Repository with { searchMode: 'regex', searchFields: [...] } to enable index-free search, or create a text index on the collection for searchMode: 'text'. See https://github.com/classytic/mongokit/blob/main/docs/README.md#queryparser-url--filter`, {
|
|
2788
|
-
code: "SEARCH_NOT_CONFIGURED",
|
|
2789
|
-
meta: {
|
|
2790
|
-
model: modelName,
|
|
2791
|
-
configuredMode: this.searchMode,
|
|
2792
|
-
availableModes: [
|
|
2793
|
-
"text",
|
|
2794
|
-
"regex",
|
|
2795
|
-
"auto"
|
|
2796
|
-
],
|
|
2797
|
-
docs: "https://github.com/classytic/mongokit/blob/main/docs/README.md#queryparser-url--filter"
|
|
2798
|
-
}
|
|
2799
|
-
});
|
|
2800
|
-
}
|
|
2801
|
-
return query;
|
|
2802
|
-
}
|
|
2803
|
-
/**
|
|
2804
|
-
* Parse sort string or object
|
|
2805
|
-
*/
|
|
2806
|
-
_parseSort(sort) {
|
|
2807
|
-
if (!sort) return { createdAt: -1 };
|
|
2808
|
-
if (typeof sort === "object") {
|
|
2809
|
-
if (Object.keys(sort).length === 0) return { createdAt: -1 };
|
|
2810
|
-
return sort;
|
|
2811
|
-
}
|
|
2812
|
-
const sortObj = {};
|
|
2813
|
-
const fields = sort.split(",").map((s) => s.trim());
|
|
2814
|
-
for (const field of fields) if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
|
|
2815
|
-
else sortObj[field] = 1;
|
|
2816
|
-
return sortObj;
|
|
2817
|
-
}
|
|
2818
|
-
/**
|
|
2819
|
-
* Parse populate specification
|
|
2820
|
-
*/
|
|
2821
|
-
_parsePopulate(populate) {
|
|
2822
|
-
if (!populate) return [];
|
|
2823
|
-
if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
|
|
2824
|
-
if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
|
|
2825
|
-
return [populate];
|
|
2826
|
-
}
|
|
2827
|
-
/**
|
|
2828
|
-
* Classify a driver error as an authoritative duplicate-key conflict.
|
|
2829
|
-
*
|
|
2830
|
-
* Used by arc's outbox / idempotency adapters to distinguish
|
|
2831
|
-
* "write already landed (idempotent no-op)" from "transient DB error
|
|
2832
|
-
* (must retry)". Every backend signals duplicate keys differently —
|
|
2833
|
-
* MongoDB `code: 11000`, Prisma `P2002`, Postgres `23505` — so this
|
|
2834
|
-
* predicate lives on the kit that knows its driver. Cross-driver
|
|
2835
|
-
* adapters then depend only on the boolean outcome, not the shape of
|
|
2836
|
-
* the underlying error.
|
|
2837
|
-
*
|
|
2838
|
-
* Deliberately narrow: matches ONLY `code === 11000` and
|
|
2839
|
-
* `codeName === 'DuplicateKey'`. Does NOT match
|
|
2840
|
-
* `err.name === 'MongoServerError'`, which is also true for
|
|
2841
|
-
* WriteConflict, NotWritablePrimary, ExceededTimeLimit, and every
|
|
2842
|
-
* other server-side error — treating those as duplicate keys would
|
|
2843
|
-
* silently swallow transactional retries.
|
|
2844
|
-
*
|
|
2845
|
-
* @example
|
|
2846
|
-
* try {
|
|
2847
|
-
* await outboxRepo.create(event);
|
|
2848
|
-
* } catch (err) {
|
|
2849
|
-
* if (outboxRepo.isDuplicateKeyError(err)) {
|
|
2850
|
-
* // Idempotent no-op — event was already written by a prior attempt.
|
|
2851
|
-
* return;
|
|
2852
|
-
* }
|
|
2853
|
-
* throw err; // transient DB error — upstream retries
|
|
2854
|
-
* }
|
|
2855
|
-
*/
|
|
2856
|
-
isDuplicateKeyError(err) {
|
|
2857
|
-
return isDuplicateKeyError(err);
|
|
2858
|
-
}
|
|
2859
|
-
/**
|
|
2860
|
-
* Handle errors with proper HTTP status codes
|
|
2861
|
-
*/
|
|
2862
|
-
_handleError(error) {
|
|
2863
|
-
if (error instanceof mongoose.Error.ValidationError) return createError(400, `Validation Error: ${Object.values(error.errors).map((err) => err.message).join(", ")}`);
|
|
2864
|
-
if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
|
|
2865
|
-
const duplicateErr = parseDuplicateKeyError(error);
|
|
2866
|
-
if (duplicateErr) return duplicateErr;
|
|
2867
|
-
if (error.status && error.message) return error;
|
|
2868
|
-
return createError(500, error.message || "Internal Server Error");
|
|
2869
|
-
}
|
|
2870
|
-
};
|
|
2871
|
-
//#endregion
|
|
2872
|
-
//#region src/plugins/cache.plugin.ts
|
|
2873
|
-
/**
|
|
2874
|
-
* Cache Plugin
|
|
2875
|
-
*
|
|
2876
|
-
* Optional caching layer for MongoKit with automatic invalidation.
|
|
2877
|
-
* Bring-your-own cache adapter (Redis, Memcached, in-memory, etc.)
|
|
2878
|
-
*
|
|
2879
|
-
* Features:
|
|
2880
|
-
* - Cache-aside (read-through) pattern with configurable TTLs
|
|
2881
|
-
* - Automatic invalidation on create/update/delete
|
|
2882
|
-
* - Collection version tags for efficient list cache invalidation
|
|
2883
|
-
* - Manual invalidation methods for microservice scenarios
|
|
2884
|
-
* - Skip cache per-operation with `skipCache: true`
|
|
2885
|
-
*
|
|
2886
|
-
* @example
|
|
2887
|
-
* ```typescript
|
|
2888
|
-
* import { Repository, cachePlugin } from '@classytic/mongokit';
|
|
2889
|
-
* import Redis from 'ioredis';
|
|
2890
|
-
*
|
|
2891
|
-
* const redis = new Redis();
|
|
2892
|
-
*
|
|
2893
|
-
* const userRepo = new Repository(UserModel, [
|
|
2894
|
-
* cachePlugin({
|
|
2895
|
-
* adapter: {
|
|
2896
|
-
* async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
|
|
2897
|
-
* async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
|
|
2898
|
-
* async delete(key) { await redis.del(key); },
|
|
2899
|
-
* async clear(pattern) {
|
|
2900
|
-
* const keys = await redis.keys(pattern || '*');
|
|
2901
|
-
* if (keys.length) await redis.del(...keys);
|
|
2902
|
-
* }
|
|
2903
|
-
* },
|
|
2904
|
-
* ttl: 60, // 1 minute default
|
|
2905
|
-
* })
|
|
2906
|
-
* ]);
|
|
2907
|
-
*
|
|
2908
|
-
* // Reads check cache first
|
|
2909
|
-
* const user = await userRepo.getById(id); // cached
|
|
2910
|
-
*
|
|
2911
|
-
* // Skip cache for fresh data
|
|
2912
|
-
* const fresh = await userRepo.getById(id, { skipCache: true });
|
|
2913
|
-
*
|
|
2914
|
-
* // Mutations auto-invalidate
|
|
2915
|
-
* await userRepo.update(id, { name: 'New Name' }); // invalidates cache
|
|
2916
|
-
*
|
|
2917
|
-
* // Manual invalidation for microservice sync
|
|
2918
|
-
* await userRepo.invalidateCache(id); // invalidate single doc
|
|
2919
|
-
* await userRepo.invalidateAllCache(); // invalidate all for this model
|
|
2920
|
-
* ```
|
|
2921
|
-
*/
|
|
2922
|
-
/**
|
|
2923
|
-
* Resolve the user's jitter config into a deterministic `ttl -> ttl` function.
|
|
2924
|
-
*
|
|
2925
|
-
* - `undefined` / `0`: identity (no jitter).
|
|
2926
|
-
* - number in (0, 1]: symmetric fractional jitter. `0.1` → uniform in [0.9, 1.1].
|
|
2927
|
-
* - function: used as-is; caller owns the math. Clamped to >= 1s below.
|
|
2928
|
-
*
|
|
2929
|
-
* The final TTL is always clamped to a whole second >= 1 so adapters that
|
|
2930
|
-
* treat 0 as "no expiry" (Redis SETEX semantics) don't accidentally persist
|
|
2931
|
-
* entries forever.
|
|
2932
|
-
*/
|
|
2933
|
-
function resolveJitter(jitter) {
|
|
2934
|
-
if (!jitter) return (ttl) => ttl;
|
|
2935
|
-
if (typeof jitter === "function") return (ttl) => Math.max(1, Math.round(jitter(ttl)));
|
|
2936
|
-
const fraction = Math.min(1, Math.max(0, jitter));
|
|
2937
|
-
if (fraction === 0) return (ttl) => ttl;
|
|
2938
|
-
return (ttl) => {
|
|
2939
|
-
const delta = ttl * fraction;
|
|
2940
|
-
const jittered = ttl - delta + Math.random() * 2 * delta;
|
|
2941
|
-
return Math.max(1, Math.round(jittered));
|
|
2942
|
-
};
|
|
2943
|
-
}
|
|
2944
|
-
/**
|
|
2945
|
-
* Cache plugin factory
|
|
2946
|
-
*
|
|
2947
|
-
* @param options - Cache configuration
|
|
2948
|
-
* @returns Plugin instance
|
|
2949
|
-
*/
|
|
2950
|
-
function cachePlugin(options) {
|
|
2951
|
-
const config = {
|
|
2952
|
-
adapter: options.adapter,
|
|
2953
|
-
ttl: options.ttl ?? 60,
|
|
2954
|
-
byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
|
|
2955
|
-
queryTtl: options.queryTtl ?? options.ttl ?? 60,
|
|
2956
|
-
prefix: options.prefix ?? "mk",
|
|
2957
|
-
debug: options.debug ?? false,
|
|
2958
|
-
skipIfLargeLimit: options.skipIf?.largeLimit ?? 100,
|
|
2959
|
-
applyJitter: resolveJitter(options.jitter)
|
|
2960
|
-
};
|
|
2961
|
-
const stats = {
|
|
2962
|
-
hits: 0,
|
|
2963
|
-
misses: 0,
|
|
2964
|
-
sets: 0,
|
|
2965
|
-
invalidations: 0,
|
|
2966
|
-
errors: 0
|
|
2967
|
-
};
|
|
2968
|
-
const log = (msg, data) => {
|
|
2969
|
-
if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
|
|
2970
|
-
};
|
|
2971
|
-
return {
|
|
2972
|
-
name: "cache",
|
|
2973
|
-
apply(repo) {
|
|
2974
|
-
const model = repo.model;
|
|
2975
|
-
const byIdKeyRegistry = /* @__PURE__ */ new Map();
|
|
2976
|
-
function trackByIdKey(docId, cacheKey) {
|
|
2977
|
-
let keys = byIdKeyRegistry.get(docId);
|
|
2978
|
-
if (!keys) {
|
|
2979
|
-
keys = /* @__PURE__ */ new Set();
|
|
2980
|
-
byIdKeyRegistry.set(docId, keys);
|
|
2981
|
-
}
|
|
2982
|
-
keys.add(cacheKey);
|
|
2983
|
-
}
|
|
2984
|
-
async function getVersion() {
|
|
2985
|
-
try {
|
|
2986
|
-
return await config.adapter.get(versionKey(config.prefix, model)) ?? 0;
|
|
2987
|
-
} catch (e) {
|
|
2988
|
-
log(`Cache error in getVersion for ${model}:`, e);
|
|
2989
|
-
return 0;
|
|
2990
|
-
}
|
|
2991
|
-
}
|
|
2992
|
-
/**
|
|
2993
|
-
* Bump collection version in the adapter (invalidates all list caches).
|
|
2994
|
-
* Uses Date.now() so version always moves forward — safe after eviction or deploy.
|
|
2995
|
-
*/
|
|
2996
|
-
async function bumpVersion() {
|
|
2997
|
-
const newVersion = Date.now();
|
|
2998
|
-
try {
|
|
2999
|
-
await config.adapter.set(versionKey(config.prefix, model), newVersion, config.applyJitter(config.ttl * 10));
|
|
3000
|
-
stats.invalidations++;
|
|
3001
|
-
log(`Bumped version for ${model} to:`, newVersion);
|
|
3002
|
-
} catch (e) {
|
|
3003
|
-
log(`Failed to bump version for ${model}:`, e);
|
|
3004
|
-
}
|
|
3005
|
-
}
|
|
3006
|
-
/**
|
|
3007
|
-
* Invalidate a specific document by ID (all shape variants).
|
|
3008
|
-
* Deletes every tracked shape-variant key individually via delete(),
|
|
3009
|
-
* so adapters without pattern-based clear() still get full invalidation.
|
|
3010
|
-
*/
|
|
3011
|
-
async function invalidateById(id) {
|
|
3012
|
-
try {
|
|
3013
|
-
const baseKey = byIdKey(config.prefix, model, id);
|
|
3014
|
-
await config.adapter.delete(baseKey);
|
|
3015
|
-
const trackedKeys = byIdKeyRegistry.get(id);
|
|
3016
|
-
if (trackedKeys) {
|
|
3017
|
-
for (const key of trackedKeys) if (key !== baseKey) await config.adapter.delete(key);
|
|
3018
|
-
byIdKeyRegistry.delete(id);
|
|
3019
|
-
}
|
|
3020
|
-
stats.invalidations++;
|
|
3021
|
-
log(`Invalidated byId cache for:`, id);
|
|
3022
|
-
} catch (e) {
|
|
3023
|
-
log(`Failed to invalidate byId cache:`, e);
|
|
3024
|
-
}
|
|
3025
|
-
}
|
|
3026
|
-
/**
|
|
3027
|
-
* before:getById - Check cache for document
|
|
3028
|
-
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
3029
|
-
*/
|
|
3030
|
-
repo.on("before:getById", async (context) => {
|
|
3031
|
-
if (context.skipCache) {
|
|
3032
|
-
log(`Skipping cache for getById: ${context.id}`);
|
|
3033
|
-
return;
|
|
3034
|
-
}
|
|
3035
|
-
const id = String(context.id);
|
|
3036
|
-
const key = byIdKey(config.prefix, model, id, {
|
|
3037
|
-
select: context.select,
|
|
3038
|
-
populate: context.populate,
|
|
3039
|
-
lean: context.lean
|
|
3040
|
-
});
|
|
3041
|
-
try {
|
|
3042
|
-
const cached = await config.adapter.get(key);
|
|
3043
|
-
if (cached !== null) {
|
|
3044
|
-
stats.hits++;
|
|
3045
|
-
log(`Cache HIT for getById:`, key);
|
|
3046
|
-
context._cacheHit = true;
|
|
3047
|
-
context._cachedResult = cached;
|
|
3048
|
-
} else {
|
|
3049
|
-
stats.misses++;
|
|
3050
|
-
log(`Cache MISS for getById:`, key);
|
|
3051
|
-
}
|
|
3052
|
-
} catch (e) {
|
|
3053
|
-
log(`Cache error for getById:`, e);
|
|
3054
|
-
stats.errors++;
|
|
3055
|
-
}
|
|
3056
|
-
}, { priority: HOOK_PRIORITY$1.CACHE });
|
|
3057
|
-
/**
|
|
3058
|
-
* before:getByQuery - Check cache for single-doc query
|
|
3059
|
-
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
3060
|
-
*/
|
|
3061
|
-
repo.on("before:getByQuery", async (context) => {
|
|
3062
|
-
if (context.skipCache) {
|
|
3063
|
-
log(`Skipping cache for getByQuery`);
|
|
3064
|
-
return;
|
|
3065
|
-
}
|
|
3066
|
-
const collectionVersion = await getVersion();
|
|
3067
|
-
const query = context.query || {};
|
|
3068
|
-
const key = byQueryKey(config.prefix, model, collectionVersion, query, {
|
|
3069
|
-
select: context.select,
|
|
3070
|
-
populate: context.populate
|
|
3071
|
-
});
|
|
3072
|
-
try {
|
|
3073
|
-
const cached = await config.adapter.get(key);
|
|
3074
|
-
if (cached !== null) {
|
|
3075
|
-
stats.hits++;
|
|
3076
|
-
log(`Cache HIT for getByQuery:`, key);
|
|
3077
|
-
context._cacheHit = true;
|
|
3078
|
-
context._cachedResult = cached;
|
|
3079
|
-
} else {
|
|
3080
|
-
stats.misses++;
|
|
3081
|
-
log(`Cache MISS for getByQuery:`, key);
|
|
3082
|
-
}
|
|
3083
|
-
} catch (e) {
|
|
3084
|
-
log(`Cache error for getByQuery:`, e);
|
|
3085
|
-
stats.errors++;
|
|
3086
|
-
}
|
|
3087
|
-
}, { priority: HOOK_PRIORITY$1.CACHE });
|
|
3088
|
-
/**
|
|
3089
|
-
* before:getOne - Same cache logic as getByQuery (single-doc)
|
|
3090
|
-
*/
|
|
3091
|
-
repo.on("before:getOne", async (context) => {
|
|
3092
|
-
if (context.skipCache) return;
|
|
3093
|
-
try {
|
|
3094
|
-
const collectionVersion = await getVersion();
|
|
3095
|
-
const query = context.query || {};
|
|
3096
|
-
const key = byQueryKey(config.prefix, model, collectionVersion, query, {
|
|
3097
|
-
select: context.select,
|
|
3098
|
-
populate: context.populate
|
|
3099
|
-
});
|
|
3100
|
-
const cached = await config.adapter.get(key);
|
|
3101
|
-
if (cached !== null && cached !== void 0) {
|
|
3102
|
-
log(`Cache HIT for getOne: ${key}`);
|
|
3103
|
-
stats.hits++;
|
|
3104
|
-
context._cacheHit = true;
|
|
3105
|
-
context._cachedResult = cached;
|
|
3106
|
-
} else {
|
|
3107
|
-
log(`Cache MISS for getOne: ${key}`);
|
|
3108
|
-
stats.misses++;
|
|
3109
|
-
context._cacheKey = key;
|
|
3110
|
-
}
|
|
3111
|
-
} catch (e) {
|
|
3112
|
-
log(`Cache error for getOne:`, e);
|
|
3113
|
-
stats.errors++;
|
|
3114
|
-
}
|
|
3115
|
-
}, { priority: HOOK_PRIORITY$1.CACHE });
|
|
3116
|
-
/**
|
|
3117
|
-
* before:getAll - Check cache for list query
|
|
3118
|
-
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
3119
|
-
*/
|
|
3120
|
-
repo.on("before:getAll", async (context) => {
|
|
3121
|
-
if (context.skipCache) {
|
|
3122
|
-
log(`Skipping cache for getAll`);
|
|
3123
|
-
return;
|
|
3124
|
-
}
|
|
3125
|
-
const limit = context.limit;
|
|
3126
|
-
if (limit && limit > config.skipIfLargeLimit) {
|
|
3127
|
-
log(`Skipping cache for large query (limit: ${limit})`);
|
|
3128
|
-
return;
|
|
3129
|
-
}
|
|
3130
|
-
const collectionVersion = await getVersion();
|
|
3131
|
-
const params = {
|
|
3132
|
-
filters: context.filters,
|
|
3133
|
-
sort: context.sort,
|
|
3134
|
-
page: context.page,
|
|
3135
|
-
limit,
|
|
3136
|
-
after: context.after,
|
|
3137
|
-
select: context.select,
|
|
3138
|
-
populate: context.populate,
|
|
3139
|
-
search: context.search,
|
|
3140
|
-
mode: context.mode,
|
|
3141
|
-
lean: context.lean,
|
|
3142
|
-
readPreference: context.readPreference,
|
|
3143
|
-
hint: context.hint,
|
|
3144
|
-
maxTimeMS: context.maxTimeMS,
|
|
3145
|
-
countStrategy: context.countStrategy
|
|
3146
|
-
};
|
|
3147
|
-
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
3148
|
-
try {
|
|
3149
|
-
const cached = await config.adapter.get(key);
|
|
3150
|
-
if (cached !== null) {
|
|
3151
|
-
stats.hits++;
|
|
3152
|
-
log(`Cache HIT for getAll:`, key);
|
|
3153
|
-
context._cacheHit = true;
|
|
3154
|
-
context._cachedResult = cached;
|
|
3155
|
-
} else {
|
|
3156
|
-
stats.misses++;
|
|
3157
|
-
log(`Cache MISS for getAll:`, key);
|
|
3158
|
-
}
|
|
3159
|
-
} catch (e) {
|
|
3160
|
-
log(`Cache error for getAll:`, e);
|
|
3161
|
-
stats.errors++;
|
|
3162
|
-
}
|
|
3163
|
-
}, { priority: HOOK_PRIORITY$1.CACHE });
|
|
3164
|
-
/**
|
|
3165
|
-
* after:getById - Cache the result
|
|
3166
|
-
*/
|
|
3167
|
-
repo.on("after:getById", async (payload) => {
|
|
3168
|
-
const { context, result } = payload;
|
|
3169
|
-
if (context._cacheHit) return;
|
|
3170
|
-
if (context.skipCache) return;
|
|
3171
|
-
if (result === null) return;
|
|
3172
|
-
const id = String(context.id);
|
|
3173
|
-
const key = byIdKey(config.prefix, model, id, {
|
|
3174
|
-
select: context.select,
|
|
3175
|
-
populate: context.populate,
|
|
3176
|
-
lean: context.lean
|
|
3177
|
-
});
|
|
3178
|
-
const ttl = config.applyJitter(context.cacheTtl ?? config.byIdTtl);
|
|
3179
|
-
try {
|
|
3180
|
-
await config.adapter.set(key, result, ttl);
|
|
3181
|
-
trackByIdKey(id, key);
|
|
3182
|
-
stats.sets++;
|
|
3183
|
-
log(`Cached getById result:`, key);
|
|
3184
|
-
} catch (e) {
|
|
3185
|
-
log(`Failed to cache getById:`, e);
|
|
3186
|
-
}
|
|
3187
|
-
});
|
|
3188
|
-
/**
|
|
3189
|
-
* after:getByQuery - Cache the result
|
|
3190
|
-
*/
|
|
3191
|
-
repo.on("after:getByQuery", async (payload) => {
|
|
3192
|
-
const { context, result } = payload;
|
|
3193
|
-
if (context._cacheHit) return;
|
|
3194
|
-
if (context.skipCache) return;
|
|
3195
|
-
if (result === null) return;
|
|
3196
|
-
const collectionVersion = await getVersion();
|
|
3197
|
-
const query = context.query || {};
|
|
3198
|
-
const key = byQueryKey(config.prefix, model, collectionVersion, query, {
|
|
3199
|
-
select: context.select,
|
|
3200
|
-
populate: context.populate
|
|
3201
|
-
});
|
|
3202
|
-
const ttl = config.applyJitter(context.cacheTtl ?? config.queryTtl);
|
|
3203
|
-
try {
|
|
3204
|
-
await config.adapter.set(key, result, ttl);
|
|
3205
|
-
stats.sets++;
|
|
3206
|
-
log(`Cached getByQuery result:`, key);
|
|
3207
|
-
} catch (e) {
|
|
3208
|
-
log(`Failed to cache getByQuery:`, e);
|
|
3209
|
-
}
|
|
3210
|
-
});
|
|
3211
|
-
/**
|
|
3212
|
-
* after:getOne - Cache the result (same as getByQuery)
|
|
3213
|
-
*/
|
|
3214
|
-
repo.on("after:getOne", async (payload) => {
|
|
3215
|
-
const { context, result } = payload;
|
|
3216
|
-
if (context._cacheHit || context.skipCache || result === null) return;
|
|
3217
|
-
const collectionVersion = await getVersion();
|
|
3218
|
-
const query = context.query || {};
|
|
3219
|
-
const key = byQueryKey(config.prefix, model, collectionVersion, query, {
|
|
3220
|
-
select: context.select,
|
|
3221
|
-
populate: context.populate
|
|
3222
|
-
});
|
|
3223
|
-
try {
|
|
3224
|
-
await config.adapter.set(key, result, config.applyJitter(context.cacheTtl ?? config.queryTtl));
|
|
3225
|
-
stats.sets++;
|
|
3226
|
-
} catch (e) {
|
|
3227
|
-
log(`Failed to cache getOne:`, e);
|
|
3228
|
-
}
|
|
3229
|
-
});
|
|
3230
|
-
/**
|
|
3231
|
-
* after:getAll - Cache the result
|
|
3232
|
-
*/
|
|
3233
|
-
repo.on("after:getAll", async (payload) => {
|
|
3234
|
-
const { context, result } = payload;
|
|
3235
|
-
if (context._cacheHit) return;
|
|
3236
|
-
if (context.skipCache) return;
|
|
3237
|
-
const limit = context.limit;
|
|
3238
|
-
if (limit && limit > config.skipIfLargeLimit) return;
|
|
3239
|
-
const collectionVersion = await getVersion();
|
|
3240
|
-
const params = {
|
|
3241
|
-
filters: context.filters,
|
|
3242
|
-
sort: context.sort,
|
|
3243
|
-
page: context.page,
|
|
3244
|
-
limit,
|
|
3245
|
-
after: context.after,
|
|
3246
|
-
select: context.select,
|
|
3247
|
-
populate: context.populate,
|
|
3248
|
-
search: context.search,
|
|
3249
|
-
mode: context.mode,
|
|
3250
|
-
lean: context.lean,
|
|
3251
|
-
readPreference: context.readPreference,
|
|
3252
|
-
hint: context.hint,
|
|
3253
|
-
maxTimeMS: context.maxTimeMS,
|
|
3254
|
-
countStrategy: context.countStrategy
|
|
3255
|
-
};
|
|
3256
|
-
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
3257
|
-
const ttl = config.applyJitter(context.cacheTtl ?? config.queryTtl);
|
|
3258
|
-
try {
|
|
3259
|
-
await config.adapter.set(key, result, ttl);
|
|
3260
|
-
stats.sets++;
|
|
3261
|
-
log(`Cached getAll result:`, key);
|
|
3262
|
-
} catch (e) {
|
|
3263
|
-
log(`Failed to cache getAll:`, e);
|
|
3264
|
-
}
|
|
3265
|
-
});
|
|
3266
|
-
/**
|
|
3267
|
-
* after:create - Bump version to invalidate list caches
|
|
3268
|
-
*/
|
|
3269
|
-
repo.on("after:create", async () => {
|
|
3270
|
-
await bumpVersion();
|
|
3271
|
-
});
|
|
3272
|
-
/**
|
|
3273
|
-
* after:createMany - Bump version to invalidate list caches
|
|
3274
|
-
*/
|
|
3275
|
-
repo.on("after:createMany", async () => {
|
|
3276
|
-
await bumpVersion();
|
|
3277
|
-
});
|
|
3278
|
-
/**
|
|
3279
|
-
* after:update - Invalidate by ID and bump version
|
|
3280
|
-
*/
|
|
3281
|
-
repo.on("after:update", async (payload) => {
|
|
3282
|
-
const { context } = payload;
|
|
3283
|
-
const id = String(context.id);
|
|
3284
|
-
await Promise.all([invalidateById(id), bumpVersion()]);
|
|
3285
|
-
});
|
|
3286
|
-
/**
|
|
3287
|
-
* after:updateMany - Bump version (can't track individual IDs efficiently)
|
|
3288
|
-
*/
|
|
3289
|
-
repo.on("after:updateMany", async () => {
|
|
3290
|
-
await bumpVersion();
|
|
3291
|
-
});
|
|
3292
|
-
/**
|
|
3293
|
-
* after:findOneAndUpdate — atomic CAS may insert (upsert) or mutate.
|
|
3294
|
-
* Result carries the matched/inserted doc; invalidate that ID and bump
|
|
3295
|
-
* version. Null result (no match, no upsert) is a no-op.
|
|
3296
|
-
*/
|
|
3297
|
-
repo.on("after:findOneAndUpdate", async (payload) => {
|
|
3298
|
-
const { result } = payload;
|
|
3299
|
-
if (!result) return;
|
|
3300
|
-
const idKey = repo.idField || "_id";
|
|
3301
|
-
const id = result?.[idKey];
|
|
3302
|
-
if (id == null) {
|
|
3303
|
-
await bumpVersion();
|
|
3304
|
-
return;
|
|
3305
|
-
}
|
|
3306
|
-
await Promise.all([invalidateById(String(id)), bumpVersion()]);
|
|
3307
|
-
});
|
|
3308
|
-
/**
|
|
3309
|
-
* after:delete - Invalidate by ID and bump version
|
|
3310
|
-
*/
|
|
3311
|
-
repo.on("after:delete", async (payload) => {
|
|
3312
|
-
const { context } = payload;
|
|
3313
|
-
const id = String(context.id);
|
|
3314
|
-
await Promise.all([invalidateById(id), bumpVersion()]);
|
|
3315
|
-
});
|
|
3316
|
-
/**
|
|
3317
|
-
* after:deleteMany - Bump version
|
|
3318
|
-
*/
|
|
3319
|
-
repo.on("after:deleteMany", async () => {
|
|
3320
|
-
await bumpVersion();
|
|
3321
|
-
});
|
|
3322
|
-
/**
|
|
3323
|
-
* after:bulkWrite - Bump version (bulk ops may insert/update/delete)
|
|
3324
|
-
*/
|
|
3325
|
-
repo.on("after:bulkWrite", async () => {
|
|
3326
|
-
await bumpVersion();
|
|
3327
|
-
});
|
|
3328
|
-
/**
|
|
3329
|
-
* Invalidate cache for a specific document
|
|
3330
|
-
* Use when document was updated outside this service
|
|
3331
|
-
*
|
|
3332
|
-
* @example
|
|
3333
|
-
* await userRepo.invalidateCache('507f1f77bcf86cd799439011');
|
|
3334
|
-
*/
|
|
3335
|
-
repo.invalidateCache = async (id) => {
|
|
3336
|
-
await invalidateById(id);
|
|
3337
|
-
log(`Manual invalidation for ID:`, id);
|
|
3338
|
-
};
|
|
3339
|
-
/**
|
|
3340
|
-
* Invalidate all list caches for this model
|
|
3341
|
-
* Use when bulk changes happened outside this service
|
|
3342
|
-
*
|
|
3343
|
-
* @example
|
|
3344
|
-
* await userRepo.invalidateListCache();
|
|
3345
|
-
*/
|
|
3346
|
-
repo.invalidateListCache = async () => {
|
|
3347
|
-
await bumpVersion();
|
|
3348
|
-
log(`Manual list cache invalidation for ${model}`);
|
|
3349
|
-
};
|
|
3350
|
-
/**
|
|
3351
|
-
* Invalidate ALL cache entries for this model
|
|
3352
|
-
* Nuclear option - use sparingly
|
|
3353
|
-
*
|
|
3354
|
-
* @example
|
|
3355
|
-
* await userRepo.invalidateAllCache();
|
|
3356
|
-
*/
|
|
3357
|
-
repo.invalidateAllCache = async () => {
|
|
3358
|
-
if (config.adapter.clear) try {
|
|
3359
|
-
await config.adapter.clear(modelPattern(config.prefix, model));
|
|
3360
|
-
stats.invalidations++;
|
|
3361
|
-
log(`Full cache invalidation for ${model}`);
|
|
3362
|
-
} catch (e) {
|
|
3363
|
-
log(`Failed full cache invalidation for ${model}:`, e);
|
|
3364
|
-
}
|
|
3365
|
-
else {
|
|
3366
|
-
await bumpVersion();
|
|
3367
|
-
log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
|
|
3368
|
-
}
|
|
3369
|
-
};
|
|
3370
|
-
/**
|
|
3371
|
-
* Get cache statistics for monitoring
|
|
3372
|
-
*
|
|
3373
|
-
* @example
|
|
3374
|
-
* const stats = userRepo.getCacheStats();
|
|
3375
|
-
* console.log(`Hit rate: ${stats.hits / (stats.hits + stats.misses) * 100}%`);
|
|
3376
|
-
*/
|
|
3377
|
-
repo.getCacheStats = () => ({ ...stats });
|
|
3378
|
-
/**
|
|
3379
|
-
* Reset cache statistics
|
|
3380
|
-
*/
|
|
3381
|
-
repo.resetCacheStats = () => {
|
|
3382
|
-
stats.hits = 0;
|
|
3383
|
-
stats.misses = 0;
|
|
3384
|
-
stats.sets = 0;
|
|
3385
|
-
stats.invalidations = 0;
|
|
3386
|
-
stats.errors = 0;
|
|
3387
|
-
};
|
|
3388
|
-
}
|
|
3389
|
-
};
|
|
3390
|
-
}
|
|
3391
|
-
//#endregion
|
|
3392
|
-
//#region src/plugins/cascade.plugin.ts
|
|
3393
|
-
/**
|
|
3394
|
-
* Cascade Delete Plugin
|
|
3395
|
-
*
|
|
3396
|
-
* Automatically deletes related documents when a parent document is deleted.
|
|
3397
|
-
*
|
|
3398
|
-
* Two routing modes per relation:
|
|
3399
|
-
*
|
|
3400
|
-
* 1. **Repo-routed (preferred)** — pass `repo: targetRepo` on the relation.
|
|
3401
|
-
* Cascade calls `targetRepo.delete(id, { mode })` / `targetRepo.deleteMany(
|
|
3402
|
-
* query, { mode })`, so the target's `before:delete` / `before:deleteMany`
|
|
3403
|
-
* hooks fire. Multi-tenant scoping, audit logging, cache invalidation,
|
|
3404
|
-
* and the target's own `softDeletePlugin` (with its configured
|
|
3405
|
-
* `deletedField`) all run correctly.
|
|
3406
|
-
*
|
|
3407
|
-
* 2. **Model-routed (legacy)** — pass `model: 'TargetModelName'`. Cascade
|
|
3408
|
-
* writes directly via `mongoose.models[name].updateMany / deleteMany`,
|
|
3409
|
-
* **bypassing** the target's hooks. Safe only for trivial targets with
|
|
3410
|
-
* no policy plugins. Retained for backwards compatibility — prefer the
|
|
3411
|
-
* repo-routed form for new code.
|
|
3412
|
-
*
|
|
3413
|
-
* The parent's delete mode propagates: a hard-deleted parent cascades hard,
|
|
3414
|
-
* a soft-deleted parent cascades soft — unless `relation.softDelete` overrides
|
|
3415
|
-
* the decision per-relation.
|
|
3416
|
-
*
|
|
3417
|
-
* @example Repo-routed (new)
|
|
3418
|
-
* ```ts
|
|
3419
|
-
* const productRepo = new Repository(Product, [
|
|
3420
|
-
* methodRegistryPlugin(),
|
|
3421
|
-
* cascadePlugin({
|
|
3422
|
-
* relations: [
|
|
3423
|
-
* { repo: stockEntryRepo, foreignKey: 'product' },
|
|
3424
|
-
* { repo: stockMovementRepo, foreignKey: 'product' },
|
|
3425
|
-
* ],
|
|
3426
|
-
* }),
|
|
3427
|
-
* ]);
|
|
3428
|
-
* ```
|
|
3429
|
-
*
|
|
3430
|
-
* @example Model-routed (legacy)
|
|
3431
|
-
* ```ts
|
|
3432
|
-
* cascadePlugin({
|
|
3433
|
-
* relations: [
|
|
3434
|
-
* { model: 'StockEntry', foreignKey: 'product' },
|
|
3435
|
-
* ],
|
|
3436
|
-
* });
|
|
3437
|
-
* ```
|
|
3438
|
-
*/
|
|
3439
|
-
/**
|
|
3440
|
-
* Collect top-level parent-context fields that a target plugin (multi-tenant,
|
|
3441
|
-
* audit) will likely need. We can't know the target's contextKey, so we
|
|
3442
|
-
* forward a well-known allow-list: `organizationId`, `tenantId`, and `user`.
|
|
3443
|
-
* These are the conventions enforced by the built-in plugins.
|
|
3444
|
-
*/
|
|
3445
|
-
function collectScopeForward(context) {
|
|
3446
|
-
const out = {};
|
|
3447
|
-
if (context.organizationId !== void 0) out.organizationId = context.organizationId;
|
|
3448
|
-
if (context.tenantId !== void 0) out.tenantId = context.tenantId;
|
|
3449
|
-
if (context.user !== void 0) out.user = context.user;
|
|
3450
|
-
return out;
|
|
3451
|
-
}
|
|
3452
|
-
/**
|
|
3453
|
-
* Cascade delete plugin.
|
|
3454
|
-
*/
|
|
3455
|
-
function cascadePlugin(options) {
|
|
3456
|
-
const { relations, parallel = true, logger } = options;
|
|
3457
|
-
if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
|
|
3458
|
-
for (const rel of relations) {
|
|
3459
|
-
if (!rel.repo && !rel.model) throw new Error("cascadePlugin: each relation needs either `repo` (preferred) or `model` (legacy)");
|
|
3460
|
-
if (!rel.foreignKey) throw new Error("cascadePlugin: each relation needs `foreignKey`");
|
|
3461
|
-
}
|
|
3462
|
-
return {
|
|
3463
|
-
name: "cascade",
|
|
3464
|
-
apply(repo) {
|
|
3465
|
-
repo.on("after:delete", async (payload) => {
|
|
3466
|
-
const { context } = payload;
|
|
3467
|
-
const deletedId = context.id;
|
|
3468
|
-
if (!deletedId) {
|
|
3469
|
-
logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
|
|
3470
|
-
return;
|
|
3471
|
-
}
|
|
3472
|
-
const isSoftParent = context.softDeleted === true;
|
|
3473
|
-
const scopeForward = collectScopeForward(context);
|
|
3474
|
-
const cascadeOne = async (relation) => {
|
|
3475
|
-
const shouldSoftDelete = relation.softDelete ?? isSoftParent;
|
|
3476
|
-
const cascadeCtx = {
|
|
3477
|
-
targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
|
|
3478
|
-
session: context.session,
|
|
3479
|
-
shouldSoftDelete,
|
|
3480
|
-
user: context.user,
|
|
3481
|
-
scopeForward
|
|
3482
|
-
};
|
|
3483
|
-
try {
|
|
3484
|
-
if (relation.repo) await cascadeViaRepoBulk(relation, deletedId, cascadeCtx);
|
|
3485
|
-
else await cascadeViaLegacyModel(relation, deletedId, cascadeCtx, logger);
|
|
3486
|
-
} catch (error) {
|
|
3487
|
-
logger?.error?.(`Cascade delete failed for '${cascadeCtx.targetModelName}'`, {
|
|
3488
|
-
parentModel: context.model,
|
|
3489
|
-
parentId: String(deletedId),
|
|
3490
|
-
relatedModel: cascadeCtx.targetModelName,
|
|
3491
|
-
foreignKey: relation.foreignKey,
|
|
3492
|
-
error: error.message
|
|
3493
|
-
});
|
|
3494
|
-
throw error;
|
|
3495
|
-
}
|
|
3496
|
-
};
|
|
3497
|
-
await runCascades(relations, cascadeOne, parallel);
|
|
3498
|
-
});
|
|
3499
|
-
repo.on("before:deleteMany", async (context) => {
|
|
3500
|
-
const query = context.query;
|
|
3501
|
-
if (!query || Object.keys(query).length === 0) return;
|
|
3502
|
-
const idField = repo.idField || "_id";
|
|
3503
|
-
context._cascadeIds = (await repo.Model.find(query, { [idField]: 1 }).lean().session(context.session ?? null)).map((doc) => doc[idField]);
|
|
3504
|
-
});
|
|
3505
|
-
repo.on("after:deleteMany", async (payload) => {
|
|
3506
|
-
const { context } = payload;
|
|
3507
|
-
const ids = context._cascadeIds;
|
|
3508
|
-
if (!ids || ids.length === 0) return;
|
|
3509
|
-
const isSoftParent = context.softDeleted === true;
|
|
3510
|
-
const scopeForward = collectScopeForward(context);
|
|
3511
|
-
const cascadeBulk = async (relation) => {
|
|
3512
|
-
const shouldSoftDelete = relation.softDelete ?? isSoftParent;
|
|
3513
|
-
const cascadeCtx = {
|
|
3514
|
-
targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
|
|
3515
|
-
session: context.session,
|
|
3516
|
-
shouldSoftDelete,
|
|
3517
|
-
user: context.user,
|
|
3518
|
-
scopeForward
|
|
3519
|
-
};
|
|
3520
|
-
try {
|
|
3521
|
-
if (relation.repo) await cascadeViaRepoBulkMany(relation, ids, cascadeCtx);
|
|
3522
|
-
else await cascadeViaLegacyModelMany(relation, ids, cascadeCtx, logger);
|
|
3523
|
-
} catch (error) {
|
|
3524
|
-
logger?.error?.(`Cascade deleteMany failed for '${cascadeCtx.targetModelName}'`, {
|
|
3525
|
-
parentModel: context.model,
|
|
3526
|
-
relatedModel: cascadeCtx.targetModelName,
|
|
3527
|
-
foreignKey: relation.foreignKey,
|
|
3528
|
-
error: error.message
|
|
3529
|
-
});
|
|
3530
|
-
throw error;
|
|
3531
|
-
}
|
|
3532
|
-
};
|
|
3533
|
-
await runCascades(relations, cascadeBulk, parallel);
|
|
3534
|
-
});
|
|
3535
|
-
}
|
|
3536
|
-
};
|
|
3537
|
-
}
|
|
3538
|
-
/**
|
|
3539
|
-
* Repo-routed cascade for a single parent delete.
|
|
3540
|
-
* Calls `repo.deleteMany({ [fk]: parentId }, { mode })` — respects all target
|
|
3541
|
-
* hooks (multi-tenant, audit, target's own soft-delete plugin).
|
|
3542
|
-
*/
|
|
3543
|
-
async function cascadeViaRepoBulk(relation, parentId, ctx) {
|
|
3544
|
-
const targetRepo = relation.repo;
|
|
3545
|
-
if (typeof targetRepo.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${ctx.targetModelName}' is missing deleteMany(). Ensure the target is a mongokit Repository or implements the StandardRepo deleteMany contract.`);
|
|
3546
|
-
await targetRepo.deleteMany({ [relation.foreignKey]: parentId }, {
|
|
3547
|
-
...ctx.scopeForward,
|
|
3548
|
-
mode: ctx.shouldSoftDelete ? "soft" : "hard",
|
|
3549
|
-
session: ctx.session
|
|
3550
|
-
});
|
|
3551
|
-
}
|
|
3552
|
-
/**
|
|
3553
|
-
* Repo-routed cascade for a bulk parent deleteMany — `$in` over snapshot ids.
|
|
3554
|
-
*/
|
|
3555
|
-
async function cascadeViaRepoBulkMany(relation, parentIds, ctx) {
|
|
3556
|
-
const targetRepo = relation.repo;
|
|
3557
|
-
if (typeof targetRepo.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${ctx.targetModelName}' is missing deleteMany(). Ensure the target is a mongokit Repository or implements the StandardRepo deleteMany contract.`);
|
|
3558
|
-
await targetRepo.deleteMany({ [relation.foreignKey]: { $in: parentIds } }, {
|
|
3559
|
-
...ctx.scopeForward,
|
|
3560
|
-
mode: ctx.shouldSoftDelete ? "soft" : "hard",
|
|
3561
|
-
session: ctx.session
|
|
3562
|
-
});
|
|
3563
|
-
}
|
|
3564
|
-
/**
|
|
3565
|
-
* Legacy cascade via `mongoose.models[name]`. Bypasses target hooks.
|
|
3566
|
-
* Retained for backwards compatibility with existing cascadePlugin consumers.
|
|
3567
|
-
*/
|
|
3568
|
-
async function cascadeViaLegacyModel(relation, parentId, ctx, logger) {
|
|
3569
|
-
const RelatedModel = mongoose.models[relation.model];
|
|
3570
|
-
if (!RelatedModel) {
|
|
3571
|
-
logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`);
|
|
3572
|
-
return;
|
|
3573
|
-
}
|
|
3574
|
-
const query = { [relation.foreignKey]: parentId };
|
|
3575
|
-
if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
|
|
3576
|
-
deletedAt: /* @__PURE__ */ new Date(),
|
|
3577
|
-
...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
|
|
3578
|
-
}, { session: ctx.session });
|
|
3579
|
-
else await RelatedModel.deleteMany(query, { session: ctx.session });
|
|
3580
|
-
}
|
|
3581
|
-
async function cascadeViaLegacyModelMany(relation, parentIds, ctx, logger) {
|
|
3582
|
-
const RelatedModel = mongoose.models[relation.model];
|
|
3583
|
-
if (!RelatedModel) {
|
|
3584
|
-
logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`);
|
|
3585
|
-
return;
|
|
3586
|
-
}
|
|
3587
|
-
const query = { [relation.foreignKey]: { $in: parentIds } };
|
|
3588
|
-
if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
|
|
3589
|
-
deletedAt: /* @__PURE__ */ new Date(),
|
|
3590
|
-
...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
|
|
3591
|
-
}, { session: ctx.session });
|
|
3592
|
-
else await RelatedModel.deleteMany(query, { session: ctx.session });
|
|
3593
|
-
}
|
|
3594
|
-
/**
|
|
3595
|
-
* Execute a list of cascade operations, honoring the `parallel` flag.
|
|
3596
|
-
* Uses `allSettled` so one failure doesn't abort siblings; throws the first
|
|
3597
|
-
* rejection (with a composite message if several failed) after all complete.
|
|
3598
|
-
*/
|
|
3599
|
-
async function runCascades(relations, fn, parallel) {
|
|
3600
|
-
if (parallel) {
|
|
3601
|
-
const failures = (await Promise.allSettled(relations.map(fn))).filter((r) => r.status === "rejected");
|
|
3602
|
-
if (failures.length) {
|
|
3603
|
-
const err = failures[0].reason;
|
|
3604
|
-
if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
|
|
3605
|
-
throw err;
|
|
3606
|
-
}
|
|
3607
|
-
} else for (const relation of relations) await fn(relation);
|
|
3608
|
-
}
|
|
3609
|
-
//#endregion
|
|
3610
|
-
//#region src/plugins/custom-id.plugin.ts
|
|
3611
|
-
/**
|
|
3612
|
-
* Custom ID Plugin
|
|
3613
|
-
*
|
|
3614
|
-
* Generates custom document IDs using pluggable generators.
|
|
3615
|
-
* Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
|
|
3616
|
-
* date-partitioned sequences, and fully custom generators.
|
|
3617
|
-
*
|
|
3618
|
-
* Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
|
|
3619
|
-
* counters collection — guaranteeing no duplicate IDs under concurrency.
|
|
3620
|
-
*
|
|
3621
|
-
* @example Basic sequential counter
|
|
3622
|
-
* ```typescript
|
|
3623
|
-
* const invoiceRepo = new Repository(InvoiceModel, [
|
|
3624
|
-
* customIdPlugin({
|
|
3625
|
-
* field: 'invoiceNumber',
|
|
3626
|
-
* generator: sequentialId({
|
|
3627
|
-
* prefix: 'INV',
|
|
3628
|
-
* model: InvoiceModel,
|
|
3629
|
-
* }),
|
|
3630
|
-
* }),
|
|
3631
|
-
* ]);
|
|
3632
|
-
*
|
|
3633
|
-
* const inv = await invoiceRepo.create({ amount: 100 });
|
|
3634
|
-
* // inv.invoiceNumber → "INV-0001"
|
|
3635
|
-
* ```
|
|
3636
|
-
*
|
|
3637
|
-
* @example Date-partitioned counter (resets monthly)
|
|
3638
|
-
* ```typescript
|
|
3639
|
-
* const billRepo = new Repository(BillModel, [
|
|
3640
|
-
* customIdPlugin({
|
|
3641
|
-
* field: 'billNumber',
|
|
3642
|
-
* generator: dateSequentialId({
|
|
3643
|
-
* prefix: 'BILL',
|
|
3644
|
-
* model: BillModel,
|
|
3645
|
-
* partition: 'monthly',
|
|
3646
|
-
* separator: '-',
|
|
3647
|
-
* padding: 4,
|
|
3648
|
-
* }),
|
|
3649
|
-
* }),
|
|
3650
|
-
* ]);
|
|
3651
|
-
*
|
|
3652
|
-
* const bill = await billRepo.create({ total: 250 });
|
|
3653
|
-
* // bill.billNumber → "BILL-2026-02-0001"
|
|
3654
|
-
* ```
|
|
3655
|
-
*
|
|
3656
|
-
* @example Custom generator function
|
|
3657
|
-
* ```typescript
|
|
3658
|
-
* const orderRepo = new Repository(OrderModel, [
|
|
3659
|
-
* customIdPlugin({
|
|
3660
|
-
* field: 'orderRef',
|
|
3661
|
-
* generator: async (context) => {
|
|
3662
|
-
* const region = context.data?.region || 'US';
|
|
3663
|
-
* const seq = await getNextSequence('orders');
|
|
3664
|
-
* return `ORD-${region}-${seq}`;
|
|
3665
|
-
* },
|
|
3666
|
-
* }),
|
|
3667
|
-
* ]);
|
|
3668
|
-
* ```
|
|
3669
|
-
*/
|
|
3670
|
-
/** Schema for the internal counters collection */
|
|
3671
|
-
const counterSchema = new mongoose.Schema({
|
|
3672
|
-
_id: {
|
|
3673
|
-
type: String,
|
|
3674
|
-
required: true
|
|
3675
|
-
},
|
|
3676
|
-
seq: {
|
|
3677
|
-
type: Number,
|
|
3678
|
-
default: 0
|
|
3679
|
-
}
|
|
3680
|
-
}, {
|
|
3681
|
-
collection: "_mongokit_counters",
|
|
3682
|
-
versionKey: false
|
|
3683
|
-
});
|
|
3684
|
-
/**
|
|
3685
|
-
* Get or create the Counter model on the given connection.
|
|
3686
|
-
* Falls back to the default mongoose connection if none is provided.
|
|
3687
|
-
* Lazy-init to avoid model registration errors if mongoose isn't connected yet.
|
|
3688
|
-
*/
|
|
3689
|
-
function getCounterModel(connection) {
|
|
3690
|
-
const conn = connection ?? mongoose.connection;
|
|
3691
|
-
if (conn.models._MongoKitCounter) return conn.models._MongoKitCounter;
|
|
3692
|
-
return conn.model("_MongoKitCounter", counterSchema);
|
|
3693
|
-
}
|
|
3694
|
-
/**
|
|
3695
|
-
* Atomically increment and return the next sequence value for a given key.
|
|
3696
|
-
* Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
|
|
3697
|
-
* heavy concurrency.
|
|
3698
|
-
*
|
|
3699
|
-
* @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
|
|
3700
|
-
* @param increment - Value to increment by (default: 1)
|
|
3701
|
-
* @param connection - Mongoose connection to use (defaults to the default connection)
|
|
3702
|
-
* @param session - Optional client session. When provided, the counter bump
|
|
3703
|
-
* participates in the caller's transaction — if the surrounding transaction
|
|
3704
|
-
* aborts, the counter does NOT advance. Without a session, the bump commits
|
|
3705
|
-
* immediately and is not rolled back if a sibling write fails.
|
|
3706
|
-
* @returns The next sequence number (after increment)
|
|
3707
|
-
*
|
|
3708
|
-
* @example
|
|
3709
|
-
* const seq = await getNextSequence('invoices');
|
|
3710
|
-
* // First call → 1, second → 2, ...
|
|
3711
|
-
*
|
|
3712
|
-
* @example Batch increment for createMany
|
|
3713
|
-
* const startSeq = await getNextSequence('invoices', 5);
|
|
3714
|
-
* // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
|
|
3715
|
-
*
|
|
3716
|
-
* @example Transactional counter bump
|
|
3717
|
-
* await withTransaction(conn, async (session) => {
|
|
3718
|
-
* const seq = await getNextSequence('invoices', 1, conn, session);
|
|
3719
|
-
* await invoiceRepo.create({ invoiceNumber: `INV-${seq}` }, { session });
|
|
3720
|
-
* // if this throws, the counter is rolled back with the insert
|
|
3721
|
-
* });
|
|
3722
|
-
*/
|
|
3723
|
-
async function getNextSequence(counterKey, increment = 1, connection, session) {
|
|
3724
|
-
const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
|
|
3725
|
-
upsert: true,
|
|
3726
|
-
returnDocument: "after",
|
|
3727
|
-
session
|
|
3728
|
-
});
|
|
3729
|
-
if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
|
|
3730
|
-
return result.seq;
|
|
3731
|
-
}
|
|
3732
|
-
/**
|
|
3733
|
-
* Generator: Simple sequential counter.
|
|
3734
|
-
* Produces IDs like `INV-0001`, `INV-0002`, etc.
|
|
3735
|
-
*
|
|
3736
|
-
* Uses atomic MongoDB counters — safe under concurrency.
|
|
3737
|
-
*
|
|
3738
|
-
* @example
|
|
3739
|
-
* ```typescript
|
|
3740
|
-
* customIdPlugin({
|
|
3741
|
-
* field: 'invoiceNumber',
|
|
3742
|
-
* generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
|
|
3743
|
-
* })
|
|
3744
|
-
* ```
|
|
3745
|
-
*/
|
|
3746
|
-
function sequentialId(options) {
|
|
3747
|
-
const { prefix, model, padding = 4, separator = "-", counterKey } = options;
|
|
3748
|
-
const key = counterKey || model.modelName;
|
|
3749
|
-
return async (context) => {
|
|
3750
|
-
const seq = await getNextSequence(key, 1, context._counterConnection, context.session);
|
|
3751
|
-
return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
|
|
3752
|
-
};
|
|
3753
|
-
}
|
|
3754
|
-
/**
|
|
3755
|
-
* Generator: Date-partitioned sequential counter.
|
|
3756
|
-
* Counter resets per period — great for invoice/bill numbering.
|
|
3757
|
-
*
|
|
3758
|
-
* Produces IDs like:
|
|
3759
|
-
* - yearly: `BILL-2026-0001`
|
|
3760
|
-
* - monthly: `BILL-2026-02-0001`
|
|
3761
|
-
* - daily: `BILL-2026-02-20-0001`
|
|
3762
|
-
*
|
|
3763
|
-
* @example
|
|
3764
|
-
* ```typescript
|
|
3765
|
-
* customIdPlugin({
|
|
3766
|
-
* field: 'billNumber',
|
|
3767
|
-
* generator: dateSequentialId({
|
|
3768
|
-
* prefix: 'BILL',
|
|
3769
|
-
* model: BillModel,
|
|
3770
|
-
* partition: 'monthly',
|
|
3771
|
-
* }),
|
|
3772
|
-
* })
|
|
3773
|
-
* ```
|
|
3774
|
-
*/
|
|
3775
|
-
function dateSequentialId(options) {
|
|
3776
|
-
const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
|
|
3777
|
-
return async (context) => {
|
|
3778
|
-
const now = /* @__PURE__ */ new Date();
|
|
3779
|
-
const year = String(now.getFullYear());
|
|
3780
|
-
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
3781
|
-
const day = String(now.getDate()).padStart(2, "0");
|
|
3782
|
-
let datePart;
|
|
3783
|
-
let counterKey;
|
|
3784
|
-
switch (partition) {
|
|
3785
|
-
case "yearly":
|
|
3786
|
-
datePart = year;
|
|
3787
|
-
counterKey = `${model.modelName}:${year}`;
|
|
3788
|
-
break;
|
|
3789
|
-
case "daily":
|
|
3790
|
-
datePart = `${year}${separator}${month}${separator}${day}`;
|
|
3791
|
-
counterKey = `${model.modelName}:${year}-${month}-${day}`;
|
|
3792
|
-
break;
|
|
3793
|
-
default:
|
|
3794
|
-
datePart = `${year}${separator}${month}`;
|
|
3795
|
-
counterKey = `${model.modelName}:${year}-${month}`;
|
|
3796
|
-
break;
|
|
3797
|
-
}
|
|
3798
|
-
const seq = await getNextSequence(counterKey, 1, context._counterConnection, context.session);
|
|
3799
|
-
return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
|
|
3800
|
-
};
|
|
3801
|
-
}
|
|
3802
|
-
/**
|
|
3803
|
-
* Generator: Prefix + random alphanumeric suffix.
|
|
3804
|
-
* Does NOT require a database round-trip — purely in-memory.
|
|
3805
|
-
*
|
|
3806
|
-
* Produces IDs like: `USR_a7b3xk9m2p1q`
|
|
3807
|
-
*
|
|
3808
|
-
* Good for: user-facing IDs where ordering doesn't matter.
|
|
3809
|
-
* Not suitable for sequential numbering.
|
|
3810
|
-
*
|
|
3811
|
-
* @example
|
|
3812
|
-
* ```typescript
|
|
3813
|
-
* customIdPlugin({
|
|
3814
|
-
* field: 'publicId',
|
|
3815
|
-
* generator: prefixedId({ prefix: 'USR', length: 10 }),
|
|
3816
|
-
* })
|
|
3817
|
-
* ```
|
|
3818
|
-
*/
|
|
3819
|
-
function prefixedId(options) {
|
|
3820
|
-
const { prefix, separator = "_", length = 12 } = options;
|
|
3821
|
-
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
3822
|
-
return (_context) => {
|
|
3823
|
-
let result = "";
|
|
3824
|
-
const bytes = new Uint8Array(length);
|
|
3825
|
-
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
|
3826
|
-
globalThis.crypto.getRandomValues(bytes);
|
|
3827
|
-
for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
|
|
3828
|
-
} else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
|
|
3829
|
-
return `${prefix}${separator}${result}`;
|
|
3830
|
-
};
|
|
3831
|
-
}
|
|
3832
|
-
/**
|
|
3833
|
-
* Custom ID plugin — injects generated IDs into documents before creation.
|
|
3834
|
-
*
|
|
3835
|
-
* @param options - Configuration for ID generation
|
|
3836
|
-
* @returns Plugin instance
|
|
3837
|
-
*
|
|
3838
|
-
* @example
|
|
3839
|
-
* ```typescript
|
|
3840
|
-
* import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
|
|
3841
|
-
*
|
|
3842
|
-
* const invoiceRepo = new Repository(InvoiceModel, [
|
|
3843
|
-
* customIdPlugin({
|
|
3844
|
-
* field: 'invoiceNumber',
|
|
3845
|
-
* generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
|
|
3846
|
-
* }),
|
|
3847
|
-
* ]);
|
|
3848
|
-
*
|
|
3849
|
-
* const inv = await invoiceRepo.create({ amount: 100 });
|
|
3850
|
-
* console.log(inv.invoiceNumber); // "INV-0001"
|
|
3851
|
-
* ```
|
|
3852
|
-
*/
|
|
3853
|
-
function customIdPlugin(options) {
|
|
3854
|
-
const fieldName = options.field || "customId";
|
|
3855
|
-
const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
|
|
3856
|
-
return {
|
|
3857
|
-
name: "custom-id",
|
|
3858
|
-
apply(repo) {
|
|
3859
|
-
const repoConnection = repo.Model.db;
|
|
3860
|
-
repo.on("before:create", async (context) => {
|
|
3861
|
-
if (!context.data) return;
|
|
3862
|
-
if (generateOnlyIfEmpty && context.data[fieldName]) return;
|
|
3863
|
-
context._counterConnection = repoConnection;
|
|
3864
|
-
context.data[fieldName] = await options.generator(context);
|
|
3865
|
-
});
|
|
3866
|
-
repo.on("before:createMany", async (context) => {
|
|
3867
|
-
if (!context.dataArray) return;
|
|
3868
|
-
context._counterConnection = repoConnection;
|
|
3869
|
-
const docsNeedingIds = [];
|
|
3870
|
-
for (const doc of context.dataArray) {
|
|
3871
|
-
if (generateOnlyIfEmpty && doc[fieldName]) continue;
|
|
3872
|
-
docsNeedingIds.push(doc);
|
|
3873
|
-
}
|
|
3874
|
-
if (docsNeedingIds.length === 0) return;
|
|
3875
|
-
for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
|
|
3876
|
-
...context,
|
|
3877
|
-
data: doc
|
|
3878
|
-
});
|
|
3879
|
-
});
|
|
3880
|
-
}
|
|
3881
|
-
};
|
|
3882
|
-
}
|
|
3883
|
-
//#endregion
|
|
3884
|
-
//#region src/plugins/elastic.plugin.ts
|
|
3885
|
-
function elasticSearchPlugin(options) {
|
|
3886
|
-
return {
|
|
3887
|
-
name: "elastic-search",
|
|
3888
|
-
apply(repo) {
|
|
3889
|
-
if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
|
|
3890
|
-
repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
|
|
3891
|
-
const { client, index, idField = "_id" } = options;
|
|
3892
|
-
const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
|
|
3893
|
-
const from = Math.max(searchOptions.from || 0, 0);
|
|
3894
|
-
const esResponse = await client.search({
|
|
3895
|
-
index,
|
|
3896
|
-
body: {
|
|
3897
|
-
query: searchQuery,
|
|
3898
|
-
size: limit,
|
|
3899
|
-
from
|
|
3900
|
-
}
|
|
3901
|
-
});
|
|
3902
|
-
const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
|
|
3903
|
-
if (hits.length === 0) return {
|
|
3904
|
-
docs: [],
|
|
3905
|
-
total: 0,
|
|
3906
|
-
limit,
|
|
3907
|
-
from
|
|
3908
|
-
};
|
|
3909
|
-
const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
|
|
3910
|
-
const total = typeof totalValue === "number" ? totalValue : 0;
|
|
3911
|
-
const docsOrder = /* @__PURE__ */ new Map();
|
|
3912
|
-
const scores = /* @__PURE__ */ new Map();
|
|
3913
|
-
const ids = [];
|
|
3914
|
-
hits.forEach((hit, idx) => {
|
|
3915
|
-
const docId = hit._source?.[idField] || hit[idField] || hit._id;
|
|
3916
|
-
if (docId) {
|
|
3917
|
-
const strId = String(docId);
|
|
3918
|
-
docsOrder.set(strId, idx);
|
|
3919
|
-
if (hit._score !== void 0) scores.set(strId, hit._score);
|
|
3920
|
-
ids.push(strId);
|
|
3921
|
-
}
|
|
3922
|
-
});
|
|
3923
|
-
if (ids.length === 0) return {
|
|
3924
|
-
docs: [],
|
|
3925
|
-
total,
|
|
3926
|
-
limit,
|
|
3927
|
-
from
|
|
3928
|
-
};
|
|
3929
|
-
const mongoIdField = repo.idField || "_id";
|
|
3930
|
-
const mongoQuery = this.Model.find({ [mongoIdField]: { $in: ids } });
|
|
3931
|
-
if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
|
|
3932
|
-
if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
|
|
3933
|
-
if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
|
|
3934
|
-
return {
|
|
3935
|
-
docs: (await mongoQuery.exec()).sort((a, b) => {
|
|
3936
|
-
const aId = String(a[mongoIdField]);
|
|
3937
|
-
const bId = String(b[mongoIdField]);
|
|
3938
|
-
return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
|
|
3939
|
-
}).map((doc) => {
|
|
3940
|
-
const strId = String(doc[mongoIdField]);
|
|
3941
|
-
if (searchOptions.mongoOptions?.lean !== false) return {
|
|
3942
|
-
...doc,
|
|
3943
|
-
_score: scores.get(strId)
|
|
3944
|
-
};
|
|
3945
|
-
return doc;
|
|
3946
|
-
}),
|
|
3947
|
-
total,
|
|
3948
|
-
limit,
|
|
3949
|
-
from
|
|
3950
|
-
};
|
|
3951
|
-
});
|
|
3952
|
-
}
|
|
3953
|
-
};
|
|
3954
|
-
}
|
|
3955
|
-
//#endregion
|
|
3956
|
-
//#region src/plugins/field-filter.plugin.ts
|
|
3957
|
-
/**
|
|
3958
|
-
* Field filter plugin that restricts fields based on user context
|
|
3959
|
-
*
|
|
3960
|
-
* @example
|
|
3961
|
-
* const fieldPreset = {
|
|
3962
|
-
* public: ['id', 'name'],
|
|
3963
|
-
* authenticated: ['email'],
|
|
3964
|
-
* admin: ['createdAt', 'internalNotes']
|
|
3965
|
-
* };
|
|
3966
|
-
*
|
|
3967
|
-
* const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
|
|
3968
|
-
*/
|
|
3969
|
-
function fieldFilterPlugin(fieldPreset) {
|
|
3970
|
-
return {
|
|
3971
|
-
name: "fieldFilter",
|
|
3972
|
-
apply(repo) {
|
|
3973
|
-
const applyFieldFiltering = (context) => {
|
|
3974
|
-
if (!fieldPreset) return;
|
|
3975
|
-
const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
|
|
3976
|
-
if (context.select) context.select = `${presetSelect} ${context.select}`;
|
|
3977
|
-
else context.select = presetSelect;
|
|
3978
|
-
};
|
|
3979
|
-
repo.on("before:getAll", applyFieldFiltering);
|
|
3980
|
-
repo.on("before:findAll", applyFieldFiltering);
|
|
3981
|
-
repo.on("before:getById", applyFieldFiltering);
|
|
3982
|
-
repo.on("before:getByQuery", applyFieldFiltering);
|
|
3983
|
-
repo.on("before:getOne", applyFieldFiltering);
|
|
3984
|
-
}
|
|
3985
|
-
};
|
|
3986
|
-
}
|
|
3987
|
-
//#endregion
|
|
3988
|
-
//#region src/plugins/method-registry.plugin.ts
|
|
3989
|
-
/**
|
|
3990
|
-
* Method registry plugin that enables dynamic method registration
|
|
3991
|
-
*/
|
|
3992
|
-
function methodRegistryPlugin() {
|
|
3993
|
-
return {
|
|
3994
|
-
name: "method-registry",
|
|
3995
|
-
apply(repo) {
|
|
3996
|
-
const registeredMethods = [];
|
|
3997
|
-
/**
|
|
3998
|
-
* Register a new method on the repository instance
|
|
3999
|
-
*/
|
|
4000
|
-
repo.registerMethod = (name, fn) => {
|
|
4001
|
-
if (repo[name]) throw new Error(`Cannot register method '${name}': Method already exists on repository. Choose a different name or use a plugin that doesn't conflict.`);
|
|
4002
|
-
if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
|
|
4003
|
-
if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
|
|
4004
|
-
repo[name] = fn.bind(repo);
|
|
4005
|
-
registeredMethods.push(name);
|
|
4006
|
-
repo.emit("method:registered", {
|
|
4007
|
-
name,
|
|
4008
|
-
fn
|
|
4009
|
-
});
|
|
4010
|
-
};
|
|
4011
|
-
/**
|
|
4012
|
-
* Check if a method is registered
|
|
4013
|
-
*/
|
|
4014
|
-
repo.hasMethod = (name) => typeof repo[name] === "function";
|
|
4015
|
-
/**
|
|
4016
|
-
* Get list of all dynamically registered methods
|
|
4017
|
-
*/
|
|
4018
|
-
repo.getRegisteredMethods = () => [...registeredMethods];
|
|
4019
|
-
}
|
|
4020
|
-
};
|
|
4021
|
-
}
|
|
4022
|
-
//#endregion
|
|
4023
|
-
//#region src/plugins/mongo-operations.plugin.ts
|
|
4024
|
-
/**
|
|
4025
|
-
* MongoDB Operations Plugin
|
|
4026
|
-
*
|
|
4027
|
-
* Adds MongoDB-specific operations to repositories.
|
|
4028
|
-
* Requires method-registry.plugin.js to be loaded first.
|
|
4029
|
-
*/
|
|
4030
|
-
/**
|
|
4031
|
-
* MongoDB operations plugin
|
|
4032
|
-
*
|
|
4033
|
-
* Adds MongoDB-specific atomic operations to repositories:
|
|
4034
|
-
* - upsert: Create or update document
|
|
4035
|
-
* - increment/decrement: Atomic numeric operations
|
|
4036
|
-
* - pushToArray/pullFromArray/addToSet: Array operations
|
|
4037
|
-
* - setField/unsetField/renameField: Field operations
|
|
4038
|
-
* - multiplyField: Multiply numeric field
|
|
4039
|
-
* - setMin/setMax: Conditional min/max updates
|
|
4040
|
-
*
|
|
4041
|
-
* @example Basic usage (no TypeScript autocomplete)
|
|
4042
|
-
* ```typescript
|
|
4043
|
-
* const repo = new Repository(ProductModel, [
|
|
4044
|
-
* methodRegistryPlugin(),
|
|
4045
|
-
* mongoOperationsPlugin(),
|
|
4046
|
-
* ]);
|
|
4047
|
-
*
|
|
4048
|
-
* // Works at runtime but TypeScript doesn't know about these methods
|
|
4049
|
-
* await (repo as any).increment(productId, 'views', 1);
|
|
4050
|
-
* await (repo as any).pushToArray(productId, 'tags', 'featured');
|
|
4051
|
-
* ```
|
|
4052
|
-
*
|
|
4053
|
-
* @example With TypeScript type safety (recommended)
|
|
4054
|
-
* ```typescript
|
|
4055
|
-
* import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
|
|
4056
|
-
* import type { MongoOperationsMethods } from '@classytic/mongokit';
|
|
4057
|
-
*
|
|
4058
|
-
* class ProductRepo extends Repository<IProduct> {
|
|
4059
|
-
* // Add your custom methods here
|
|
4060
|
-
* }
|
|
4061
|
-
*
|
|
4062
|
-
* // Create with type assertion to get autocomplete for plugin methods
|
|
4063
|
-
* type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
|
|
4064
|
-
*
|
|
4065
|
-
* const repo = new ProductRepo(ProductModel, [
|
|
4066
|
-
* methodRegistryPlugin(),
|
|
4067
|
-
* mongoOperationsPlugin(),
|
|
4068
|
-
* ]) as ProductRepoWithPlugins;
|
|
4069
|
-
*
|
|
4070
|
-
* // Now TypeScript provides autocomplete and type checking!
|
|
4071
|
-
* await repo.increment(productId, 'views', 1);
|
|
4072
|
-
* await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
|
|
4073
|
-
* await repo.pushToArray(productId, 'tags', 'featured');
|
|
4074
|
-
* ```
|
|
4075
|
-
*/
|
|
4076
|
-
function mongoOperationsPlugin() {
|
|
4077
|
-
return {
|
|
4078
|
-
name: "mongo-operations",
|
|
4079
|
-
apply(repo) {
|
|
4080
|
-
if (!repo.registerMethod) throw new Error("mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array.");
|
|
4081
|
-
/**
|
|
4082
|
-
* Update existing document or insert new one
|
|
4083
|
-
*/
|
|
4084
|
-
repo.registerMethod("upsert", async function(query, data, options = {}) {
|
|
4085
|
-
return upsert(this.Model, query, data, options);
|
|
4086
|
-
});
|
|
4087
|
-
const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
|
|
4088
|
-
if (typeof value !== "number") throw createError(400, `${operationName} value must be a number`);
|
|
4089
|
-
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
4090
|
-
};
|
|
4091
|
-
/**
|
|
4092
|
-
* Atomically increment numeric field
|
|
4093
|
-
*/
|
|
4094
|
-
repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
|
|
4095
|
-
return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
|
|
4096
|
-
});
|
|
4097
|
-
/**
|
|
4098
|
-
* Atomically decrement numeric field
|
|
4099
|
-
*/
|
|
4100
|
-
repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
|
|
4101
|
-
return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
|
|
4102
|
-
});
|
|
4103
|
-
const applyOperator = function(id, field, value, operator, options) {
|
|
4104
|
-
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
4105
|
-
};
|
|
4106
|
-
/**
|
|
4107
|
-
* Push value to array field
|
|
4108
|
-
*/
|
|
4109
|
-
repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
|
|
4110
|
-
return applyOperator.call(this, id, field, value, "$push", options);
|
|
4111
|
-
});
|
|
4112
|
-
/**
|
|
4113
|
-
* Remove value from array field
|
|
4114
|
-
*/
|
|
4115
|
-
repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
|
|
4116
|
-
return applyOperator.call(this, id, field, value, "$pull", options);
|
|
4117
|
-
});
|
|
4118
|
-
/**
|
|
4119
|
-
* Add value to array only if not already present (unique)
|
|
4120
|
-
*/
|
|
4121
|
-
repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
|
|
4122
|
-
return applyOperator.call(this, id, field, value, "$addToSet", options);
|
|
4123
|
-
});
|
|
4124
|
-
/**
|
|
4125
|
-
* Set field value (alias for update with $set)
|
|
4126
|
-
*/
|
|
4127
|
-
repo.registerMethod("setField", async function(id, field, value, options = {}) {
|
|
4128
|
-
return applyOperator.call(this, id, field, value, "$set", options);
|
|
4129
|
-
});
|
|
4130
|
-
/**
|
|
4131
|
-
* Unset (remove) field from document
|
|
4132
|
-
*/
|
|
4133
|
-
repo.registerMethod("unsetField", async function(id, fields, options = {}) {
|
|
4134
|
-
const unsetObj = (Array.isArray(fields) ? fields : [fields]).reduce((acc, field) => {
|
|
4135
|
-
acc[field] = "";
|
|
4136
|
-
return acc;
|
|
4137
|
-
}, {});
|
|
4138
|
-
return this.update(id, { $unset: unsetObj }, options);
|
|
4139
|
-
});
|
|
4140
|
-
/**
|
|
4141
|
-
* Rename field in document
|
|
4142
|
-
*/
|
|
4143
|
-
repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
|
|
4144
|
-
return this.update(id, { $rename: { [oldName]: newName } }, options);
|
|
4145
|
-
});
|
|
4146
|
-
/**
|
|
4147
|
-
* Multiply numeric field by value
|
|
4148
|
-
*/
|
|
4149
|
-
repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
|
|
4150
|
-
return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
|
|
4151
|
-
});
|
|
4152
|
-
/**
|
|
4153
|
-
* Set field to minimum value (only if current value is greater)
|
|
4154
|
-
*/
|
|
4155
|
-
repo.registerMethod("setMin", async function(id, field, value, options = {}) {
|
|
4156
|
-
return applyOperator.call(this, id, field, value, "$min", options);
|
|
4157
|
-
});
|
|
4158
|
-
/**
|
|
4159
|
-
* Set field to maximum value (only if current value is less)
|
|
4160
|
-
*/
|
|
4161
|
-
repo.registerMethod("setMax", async function(id, field, value, options = {}) {
|
|
4162
|
-
return applyOperator.call(this, id, field, value, "$max", options);
|
|
4163
|
-
});
|
|
4164
|
-
/**
|
|
4165
|
-
* Atomic update with multiple MongoDB operators in a single call
|
|
4166
|
-
*
|
|
4167
|
-
* Combines $inc, $set, $push, $pull, $addToSet, $unset, $setOnInsert, $min, $max, $mul, $rename
|
|
4168
|
-
* into one atomic database operation.
|
|
4169
|
-
*
|
|
4170
|
-
* @example
|
|
4171
|
-
* // Combine $inc + $set in one atomic call
|
|
4172
|
-
* await repo.atomicUpdate(id, {
|
|
4173
|
-
* $inc: { views: 1, commentCount: 1 },
|
|
4174
|
-
* $set: { lastActiveAt: new Date() }
|
|
4175
|
-
* });
|
|
4176
|
-
*
|
|
4177
|
-
* // Multiple operators: $inc + $set + $push
|
|
4178
|
-
* await repo.atomicUpdate(id, {
|
|
4179
|
-
* $inc: { 'metrics.total': 1 },
|
|
4180
|
-
* $set: { updatedAt: new Date() },
|
|
4181
|
-
* $push: { history: { action: 'update', at: new Date() } }
|
|
4182
|
-
* });
|
|
4183
|
-
*
|
|
4184
|
-
* // $push with $each modifier
|
|
4185
|
-
* await repo.atomicUpdate(id, {
|
|
4186
|
-
* $push: { tags: { $each: ['featured', 'popular'] } },
|
|
4187
|
-
* $inc: { tagCount: 2 }
|
|
4188
|
-
* });
|
|
4189
|
-
*
|
|
4190
|
-
* // With arrayFilters for positional updates
|
|
4191
|
-
* await repo.atomicUpdate(id, {
|
|
4192
|
-
* $set: { 'items.$[elem].quantity': 5 }
|
|
4193
|
-
* }, { arrayFilters: [{ 'elem._id': itemId }] });
|
|
4194
|
-
*/
|
|
4195
|
-
repo.registerMethod("atomicUpdate", async function(id, operators, options = {}) {
|
|
4196
|
-
const validOperators = new Set([
|
|
4197
|
-
"$inc",
|
|
4198
|
-
"$set",
|
|
4199
|
-
"$unset",
|
|
4200
|
-
"$push",
|
|
4201
|
-
"$pull",
|
|
4202
|
-
"$addToSet",
|
|
4203
|
-
"$pop",
|
|
4204
|
-
"$rename",
|
|
4205
|
-
"$min",
|
|
4206
|
-
"$max",
|
|
4207
|
-
"$mul",
|
|
4208
|
-
"$setOnInsert",
|
|
4209
|
-
"$bit",
|
|
4210
|
-
"$currentDate"
|
|
4211
|
-
]);
|
|
4212
|
-
const keys = Object.keys(operators);
|
|
4213
|
-
if (keys.length === 0) throw createError(400, "atomicUpdate requires at least one operator");
|
|
4214
|
-
for (const key of keys) if (!validOperators.has(key)) throw createError(400, `Invalid update operator: '${key}'. Valid operators: ${[...validOperators].join(", ")}`);
|
|
4215
|
-
return this.update(id, operators, options);
|
|
4216
|
-
});
|
|
4217
|
-
}
|
|
4218
|
-
};
|
|
4219
|
-
}
|
|
4220
|
-
//#endregion
|
|
4221
|
-
//#region src/plugins/multi-tenant.plugin.ts
|
|
4222
|
-
/**
|
|
4223
|
-
* True when the op's policy target already has `tenantField` set by the
|
|
4224
|
-
* caller. Used to decide whether the plugin can safely skip injecting a
|
|
4225
|
-
* tenant scope rather than throwing on a missing context.
|
|
4226
|
-
*
|
|
4227
|
-
* - `data` — `context.data[tenantField]` is present
|
|
4228
|
-
* - `dataArray` — every row in `context.dataArray` has `tenantField`
|
|
4229
|
-
* - `query` — `context.query[tenantField]` is present
|
|
4230
|
-
* - `filters` — `context.filters[tenantField]` is present
|
|
4231
|
-
* - `operations` — every bulkWrite sub-op's filter/document has `tenantField`
|
|
4232
|
-
* - `none` — unreachable (the hook isn't registered for these ops)
|
|
4233
|
-
*
|
|
4234
|
-
* For multi-row targets (`dataArray`, `operations`) we require EVERY row to
|
|
4235
|
-
* be stamped. Partial stamping is ambiguous (we have no resolver value to
|
|
4236
|
-
* fill in the gaps) and is safer to treat as "not stamped" so the caller
|
|
4237
|
-
* either stamps all rows or supplies a context/resolver.
|
|
4238
|
-
*/
|
|
4239
|
-
function payloadHasTenantField(context, policyKey, tenantField) {
|
|
4240
|
-
switch (policyKey) {
|
|
4241
|
-
case "data": return context.data?.[tenantField] != null;
|
|
4242
|
-
case "dataArray": {
|
|
4243
|
-
const arr = context.dataArray;
|
|
4244
|
-
if (!Array.isArray(arr) || arr.length === 0) return false;
|
|
4245
|
-
return arr.every((row) => row && row[tenantField] != null);
|
|
4246
|
-
}
|
|
4247
|
-
case "query": return context.query?.[tenantField] != null;
|
|
4248
|
-
case "filters": return context.filters?.[tenantField] != null;
|
|
4249
|
-
case "operations": {
|
|
4250
|
-
const ops = context.operations;
|
|
4251
|
-
if (!Array.isArray(ops) || ops.length === 0) return false;
|
|
4252
|
-
return ops.every((subOp) => {
|
|
4253
|
-
for (const key of [
|
|
4254
|
-
"updateOne",
|
|
4255
|
-
"updateMany",
|
|
4256
|
-
"deleteOne",
|
|
4257
|
-
"deleteMany",
|
|
4258
|
-
"replaceOne"
|
|
4259
|
-
]) {
|
|
4260
|
-
const body = subOp[key];
|
|
4261
|
-
if (body) return body.filter?.[tenantField] != null;
|
|
4262
|
-
}
|
|
4263
|
-
const ins = subOp.insertOne;
|
|
4264
|
-
if (ins) return ins.document?.[tenantField] != null;
|
|
4265
|
-
return false;
|
|
4266
|
-
});
|
|
4267
|
-
}
|
|
4268
|
-
default: return false;
|
|
4269
|
-
}
|
|
4270
|
-
}
|
|
4271
|
-
function multiTenantPlugin(options = {}) {
|
|
4272
|
-
const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext, fieldType = "string", allowDataInjection = true } = options;
|
|
4273
|
-
const extraOps = [{
|
|
4274
|
-
op: "getDeleted",
|
|
4275
|
-
policyKey: "filters"
|
|
4276
|
-
}];
|
|
4277
|
-
return {
|
|
4278
|
-
name: "multi-tenant",
|
|
4279
|
-
apply(repo) {
|
|
4280
|
-
const allOps = [...ALL_OPERATIONS.map((op) => ({
|
|
4281
|
-
op,
|
|
4282
|
-
policyKey: OP_REGISTRY[op].policyKey
|
|
4283
|
-
})), ...extraOps];
|
|
4284
|
-
for (const { op, policyKey } of allOps) {
|
|
4285
|
-
if (skipOperations.includes(op)) continue;
|
|
4286
|
-
if (policyKey === "none") continue;
|
|
4287
|
-
repo.on(`before:${op}`, (context) => {
|
|
4288
|
-
if (skipWhen?.(context, op)) return;
|
|
4289
|
-
let tenantId = context[contextKey];
|
|
4290
|
-
if (!tenantId && resolveContext) {
|
|
4291
|
-
tenantId = resolveContext();
|
|
4292
|
-
if (tenantId) context[contextKey] = tenantId;
|
|
4293
|
-
}
|
|
4294
|
-
if (!tenantId && allowDataInjection && payloadHasTenantField(context, policyKey, tenantField)) return;
|
|
4295
|
-
if (!tenantId && required) throw new Error(`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'. Pass it via options or set required: false.`);
|
|
4296
|
-
if (!tenantId) return;
|
|
4297
|
-
const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
|
|
4298
|
-
switch (policyKey) {
|
|
4299
|
-
case "filters":
|
|
4300
|
-
context.filters = {
|
|
4301
|
-
...context.filters,
|
|
4302
|
-
[tenantField]: castId
|
|
4303
|
-
};
|
|
4304
|
-
break;
|
|
4305
|
-
case "query":
|
|
4306
|
-
context.query = {
|
|
4307
|
-
...context.query,
|
|
4308
|
-
[tenantField]: castId
|
|
4309
|
-
};
|
|
4310
|
-
break;
|
|
4311
|
-
case "data":
|
|
4312
|
-
if (context.data) context.data[tenantField] = castId;
|
|
4313
|
-
break;
|
|
4314
|
-
case "dataArray":
|
|
4315
|
-
if (context.dataArray) {
|
|
4316
|
-
for (const doc of context.dataArray) if (doc && typeof doc === "object") doc[tenantField] = castId;
|
|
4317
|
-
}
|
|
4318
|
-
break;
|
|
4319
|
-
case "operations":
|
|
4320
|
-
if (context.operations) {
|
|
4321
|
-
const ops = context.operations;
|
|
4322
|
-
for (const subOp of ops) {
|
|
4323
|
-
for (const key of [
|
|
4324
|
-
"updateOne",
|
|
4325
|
-
"updateMany",
|
|
4326
|
-
"deleteOne",
|
|
4327
|
-
"deleteMany",
|
|
4328
|
-
"replaceOne"
|
|
4329
|
-
]) {
|
|
4330
|
-
const opBody = subOp[key];
|
|
4331
|
-
if (opBody?.filter) opBody.filter = {
|
|
4332
|
-
...opBody.filter,
|
|
4333
|
-
[tenantField]: castId
|
|
4334
|
-
};
|
|
4335
|
-
}
|
|
4336
|
-
const insertBody = subOp.insertOne;
|
|
4337
|
-
if (insertBody?.document) insertBody.document[tenantField] = castId;
|
|
4338
|
-
}
|
|
4339
|
-
}
|
|
4340
|
-
break;
|
|
4341
|
-
}
|
|
4342
|
-
}, { priority: HOOK_PRIORITY$1.POLICY });
|
|
4343
|
-
}
|
|
4344
|
-
}
|
|
4345
|
-
};
|
|
4346
|
-
}
|
|
4347
|
-
//#endregion
|
|
4348
|
-
//#region src/plugins/observability.plugin.ts
|
|
4349
|
-
/**
|
|
4350
|
-
* Observability Plugin
|
|
4351
|
-
*
|
|
4352
|
-
* Adds operation timing, structured metrics, and APM hook points.
|
|
4353
|
-
* Works with any monitoring system — just provide an onMetric callback.
|
|
4354
|
-
*
|
|
4355
|
-
* @example
|
|
4356
|
-
* ```typescript
|
|
4357
|
-
* const repo = new Repository(User, [
|
|
4358
|
-
* observabilityPlugin({
|
|
4359
|
-
* onMetric: (metric) => {
|
|
4360
|
-
* // Send to DataDog, New Relic, OpenTelemetry, or console
|
|
4361
|
-
* console.log(`${metric.operation} took ${metric.durationMs}ms`);
|
|
4362
|
-
* statsd.histogram('mongokit.operation', metric.durationMs, { op: metric.operation });
|
|
4363
|
-
* },
|
|
4364
|
-
* }),
|
|
4365
|
-
* ]);
|
|
4366
|
-
* ```
|
|
4367
|
-
*/
|
|
4368
|
-
const DEFAULT_OPS = ALL_OPERATIONS;
|
|
4369
|
-
const timers = /* @__PURE__ */ new WeakMap();
|
|
4370
|
-
function observabilityPlugin(options) {
|
|
4371
|
-
const { onMetric, slowThresholdMs } = options;
|
|
4372
|
-
const ops = options.operations ?? DEFAULT_OPS;
|
|
4373
|
-
return {
|
|
4374
|
-
name: "observability",
|
|
4375
|
-
apply(repo) {
|
|
4376
|
-
for (const op of ops) {
|
|
4377
|
-
repo.on(`before:${op}`, (context) => {
|
|
4378
|
-
timers.set(context, performance.now());
|
|
4379
|
-
}, { priority: 300 });
|
|
4380
|
-
repo.on(`after:${op}`, ({ context }) => {
|
|
4381
|
-
const start = timers.get(context);
|
|
4382
|
-
if (start == null) return;
|
|
4383
|
-
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
4384
|
-
timers.delete(context);
|
|
4385
|
-
if (slowThresholdMs != null && durationMs < slowThresholdMs) return;
|
|
4386
|
-
onMetric({
|
|
4387
|
-
operation: op,
|
|
4388
|
-
model: context.model || repo.model,
|
|
4389
|
-
durationMs,
|
|
4390
|
-
success: true,
|
|
4391
|
-
startedAt: new Date(Date.now() - durationMs),
|
|
4392
|
-
userId: context.user?._id?.toString() || context.user?.id?.toString(),
|
|
4393
|
-
organizationId: context.organizationId?.toString()
|
|
4394
|
-
});
|
|
4395
|
-
});
|
|
4396
|
-
repo.on(`error:${op}`, ({ context, error }) => {
|
|
4397
|
-
const start = timers.get(context);
|
|
4398
|
-
if (start == null) return;
|
|
4399
|
-
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
4400
|
-
timers.delete(context);
|
|
4401
|
-
onMetric({
|
|
4402
|
-
operation: op,
|
|
4403
|
-
model: context.model || repo.model,
|
|
4404
|
-
durationMs,
|
|
4405
|
-
success: false,
|
|
4406
|
-
error: error.message,
|
|
4407
|
-
startedAt: new Date(Date.now() - durationMs),
|
|
4408
|
-
userId: context.user?._id?.toString() || context.user?.id?.toString(),
|
|
4409
|
-
organizationId: context.organizationId?.toString()
|
|
4410
|
-
});
|
|
4411
|
-
});
|
|
4412
|
-
}
|
|
4413
|
-
}
|
|
4414
|
-
};
|
|
4415
|
-
}
|
|
4416
|
-
//#endregion
|
|
4417
|
-
//#region src/plugins/soft-delete.plugin.ts
|
|
4418
|
-
/**
|
|
4419
|
-
* Build filter condition based on filter mode
|
|
4420
|
-
*/
|
|
4421
|
-
function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
|
|
4422
|
-
if (includeDeleted) return {};
|
|
4423
|
-
if (filterMode === "exists") return { [deletedField]: { $exists: false } };
|
|
4424
|
-
return { [deletedField]: null };
|
|
4425
|
-
}
|
|
4426
|
-
/**
|
|
4427
|
-
* Build filter condition for finding deleted documents
|
|
4428
|
-
*/
|
|
4429
|
-
function buildGetDeletedFilter(deletedField, filterMode) {
|
|
4430
|
-
if (filterMode === "exists") return { [deletedField]: {
|
|
4431
|
-
$exists: true,
|
|
4432
|
-
$ne: null
|
|
4433
|
-
} };
|
|
4434
|
-
return { [deletedField]: { $ne: null } };
|
|
4435
|
-
}
|
|
4436
|
-
/**
|
|
4437
|
-
* Soft delete plugin
|
|
4438
|
-
*
|
|
4439
|
-
* @example Basic usage
|
|
4440
|
-
* ```typescript
|
|
4441
|
-
* const repo = new Repository(Model, [
|
|
4442
|
-
* softDeletePlugin({ deletedField: 'deletedAt' })
|
|
4443
|
-
* ]);
|
|
4444
|
-
*
|
|
4445
|
-
* // Delete (soft)
|
|
4446
|
-
* await repo.delete(id);
|
|
4447
|
-
*
|
|
4448
|
-
* // Restore
|
|
4449
|
-
* await repo.restore(id);
|
|
4450
|
-
*
|
|
4451
|
-
* // Get deleted documents
|
|
4452
|
-
* await repo.getDeleted({ page: 1, limit: 20 });
|
|
4453
|
-
* ```
|
|
4454
|
-
*
|
|
4455
|
-
* @example With null filter mode (for schemas with default: null)
|
|
4456
|
-
* ```typescript
|
|
4457
|
-
* // Schema: { deletedAt: { type: Date, default: null } }
|
|
4458
|
-
* const repo = new Repository(Model, [
|
|
4459
|
-
* softDeletePlugin({
|
|
4460
|
-
* deletedField: 'deletedAt',
|
|
4461
|
-
* filterMode: 'null', // default - works with default: null
|
|
4462
|
-
* })
|
|
4463
|
-
* ]);
|
|
4464
|
-
* ```
|
|
4465
|
-
*
|
|
4466
|
-
* @example With TTL for auto-cleanup
|
|
4467
|
-
* ```typescript
|
|
4468
|
-
* const repo = new Repository(Model, [
|
|
4469
|
-
* softDeletePlugin({
|
|
4470
|
-
* deletedField: 'deletedAt',
|
|
4471
|
-
* ttlDays: 30, // Auto-delete after 30 days
|
|
4472
|
-
* })
|
|
4473
|
-
* ]);
|
|
4474
|
-
* ```
|
|
4475
|
-
*/
|
|
4476
|
-
function softDeletePlugin(options = {}) {
|
|
4477
|
-
const deletedField = options.deletedField || "deletedAt";
|
|
4478
|
-
const deletedByField = options.deletedByField || "deletedBy";
|
|
4479
|
-
const filterMode = options.filterMode || "null";
|
|
4480
|
-
const addRestoreMethod = options.addRestoreMethod !== false;
|
|
4481
|
-
const addGetDeletedMethod = options.addGetDeletedMethod !== false;
|
|
4482
|
-
const ttlDays = options.ttlDays;
|
|
4483
|
-
return {
|
|
4484
|
-
name: "softDelete",
|
|
4485
|
-
apply(repo) {
|
|
4486
|
-
try {
|
|
4487
|
-
const schemaPaths = repo.Model.schema.paths;
|
|
4488
|
-
for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
|
|
4489
|
-
if (pathName === "_id" || pathName === deletedField) continue;
|
|
4490
|
-
if (schemaType.options?.unique) warn(`[softDeletePlugin] Field '${pathName}' on model '${repo.Model.modelName}' has a unique index. With soft-delete enabled, deleted documents will block new documents with the same '${pathName}'. Fix: change to a compound partial index — { ${pathName}: 1 }, { unique: true, partialFilterExpression: { ${deletedField}: null } }`);
|
|
4491
|
-
}
|
|
4492
|
-
} catch (err) {
|
|
4493
|
-
warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
4494
|
-
}
|
|
4495
|
-
if (ttlDays !== void 0 && ttlDays > 0) {
|
|
4496
|
-
const ttlSeconds = ttlDays * 24 * 60 * 60;
|
|
4497
|
-
repo.Model.collection.createIndex({ [deletedField]: 1 }, {
|
|
4498
|
-
expireAfterSeconds: ttlSeconds,
|
|
4499
|
-
partialFilterExpression: { [deletedField]: { $type: "date" } }
|
|
4500
|
-
}).catch((err) => {
|
|
4501
|
-
if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
|
|
4502
|
-
});
|
|
4503
|
-
}
|
|
4504
|
-
repo.on("before:delete", async (context) => {
|
|
4505
|
-
if (context.deleteMode === "hard") return;
|
|
4506
|
-
if (options.soft !== false) {
|
|
4507
|
-
const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
|
|
4508
|
-
if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
|
|
4509
|
-
const deleteQuery = {
|
|
4510
|
-
[repo.idField || "_id"]: context.id,
|
|
4511
|
-
...context.query || {}
|
|
4512
|
-
};
|
|
4513
|
-
if (!await repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session })) {
|
|
4514
|
-
const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
|
|
4515
|
-
error.status = 404;
|
|
4516
|
-
throw error;
|
|
4517
|
-
}
|
|
4518
|
-
context.softDeleted = true;
|
|
4519
|
-
}
|
|
4520
|
-
}, { priority: HOOK_PRIORITY$1.POLICY });
|
|
4521
|
-
const injectDeleteFilterToFilters = (context) => {
|
|
4522
|
-
if (options.soft !== false) {
|
|
4523
|
-
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
4524
|
-
if (Object.keys(deleteFilter).length > 0) context.filters = {
|
|
4525
|
-
...context.filters || {},
|
|
4526
|
-
...deleteFilter
|
|
4527
|
-
};
|
|
4528
|
-
}
|
|
4529
|
-
};
|
|
4530
|
-
const injectDeleteFilterToQuery = (context) => {
|
|
4531
|
-
if (options.soft !== false) {
|
|
4532
|
-
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
4533
|
-
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
4534
|
-
...context.query || {},
|
|
4535
|
-
...deleteFilter
|
|
4536
|
-
};
|
|
4537
|
-
}
|
|
4538
|
-
};
|
|
4539
|
-
const SOFT_DELETE_SPECIAL = new Set([
|
|
4540
|
-
"updateMany",
|
|
4541
|
-
"deleteMany",
|
|
4542
|
-
"bulkWrite"
|
|
4543
|
-
]);
|
|
4544
|
-
for (const op of ALL_OPERATIONS) {
|
|
4545
|
-
if (SOFT_DELETE_SPECIAL.has(op)) continue;
|
|
4546
|
-
const desc = OP_REGISTRY[op];
|
|
4547
|
-
const isFilterableMutation = op === "findOneAndUpdate";
|
|
4548
|
-
if (desc.mutates && !isFilterableMutation) continue;
|
|
4549
|
-
if (desc.policyKey === "filters") repo.on(`before:${op}`, injectDeleteFilterToFilters, { priority: HOOK_PRIORITY$1.POLICY });
|
|
4550
|
-
else if (desc.policyKey === "query") repo.on(`before:${op}`, injectDeleteFilterToQuery, { priority: HOOK_PRIORITY$1.POLICY });
|
|
4551
|
-
}
|
|
4552
|
-
repo.on("before:updateMany", (context) => {
|
|
4553
|
-
if (options.soft !== false) {
|
|
4554
|
-
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
4555
|
-
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
4556
|
-
...context.query || {},
|
|
4557
|
-
...deleteFilter
|
|
4558
|
-
};
|
|
4559
|
-
}
|
|
4560
|
-
}, { priority: HOOK_PRIORITY$1.POLICY });
|
|
4561
|
-
repo.on("before:deleteMany", async (context) => {
|
|
4562
|
-
if (context.deleteMode === "hard") return;
|
|
4563
|
-
if (options.soft !== false) {
|
|
4564
|
-
const deleteFilter = buildDeletedFilter(deletedField, filterMode, false);
|
|
4565
|
-
const finalQuery = {
|
|
4566
|
-
...context.query || {},
|
|
4567
|
-
...deleteFilter
|
|
4568
|
-
};
|
|
4569
|
-
const updateRes = await repo.Model.updateMany(finalQuery, { $set: { [deletedField]: /* @__PURE__ */ new Date() } }, { session: context.session });
|
|
4570
|
-
context.softDeleted = true;
|
|
4571
|
-
context.softDeletedCount = updateRes?.modifiedCount ?? 0;
|
|
4572
|
-
}
|
|
4573
|
-
}, { priority: HOOK_PRIORITY$1.POLICY });
|
|
4574
|
-
if (addRestoreMethod) {
|
|
4575
|
-
const restoreMethod = async function(id, restoreOptions = {}) {
|
|
4576
|
-
const context = await this._buildContext("restore", {
|
|
4577
|
-
id,
|
|
4578
|
-
...restoreOptions
|
|
4579
|
-
});
|
|
4580
|
-
const updateData = {
|
|
4581
|
-
[deletedField]: null,
|
|
4582
|
-
[deletedByField]: null
|
|
4583
|
-
};
|
|
4584
|
-
const restoreQuery = {
|
|
4585
|
-
_id: id,
|
|
4586
|
-
...context.query || {}
|
|
4587
|
-
};
|
|
4588
|
-
const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
|
|
4589
|
-
returnDocument: "after",
|
|
4590
|
-
session: restoreOptions.session
|
|
4591
|
-
});
|
|
4592
|
-
if (!result) {
|
|
4593
|
-
const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
|
|
4594
|
-
error.status = 404;
|
|
4595
|
-
throw error;
|
|
4596
|
-
}
|
|
4597
|
-
await this.emitAsync("after:restore", {
|
|
4598
|
-
context,
|
|
4599
|
-
result
|
|
4600
|
-
});
|
|
4601
|
-
return result;
|
|
4602
|
-
};
|
|
4603
|
-
if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
|
|
4604
|
-
else repo.restore = restoreMethod.bind(repo);
|
|
4605
|
-
}
|
|
4606
|
-
if (addGetDeletedMethod) {
|
|
4607
|
-
const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
|
|
4608
|
-
const context = await this._buildContext("getDeleted", {
|
|
4609
|
-
...params,
|
|
4610
|
-
...getDeletedOptions
|
|
4611
|
-
});
|
|
4612
|
-
const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
|
|
4613
|
-
const combinedFilters = {
|
|
4614
|
-
...params.filters || {},
|
|
4615
|
-
...deletedFilter,
|
|
4616
|
-
...context.filters || {},
|
|
4617
|
-
...context.query || {}
|
|
4618
|
-
};
|
|
4619
|
-
const page = params.page || 1;
|
|
4620
|
-
const limit = params.limit || 20;
|
|
4621
|
-
const skip = (page - 1) * limit;
|
|
4622
|
-
let sortSpec = { [deletedField]: -1 };
|
|
4623
|
-
if (params.sort) if (typeof params.sort === "string") {
|
|
4624
|
-
const sortOrder = params.sort.startsWith("-") ? -1 : 1;
|
|
4625
|
-
sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
|
|
4626
|
-
} else sortSpec = params.sort;
|
|
4627
|
-
let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
|
|
4628
|
-
if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
|
|
4629
|
-
if (getDeletedOptions.select) {
|
|
4630
|
-
const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
|
|
4631
|
-
query = query.select(selectValue);
|
|
4632
|
-
}
|
|
4633
|
-
if (getDeletedOptions.populate) {
|
|
4634
|
-
const populateSpec = getDeletedOptions.populate;
|
|
4635
|
-
if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
|
|
4636
|
-
else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
|
|
4637
|
-
else query = query.populate(populateSpec);
|
|
4638
|
-
}
|
|
4639
|
-
if (getDeletedOptions.lean !== false) query = query.lean();
|
|
4640
|
-
const [docs, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
|
|
4641
|
-
const pages = Math.ceil(total / limit);
|
|
4642
|
-
return {
|
|
4643
|
-
method: "offset",
|
|
4644
|
-
docs,
|
|
4645
|
-
page,
|
|
4646
|
-
limit,
|
|
4647
|
-
total,
|
|
4648
|
-
pages,
|
|
4649
|
-
hasNext: page < pages,
|
|
4650
|
-
hasPrev: page > 1
|
|
4651
|
-
};
|
|
4652
|
-
};
|
|
4653
|
-
if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
|
|
4654
|
-
else repo.getDeleted = getDeletedMethod.bind(repo);
|
|
4655
|
-
}
|
|
4656
|
-
}
|
|
4657
|
-
};
|
|
4658
|
-
}
|
|
4659
|
-
//#endregion
|
|
4660
|
-
//#region src/plugins/subdocument.plugin.ts
|
|
4661
|
-
/**
|
|
4662
|
-
* Subdocument plugin for managing nested arrays
|
|
4663
|
-
*
|
|
4664
|
-
* @example
|
|
4665
|
-
* const repo = new Repository(Model, [
|
|
4666
|
-
* methodRegistryPlugin(),
|
|
4667
|
-
* subdocumentPlugin(),
|
|
4668
|
-
* ]);
|
|
4669
|
-
*
|
|
4670
|
-
* await repo.addSubdocument(parentId, 'items', { name: 'Item 1' });
|
|
4671
|
-
* await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
|
|
4672
|
-
*/
|
|
4673
|
-
function subdocumentPlugin() {
|
|
4674
|
-
return {
|
|
4675
|
-
name: "subdocument",
|
|
4676
|
-
apply(repo) {
|
|
4677
|
-
if (!repo.registerMethod) throw new Error("subdocumentPlugin requires methodRegistryPlugin");
|
|
4678
|
-
/**
|
|
4679
|
-
* Add subdocument to array
|
|
4680
|
-
*/
|
|
4681
|
-
repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
|
|
4682
|
-
return this.update(parentId, { $push: { [arrayPath]: subData } }, options);
|
|
4683
|
-
});
|
|
4684
|
-
/**
|
|
4685
|
-
* Get subdocument from array
|
|
4686
|
-
*/
|
|
4687
|
-
repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
4688
|
-
return this._executeQuery(async (Model) => {
|
|
4689
|
-
const parent = await Model.findById(parentId).session(options.session).exec();
|
|
4690
|
-
if (!parent) throw createError(404, "Parent not found");
|
|
4691
|
-
const arrayField = parent[arrayPath];
|
|
4692
|
-
if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
|
|
4693
|
-
const sub = arrayField.id(subId);
|
|
4694
|
-
if (!sub) throw createError(404, "Subdocument not found");
|
|
4695
|
-
return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
|
|
4696
|
-
});
|
|
4697
|
-
});
|
|
4698
|
-
/**
|
|
4699
|
-
* Update subdocument in array
|
|
4700
|
-
*/
|
|
4701
|
-
repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
|
|
4702
|
-
return this._executeQuery(async (Model) => {
|
|
4703
|
-
const query = {
|
|
4704
|
-
_id: parentId,
|
|
4705
|
-
[`${arrayPath}._id`]: subId
|
|
4706
|
-
};
|
|
4707
|
-
const update = { $set: { [`${arrayPath}.$`]: {
|
|
4708
|
-
...updateData,
|
|
4709
|
-
_id: subId
|
|
4710
|
-
} } };
|
|
4711
|
-
const result = await Model.findOneAndUpdate(query, update, {
|
|
4712
|
-
returnDocument: "after",
|
|
4713
|
-
runValidators: true,
|
|
4714
|
-
session: options.session
|
|
4715
|
-
}).exec();
|
|
4716
|
-
if (!result) throw createError(404, "Parent or subdocument not found");
|
|
4717
|
-
return result;
|
|
4718
|
-
});
|
|
4719
|
-
});
|
|
4720
|
-
/**
|
|
4721
|
-
* Delete subdocument from array
|
|
4722
|
-
*/
|
|
4723
|
-
repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
4724
|
-
return this.update(parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
|
|
4725
|
-
});
|
|
4726
|
-
}
|
|
4727
|
-
};
|
|
4728
|
-
}
|
|
4729
|
-
//#endregion
|
|
4730
|
-
//#region src/plugins/tenant-context.ts
|
|
4731
|
-
/**
|
|
4732
|
-
* Request-scoped tenant context via AsyncLocalStorage.
|
|
4733
|
-
*
|
|
4734
|
-
* The multi-tenant plugin already accepts a `resolveContext` callback for
|
|
4735
|
-
* reading tenant ID from an external store. This helper wraps Node's
|
|
4736
|
-
* `AsyncLocalStorage` into a tiny, batteries-included API so apps don't need
|
|
4737
|
-
* to wire the plumbing by hand — a common source of cross-tenant leaks.
|
|
4738
|
-
*
|
|
4739
|
-
* @example
|
|
4740
|
-
* import { createTenantContext, multiTenantPlugin } from '@classytic/mongokit';
|
|
4741
|
-
*
|
|
4742
|
-
* const tenantContext = createTenantContext();
|
|
4743
|
-
*
|
|
4744
|
-
* // In your HTTP middleware (Express / Fastify / NestJS):
|
|
4745
|
-
* app.use((req, _res, next) => {
|
|
4746
|
-
* tenantContext.run({ tenantId: req.auth.organizationId }, next);
|
|
4747
|
-
* });
|
|
4748
|
-
*
|
|
4749
|
-
* // In your repository definition:
|
|
4750
|
-
* const repo = new Repository(Invoice, [
|
|
4751
|
-
* multiTenantPlugin({
|
|
4752
|
-
* tenantField: 'organizationId',
|
|
4753
|
-
* resolveContext: () => tenantContext.getTenantId(),
|
|
4754
|
-
* }),
|
|
4755
|
-
* ]);
|
|
4756
|
-
*
|
|
4757
|
-
* // In your handler — no need to pass organizationId manually:
|
|
4758
|
-
* await repo.getAll({ filters: { status: 'paid' } });
|
|
4759
|
-
*/
|
|
4760
|
-
function createTenantContext() {
|
|
4761
|
-
const storage = new AsyncLocalStorage();
|
|
4762
|
-
return {
|
|
4763
|
-
storage,
|
|
4764
|
-
run(store, fn) {
|
|
4765
|
-
return storage.run(store, fn);
|
|
4766
|
-
},
|
|
4767
|
-
getStore() {
|
|
4768
|
-
return storage.getStore();
|
|
4769
|
-
},
|
|
4770
|
-
getTenantId() {
|
|
4771
|
-
return storage.getStore()?.tenantId;
|
|
4772
|
-
},
|
|
4773
|
-
requireTenantId() {
|
|
4774
|
-
const id = storage.getStore()?.tenantId;
|
|
4775
|
-
if (!id) throw new Error("[mongokit] No tenant context active. Wrap the request in tenantContext.run({ tenantId }, fn).");
|
|
4776
|
-
return id;
|
|
4777
|
-
}
|
|
4778
|
-
};
|
|
4779
|
-
}
|
|
4780
|
-
//#endregion
|
|
4781
|
-
//#region src/plugins/timestamp.plugin.ts
|
|
4782
|
-
/**
|
|
4783
|
-
* Timestamp plugin that auto-injects timestamps
|
|
4784
|
-
*
|
|
4785
|
-
* @example
|
|
4786
|
-
* const repo = new Repository(Model, [timestampPlugin()]);
|
|
4787
|
-
*/
|
|
4788
|
-
function timestampPlugin() {
|
|
4789
|
-
return {
|
|
4790
|
-
name: "timestamp",
|
|
4791
|
-
apply(repo) {
|
|
4792
|
-
repo.on("before:create", (context) => {
|
|
4793
|
-
if (!context.data) return;
|
|
4794
|
-
const now = /* @__PURE__ */ new Date();
|
|
4795
|
-
if (!context.data.createdAt) context.data.createdAt = now;
|
|
4796
|
-
if (!context.data.updatedAt) context.data.updatedAt = now;
|
|
4797
|
-
});
|
|
4798
|
-
repo.on("before:update", (context) => {
|
|
4799
|
-
if (!context.data) return;
|
|
4800
|
-
context.data.updatedAt = /* @__PURE__ */ new Date();
|
|
4801
|
-
});
|
|
4802
|
-
repo.on("before:findOneAndUpdate", (context) => {
|
|
4803
|
-
if (!context.data) return;
|
|
4804
|
-
const now = /* @__PURE__ */ new Date();
|
|
4805
|
-
if (Array.isArray(context.data)) return;
|
|
4806
|
-
const update = context.data;
|
|
4807
|
-
if (Object.keys(update).some((k) => k.startsWith("$"))) {
|
|
4808
|
-
const set = update.$set || {};
|
|
4809
|
-
set.updatedAt = now;
|
|
4810
|
-
update.$set = set;
|
|
4811
|
-
const setOnInsert = update.$setOnInsert || {};
|
|
4812
|
-
if (!("createdAt" in setOnInsert)) setOnInsert.createdAt = now;
|
|
4813
|
-
update.$setOnInsert = setOnInsert;
|
|
4814
|
-
} else update.updatedAt = now;
|
|
4815
|
-
});
|
|
4816
|
-
}
|
|
4817
|
-
};
|
|
4818
|
-
}
|
|
4819
|
-
//#endregion
|
|
4820
|
-
//#region src/plugins/validation-chain.plugin.ts
|
|
4821
|
-
/**
|
|
4822
|
-
* Validation chain plugin
|
|
4823
|
-
*
|
|
4824
|
-
* @example
|
|
4825
|
-
* const repo = new Repository(Model, [
|
|
4826
|
-
* validationChainPlugin([
|
|
4827
|
-
* requireField('email'),
|
|
4828
|
-
* uniqueField('email', 'Email already exists'),
|
|
4829
|
-
* blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
|
|
4830
|
-
* ])
|
|
4831
|
-
* ]);
|
|
4832
|
-
*/
|
|
4833
|
-
function validationChainPlugin(validators = [], options = {}) {
|
|
4834
|
-
const { stopOnFirstError = true } = options;
|
|
4835
|
-
validators.forEach((v, idx) => {
|
|
4836
|
-
if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
|
|
4837
|
-
if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
|
|
4838
|
-
});
|
|
4839
|
-
const validatorsByOperation = {
|
|
4840
|
-
create: [],
|
|
4841
|
-
update: [],
|
|
4842
|
-
findOneAndUpdate: [],
|
|
4843
|
-
delete: [],
|
|
4844
|
-
createMany: []
|
|
4845
|
-
};
|
|
4846
|
-
const allOperationsValidators = [];
|
|
4847
|
-
validators.forEach((v) => {
|
|
4848
|
-
if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
|
|
4849
|
-
else v.operations.forEach((op) => {
|
|
4850
|
-
if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
|
|
4851
|
-
});
|
|
4852
|
-
});
|
|
4853
|
-
return {
|
|
4854
|
-
name: "validation-chain",
|
|
4855
|
-
apply(repo) {
|
|
4856
|
-
const getValidatorsForOperation = (operation) => {
|
|
4857
|
-
const specific = validatorsByOperation[operation] || [];
|
|
4858
|
-
return [...allOperationsValidators, ...specific];
|
|
4859
|
-
};
|
|
4860
|
-
const runValidators = async (operation, context) => {
|
|
4861
|
-
const operationValidators = getValidatorsForOperation(operation);
|
|
4862
|
-
const errors = [];
|
|
4863
|
-
for (const validator of operationValidators) try {
|
|
4864
|
-
await validator.validate(context, repo);
|
|
4865
|
-
} catch (error) {
|
|
4866
|
-
if (stopOnFirstError) throw error;
|
|
4867
|
-
errors.push({
|
|
4868
|
-
validator: validator.name,
|
|
4869
|
-
error: error.message || String(error)
|
|
4870
|
-
});
|
|
4871
|
-
}
|
|
4872
|
-
if (errors.length > 0) {
|
|
4873
|
-
const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
|
|
4874
|
-
err.validationErrors = errors;
|
|
4875
|
-
throw err;
|
|
4876
|
-
}
|
|
4877
|
-
};
|
|
4878
|
-
repo.on("before:create", async (context) => runValidators("create", context));
|
|
4879
|
-
repo.on("before:createMany", async (context) => runValidators("createMany", context));
|
|
4880
|
-
repo.on("before:update", async (context) => runValidators("update", context));
|
|
4881
|
-
repo.on("before:findOneAndUpdate", async (context) => runValidators("findOneAndUpdate", context));
|
|
4882
|
-
repo.on("before:delete", async (context) => runValidators("delete", context));
|
|
4883
|
-
}
|
|
4884
|
-
};
|
|
4885
|
-
}
|
|
4886
|
-
/**
|
|
4887
|
-
* Block operation if condition is true
|
|
4888
|
-
*
|
|
4889
|
-
* @example
|
|
4890
|
-
* blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
|
|
4891
|
-
*/
|
|
4892
|
-
function blockIf(name, operations, condition, errorMessage) {
|
|
4893
|
-
return {
|
|
4894
|
-
name,
|
|
4895
|
-
operations,
|
|
4896
|
-
validate: (context) => {
|
|
4897
|
-
if (condition(context)) throw createError(403, errorMessage);
|
|
4898
|
-
}
|
|
4899
|
-
};
|
|
4900
|
-
}
|
|
4901
|
-
/**
|
|
4902
|
-
* Require a field to be present
|
|
4903
|
-
*/
|
|
4904
|
-
function requireField(field, operations = ["create"]) {
|
|
4905
|
-
return {
|
|
4906
|
-
name: `require-${field}`,
|
|
4907
|
-
operations,
|
|
4908
|
-
validate: (context) => {
|
|
4909
|
-
if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
|
|
4910
|
-
}
|
|
4911
|
-
};
|
|
4912
|
-
}
|
|
4913
|
-
/**
|
|
4914
|
-
* Auto-inject a value if not present
|
|
4915
|
-
*/
|
|
4916
|
-
function autoInject(field, getter, operations = ["create"]) {
|
|
4917
|
-
return {
|
|
4918
|
-
name: `auto-inject-${field}`,
|
|
4919
|
-
operations,
|
|
4920
|
-
validate: (context) => {
|
|
4921
|
-
if (context.data && !(field in context.data)) {
|
|
4922
|
-
const value = getter(context);
|
|
4923
|
-
if (value !== null && value !== void 0) context.data[field] = value;
|
|
4924
|
-
}
|
|
4925
|
-
}
|
|
4926
|
-
};
|
|
4927
|
-
}
|
|
4928
|
-
/**
|
|
4929
|
-
* Make a field immutable (cannot be updated)
|
|
4930
|
-
*/
|
|
4931
|
-
function immutableField(field) {
|
|
4932
|
-
return {
|
|
4933
|
-
name: `immutable-${field}`,
|
|
4934
|
-
operations: ["update"],
|
|
4935
|
-
validate: (context) => {
|
|
4936
|
-
if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
|
|
4937
|
-
}
|
|
4938
|
-
};
|
|
4939
|
-
}
|
|
4940
|
-
/**
|
|
4941
|
-
* Ensure field value is unique
|
|
4942
|
-
*/
|
|
4943
|
-
function uniqueField(field, errorMessage) {
|
|
4944
|
-
return {
|
|
4945
|
-
name: `unique-${field}`,
|
|
4946
|
-
operations: ["create", "update"],
|
|
4947
|
-
validate: async (context, repo) => {
|
|
4948
|
-
if (!context.data?.[field]) return;
|
|
4949
|
-
if (!repo) {
|
|
4950
|
-
warn(`[mongokit] uniqueField('${field}'): repo not available, skipping uniqueness check`);
|
|
4951
|
-
return;
|
|
4952
|
-
}
|
|
4953
|
-
const query = { [field]: context.data[field] };
|
|
4954
|
-
const getByQuery = repo.getByQuery;
|
|
4955
|
-
if (typeof getByQuery !== "function") {
|
|
4956
|
-
warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
|
|
4957
|
-
return;
|
|
4958
|
-
}
|
|
4959
|
-
const idKey = repo.idField || "_id";
|
|
4960
|
-
const existing = await getByQuery.call(repo, query, {
|
|
4961
|
-
select: idKey,
|
|
4962
|
-
lean: true,
|
|
4963
|
-
throwOnNotFound: false
|
|
4964
|
-
});
|
|
4965
|
-
if (existing && String(existing[idKey]) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
|
|
4966
|
-
}
|
|
4967
|
-
};
|
|
4968
|
-
}
|
|
4969
|
-
//#endregion
|
|
4970
|
-
export { AuditTrailQuery as A, cachePlugin as C, withTransaction as D, isTransactionUnsupported as E, MUTATING_OPERATIONS as F, OP_REGISTRY as I, READ_OPERATIONS as L, auditLogPlugin as M, aggregateHelpersPlugin as N, AggregationBuilder as O, ALL_OPERATIONS as P, operationsByPolicyKey as R, cascadePlugin as S, Repository as T, customIdPlugin as _, uniqueField as a, prefixedId as b, createTenantContext as c, observabilityPlugin as d, multiTenantPlugin as f, elasticSearchPlugin as g, fieldFilterPlugin as h, requireField as i, auditTrailPlugin as j, batchOperationsPlugin as k, subdocumentPlugin as l, methodRegistryPlugin as m, blockIf as n, validationChainPlugin as o, mongoOperationsPlugin as p, immutableField as r, timestampPlugin as s, autoInject as t, softDeletePlugin as u, dateSequentialId as v, HOOK_PRIORITY$1 as w, sequentialId as x, getNextSequence as y };
|