ts-patch-mongoose 2.9.6 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/patch.ts CHANGED
@@ -1,88 +1,79 @@
1
1
  import jsonpatch from 'fast-json-patch'
2
- // Using CJS lodash with .js extensions for ESM compatibility
3
- import chunk from 'lodash/chunk.js'
4
- import isEmpty from 'lodash/isEmpty.js'
5
- import isFunction from 'lodash/isFunction.js'
6
- import omit from 'omit-deep'
7
2
  import em from './em'
3
+ import { chunk, isEmpty, isFunction } from './helpers'
8
4
  import { HistoryModel } from './model'
5
+ import { omitDeep as omit } from './omit-deep'
9
6
 
10
7
  import type { HydratedDocument, MongooseError, Types } from 'mongoose'
11
8
  import type { Metadata, PatchContext, PatchEvent, PluginOptions, User } from './types'
12
9
 
13
- function isPatchHistoryEnabled<T>(opts: PluginOptions<T>, context: PatchContext<T>): boolean {
10
+ const isPatchHistoryEnabled = <T>(opts: PluginOptions<T>, context: PatchContext<T>): boolean => {
14
11
  return !opts.patchHistoryDisabled && !context.ignorePatchHistory
15
12
  }
16
13
 
17
- export function getJsonOmit<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> {
18
- const object = JSON.parse(JSON.stringify(doc)) as Partial<T>
19
-
20
- if (opts.omit) {
21
- return omit(object, opts.omit)
22
- }
23
-
24
- return object
14
+ const applyOmit = <T>(object: Partial<T>, opts: PluginOptions<T>): Partial<T> => {
15
+ return opts.omit ? omit(object, opts.omit) : object
25
16
  }
26
17
 
27
- export function getObjectOmit<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> {
28
- if (opts.omit) {
29
- return omit(isFunction(doc?.toObject) ? doc.toObject() : doc, opts.omit)
30
- }
18
+ const replacer = (_key: string, value: unknown): unknown => (typeof value === 'bigint' ? value.toString() : value)
31
19
 
32
- return doc
20
+ export const getJsonOmit = <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> => {
21
+ // NOSONAR — structuredClone cannot handle mongoose documents (they contain non-cloneable methods)
22
+ return applyOmit(JSON.parse(JSON.stringify(doc, replacer)) as Partial<T>, opts)
33
23
  }
34
24
 
35
- export async function getUser<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<User | undefined> {
36
- if (isFunction(opts.getUser)) {
37
- return await opts.getUser(doc)
38
- }
39
- return undefined
25
+ export const getObjectOmit = <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> => {
26
+ return applyOmit(isFunction(doc?.toObject) ? doc.toObject() : doc, opts)
40
27
  }
41
28
 
42
- export async function getReason<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<string | undefined> {
43
- if (isFunction(opts.getReason)) {
44
- return await opts.getReason(doc)
29
+ const getOptionalField = async <T, R>(fn: ((doc: HydratedDocument<T>) => Promise<R> | R) | undefined, doc?: HydratedDocument<T>): Promise<R | undefined> => {
30
+ if (isFunction(fn)) {
31
+ return await fn(doc as HydratedDocument<T>)
45
32
  }
46
33
  return undefined
47
34
  }
48
35
 
49
- export async function getMetadata<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<Metadata | undefined> {
50
- if (isFunction(opts.getMetadata)) {
51
- return await opts.getMetadata(doc)
52
- }
53
- return undefined
54
- }
36
+ export const getUser = async <T>(opts: PluginOptions<T>, doc?: HydratedDocument<T>): Promise<User | undefined> => getOptionalField(opts.getUser, doc)
55
37
 
56
- export function getValue<T>(item: PromiseSettledResult<T>): T | undefined {
38
+ export const getReason = async <T>(opts: PluginOptions<T>, doc?: HydratedDocument<T>): Promise<string | undefined> => getOptionalField(opts.getReason, doc)
39
+
40
+ export const getMetadata = async <T>(opts: PluginOptions<T>, doc?: HydratedDocument<T>): Promise<Metadata | undefined> => getOptionalField(opts.getMetadata, doc)
41
+
42
+ export const getValue = <T>(item: PromiseSettledResult<T>): T | undefined => {
57
43
  return item.status === 'fulfilled' ? item.value : undefined
58
44
  }
59
45
 
60
- export async function getData<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<[User | undefined, string | undefined, Metadata | undefined]> {
46
+ export const getData = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<[User | undefined, string | undefined, Metadata | undefined]> => {
61
47
  return Promise.allSettled([getUser(opts, doc), getReason(opts, doc), getMetadata(opts, doc)]).then(([user, reason, metadata]) => {
62
48
  return [getValue(user), getValue(reason), getValue(metadata)]
63
49
  })
64
50
  }
65
51
 
66
- export function emitEvent<T>(context: PatchContext<T>, event: string | undefined, data: PatchEvent<T>): void {
52
+ export const emitEvent = <T>(context: PatchContext<T>, event: string | undefined, data: PatchEvent<T>): void => {
67
53
  if (event && !context.ignoreEvent) {
68
- em.emit(event, data)
54
+ try {
55
+ em.emit(event, data)
56
+ } catch {
57
+ // Listener errors must not crash patch history recording
58
+ }
69
59
  }
70
60
  }
71
61
 
72
- export async function bulkPatch<T>(opts: PluginOptions<T>, context: PatchContext<T>, eventKey: 'eventCreated' | 'eventDeleted', docsKey: 'createdDocs' | 'deletedDocs'): Promise<void> {
62
+ export const bulkPatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>, eventKey: 'eventCreated' | 'eventDeleted', docsKey: 'createdDocs' | 'deletedDocs'): Promise<void> => {
73
63
  const history = isPatchHistoryEnabled(opts, context)
74
64
  const event = opts[eventKey]
75
65
  const docs = context[docsKey]
76
66
  const key = eventKey === 'eventCreated' ? 'doc' : 'oldDoc'
77
67
 
78
- if (isEmpty(docs) || (!event && !history)) return
68
+ if (isEmpty(docs) || !docs || (!event && !history)) return
79
69
 
80
70
  const chunks = chunk(docs, 1000)
81
- for (const chunk of chunks) {
71
+ for (const batch of chunks) {
82
72
  const bulk = []
83
73
 
84
- for (const doc of chunk) {
85
- emitEvent(context, event, { [key]: doc })
74
+ for (const doc of batch) {
75
+ const omitted = getObjectOmit(opts, doc)
76
+ emitEvent(context, event, { [key]: omitted })
86
77
 
87
78
  if (history) {
88
79
  const [user, reason, metadata] = await getData(opts, doc)
@@ -93,7 +84,7 @@ export async function bulkPatch<T>(opts: PluginOptions<T>, context: PatchContext
93
84
  modelName: context.modelName,
94
85
  collectionName: context.collectionName,
95
86
  collectionId: doc._id as Types.ObjectId,
96
- doc: getObjectOmit(opts, doc),
87
+ doc: omitted,
97
88
  version: 0,
98
89
  ...(user !== undefined && { user }),
99
90
  ...(reason !== undefined && { reason }),
@@ -105,18 +96,19 @@ export async function bulkPatch<T>(opts: PluginOptions<T>, context: PatchContext
105
96
  }
106
97
 
107
98
  if (history && !isEmpty(bulk)) {
99
+ const onError = opts.onError ?? console.error
108
100
  await HistoryModel.bulkWrite(bulk, { ordered: false }).catch((error: MongooseError) => {
109
- console.error(error.message)
101
+ onError(error)
110
102
  })
111
103
  }
112
104
  }
113
105
  }
114
106
 
115
- export async function createPatch<T>(opts: PluginOptions<T>, context: PatchContext<T>): Promise<void> {
107
+ export const createPatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>): Promise<void> => {
116
108
  await bulkPatch(opts, context, 'eventCreated', 'createdDocs')
117
109
  }
118
110
 
119
- export async function updatePatch<T>(opts: PluginOptions<T>, context: PatchContext<T>, current: HydratedDocument<T>, original: HydratedDocument<T>): Promise<void> {
111
+ export const updatePatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>, current: HydratedDocument<T>, original: HydratedDocument<T>): Promise<void> => {
120
112
  const history = isPatchHistoryEnabled(opts, context)
121
113
 
122
114
  const currentObject = getJsonOmit(opts, current)
@@ -140,6 +132,7 @@ export async function updatePatch<T>(opts: PluginOptions<T>, context: PatchConte
140
132
  }
141
133
 
142
134
  const [user, reason, metadata] = await getData(opts, current)
135
+ const onError = opts.onError ?? console.error
143
136
  await HistoryModel.create({
144
137
  op: context.op,
145
138
  modelName: context.modelName,
@@ -150,10 +143,12 @@ export async function updatePatch<T>(opts: PluginOptions<T>, context: PatchConte
150
143
  ...(user !== undefined && { user }),
151
144
  ...(reason !== undefined && { reason }),
152
145
  ...(metadata !== undefined && { metadata }),
146
+ }).catch((error: MongooseError) => {
147
+ onError(error)
153
148
  })
154
149
  }
155
150
  }
156
151
 
157
- export async function deletePatch<T>(opts: PluginOptions<T>, context: PatchContext<T>): Promise<void> {
152
+ export const deletePatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>): Promise<void> => {
158
153
  await bulkPatch(opts, context, 'eventDeleted', 'deletedDocs')
159
154
  }
package/src/types.ts CHANGED
@@ -49,4 +49,5 @@ export interface PluginOptions<T> {
49
49
  omit?: string[]
50
50
  patchHistoryDisabled?: boolean
51
51
  preDelete?: (docs: HydratedDocument<T>[]) => Promise<void>
52
+ onError?: (error: Error) => void
52
53
  }
package/src/version.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import mongoose from 'mongoose'
2
- import { satisfies } from 'semver'
3
2
 
4
- export const isMongooseLessThan8 = satisfies(mongoose.version, '<8')
5
- export const isMongooseLessThan7 = satisfies(mongoose.version, '<7')
6
- export const isMongoose6 = satisfies(mongoose.version, '6')
3
+ const major = Number.parseInt(mongoose.version, 10)
4
+
5
+ export const isMongooseLessThan8 = major < 8
6
+ export const isMongooseLessThan7 = major < 7
7
+ export const isMongoose6 = major === 6
7
8
 
8
9
  /* v8 ignore start */
9
10
  if (isMongoose6) {
package/tests/em.test.ts CHANGED
@@ -1,10 +1,14 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
3
  import { patchEventEmitter } from '../src/index'
4
4
  import { emitEvent } from '../src/patch'
5
5
 
6
6
  describe('em', () => {
7
- it('should subscribe and count', async () => {
7
+ afterEach(() => {
8
+ patchEventEmitter.removeAllListeners()
9
+ })
10
+
11
+ it('should subscribe and count', () => {
8
12
  let count = 0
9
13
  const fn = () => {
10
14
  count++
@@ -17,7 +21,7 @@ describe('em', () => {
17
21
  expect(count).toBe(1)
18
22
  })
19
23
 
20
- it('emitEvent', async () => {
24
+ it('emitEvent', () => {
21
25
  const fn = vi.fn()
22
26
  patchEventEmitter.on('test', fn)
23
27
 
@@ -27,13 +31,12 @@ describe('em', () => {
27
31
  collectionName: 'tests',
28
32
  }
29
33
 
34
+ // @ts-expect-error expected
30
35
  emitEvent(context, 'test', { doc: { name: 'test' } })
31
- expect(fn).toHaveBeenCalledTimes(1)
32
-
33
- patchEventEmitter.off('test', fn)
36
+ expect(fn).toHaveBeenCalledOnce()
34
37
  })
35
38
 
36
- it('emitEvent ignore', async () => {
39
+ it('emitEvent ignore', () => {
37
40
  const fn = vi.fn()
38
41
  patchEventEmitter.on('test', fn)
39
42
 
@@ -44,9 +47,24 @@ describe('em', () => {
44
47
  collectionName: 'tests',
45
48
  }
46
49
 
50
+ // @ts-expect-error expected
47
51
  emitEvent(context, 'test', { doc: { name: 'test' } })
48
52
  expect(fn).toHaveBeenCalledTimes(0)
53
+ })
49
54
 
50
- patchEventEmitter.off('test', fn)
55
+ it('emitEvent should not throw when listener throws', () => {
56
+ const fn = () => {
57
+ throw new Error('listener error')
58
+ }
59
+ patchEventEmitter.on('throw-test', fn)
60
+
61
+ const context = {
62
+ op: 'test',
63
+ modelName: 'Test',
64
+ collectionName: 'tests',
65
+ }
66
+
67
+ // @ts-expect-error expected
68
+ expect(() => emitEvent(context, 'throw-test', { doc: { name: 'test' } })).not.toThrow()
51
69
  })
52
70
  })
@@ -1,9 +1,9 @@
1
1
  import type { Mock, MockInstance } from 'vitest'
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
3
 
4
- import ms from 'ms'
5
- import { setPatchHistoryTTL } from '../src/helpers'
4
+ import { cloneDeep, isEmpty, setPatchHistoryTTL } from '../src/helpers'
6
5
  import { HistoryModel } from '../src/model'
6
+ import { ms } from '../src/ms'
7
7
 
8
8
  vi.mock('../src/model', () => ({
9
9
  HistoryModel: {
@@ -82,3 +82,292 @@ describe('useTTL', () => {
82
82
  expect(createIndexSpy).toHaveBeenCalledWith({ createdAt: 1 }, { expireAfterSeconds, name })
83
83
  })
84
84
  })
85
+
86
+ describe('isEmpty', () => {
87
+ it('should return true for null and undefined', () => {
88
+ expect(isEmpty(null)).toBe(true)
89
+ expect(isEmpty(undefined)).toBe(true)
90
+ })
91
+
92
+ it('should return true for empty arrays and strings', () => {
93
+ expect(isEmpty([])).toBe(true)
94
+ expect(isEmpty('')).toBe(true)
95
+ })
96
+
97
+ it('should return false for non-empty arrays and strings', () => {
98
+ expect(isEmpty([1, 2])).toBe(false)
99
+ expect(isEmpty('hello')).toBe(false)
100
+ })
101
+
102
+ it('should return true for empty objects', () => {
103
+ expect(isEmpty({})).toBe(true)
104
+ })
105
+
106
+ it('should return false for non-empty objects', () => {
107
+ expect(isEmpty({ a: 1 })).toBe(false)
108
+ })
109
+
110
+ it('should return true for empty Map and Set', () => {
111
+ expect(isEmpty(new Map())).toBe(true)
112
+ expect(isEmpty(new Set())).toBe(true)
113
+ })
114
+
115
+ it('should return false for non-empty Map and Set', () => {
116
+ expect(isEmpty(new Map([['a', 1]]))).toBe(false)
117
+ expect(isEmpty(new Set([1]))).toBe(false)
118
+ })
119
+
120
+ it('should return true for numbers, booleans, and functions (matches lodash)', () => {
121
+ expect(isEmpty(0)).toBe(true)
122
+ expect(isEmpty(1)).toBe(true)
123
+ expect(isEmpty(true)).toBe(true)
124
+ expect(isEmpty(false)).toBe(true)
125
+ expect(isEmpty(() => {})).toBe(true)
126
+ })
127
+
128
+ it('should return true for Date and RegExp (matches lodash)', () => {
129
+ expect(isEmpty(new Date())).toBe(true)
130
+ expect(isEmpty(/test/)).toBe(true)
131
+ })
132
+ })
133
+
134
+ describe('cloneDeep', () => {
135
+ it('should clone primitives', () => {
136
+ expect(cloneDeep(42)).toBe(42)
137
+ expect(cloneDeep('hello')).toBe('hello')
138
+ expect(cloneDeep(null)).toBe(null)
139
+ expect(cloneDeep(undefined)).toBe(undefined)
140
+ expect(cloneDeep(true)).toBe(true)
141
+ })
142
+
143
+ it('should deep clone plain objects', () => {
144
+ const original = { a: 1, b: { c: 2 } }
145
+ const cloned = cloneDeep(original)
146
+ expect(cloned).toEqual(original)
147
+ expect(cloned).not.toBe(original)
148
+ expect(cloned.b).not.toBe(original.b)
149
+ })
150
+
151
+ it('should deep clone arrays', () => {
152
+ const original = [1, [2, 3], { a: 4 }]
153
+ const cloned = cloneDeep(original)
154
+ expect(cloned).toEqual(original)
155
+ expect(cloned).not.toBe(original)
156
+ expect(cloned[1]).not.toBe(original[1])
157
+ expect(cloned[2]).not.toBe(original[2])
158
+ })
159
+
160
+ it('should clone Date instances', () => {
161
+ const original = new Date('2026-01-01')
162
+ const cloned = cloneDeep(original)
163
+ expect(cloned).toEqual(original)
164
+ expect(cloned).not.toBe(original)
165
+ expect(cloned.getTime()).toBe(original.getTime())
166
+ })
167
+
168
+ it('should clone RegExp instances', () => {
169
+ const original = /test/gi
170
+ const cloned = cloneDeep(original)
171
+ expect(cloned).not.toBe(original)
172
+ expect(cloned.source).toBe(original.source)
173
+ expect(cloned.flags).toBe(original.flags)
174
+ })
175
+
176
+ it('should clone Map instances', () => {
177
+ const original = new Map([['a', { nested: 1 }]])
178
+ const cloned = cloneDeep(original)
179
+ expect(cloned).not.toBe(original)
180
+ expect(cloned.get('a')).toEqual({ nested: 1 })
181
+ expect(cloned.get('a')).not.toBe(original.get('a'))
182
+ })
183
+
184
+ it('should clone Set instances', () => {
185
+ const obj = { a: 1 }
186
+ const original = new Set([obj])
187
+ const cloned = cloneDeep(original)
188
+ expect(cloned).not.toBe(original)
189
+ expect(cloned.size).toBe(1)
190
+ const [clonedItem] = cloned
191
+ expect(clonedItem).toEqual(obj)
192
+ expect(clonedItem).not.toBe(obj)
193
+ })
194
+
195
+ it('should handle circular references in objects', () => {
196
+ const original: Record<string, unknown> = { a: 1 }
197
+ original.self = original
198
+ const cloned = cloneDeep(original)
199
+ expect(cloned.a).toBe(1)
200
+ expect(cloned.self).toBe(cloned)
201
+ expect(cloned).not.toBe(original)
202
+ })
203
+
204
+ it('should handle circular references in arrays', () => {
205
+ const original: unknown[] = [1, 2]
206
+ original.push(original)
207
+ const cloned = cloneDeep(original)
208
+ expect(cloned[0]).toBe(1)
209
+ expect(cloned[1]).toBe(2)
210
+ expect(cloned[2]).toBe(cloned)
211
+ expect(cloned).not.toBe(original)
212
+ })
213
+
214
+ it('should handle circular references in nested objects', () => {
215
+ const child: Record<string, unknown> = { value: 'child' }
216
+ const parent: Record<string, unknown> = { child }
217
+ child.parent = parent
218
+ const cloned = cloneDeep(parent)
219
+ expect(cloned).not.toBe(parent)
220
+ expect(cloned.child).not.toBe(child)
221
+ expect((cloned.child as Record<string, unknown>).value).toBe('child')
222
+ expect((cloned.child as Record<string, unknown>).parent).toBe(cloned)
223
+ })
224
+
225
+ it('should handle circular references in Maps', () => {
226
+ const original = new Map<string, unknown>()
227
+ original.set('self', original)
228
+ const cloned = cloneDeep(original)
229
+ expect(cloned).not.toBe(original)
230
+ expect(cloned.get('self')).toBe(cloned)
231
+ })
232
+
233
+ it('should handle circular references in Sets', () => {
234
+ const original = new Set<unknown>()
235
+ original.add(original)
236
+ const cloned = cloneDeep(original)
237
+ expect(cloned).not.toBe(original)
238
+ expect(cloned.has(cloned)).toBe(true)
239
+ expect(cloned.size).toBe(1)
240
+ })
241
+
242
+ it('should clone objects with toJSON method via JSON round-trip', () => {
243
+ const original = { value: 42, toJSON: () => ({ value: 42 }) }
244
+ const cloned = cloneDeep(original)
245
+ expect(cloned).toEqual({ value: 42 })
246
+ expect(cloned).not.toBe(original)
247
+ })
248
+
249
+ it('should clone ArrayBuffer', () => {
250
+ const original = new ArrayBuffer(8)
251
+ new Uint8Array(original).set([1, 2, 3, 4, 5, 6, 7, 8])
252
+ const cloned = cloneDeep(original)
253
+ expect(cloned).not.toBe(original)
254
+ expect(cloned.byteLength).toBe(8)
255
+ expect(new Uint8Array(cloned)).toEqual(new Uint8Array(original))
256
+ })
257
+
258
+ it('should clone DataView', () => {
259
+ const buffer = new ArrayBuffer(16)
260
+ const original = new DataView(buffer, 4, 8)
261
+ original.setInt32(0, 42)
262
+ const cloned = cloneDeep(original)
263
+ expect(cloned).not.toBe(original)
264
+ expect(cloned.buffer).not.toBe(original.buffer)
265
+ expect(cloned.byteOffset).toBe(4)
266
+ expect(cloned.byteLength).toBe(8)
267
+ expect(cloned.getInt32(0)).toBe(42)
268
+ })
269
+
270
+ it('should clone TypedArrays with offset and length', () => {
271
+ const buffer = new ArrayBuffer(16)
272
+ const original = new Uint8Array(buffer, 4, 8)
273
+ original.set([10, 20, 30, 40, 50, 60, 70, 80])
274
+ const cloned = cloneDeep(original)
275
+ expect(cloned).not.toBe(original)
276
+ expect(cloned.buffer).not.toBe(original.buffer)
277
+ expect(cloned.byteOffset).toBe(4)
278
+ expect(cloned.length).toBe(8)
279
+ expect(Array.from(cloned)).toEqual([10, 20, 30, 40, 50, 60, 70, 80])
280
+ })
281
+
282
+ it('should clone Float64Array', () => {
283
+ const original = new Float64Array([1.1, 2.2, 3.3])
284
+ const cloned = cloneDeep(original)
285
+ expect(cloned).not.toBe(original)
286
+ expect(cloned.buffer).not.toBe(original.buffer)
287
+ expect(Array.from(cloned)).toEqual([1.1, 2.2, 3.3])
288
+ })
289
+
290
+ it('should clone RegExp with lastIndex', () => {
291
+ const original = /foo/g
292
+ original.exec('foobar')
293
+ expect(original.lastIndex).toBe(3)
294
+ const cloned = cloneDeep(original)
295
+ expect(cloned).not.toBe(original)
296
+ expect(cloned.lastIndex).toBe(3)
297
+ expect(cloned.source).toBe('foo')
298
+ expect(cloned.flags).toBe('g')
299
+ })
300
+
301
+ it('should clone object without constructor', () => {
302
+ const original = Object.create(null) as Record<string, unknown>
303
+ original.a = 1
304
+ original.b = { c: 2 }
305
+ const cloned = cloneDeep(original)
306
+ expect(cloned).not.toBe(original)
307
+ expect(cloned.a).toBe(1)
308
+ expect(cloned.b).toEqual({ c: 2 })
309
+ expect(cloned.b).not.toBe(original.b)
310
+ })
311
+
312
+ it('should clone Error instances preserving message and stack', () => {
313
+ const original = new Error('test error')
314
+ const cloned = cloneDeep(original)
315
+ expect(cloned).not.toBe(original)
316
+ expect(cloned).toBeInstanceOf(Error)
317
+ expect(cloned.message).toBe('test error')
318
+ expect(cloned.stack).toBe(original.stack)
319
+ })
320
+
321
+ it('should fall through to cloneCollection for partial BSON-like objects', () => {
322
+ const original = { _bsontype: 'SomeType', value: 42 }
323
+ const cloned = cloneDeep(original)
324
+ expect(cloned).toEqual({ _bsontype: 'SomeType', value: 42 })
325
+ expect(cloned).not.toBe(original)
326
+ })
327
+
328
+ it('should clone BSON-like objects via toHexString', () => {
329
+ const hex = '507f1f77bcf86cd799439011'
330
+ const original = {
331
+ _bsontype: 'ObjectId',
332
+ toHexString: () => hex,
333
+ toJSON: () => hex,
334
+ }
335
+ Object.setPrototypeOf(original, {
336
+ constructor: class ObjectId {
337
+ hex: string
338
+ constructor(h: string) {
339
+ this.hex = h
340
+ }
341
+ toHexString() {
342
+ return this.hex
343
+ }
344
+ },
345
+ })
346
+ const cloned = cloneDeep(original)
347
+ expect(cloned).not.toBe(original)
348
+ expect(cloned.toHexString()).toBe(hex)
349
+ })
350
+ })
351
+
352
+ describe('setPatchHistoryTTL', () => {
353
+ it('should call custom onError handler on failure', async () => {
354
+ const indexes = HistoryModel.collection.indexes as Mock
355
+ indexes.mockRejectedValue(new Error('connection failed'))
356
+
357
+ const onError = vi.fn()
358
+ await setPatchHistoryTTL('1h', onError)
359
+
360
+ expect(onError).toHaveBeenCalledOnce()
361
+ expect(onError).toHaveBeenCalledWith(expect.any(Error))
362
+ })
363
+
364
+ it('should fall back to console.error when no onError provided', async () => {
365
+ const indexes = HistoryModel.collection.indexes as Mock
366
+ indexes.mockRejectedValue(new Error('connection failed'))
367
+
368
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
369
+ await setPatchHistoryTTL('1h')
370
+
371
+ expect(spy).toHaveBeenCalledOnce()
372
+ })
373
+ })
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { ms, UNITS } from '../src/ms'
4
+
5
+ const { s, m, h, d, w, mo, y } = UNITS
6
+
7
+ describe('ms', () => {
8
+ it('should parse milliseconds', () => {
9
+ expect(ms('100ms')).toBe(100)
10
+ expect(ms('500msecs')).toBe(500)
11
+ expect(ms('1millisecond')).toBe(1)
12
+ expect(ms('5milliseconds')).toBe(5)
13
+ })
14
+
15
+ it('should parse seconds', () => {
16
+ expect(ms('1s')).toBe(s)
17
+ expect(ms('5sec')).toBe(5 * s)
18
+ expect(ms('10secs')).toBe(10 * s)
19
+ expect(ms('1second')).toBe(s)
20
+ expect(ms('2seconds')).toBe(2 * s)
21
+ })
22
+
23
+ it('should parse minutes', () => {
24
+ expect(ms('1m')).toBe(m)
25
+ expect(ms('5min')).toBe(5 * m)
26
+ expect(ms('10mins')).toBe(10 * m)
27
+ expect(ms('1minute')).toBe(m)
28
+ expect(ms('2minutes')).toBe(2 * m)
29
+ })
30
+
31
+ it('should parse hours', () => {
32
+ expect(ms('1h')).toBe(h)
33
+ expect(ms('2hr')).toBe(2 * h)
34
+ expect(ms('3hrs')).toBe(3 * h)
35
+ expect(ms('1hour')).toBe(h)
36
+ expect(ms('2hours')).toBe(2 * h)
37
+ })
38
+
39
+ it('should parse days', () => {
40
+ expect(ms('1d')).toBe(d)
41
+ expect(ms('1day')).toBe(d)
42
+ expect(ms('2days')).toBe(2 * d)
43
+ })
44
+
45
+ it('should parse weeks', () => {
46
+ expect(ms('1w')).toBe(w)
47
+ expect(ms('1week')).toBe(w)
48
+ expect(ms('2weeks')).toBe(2 * w)
49
+ })
50
+
51
+ it('should parse months', () => {
52
+ expect(ms('1mo')).toBe(mo)
53
+ expect(ms('1month')).toBe(mo)
54
+ expect(ms('2months')).toBe(2 * mo)
55
+ expect(ms('6mo')).toBe(6 * mo)
56
+ expect(ms('0.5mo')).toBe(0.5 * mo)
57
+ })
58
+
59
+ it('should parse years', () => {
60
+ expect(ms('1y')).toBe(y)
61
+ expect(ms('1yr')).toBe(y)
62
+ expect(ms('1year')).toBe(y)
63
+ expect(ms('2years')).toBe(2 * y)
64
+ })
65
+
66
+ it('should default to milliseconds when no unit', () => {
67
+ expect(ms('500')).toBe(500)
68
+ })
69
+
70
+ it('should handle decimal values', () => {
71
+ expect(ms('1.5h')).toBe(1.5 * h)
72
+ expect(ms('0.5d')).toBe(0.5 * d)
73
+ })
74
+
75
+ it('should handle negative values', () => {
76
+ expect(ms('-1s')).toBe(-s)
77
+ expect(ms('-500ms')).toBe(-500)
78
+ })
79
+
80
+ it('should handle spaces between number and unit', () => {
81
+ expect(ms('1 hour')).toBe(h)
82
+ expect(ms('2 days')).toBe(2 * d)
83
+ expect(ms('500 ms')).toBe(500)
84
+ expect(ms('30 seconds')).toBe(30 * s)
85
+ expect(ms('5 min')).toBe(5 * m)
86
+ expect(ms('1 mo')).toBe(mo)
87
+ expect(ms('1 week')).toBe(w)
88
+ expect(ms('1 year')).toBe(y)
89
+ })
90
+
91
+ it('should be case insensitive', () => {
92
+ // @ts-expect-error runtime check
93
+ expect(ms('1H')).toBe(h)
94
+ // @ts-expect-error runtime check
95
+ expect(ms('1D')).toBe(d)
96
+ // @ts-expect-error runtime check
97
+ expect(ms('1MS')).toBe(1)
98
+ })
99
+
100
+ it('should return NaN for strings over 100 chars', () => {
101
+ // @ts-expect-error testing invalid input
102
+ expect(ms('a'.repeat(101))).toBeNaN()
103
+ })
104
+
105
+ it('should return NaN for invalid strings', () => {
106
+ // @ts-expect-error testing invalid input
107
+ expect(ms('abc')).toBeNaN()
108
+ // @ts-expect-error testing invalid input
109
+ expect(ms('')).toBeNaN()
110
+ // @ts-expect-error testing invalid input
111
+ expect(ms('hello world')).toBeNaN()
112
+ })
113
+ })