ts-patch-mongoose 3.1.0 → 4.0.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 +39 -27
- package/dist/index.cjs +287 -32
- package/dist/index.d.cts +23 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +23 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +287 -32
- package/package.json +19 -24
- package/biome.json +0 -47
- package/src/em.ts +0 -6
- package/src/helpers.ts +0 -174
- package/src/hooks/delete-hooks.ts +0 -49
- package/src/hooks/save-hooks.ts +0 -30
- package/src/hooks/update-hooks.ts +0 -125
- package/src/index.ts +0 -65
- package/src/model.ts +0 -50
- package/src/modules/power-assign.d.ts +0 -3
- package/src/ms.ts +0 -66
- package/src/omit-deep.ts +0 -56
- package/src/patch.ts +0 -154
- package/src/types.ts +0 -53
- package/src/version.ts +0 -13
- package/tests/constants/events.ts +0 -7
- package/tests/em.test.ts +0 -70
- package/tests/helpers.test.ts +0 -373
- 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 -235
- package/tests/patch.test.ts +0 -200
- package/tests/plugin-all-features.test.ts +0 -844
- package/tests/plugin-complex-data.test.ts +0 -2647
- 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 -24
package/src/patch.ts
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import jsonpatch from 'fast-json-patch'
|
|
2
|
-
import em from './em'
|
|
3
|
-
import { chunk, isEmpty, isFunction } from './helpers'
|
|
4
|
-
import { HistoryModel } from './model'
|
|
5
|
-
import { omitDeep as omit } from './omit-deep'
|
|
6
|
-
|
|
7
|
-
import type { HydratedDocument, MongooseError, Types } from 'mongoose'
|
|
8
|
-
import type { Metadata, PatchContext, PatchEvent, PluginOptions, User } from './types'
|
|
9
|
-
|
|
10
|
-
const isPatchHistoryEnabled = <T>(opts: PluginOptions<T>, context: PatchContext<T>): boolean => {
|
|
11
|
-
return !opts.patchHistoryDisabled && !context.ignorePatchHistory
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const applyOmit = <T>(object: Partial<T>, opts: PluginOptions<T>): Partial<T> => {
|
|
15
|
-
return opts.omit ? omit(object, opts.omit) : object
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const replacer = (_key: string, value: unknown): unknown => (typeof value === 'bigint' ? value.toString() : value)
|
|
19
|
-
|
|
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)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const getObjectOmit = <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> => {
|
|
26
|
-
return applyOmit(isFunction(doc?.toObject) ? doc.toObject() : doc, opts)
|
|
27
|
-
}
|
|
28
|
-
|
|
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>)
|
|
32
|
-
}
|
|
33
|
-
return undefined
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const getUser = async <T>(opts: PluginOptions<T>, doc?: HydratedDocument<T>): Promise<User | undefined> => getOptionalField(opts.getUser, doc)
|
|
37
|
-
|
|
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 => {
|
|
43
|
-
return item.status === 'fulfilled' ? item.value : undefined
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export const getData = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<[User | undefined, string | undefined, Metadata | undefined]> => {
|
|
47
|
-
return Promise.allSettled([getUser(opts, doc), getReason(opts, doc), getMetadata(opts, doc)]).then(([user, reason, metadata]) => {
|
|
48
|
-
return [getValue(user), getValue(reason), getValue(metadata)]
|
|
49
|
-
})
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export const emitEvent = <T>(context: PatchContext<T>, event: string | undefined, data: PatchEvent<T>): void => {
|
|
53
|
-
if (event && !context.ignoreEvent) {
|
|
54
|
-
try {
|
|
55
|
-
em.emit(event, data)
|
|
56
|
-
} catch {
|
|
57
|
-
// Listener errors must not crash patch history recording
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export const bulkPatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>, eventKey: 'eventCreated' | 'eventDeleted', docsKey: 'createdDocs' | 'deletedDocs'): Promise<void> => {
|
|
63
|
-
const history = isPatchHistoryEnabled(opts, context)
|
|
64
|
-
const event = opts[eventKey]
|
|
65
|
-
const docs = context[docsKey]
|
|
66
|
-
const key = eventKey === 'eventCreated' ? 'doc' : 'oldDoc'
|
|
67
|
-
|
|
68
|
-
if (isEmpty(docs) || !docs || (!event && !history)) return
|
|
69
|
-
|
|
70
|
-
const chunks = chunk(docs, 1000)
|
|
71
|
-
for (const batch of chunks) {
|
|
72
|
-
const bulk = []
|
|
73
|
-
|
|
74
|
-
for (const doc of batch) {
|
|
75
|
-
const omitted = getObjectOmit(opts, doc)
|
|
76
|
-
emitEvent(context, event, { [key]: omitted })
|
|
77
|
-
|
|
78
|
-
if (history) {
|
|
79
|
-
const [user, reason, metadata] = await getData(opts, doc)
|
|
80
|
-
bulk.push({
|
|
81
|
-
insertOne: {
|
|
82
|
-
document: {
|
|
83
|
-
op: context.op,
|
|
84
|
-
modelName: context.modelName,
|
|
85
|
-
collectionName: context.collectionName,
|
|
86
|
-
collectionId: doc._id as Types.ObjectId,
|
|
87
|
-
doc: omitted,
|
|
88
|
-
version: 0,
|
|
89
|
-
...(user !== undefined && { user }),
|
|
90
|
-
...(reason !== undefined && { reason }),
|
|
91
|
-
...(metadata !== undefined && { metadata }),
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
})
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (history && !isEmpty(bulk)) {
|
|
99
|
-
const onError = opts.onError ?? console.error
|
|
100
|
-
await HistoryModel.bulkWrite(bulk, { ordered: false }).catch((error: MongooseError) => {
|
|
101
|
-
onError(error)
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export const createPatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>): Promise<void> => {
|
|
108
|
-
await bulkPatch(opts, context, 'eventCreated', 'createdDocs')
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export const updatePatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>, current: HydratedDocument<T>, original: HydratedDocument<T>): Promise<void> => {
|
|
112
|
-
const history = isPatchHistoryEnabled(opts, context)
|
|
113
|
-
|
|
114
|
-
const currentObject = getJsonOmit(opts, current)
|
|
115
|
-
const originalObject = getJsonOmit(opts, original)
|
|
116
|
-
if (isEmpty(originalObject) || isEmpty(currentObject)) return
|
|
117
|
-
|
|
118
|
-
const patch = jsonpatch.compare(originalObject, currentObject, true)
|
|
119
|
-
if (isEmpty(patch)) return
|
|
120
|
-
|
|
121
|
-
emitEvent(context, opts.eventUpdated, { oldDoc: original, doc: current, patch })
|
|
122
|
-
|
|
123
|
-
if (history) {
|
|
124
|
-
let version = 0
|
|
125
|
-
|
|
126
|
-
const lastHistory = await HistoryModel.findOne({ collectionId: original._id as Types.ObjectId })
|
|
127
|
-
.sort('-version')
|
|
128
|
-
.exec()
|
|
129
|
-
|
|
130
|
-
if (lastHistory && lastHistory.version >= 0) {
|
|
131
|
-
version = lastHistory.version + 1
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const [user, reason, metadata] = await getData(opts, current)
|
|
135
|
-
const onError = opts.onError ?? console.error
|
|
136
|
-
await HistoryModel.create({
|
|
137
|
-
op: context.op,
|
|
138
|
-
modelName: context.modelName,
|
|
139
|
-
collectionName: context.collectionName,
|
|
140
|
-
collectionId: original._id as Types.ObjectId,
|
|
141
|
-
patch,
|
|
142
|
-
version,
|
|
143
|
-
...(user !== undefined && { user }),
|
|
144
|
-
...(reason !== undefined && { reason }),
|
|
145
|
-
...(metadata !== undefined && { metadata }),
|
|
146
|
-
}).catch((error: MongooseError) => {
|
|
147
|
-
onError(error)
|
|
148
|
-
})
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export const deletePatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>): Promise<void> => {
|
|
153
|
-
await bulkPatch(opts, context, 'eventDeleted', 'deletedDocs')
|
|
154
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { Operation } from 'fast-json-patch'
|
|
2
|
-
import type { HydratedDocument, Query, Types } from 'mongoose'
|
|
3
|
-
|
|
4
|
-
export interface History {
|
|
5
|
-
op: string
|
|
6
|
-
modelName: string
|
|
7
|
-
collectionName: string
|
|
8
|
-
collectionId: Types.ObjectId
|
|
9
|
-
version: number
|
|
10
|
-
doc?: object
|
|
11
|
-
user?: object
|
|
12
|
-
reason?: string
|
|
13
|
-
metadata?: object
|
|
14
|
-
patch?: Operation[]
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface PatchEvent<T> {
|
|
18
|
-
oldDoc?: HydratedDocument<T>
|
|
19
|
-
doc?: HydratedDocument<T>
|
|
20
|
-
patch?: Operation[]
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface PatchContext<T> {
|
|
24
|
-
op: string
|
|
25
|
-
modelName: string
|
|
26
|
-
collectionName: string
|
|
27
|
-
isNew?: boolean
|
|
28
|
-
createdDocs?: HydratedDocument<T>[]
|
|
29
|
-
deletedDocs?: HydratedDocument<T>[]
|
|
30
|
-
ignoreEvent?: boolean
|
|
31
|
-
ignorePatchHistory?: boolean
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export type HookContext<T> = Query<T, T> & { op: string; _context: PatchContext<T> }
|
|
35
|
-
|
|
36
|
-
export type User = Record<string, unknown>
|
|
37
|
-
|
|
38
|
-
export type Metadata = Record<string, unknown>
|
|
39
|
-
|
|
40
|
-
export interface PluginOptions<T> {
|
|
41
|
-
modelName?: string
|
|
42
|
-
collectionName?: string
|
|
43
|
-
eventUpdated?: string
|
|
44
|
-
eventCreated?: string
|
|
45
|
-
eventDeleted?: string
|
|
46
|
-
getUser?: (doc: HydratedDocument<T>) => Promise<User> | User
|
|
47
|
-
getReason?: (doc: HydratedDocument<T>) => Promise<string> | string
|
|
48
|
-
getMetadata?: (doc: HydratedDocument<T>) => Promise<Metadata> | Metadata
|
|
49
|
-
omit?: string[]
|
|
50
|
-
patchHistoryDisabled?: boolean
|
|
51
|
-
preDelete?: (docs: HydratedDocument<T>[]) => Promise<void>
|
|
52
|
-
onError?: (error: Error) => void
|
|
53
|
-
}
|
package/src/version.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import mongoose from 'mongoose'
|
|
2
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
/* v8 ignore start */
|
|
10
|
-
if (isMongoose6) {
|
|
11
|
-
mongoose.set('strictQuery', false)
|
|
12
|
-
}
|
|
13
|
-
/* v8 ignore end */
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export const USER_CREATED = 'user-created'
|
|
2
|
-
export const USER_UPDATED = 'user-updated'
|
|
3
|
-
export const USER_DELETED = 'user-deleted'
|
|
4
|
-
|
|
5
|
-
export const GLOBAL_CREATED = 'global-created'
|
|
6
|
-
export const GLOBAL_UPDATED = 'global-updated'
|
|
7
|
-
export const GLOBAL_DELETED = 'global-deleted'
|
package/tests/em.test.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
|
|
3
|
-
import { patchEventEmitter } from '../src/index'
|
|
4
|
-
import { emitEvent } from '../src/patch'
|
|
5
|
-
|
|
6
|
-
describe('em', () => {
|
|
7
|
-
afterEach(() => {
|
|
8
|
-
patchEventEmitter.removeAllListeners()
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
it('should subscribe and count', () => {
|
|
12
|
-
let count = 0
|
|
13
|
-
const fn = () => {
|
|
14
|
-
count++
|
|
15
|
-
}
|
|
16
|
-
patchEventEmitter.on('test', fn)
|
|
17
|
-
patchEventEmitter.emit('test')
|
|
18
|
-
expect(count).toBe(1)
|
|
19
|
-
patchEventEmitter.off('test', fn)
|
|
20
|
-
patchEventEmitter.emit('test')
|
|
21
|
-
expect(count).toBe(1)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('emitEvent', () => {
|
|
25
|
-
const fn = vi.fn()
|
|
26
|
-
patchEventEmitter.on('test', fn)
|
|
27
|
-
|
|
28
|
-
const context = {
|
|
29
|
-
op: 'test',
|
|
30
|
-
modelName: 'Test',
|
|
31
|
-
collectionName: 'tests',
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// @ts-expect-error expected
|
|
35
|
-
emitEvent(context, 'test', { doc: { name: 'test' } })
|
|
36
|
-
expect(fn).toHaveBeenCalledOnce()
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('emitEvent ignore', () => {
|
|
40
|
-
const fn = vi.fn()
|
|
41
|
-
patchEventEmitter.on('test', fn)
|
|
42
|
-
|
|
43
|
-
const context = {
|
|
44
|
-
ignoreEvent: true,
|
|
45
|
-
op: 'test',
|
|
46
|
-
modelName: 'Test',
|
|
47
|
-
collectionName: 'tests',
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// @ts-expect-error expected
|
|
51
|
-
emitEvent(context, 'test', { doc: { name: 'test' } })
|
|
52
|
-
expect(fn).toHaveBeenCalledTimes(0)
|
|
53
|
-
})
|
|
54
|
-
|
|
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()
|
|
69
|
-
})
|
|
70
|
-
})
|
package/tests/helpers.test.ts
DELETED
|
@@ -1,373 +0,0 @@
|
|
|
1
|
-
import type { Mock, MockInstance } from 'vitest'
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
|
|
4
|
-
import { cloneDeep, isEmpty, setPatchHistoryTTL } from '../src/helpers'
|
|
5
|
-
import { HistoryModel } from '../src/model'
|
|
6
|
-
import { ms } from '../src/ms'
|
|
7
|
-
|
|
8
|
-
vi.mock('../src/model', () => ({
|
|
9
|
-
HistoryModel: {
|
|
10
|
-
collection: {
|
|
11
|
-
indexes: vi.fn(),
|
|
12
|
-
dropIndex: vi.fn(),
|
|
13
|
-
createIndex: vi.fn(),
|
|
14
|
-
},
|
|
15
|
-
},
|
|
16
|
-
}))
|
|
17
|
-
|
|
18
|
-
const name = 'createdAt_1_TTL'
|
|
19
|
-
|
|
20
|
-
describe('useTTL', () => {
|
|
21
|
-
let dropIndexSpy: MockInstance
|
|
22
|
-
let createIndexSpy: MockInstance
|
|
23
|
-
const indexes = HistoryModel.collection.indexes as Mock
|
|
24
|
-
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
vi.clearAllMocks()
|
|
27
|
-
dropIndexSpy = vi.spyOn(HistoryModel.collection, 'dropIndex')
|
|
28
|
-
createIndexSpy = vi.spyOn(HistoryModel.collection, 'createIndex')
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
afterEach(() => {
|
|
32
|
-
vi.restoreAllMocks()
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('should drop the index if historyTTL is not set and index exists', async () => {
|
|
36
|
-
indexes.mockResolvedValue([{ name }])
|
|
37
|
-
|
|
38
|
-
// @ts-expect-error ttl can't be undefined in this case but we want to test it
|
|
39
|
-
await setPatchHistoryTTL(undefined)
|
|
40
|
-
expect(dropIndexSpy).toHaveBeenCalledWith(name)
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('should drop the index if historyTTL is less than 1 second and index exists', async () => {
|
|
44
|
-
indexes.mockResolvedValue([{ name }])
|
|
45
|
-
|
|
46
|
-
await setPatchHistoryTTL('500ms')
|
|
47
|
-
expect(dropIndexSpy).toHaveBeenCalledWith(name)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('should not recreate the index if it already exists with the correct TTL', async () => {
|
|
51
|
-
const ttl = '1h'
|
|
52
|
-
const expireAfterSeconds = ms(ttl) / 1000
|
|
53
|
-
|
|
54
|
-
indexes.mockResolvedValue([{ name, expireAfterSeconds }])
|
|
55
|
-
|
|
56
|
-
await setPatchHistoryTTL(ttl)
|
|
57
|
-
expect(dropIndexSpy).not.toHaveBeenCalled()
|
|
58
|
-
expect(createIndexSpy).not.toHaveBeenCalled()
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('should drop and recreate the index if TTL is different', async () => {
|
|
62
|
-
const ttlBefore = '1h'
|
|
63
|
-
const ttlAfter = '2h'
|
|
64
|
-
|
|
65
|
-
const expireAfterSecondsBefore = ms(ttlBefore) / 1000
|
|
66
|
-
const expireAfterSecondsAfter = ms(ttlAfter) / 1000
|
|
67
|
-
|
|
68
|
-
indexes.mockResolvedValue([{ name, expireAfterSeconds: expireAfterSecondsBefore }])
|
|
69
|
-
|
|
70
|
-
await setPatchHistoryTTL(ttlAfter)
|
|
71
|
-
expect(dropIndexSpy).toHaveBeenCalledWith(name)
|
|
72
|
-
expect(createIndexSpy).toHaveBeenCalledWith({ createdAt: 1 }, { expireAfterSeconds: expireAfterSecondsAfter, name })
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('should create the index if it does not exist', async () => {
|
|
76
|
-
const ttl = '1h'
|
|
77
|
-
const expireAfterSeconds = ms(ttl) / 1000
|
|
78
|
-
|
|
79
|
-
indexes.mockResolvedValue([])
|
|
80
|
-
|
|
81
|
-
await setPatchHistoryTTL(ttl)
|
|
82
|
-
expect(createIndexSpy).toHaveBeenCalledWith({ createdAt: 1 }, { expireAfterSeconds, name })
|
|
83
|
-
})
|
|
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/mongo/.gitignore
DELETED