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/README.md +42 -27
- package/biome.json +2 -5
- package/dist/index.cjs +307 -93
- package/dist/index.d.cts +42 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +42 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +307 -93
- package/package.json +13 -19
- package/src/helpers.ts +132 -10
- package/src/hooks/delete-hooks.ts +5 -7
- package/src/hooks/update-hooks.ts +48 -34
- package/src/index.ts +4 -32
- package/src/ms.ts +66 -0
- package/src/omit-deep.ts +56 -0
- package/src/patch.ts +42 -47
- package/src/types.ts +1 -0
- package/src/version.ts +5 -4
- package/tests/em.test.ts +26 -8
- package/tests/helpers.test.ts +291 -2
- package/tests/ms.test.ts +113 -0
- package/tests/omit-deep.test.ts +235 -0
- package/tests/patch.test.ts +6 -5
- package/tests/plugin-all-features.test.ts +844 -0
- package/tests/plugin-complex-data.test.ts +2647 -0
- package/tests/plugin-event-created.test.ts +10 -10
- package/tests/plugin-event-deleted.test.ts +10 -10
- package/tests/plugin-event-updated.test.ts +9 -9
- package/tests/plugin-global.test.ts +6 -6
- package/tests/plugin-omit-all.test.ts +1 -1
- package/tests/plugin-patch-history-disabled.test.ts +1 -1
- package/tests/plugin-pre-delete.test.ts +8 -8
- package/tests/plugin-pre-save.test.ts +2 -2
- package/tests/plugin.test.ts +3 -3
- package/tsconfig.json +2 -3
- package/vite.config.mts +2 -1
- package/src/modules/omit-deep.d.ts +0 -3
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
|
-
|
|
10
|
+
const isPatchHistoryEnabled = <T>(opts: PluginOptions<T>, context: PatchContext<T>): boolean => {
|
|
14
11
|
return !opts.patchHistoryDisabled && !context.ignorePatchHistory
|
|
15
12
|
}
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
43
|
-
if (isFunction(
|
|
44
|
-
return await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
52
|
+
export const emitEvent = <T>(context: PatchContext<T>, event: string | undefined, data: PatchEvent<T>): void => {
|
|
67
53
|
if (event && !context.ignoreEvent) {
|
|
68
|
-
|
|
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
|
|
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
|
|
71
|
+
for (const batch of chunks) {
|
|
82
72
|
const bulk = []
|
|
83
73
|
|
|
84
|
-
for (const doc of
|
|
85
|
-
|
|
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:
|
|
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
|
-
|
|
101
|
+
onError(error)
|
|
110
102
|
})
|
|
111
103
|
}
|
|
112
104
|
}
|
|
113
105
|
}
|
|
114
106
|
|
|
115
|
-
export async
|
|
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
|
|
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
|
|
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
package/src/version.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import mongoose from 'mongoose'
|
|
2
|
-
import { satisfies } from 'semver'
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export const
|
|
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
|
-
|
|
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',
|
|
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).
|
|
32
|
-
|
|
33
|
-
patchEventEmitter.off('test', fn)
|
|
36
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
34
37
|
})
|
|
35
38
|
|
|
36
|
-
it('emitEvent ignore',
|
|
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
|
-
|
|
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
|
})
|
package/tests/helpers.test.ts
CHANGED
|
@@ -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
|
|
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
|
+
})
|
package/tests/ms.test.ts
ADDED
|
@@ -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
|
+
})
|