ts-patch-mongoose 3.0.0 → 3.1.2
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 +31 -25
- package/dist/index.cjs +112 -115
- package/dist/index.d.cts +4 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +112 -115
- package/package.json +14 -15
- package/src/helpers.ts +16 -3
- package/src/hooks/delete-hooks.ts +5 -4
- package/src/hooks/update-hooks.ts +47 -19
- package/src/index.ts +8 -5
- package/src/ms.ts +4 -3
- package/src/omit-deep.ts +24 -63
- package/src/patch.ts +30 -33
- package/src/types.ts +3 -0
- package/biome.json +0 -50
- package/tests/constants/events.ts +0 -7
- package/tests/em.test.ts +0 -54
- package/tests/helpers.test.ts +0 -311
- package/tests/mongo/.gitignore +0 -3
- package/tests/mongo/server.ts +0 -31
- package/tests/ms.test.ts +0 -113
- package/tests/omit-deep.test.ts +0 -220
- package/tests/patch.test.ts +0 -199
- package/tests/plugin-all-features.test.ts +0 -741
- package/tests/plugin-complex-data.test.ts +0 -1332
- package/tests/plugin-event-created.test.ts +0 -371
- package/tests/plugin-event-deleted.test.ts +0 -400
- package/tests/plugin-event-updated.test.ts +0 -503
- package/tests/plugin-global.test.ts +0 -545
- package/tests/plugin-omit-all.test.ts +0 -349
- package/tests/plugin-patch-history-disabled.test.ts +0 -162
- package/tests/plugin-pre-delete.test.ts +0 -160
- package/tests/plugin-pre-save.test.ts +0 -54
- package/tests/plugin.test.ts +0 -576
- package/tests/schemas/Description.ts +0 -15
- package/tests/schemas/Product.ts +0 -38
- package/tests/schemas/User.ts +0 -22
- package/tsconfig.json +0 -32
- package/vite.config.mts +0 -23
package/dist/index.mjs
CHANGED
|
@@ -98,9 +98,10 @@ const ms = (val) => {
|
|
|
98
98
|
if (str.length > 100) return Number.NaN;
|
|
99
99
|
const match = RE.exec(str);
|
|
100
100
|
if (!match) return Number.NaN;
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
101
|
+
const [, numStr, unitStr] = match;
|
|
102
|
+
const n = Number.parseFloat(String(numStr));
|
|
103
|
+
const type = (unitStr ?? "ms").toLowerCase();
|
|
104
|
+
return n * UNITS[type];
|
|
104
105
|
};
|
|
105
106
|
|
|
106
107
|
const isArray = Array.isArray;
|
|
@@ -138,6 +139,12 @@ const cloneImmutable = (value) => {
|
|
|
138
139
|
cloned.lastIndex = re.lastIndex;
|
|
139
140
|
return cloned;
|
|
140
141
|
}
|
|
142
|
+
case "[object Error]": {
|
|
143
|
+
const err = value;
|
|
144
|
+
const cloned = new err.constructor(err.message);
|
|
145
|
+
if (err.stack) cloned.stack = err.stack;
|
|
146
|
+
return cloned;
|
|
147
|
+
}
|
|
141
148
|
case "[object ArrayBuffer]":
|
|
142
149
|
return cloneArrayBuffer(value);
|
|
143
150
|
case "[object DataView]": {
|
|
@@ -186,7 +193,11 @@ const cloneDeep = (value, seen = /* @__PURE__ */ new WeakMap()) => {
|
|
|
186
193
|
if (seen.has(value)) return seen.get(value);
|
|
187
194
|
const immutable = cloneImmutable(value);
|
|
188
195
|
if (immutable !== void 0) return immutable;
|
|
189
|
-
|
|
196
|
+
const record = value;
|
|
197
|
+
if (typeof record._bsontype === "string" && typeof record.toHexString === "function") {
|
|
198
|
+
return new value.constructor(record.toHexString());
|
|
199
|
+
}
|
|
200
|
+
if (typeof record.toJSON === "function") {
|
|
190
201
|
return JSON.parse(JSON.stringify(value));
|
|
191
202
|
}
|
|
192
203
|
return cloneCollection(value, seen);
|
|
@@ -205,7 +216,7 @@ const toObjectOptions = {
|
|
|
205
216
|
depopulate: true,
|
|
206
217
|
virtuals: false
|
|
207
218
|
};
|
|
208
|
-
const setPatchHistoryTTL = async (ttl) => {
|
|
219
|
+
const setPatchHistoryTTL = async (ttl, onError) => {
|
|
209
220
|
const name = "createdAt_1_TTL";
|
|
210
221
|
try {
|
|
211
222
|
const indexes = await HistoryModel.collection.indexes();
|
|
@@ -228,7 +239,8 @@ const setPatchHistoryTTL = async (ttl) => {
|
|
|
228
239
|
}
|
|
229
240
|
await HistoryModel.collection.createIndex({ createdAt: 1 }, { expireAfterSeconds, name });
|
|
230
241
|
} catch (err) {
|
|
231
|
-
|
|
242
|
+
const handler = onError ?? console.error;
|
|
243
|
+
handler(err);
|
|
232
244
|
}
|
|
233
245
|
};
|
|
234
246
|
|
|
@@ -244,113 +256,65 @@ const isPlainObject = (val) => {
|
|
|
244
256
|
const isUnsafeKey = (key) => {
|
|
245
257
|
return key === "__proto__" || key === "constructor" || key === "prototype";
|
|
246
258
|
};
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
for (const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (val instanceof Error) return val.message !== "";
|
|
263
|
-
if (val instanceof Map || val instanceof Set) return val.size !== 0;
|
|
264
|
-
if (typeof val === "object") {
|
|
265
|
-
for (const key of Object.keys(val)) {
|
|
266
|
-
if (hasValue(val[key])) return true;
|
|
267
|
-
}
|
|
268
|
-
return false;
|
|
269
|
-
}
|
|
270
|
-
return true;
|
|
271
|
-
};
|
|
272
|
-
const has = (obj, path) => {
|
|
273
|
-
if (obj != null && typeof obj === "object" && typeof path === "string") {
|
|
274
|
-
return hasValue(getValue$1(obj, path));
|
|
275
|
-
}
|
|
276
|
-
return false;
|
|
277
|
-
};
|
|
278
|
-
const unset = (obj, prop) => {
|
|
279
|
-
if (typeof obj !== "object" || obj === null) return false;
|
|
280
|
-
if (Object.hasOwn(obj, prop)) {
|
|
281
|
-
delete obj[prop];
|
|
282
|
-
return true;
|
|
283
|
-
}
|
|
284
|
-
if (has(obj, prop)) {
|
|
285
|
-
const segs = prop.split(".");
|
|
286
|
-
let last = segs.pop();
|
|
287
|
-
while (segs.length && segs.at(-1)?.slice(-1) === "\\") {
|
|
288
|
-
last = `${segs.pop().slice(0, -1)}.${last}`;
|
|
289
|
-
}
|
|
290
|
-
let target = obj;
|
|
291
|
-
while (segs.length) {
|
|
292
|
-
const seg = segs.shift();
|
|
293
|
-
if (isUnsafeKey(seg)) return false;
|
|
294
|
-
target = target[seg];
|
|
259
|
+
const classifyKeys = (omitKeys) => {
|
|
260
|
+
const topLevel = /* @__PURE__ */ new Set();
|
|
261
|
+
const nested = /* @__PURE__ */ new Map();
|
|
262
|
+
for (const key of omitKeys) {
|
|
263
|
+
const dotIdx = key.indexOf(".");
|
|
264
|
+
if (dotIdx === -1) {
|
|
265
|
+
topLevel.add(key);
|
|
266
|
+
} else {
|
|
267
|
+
const head = key.slice(0, dotIdx);
|
|
268
|
+
const tail = key.slice(dotIdx + 1);
|
|
269
|
+
if (!isUnsafeKey(head)) {
|
|
270
|
+
const existing = nested.get(head) ?? [];
|
|
271
|
+
existing.push(tail);
|
|
272
|
+
nested.set(head, existing);
|
|
273
|
+
}
|
|
295
274
|
}
|
|
296
|
-
return delete target[last ?? ""];
|
|
297
275
|
}
|
|
298
|
-
return
|
|
276
|
+
return { topLevel, nested };
|
|
299
277
|
};
|
|
300
278
|
const omitDeep = (value, keys) => {
|
|
301
279
|
if (value === void 0) return {};
|
|
302
280
|
if (Array.isArray(value)) {
|
|
303
|
-
|
|
304
|
-
value[i] = omitDeep(value[i], keys);
|
|
305
|
-
}
|
|
306
|
-
return value;
|
|
281
|
+
return value.map((item) => omitDeep(item, keys));
|
|
307
282
|
}
|
|
308
283
|
if (!isPlainObject(value)) return value;
|
|
309
284
|
const omitKeys = typeof keys === "string" ? [keys] : keys;
|
|
310
285
|
if (!Array.isArray(omitKeys)) return value;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
286
|
+
const { topLevel, nested } = classifyKeys(omitKeys);
|
|
287
|
+
const result = {};
|
|
314
288
|
for (const key of Object.keys(value)) {
|
|
315
|
-
|
|
289
|
+
if (topLevel.has(key)) continue;
|
|
290
|
+
const nestedKeys = nested.get(key);
|
|
291
|
+
result[key] = omitDeep(value[key], nestedKeys ?? omitKeys);
|
|
316
292
|
}
|
|
317
|
-
return
|
|
293
|
+
return result;
|
|
318
294
|
};
|
|
319
295
|
|
|
320
296
|
const isPatchHistoryEnabled = (opts, context) => {
|
|
321
297
|
return !opts.patchHistoryDisabled && !context.ignorePatchHistory;
|
|
322
298
|
};
|
|
299
|
+
const applyOmit = (object, opts) => {
|
|
300
|
+
return opts.omit ? omitDeep(object, opts.omit) : object;
|
|
301
|
+
};
|
|
302
|
+
const replacer = (_key, value) => typeof value === "bigint" ? value.toString() : value;
|
|
323
303
|
const getJsonOmit = (opts, doc) => {
|
|
324
|
-
|
|
325
|
-
if (opts.omit) {
|
|
326
|
-
return omitDeep(object, opts.omit);
|
|
327
|
-
}
|
|
328
|
-
return object;
|
|
304
|
+
return applyOmit(JSON.parse(JSON.stringify(doc, replacer)), opts);
|
|
329
305
|
};
|
|
330
306
|
const getObjectOmit = (opts, doc) => {
|
|
331
|
-
|
|
332
|
-
return omitDeep(isFunction(doc?.toObject) ? doc.toObject() : doc, opts.omit);
|
|
333
|
-
}
|
|
334
|
-
return doc;
|
|
335
|
-
};
|
|
336
|
-
const getUser = async (opts, doc) => {
|
|
337
|
-
if (isFunction(opts.getUser)) {
|
|
338
|
-
return await opts.getUser(doc);
|
|
339
|
-
}
|
|
340
|
-
return void 0;
|
|
341
|
-
};
|
|
342
|
-
const getReason = async (opts, doc) => {
|
|
343
|
-
if (isFunction(opts.getReason)) {
|
|
344
|
-
return await opts.getReason(doc);
|
|
345
|
-
}
|
|
346
|
-
return void 0;
|
|
307
|
+
return applyOmit(isFunction(doc?.toObject) ? doc.toObject() : doc, opts);
|
|
347
308
|
};
|
|
348
|
-
const
|
|
349
|
-
if (isFunction(
|
|
350
|
-
return await
|
|
309
|
+
const getOptionalField = async (fn, doc) => {
|
|
310
|
+
if (isFunction(fn)) {
|
|
311
|
+
return await fn(doc);
|
|
351
312
|
}
|
|
352
313
|
return void 0;
|
|
353
314
|
};
|
|
315
|
+
const getUser = async (opts, doc) => getOptionalField(opts.getUser, doc);
|
|
316
|
+
const getReason = async (opts, doc) => getOptionalField(opts.getReason, doc);
|
|
317
|
+
const getMetadata = async (opts, doc) => getOptionalField(opts.getMetadata, doc);
|
|
354
318
|
const getValue = (item) => {
|
|
355
319
|
return item.status === "fulfilled" ? item.value : void 0;
|
|
356
320
|
};
|
|
@@ -361,7 +325,10 @@ const getData = async (opts, doc) => {
|
|
|
361
325
|
};
|
|
362
326
|
const emitEvent = (context, event, data) => {
|
|
363
327
|
if (event && !context.ignoreEvent) {
|
|
364
|
-
|
|
328
|
+
try {
|
|
329
|
+
em.emit(event, data);
|
|
330
|
+
} catch {
|
|
331
|
+
}
|
|
365
332
|
}
|
|
366
333
|
};
|
|
367
334
|
const bulkPatch = async (opts, context, eventKey, docsKey) => {
|
|
@@ -374,7 +341,8 @@ const bulkPatch = async (opts, context, eventKey, docsKey) => {
|
|
|
374
341
|
for (const batch of chunks) {
|
|
375
342
|
const bulk = [];
|
|
376
343
|
for (const doc of batch) {
|
|
377
|
-
|
|
344
|
+
const omitted = getObjectOmit(opts, doc);
|
|
345
|
+
emitEvent(context, event, { [key]: omitted });
|
|
378
346
|
if (history) {
|
|
379
347
|
const [user, reason, metadata] = await getData(opts, doc);
|
|
380
348
|
bulk.push({
|
|
@@ -384,7 +352,7 @@ const bulkPatch = async (opts, context, eventKey, docsKey) => {
|
|
|
384
352
|
modelName: context.modelName,
|
|
385
353
|
collectionName: context.collectionName,
|
|
386
354
|
collectionId: doc._id,
|
|
387
|
-
doc:
|
|
355
|
+
doc: omitted,
|
|
388
356
|
version: 0,
|
|
389
357
|
...user !== void 0 && { user },
|
|
390
358
|
...reason !== void 0 && { reason },
|
|
@@ -395,8 +363,9 @@ const bulkPatch = async (opts, context, eventKey, docsKey) => {
|
|
|
395
363
|
}
|
|
396
364
|
}
|
|
397
365
|
if (history && !isEmpty(bulk)) {
|
|
366
|
+
const onError = opts.onError ?? console.error;
|
|
398
367
|
await HistoryModel.bulkWrite(bulk, { ordered: false }).catch((error) => {
|
|
399
|
-
|
|
368
|
+
onError(error);
|
|
400
369
|
});
|
|
401
370
|
}
|
|
402
371
|
}
|
|
@@ -415,10 +384,11 @@ const updatePatch = async (opts, context, current, original) => {
|
|
|
415
384
|
if (history) {
|
|
416
385
|
let version = 0;
|
|
417
386
|
const lastHistory = await HistoryModel.findOne({ collectionId: original._id }).sort("-version").exec();
|
|
418
|
-
if (lastHistory
|
|
387
|
+
if (lastHistory) {
|
|
419
388
|
version = lastHistory.version + 1;
|
|
420
389
|
}
|
|
421
390
|
const [user, reason, metadata] = await getData(opts, current);
|
|
391
|
+
const onError = opts.onError ?? console.error;
|
|
422
392
|
await HistoryModel.create({
|
|
423
393
|
op: context.op,
|
|
424
394
|
modelName: context.modelName,
|
|
@@ -429,6 +399,8 @@ const updatePatch = async (opts, context, current, original) => {
|
|
|
429
399
|
...user !== void 0 && { user },
|
|
430
400
|
...reason !== void 0 && { reason },
|
|
431
401
|
...metadata !== void 0 && { metadata }
|
|
402
|
+
}).catch((error) => {
|
|
403
|
+
onError(error);
|
|
432
404
|
});
|
|
433
405
|
}
|
|
434
406
|
};
|
|
@@ -445,8 +417,8 @@ const deleteHooksInitialize = (schema, opts) => {
|
|
|
445
417
|
const filter = this.getFilter();
|
|
446
418
|
this._context = {
|
|
447
419
|
op: this.op,
|
|
448
|
-
modelName: opts.modelName ??
|
|
449
|
-
collectionName: opts.collectionName ??
|
|
420
|
+
modelName: opts.modelName ?? model.modelName,
|
|
421
|
+
collectionName: opts.collectionName ?? model.collection.collectionName,
|
|
450
422
|
ignoreEvent: options.ignoreEvent,
|
|
451
423
|
ignorePatchHistory: options.ignorePatchHistory
|
|
452
424
|
};
|
|
@@ -462,12 +434,13 @@ const deleteHooksInitialize = (schema, opts) => {
|
|
|
462
434
|
}
|
|
463
435
|
}
|
|
464
436
|
if (opts.preDelete && isArray(this._context.deletedDocs) && !isEmpty(this._context.deletedDocs)) {
|
|
465
|
-
await opts.preDelete(this._context.deletedDocs);
|
|
437
|
+
await opts.preDelete(cloneDeep(this._context.deletedDocs));
|
|
466
438
|
}
|
|
467
439
|
});
|
|
468
440
|
schema.post(deleteMethods, { document: false, query: true }, async function() {
|
|
469
441
|
const options = this.getOptions();
|
|
470
442
|
if (isHookIgnored(options)) return;
|
|
443
|
+
if (!this._context) return;
|
|
471
444
|
await deletePatch(opts, this._context);
|
|
472
445
|
});
|
|
473
446
|
};
|
|
@@ -495,15 +468,42 @@ const saveHooksInitialize = (schema, opts) => {
|
|
|
495
468
|
};
|
|
496
469
|
|
|
497
470
|
const updateMethods = ["update", "updateOne", "replaceOne", "updateMany", "findOneAndUpdate", "findOneAndReplace", "findByIdAndUpdate"];
|
|
471
|
+
const trackChangedFields = (fields, updated, changed) => {
|
|
472
|
+
if (!fields) return;
|
|
473
|
+
for (const key of Object.keys(fields)) {
|
|
474
|
+
const [root = key] = key.split(".");
|
|
475
|
+
changed.set(root, updated[root]);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
const applyPullAll = (updated, fields, changed) => {
|
|
479
|
+
for (const [field, values] of Object.entries(fields)) {
|
|
480
|
+
const arr = updated[field];
|
|
481
|
+
if (Array.isArray(arr)) {
|
|
482
|
+
const filtered = arr.filter((item) => !values.some((v) => JSON.stringify(v) === JSON.stringify(item)));
|
|
483
|
+
updated[field] = filtered;
|
|
484
|
+
changed.set(field, filtered);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
};
|
|
498
488
|
const assignUpdate = (document, update, commands) => {
|
|
499
489
|
let updated = assign(document.toObject(toObjectOptions), update);
|
|
490
|
+
const changedByCommand = /* @__PURE__ */ new Map();
|
|
500
491
|
for (const command of commands) {
|
|
492
|
+
const [op = ""] = Object.keys(command);
|
|
493
|
+
const fields = command[op];
|
|
501
494
|
try {
|
|
502
495
|
updated = assign(updated, command);
|
|
496
|
+
trackChangedFields(fields, updated, changedByCommand);
|
|
503
497
|
} catch {
|
|
498
|
+
if (op === "$pullAll" && fields) {
|
|
499
|
+
applyPullAll(updated, fields, changedByCommand);
|
|
500
|
+
}
|
|
504
501
|
}
|
|
505
502
|
}
|
|
506
503
|
const doc = document.set(updated).toObject(toObjectOptions);
|
|
504
|
+
for (const [field, value] of changedByCommand) {
|
|
505
|
+
doc[field] = value;
|
|
506
|
+
}
|
|
507
507
|
if (update.createdAt) doc.createdAt = update.createdAt;
|
|
508
508
|
return doc;
|
|
509
509
|
};
|
|
@@ -523,17 +523,16 @@ const splitUpdateAndCommands = (updateQuery) => {
|
|
|
523
523
|
return { update, commands };
|
|
524
524
|
};
|
|
525
525
|
const updateHooksInitialize = (schema, opts) => {
|
|
526
|
-
schema.pre(updateMethods, async function() {
|
|
526
|
+
schema.pre(updateMethods, { document: false, query: true }, async function() {
|
|
527
527
|
const options = this.getOptions();
|
|
528
528
|
if (isHookIgnored(options)) return;
|
|
529
529
|
const model = this.model;
|
|
530
530
|
const filter = this.getFilter();
|
|
531
|
-
const count = await this.model.countDocuments(filter).exec();
|
|
532
531
|
this._context = {
|
|
533
532
|
op: this.op,
|
|
534
|
-
modelName: opts.modelName ??
|
|
535
|
-
collectionName: opts.collectionName ??
|
|
536
|
-
isNew: Boolean(options.upsert) &&
|
|
533
|
+
modelName: opts.modelName ?? model.modelName,
|
|
534
|
+
collectionName: opts.collectionName ?? model.collection.collectionName,
|
|
535
|
+
isNew: Boolean(options.upsert) && await model.countDocuments(filter).exec() === 0,
|
|
537
536
|
ignoreEvent: options.ignoreEvent,
|
|
538
537
|
ignorePatchHistory: options.ignorePatchHistory
|
|
539
538
|
};
|
|
@@ -545,24 +544,21 @@ const updateHooksInitialize = (schema, opts) => {
|
|
|
545
544
|
await updatePatch(opts, this._context, assignUpdate(doc, update, commands), origDoc);
|
|
546
545
|
});
|
|
547
546
|
});
|
|
548
|
-
schema.post(updateMethods, async function() {
|
|
547
|
+
schema.post(updateMethods, { document: false, query: true }, async function() {
|
|
549
548
|
const options = this.getOptions();
|
|
550
549
|
if (isHookIgnored(options)) return;
|
|
550
|
+
if (!this._context) return;
|
|
551
551
|
if (!this._context.isNew) return;
|
|
552
552
|
const model = this.model;
|
|
553
553
|
const updateQuery = this.getUpdate();
|
|
554
554
|
const { update, commands } = splitUpdateAndCommands(updateQuery);
|
|
555
|
-
let current = null;
|
|
556
555
|
const filter = this.getFilter();
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
current =
|
|
563
|
-
}
|
|
564
|
-
if (!isEmpty(filter) && !current) {
|
|
565
|
-
current = await model.findOne(filter).sort("desc").lean().exec();
|
|
556
|
+
const candidates = [update, assignUpdate(model.hydrate({}), update, commands), filter];
|
|
557
|
+
let current = null;
|
|
558
|
+
for (const query of candidates) {
|
|
559
|
+
if (current || isEmpty(query)) continue;
|
|
560
|
+
const found = await model.findOne(query).sort({ _id: -1 }).lean().exec();
|
|
561
|
+
current = found;
|
|
566
562
|
}
|
|
567
563
|
if (current) {
|
|
568
564
|
this._context.createdDocs = [current];
|
|
@@ -594,13 +590,14 @@ const patchHistoryPlugin = (schema, opts) => {
|
|
|
594
590
|
await createPatch(opts, context);
|
|
595
591
|
});
|
|
596
592
|
if (isMongooseLessThan8) {
|
|
597
|
-
|
|
593
|
+
const legacySchema = schema;
|
|
594
|
+
legacySchema.pre(remove, { document: true, query: false }, async function() {
|
|
598
595
|
const original = this.toObject(toObjectOptions);
|
|
599
596
|
if (opts.preDelete && !isEmpty(original)) {
|
|
600
597
|
await opts.preDelete([original]);
|
|
601
598
|
}
|
|
602
599
|
});
|
|
603
|
-
|
|
600
|
+
legacySchema.post(remove, { document: true, query: false }, async function() {
|
|
604
601
|
const original = this.toObject(toObjectOptions);
|
|
605
602
|
const model = this.constructor;
|
|
606
603
|
const context = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-patch-mongoose",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.2",
|
|
4
4
|
"description": "Patch history & events for mongoose models",
|
|
5
5
|
"author": "ilovepixelart",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,11 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
42
42
|
"dist",
|
|
43
|
-
"src"
|
|
44
|
-
"tests",
|
|
45
|
-
"tsconfig.json",
|
|
46
|
-
"vite.config.mts",
|
|
47
|
-
"biome.json"
|
|
43
|
+
"src"
|
|
48
44
|
],
|
|
49
45
|
"type": "module",
|
|
50
46
|
"exports": {
|
|
@@ -67,25 +63,26 @@
|
|
|
67
63
|
"test": "vitest run --coverage",
|
|
68
64
|
"test:open": "vitest run --coverage && open-cli coverage/lcov-report/index.html",
|
|
69
65
|
"type:check": "tsc --noEmit",
|
|
66
|
+
"type:check:tests": "tsc --noEmit -p tests/tsconfig.json",
|
|
70
67
|
"build": "pkgroll --clean-dist",
|
|
71
|
-
"release": "npm install && npm run biome && npm run type:check && npm run build && np --no-publish"
|
|
68
|
+
"release": "npm install && npm run biome && npm run type:check && npm run type:check:tests && npm run build && np --no-publish"
|
|
72
69
|
},
|
|
73
70
|
"dependencies": {
|
|
74
71
|
"fast-json-patch": "3.1.1",
|
|
75
72
|
"power-assign": "0.2.10"
|
|
76
73
|
},
|
|
77
74
|
"devDependencies": {
|
|
78
|
-
"@biomejs/biome": "2.4.
|
|
79
|
-
"@types/node": "25.
|
|
80
|
-
"@vitest/coverage-v8": "4.1.
|
|
75
|
+
"@biomejs/biome": "2.4.11",
|
|
76
|
+
"@types/node": "25.6.0",
|
|
77
|
+
"@vitest/coverage-v8": "4.1.4",
|
|
81
78
|
"mongodb-memory-server": "11.0.1",
|
|
82
|
-
"mongoose": "9.
|
|
83
|
-
"np": "11.0.
|
|
84
|
-
"open-cli": "
|
|
79
|
+
"mongoose": "9.4.1",
|
|
80
|
+
"np": "11.0.3",
|
|
81
|
+
"open-cli": "9.0.0",
|
|
85
82
|
"pkgroll": "2.27.0",
|
|
86
83
|
"simple-git-hooks": "2.13.1",
|
|
87
84
|
"typescript": "5.9.3",
|
|
88
|
-
"vitest": "4.1.
|
|
85
|
+
"vitest": "4.1.4"
|
|
89
86
|
},
|
|
90
87
|
"peerDependencies": {
|
|
91
88
|
"mongoose": ">=6.6.0 < 10"
|
|
@@ -99,6 +96,8 @@
|
|
|
99
96
|
},
|
|
100
97
|
"overrides": {
|
|
101
98
|
"tmp": "0.2.5",
|
|
102
|
-
"file-type": "21.3.2"
|
|
99
|
+
"file-type": "21.3.2",
|
|
100
|
+
"lodash": "4.18.1",
|
|
101
|
+
"vite": "8.0.8"
|
|
103
102
|
}
|
|
104
103
|
}
|
package/src/helpers.ts
CHANGED
|
@@ -44,6 +44,12 @@ const cloneImmutable = <T>(value: T): T | undefined => {
|
|
|
44
44
|
cloned.lastIndex = re.lastIndex
|
|
45
45
|
return cloned as T
|
|
46
46
|
}
|
|
47
|
+
case '[object Error]': {
|
|
48
|
+
const err = value as unknown as Error
|
|
49
|
+
const cloned = new (err.constructor as ErrorConstructor)(err.message)
|
|
50
|
+
if (err.stack) cloned.stack = err.stack
|
|
51
|
+
return cloned as T
|
|
52
|
+
}
|
|
47
53
|
case '[object ArrayBuffer]':
|
|
48
54
|
return cloneArrayBuffer(value as unknown as ArrayBuffer) as T
|
|
49
55
|
case '[object DataView]': {
|
|
@@ -101,7 +107,13 @@ export const cloneDeep = <T>(value: T, seen = new WeakMap<object, unknown>()): T
|
|
|
101
107
|
const immutable = cloneImmutable(value)
|
|
102
108
|
if (immutable !== undefined) return immutable
|
|
103
109
|
|
|
104
|
-
|
|
110
|
+
const record = value as Record<string, unknown>
|
|
111
|
+
|
|
112
|
+
if (typeof record._bsontype === 'string' && typeof record.toHexString === 'function') {
|
|
113
|
+
return new (value.constructor as new (hex: string) => T)((record.toHexString as () => string)())
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof record.toJSON === 'function') {
|
|
105
117
|
// NOSONAR — structuredClone cannot handle objects with non-cloneable methods (e.g. mongoose documents)
|
|
106
118
|
return JSON.parse(JSON.stringify(value)) as T
|
|
107
119
|
}
|
|
@@ -126,7 +138,7 @@ export const toObjectOptions: ToObjectOptions = {
|
|
|
126
138
|
virtuals: false,
|
|
127
139
|
}
|
|
128
140
|
|
|
129
|
-
export const setPatchHistoryTTL = async (ttl: Duration): Promise<void> => {
|
|
141
|
+
export const setPatchHistoryTTL = async (ttl: Duration, onError?: (error: Error) => void): Promise<void> => {
|
|
130
142
|
const name = 'createdAt_1_TTL'
|
|
131
143
|
try {
|
|
132
144
|
const indexes = await HistoryModel.collection.indexes()
|
|
@@ -156,6 +168,7 @@ export const setPatchHistoryTTL = async (ttl: Duration): Promise<void> => {
|
|
|
156
168
|
|
|
157
169
|
await HistoryModel.collection.createIndex({ createdAt: 1 }, { expireAfterSeconds, name })
|
|
158
170
|
} catch (err) {
|
|
159
|
-
|
|
171
|
+
const handler = onError ?? console.error
|
|
172
|
+
handler(err as Error)
|
|
160
173
|
}
|
|
161
174
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isArray, isEmpty, isHookIgnored } from '../helpers'
|
|
1
|
+
import { cloneDeep, isArray, isEmpty, isHookIgnored } from '../helpers'
|
|
2
2
|
import { deletePatch } from '../patch'
|
|
3
3
|
|
|
4
4
|
import type { HydratedDocument, Model, MongooseQueryMiddleware, Schema } from 'mongoose'
|
|
@@ -16,8 +16,8 @@ export const deleteHooksInitialize = <T>(schema: Schema<T>, opts: PluginOptions<
|
|
|
16
16
|
|
|
17
17
|
this._context = {
|
|
18
18
|
op: this.op,
|
|
19
|
-
modelName: opts.modelName ??
|
|
20
|
-
collectionName: opts.collectionName ??
|
|
19
|
+
modelName: opts.modelName ?? model.modelName,
|
|
20
|
+
collectionName: opts.collectionName ?? model.collection.collectionName,
|
|
21
21
|
ignoreEvent: options.ignoreEvent as boolean,
|
|
22
22
|
ignorePatchHistory: options.ignorePatchHistory as boolean,
|
|
23
23
|
}
|
|
@@ -35,13 +35,14 @@ export const deleteHooksInitialize = <T>(schema: Schema<T>, opts: PluginOptions<
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
if (opts.preDelete && isArray(this._context.deletedDocs) && !isEmpty(this._context.deletedDocs)) {
|
|
38
|
-
await opts.preDelete(this._context.deletedDocs)
|
|
38
|
+
await opts.preDelete(cloneDeep(this._context.deletedDocs))
|
|
39
39
|
}
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
schema.post(deleteMethods as MongooseQueryMiddleware[], { document: false, query: true }, async function (this: HookContext<T>) {
|
|
43
43
|
const options = this.getOptions()
|
|
44
44
|
if (isHookIgnored(options)) return
|
|
45
|
+
if (!this._context) return
|
|
45
46
|
|
|
46
47
|
await deletePatch(opts, this._context)
|
|
47
48
|
})
|
|
@@ -7,17 +7,46 @@ import type { HookContext, PluginOptions } from '../types'
|
|
|
7
7
|
|
|
8
8
|
const updateMethods = ['update', 'updateOne', 'replaceOne', 'updateMany', 'findOneAndUpdate', 'findOneAndReplace', 'findByIdAndUpdate']
|
|
9
9
|
|
|
10
|
+
const trackChangedFields = (fields: Record<string, unknown> | undefined, updated: Record<string, unknown>, changed: Map<string, unknown>): void => {
|
|
11
|
+
if (!fields) return
|
|
12
|
+
for (const key of Object.keys(fields)) {
|
|
13
|
+
const [root = key] = key.split('.')
|
|
14
|
+
changed.set(root, updated[root])
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const applyPullAll = (updated: Record<string, unknown>, fields: Record<string, unknown[]>, changed: Map<string, unknown>): void => {
|
|
19
|
+
for (const [field, values] of Object.entries(fields)) {
|
|
20
|
+
const arr = updated[field]
|
|
21
|
+
if (Array.isArray(arr)) {
|
|
22
|
+
const filtered = arr.filter((item: unknown) => !values.some((v) => JSON.stringify(v) === JSON.stringify(item)))
|
|
23
|
+
updated[field] = filtered
|
|
24
|
+
changed.set(field, filtered)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
export const assignUpdate = <T>(document: HydratedDocument<T>, update: UpdateQuery<T>, commands: Record<string, unknown>[]): HydratedDocument<T> => {
|
|
11
|
-
let updated = assign(document.toObject(toObjectOptions), update)
|
|
30
|
+
let updated = assign(document.toObject(toObjectOptions), update) as Record<string, unknown>
|
|
31
|
+
const changedByCommand = new Map<string, unknown>()
|
|
32
|
+
|
|
12
33
|
for (const command of commands) {
|
|
34
|
+
const [op = ''] = Object.keys(command)
|
|
35
|
+
const fields = command[op] as Record<string, unknown> | undefined
|
|
13
36
|
try {
|
|
14
37
|
updated = assign(updated, command)
|
|
38
|
+
trackChangedFields(fields, updated, changedByCommand)
|
|
15
39
|
} catch {
|
|
16
|
-
|
|
40
|
+
if (op === '$pullAll' && fields) {
|
|
41
|
+
applyPullAll(updated, fields as Record<string, unknown[]>, changedByCommand)
|
|
42
|
+
}
|
|
17
43
|
}
|
|
18
44
|
}
|
|
19
45
|
|
|
20
46
|
const doc = document.set(updated).toObject(toObjectOptions) as HydratedDocument<T> & { createdAt?: Date }
|
|
47
|
+
for (const [field, value] of changedByCommand) {
|
|
48
|
+
;(doc as unknown as Record<string, unknown>)[field] = value
|
|
49
|
+
}
|
|
21
50
|
if (update.createdAt) doc.createdAt = update.createdAt
|
|
22
51
|
return doc
|
|
23
52
|
}
|
|
@@ -41,19 +70,18 @@ export const splitUpdateAndCommands = <T>(updateQuery: UpdateWithAggregationPipe
|
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
export const updateHooksInitialize = <T>(schema: Schema<T>, opts: PluginOptions<T>): void => {
|
|
44
|
-
schema.pre(updateMethods as MongooseQueryMiddleware[], async function (this: HookContext<T>) {
|
|
73
|
+
schema.pre(updateMethods as MongooseQueryMiddleware[], { document: false, query: true }, async function (this: HookContext<T>) {
|
|
45
74
|
const options = this.getOptions()
|
|
46
75
|
if (isHookIgnored(options)) return
|
|
47
76
|
|
|
48
77
|
const model = this.model as Model<T>
|
|
49
78
|
const filter = this.getFilter()
|
|
50
|
-
const count = await this.model.countDocuments(filter).exec()
|
|
51
79
|
|
|
52
80
|
this._context = {
|
|
53
81
|
op: this.op,
|
|
54
|
-
modelName: opts.modelName ??
|
|
55
|
-
collectionName: opts.collectionName ??
|
|
56
|
-
isNew: Boolean(options.upsert) &&
|
|
82
|
+
modelName: opts.modelName ?? model.modelName,
|
|
83
|
+
collectionName: opts.collectionName ?? model.collection.collectionName,
|
|
84
|
+
isNew: Boolean(options.upsert) && (await model.countDocuments(filter).exec()) === 0,
|
|
57
85
|
ignoreEvent: options.ignoreEvent as boolean,
|
|
58
86
|
ignorePatchHistory: options.ignorePatchHistory as boolean,
|
|
59
87
|
}
|
|
@@ -68,9 +96,10 @@ export const updateHooksInitialize = <T>(schema: Schema<T>, opts: PluginOptions<
|
|
|
68
96
|
})
|
|
69
97
|
})
|
|
70
98
|
|
|
71
|
-
schema.post(updateMethods as MongooseQueryMiddleware[], async function (this: HookContext<T>) {
|
|
99
|
+
schema.post(updateMethods as MongooseQueryMiddleware[], { document: false, query: true }, async function (this: HookContext<T>) {
|
|
72
100
|
const options = this.getOptions()
|
|
73
101
|
if (isHookIgnored(options)) return
|
|
102
|
+
if (!this._context) return
|
|
74
103
|
|
|
75
104
|
if (!this._context.isNew) return
|
|
76
105
|
|
|
@@ -78,19 +107,18 @@ export const updateHooksInitialize = <T>(schema: Schema<T>, opts: PluginOptions<
|
|
|
78
107
|
const updateQuery = this.getUpdate()
|
|
79
108
|
const { update, commands } = splitUpdateAndCommands(updateQuery)
|
|
80
109
|
|
|
81
|
-
let current: HydratedDocument<T> | null = null
|
|
82
110
|
const filter = this.getFilter()
|
|
83
|
-
const
|
|
84
|
-
if (!isEmpty(update) && !current) {
|
|
85
|
-
current = (await model.findOne(update).sort('desc').lean().exec()) as HydratedDocument<T>
|
|
86
|
-
}
|
|
111
|
+
const candidates = [update, assignUpdate(model.hydrate({}), update, commands), filter]
|
|
87
112
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
113
|
+
let current: HydratedDocument<T> | null = null
|
|
114
|
+
for (const query of candidates) {
|
|
115
|
+
if (current || isEmpty(query)) continue
|
|
116
|
+
const found = await model
|
|
117
|
+
.findOne(query as never)
|
|
118
|
+
.sort({ _id: -1 })
|
|
119
|
+
.lean()
|
|
120
|
+
.exec()
|
|
121
|
+
current = found as HydratedDocument<T>
|
|
94
122
|
}
|
|
95
123
|
|
|
96
124
|
if (current) {
|