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.
Files changed (41) hide show
  1. package/README.md +31 -25
  2. package/dist/index.cjs +112 -115
  3. package/dist/index.d.cts +4 -1
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +4 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +112 -115
  8. package/package.json +14 -15
  9. package/src/helpers.ts +16 -3
  10. package/src/hooks/delete-hooks.ts +5 -4
  11. package/src/hooks/update-hooks.ts +47 -19
  12. package/src/index.ts +8 -5
  13. package/src/ms.ts +4 -3
  14. package/src/omit-deep.ts +24 -63
  15. package/src/patch.ts +30 -33
  16. package/src/types.ts +3 -0
  17. package/biome.json +0 -50
  18. package/tests/constants/events.ts +0 -7
  19. package/tests/em.test.ts +0 -54
  20. package/tests/helpers.test.ts +0 -311
  21. package/tests/mongo/.gitignore +0 -3
  22. package/tests/mongo/server.ts +0 -31
  23. package/tests/ms.test.ts +0 -113
  24. package/tests/omit-deep.test.ts +0 -220
  25. package/tests/patch.test.ts +0 -199
  26. package/tests/plugin-all-features.test.ts +0 -741
  27. package/tests/plugin-complex-data.test.ts +0 -1332
  28. package/tests/plugin-event-created.test.ts +0 -371
  29. package/tests/plugin-event-deleted.test.ts +0 -400
  30. package/tests/plugin-event-updated.test.ts +0 -503
  31. package/tests/plugin-global.test.ts +0 -545
  32. package/tests/plugin-omit-all.test.ts +0 -349
  33. package/tests/plugin-patch-history-disabled.test.ts +0 -162
  34. package/tests/plugin-pre-delete.test.ts +0 -160
  35. package/tests/plugin-pre-save.test.ts +0 -54
  36. package/tests/plugin.test.ts +0 -576
  37. package/tests/schemas/Description.ts +0 -15
  38. package/tests/schemas/Product.ts +0 -38
  39. package/tests/schemas/User.ts +0 -22
  40. package/tsconfig.json +0 -32
  41. 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 n = Number.parseFloat(match[1] ?? "");
102
- const type = (match[2] ?? "ms").toLowerCase();
103
- return n * (UNITS[type] ?? 0);
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
- if ("toJSON" in value && typeof value.toJSON === "function") {
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
- console.error("Couldn't create or update index for history collection", err);
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 getValue$1 = (obj, path) => {
248
- const segs = path.split(".");
249
- let current = obj;
250
- for (const seg of segs) {
251
- if (current == null || typeof current !== "object") return void 0;
252
- current = current[seg];
253
- }
254
- return current;
255
- };
256
- const hasValue = (val) => {
257
- if (val == null) return false;
258
- if (typeof val === "boolean" || typeof val === "number" || typeof val === "function") return true;
259
- if (typeof val === "string") return val.length !== 0;
260
- if (Array.isArray(val)) return val.length !== 0;
261
- if (val instanceof RegExp) return val.source !== "(?:)" && val.source !== "";
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 true;
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
- for (let i = 0; i < value.length; i++) {
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
- for (const key of omitKeys) {
312
- unset(value, key);
313
- }
286
+ const { topLevel, nested } = classifyKeys(omitKeys);
287
+ const result = {};
314
288
  for (const key of Object.keys(value)) {
315
- value[key] = omitDeep(value[key], omitKeys);
289
+ if (topLevel.has(key)) continue;
290
+ const nestedKeys = nested.get(key);
291
+ result[key] = omitDeep(value[key], nestedKeys ?? omitKeys);
316
292
  }
317
- return value;
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
- const object = JSON.parse(JSON.stringify(doc));
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
- if (opts.omit) {
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 getMetadata = async (opts, doc) => {
349
- if (isFunction(opts.getMetadata)) {
350
- return await opts.getMetadata(doc);
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
- em.emit(event, data);
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
- emitEvent(context, event, { [key]: doc });
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: getObjectOmit(opts, 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
- console.error(error.message);
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 && lastHistory.version >= 0) {
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 ?? this.model.modelName,
449
- collectionName: opts.collectionName ?? this.model.collection.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 ?? this.model.modelName,
535
- collectionName: opts.collectionName ?? this.model.collection.collectionName,
536
- isNew: Boolean(options.upsert) && count === 0,
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 combined = assignUpdate(model.hydrate({}), update, commands);
558
- if (!isEmpty(update) && !current) {
559
- current = await model.findOne(update).sort("desc").lean().exec();
560
- }
561
- if (!isEmpty(combined) && !current) {
562
- current = await model.findOne(combined).sort("desc").lean().exec();
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
- schema.pre(remove, { document: true, query: false }, async function() {
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
- schema.post(remove, { document: true, query: false }, async function() {
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.0.0",
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.7",
79
- "@types/node": "25.5.0",
80
- "@vitest/coverage-v8": "4.1.0",
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.3.0",
83
- "np": "11.0.2",
84
- "open-cli": "8.0.0",
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.0"
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
- if ('toJSON' in value && typeof (value as Record<string, unknown>).toJSON === 'function') {
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
- console.error("Couldn't create or update index for history collection", err)
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 ?? this.model.modelName,
20
- collectionName: opts.collectionName ?? this.model.collection.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
- // we catch assign keys that are not implemented
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 ?? this.model.modelName,
55
- collectionName: opts.collectionName ?? this.model.collection.collectionName,
56
- isNew: Boolean(options.upsert) && count === 0,
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 combined = assignUpdate(model.hydrate({}), update, commands)
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
- if (!isEmpty(combined) && !current) {
89
- current = (await model.findOne(combined).sort('desc').lean().exec()) as HydratedDocument<T>
90
- }
91
-
92
- if (!isEmpty(filter) && !current) {
93
- current = (await model.findOne(filter).sort('desc').lean().exec()) as HydratedDocument<T>
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) {