ts-patch-mongoose 2.9.6 → 3.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.
@@ -1,12 +1,5 @@
1
- // Using CJS lodash with .js extensions for ESM compatibility
2
- import cloneDeep from 'lodash/cloneDeep.js'
3
- import forEach from 'lodash/forEach.js'
4
- import isArray from 'lodash/isArray.js'
5
- import isEmpty from 'lodash/isEmpty.js'
6
- import isObjectLike from 'lodash/isObjectLike.js'
7
- import keys from 'lodash/keys.js'
8
1
  import { assign } from 'power-assign'
9
- import { isHookIgnored, toObjectOptions } from '../helpers'
2
+ import { cloneDeep, isArray, isEmpty, isHookIgnored, isObjectLike, toObjectOptions } from '../helpers'
10
3
  import { createPatch, updatePatch } from '../patch'
11
4
 
12
5
  import type { HydratedDocument, Model, MongooseQueryMiddleware, Schema, UpdateQuery, UpdateWithAggregationPipeline } from 'mongoose'
@@ -16,14 +9,13 @@ const updateMethods = ['update', 'updateOne', 'replaceOne', 'updateMany', 'findO
16
9
 
17
10
  export const assignUpdate = <T>(document: HydratedDocument<T>, update: UpdateQuery<T>, commands: Record<string, unknown>[]): HydratedDocument<T> => {
18
11
  let updated = assign(document.toObject(toObjectOptions), update)
19
- // Try catch not working for of loop, keep it as is
20
- forEach(commands, (command) => {
12
+ for (const command of commands) {
21
13
  try {
22
14
  updated = assign(updated, command)
23
15
  } catch {
24
16
  // we catch assign keys that are not implemented
25
17
  }
26
- })
18
+ }
27
19
 
28
20
  const doc = document.set(updated).toObject(toObjectOptions) as HydratedDocument<T> & { createdAt?: Date }
29
21
  if (update.createdAt) doc.createdAt = update.createdAt
@@ -36,12 +28,12 @@ export const splitUpdateAndCommands = <T>(updateQuery: UpdateWithAggregationPipe
36
28
 
37
29
  if (!isEmpty(updateQuery) && !isArray(updateQuery) && isObjectLike(updateQuery)) {
38
30
  update = cloneDeep(updateQuery)
39
- const keysWithDollarSign = keys(update).filter((key) => key.startsWith('$'))
31
+ const keysWithDollarSign = Object.keys(update).filter((key) => key.startsWith('$'))
40
32
  if (!isEmpty(keysWithDollarSign)) {
41
- forEach(keysWithDollarSign, (key) => {
33
+ for (const key of keysWithDollarSign) {
42
34
  commands.push({ [key]: update[key] as unknown })
43
35
  delete update[key]
44
- })
36
+ }
45
37
  }
46
38
  }
47
39
 
@@ -98,7 +90,6 @@ export const updateHooksInitialize = <T>(schema: Schema<T>, opts: PluginOptions<
98
90
  }
99
91
 
100
92
  if (!isEmpty(filter) && !current) {
101
- console.log('filter', filter)
102
93
  current = (await model.findOne(filter).sort('desc').lean().exec()) as HydratedDocument<T>
103
94
  }
104
95
 
package/src/index.ts CHANGED
@@ -1,6 +1,4 @@
1
- // Using CJS lodash with .js extension for ESM compatibility
2
- import isEmpty from 'lodash/isEmpty.js'
3
- import { toObjectOptions } from './helpers'
1
+ import { isEmpty, toObjectOptions } from './helpers'
4
2
  import { deleteHooksInitialize } from './hooks/delete-hooks'
5
3
  import { saveHooksInitialize } from './hooks/save-hooks'
6
4
  import { updateHooksInitialize } from './hooks/update-hooks'
@@ -12,34 +10,17 @@ import type { PatchContext, PluginOptions } from './types'
12
10
 
13
11
  const remove = isMongooseLessThan7 ? 'remove' : 'deleteOne'
14
12
 
15
- /**
16
- * @description Event emitter for patch operations.
17
- */
18
13
  export { default as patchEventEmitter } from './em'
19
14
  export { setPatchHistoryTTL } from './helpers'
20
15
  export * from './types'
21
16
 
22
- /**
23
- * @description Mongoose plugin to track and manage patch history for documents.
24
- * This plugin initializes hooks for save, update, and delete operations to create
25
- * and manage patches.
26
- *
27
- * @template T
28
- * @param {Schema<T>} schema - The Mongoose schema to apply the plugin to.
29
- * @param {PluginOptions<T>} opts - Options for configuring the plugin.
30
- * @returns {void}
31
- */
32
- export const patchHistoryPlugin = function plugin<T>(schema: Schema<T>, opts: PluginOptions<T>): void {
33
- // Initialize hooks
17
+ export type { Duration } from './ms'
18
+
19
+ export const patchHistoryPlugin = <T>(schema: Schema<T>, opts: PluginOptions<T>): void => {
34
20
  saveHooksInitialize(schema, opts)
35
21
  updateHooksInitialize(schema, opts)
36
22
  deleteHooksInitialize(schema, opts)
37
23
 
38
- /**
39
- * @description Corner case for insertMany()
40
- * @param {Array<HydratedDocument<T>>} docs - The documents being inserted.
41
- * @returns {Promise<void>}
42
- */
43
24
  schema.post('insertMany', async function (docs) {
44
25
  const context = {
45
26
  op: 'create',
@@ -55,10 +36,6 @@ export const patchHistoryPlugin = function plugin<T>(schema: Schema<T>, opts: Pl
55
36
  // In Mongoose 7, doc.deleteOne() returned a promise that resolved to doc.
56
37
  // In Mongoose 8, doc.deleteOne() returns a query for easier chaining, as well as consistency with doc.updateOne().
57
38
  if (isMongooseLessThan8) {
58
- /**
59
- * @description Pre-delete hook for Mongoose 7 and below
60
- * @returns {Promise<void>}
61
- */
62
39
  // @ts-expect-error - Mongoose 7 and below
63
40
  schema.pre(remove, { document: true, query: false }, async function () {
64
41
  // @ts-expect-error - Mongoose 7 and below
@@ -69,11 +46,6 @@ export const patchHistoryPlugin = function plugin<T>(schema: Schema<T>, opts: Pl
69
46
  }
70
47
  })
71
48
 
72
- /**
73
- * @description Post-delete hook for Mongoose 7 and below
74
- * @param {HydratedDocument<T>} this - The document being deleted.
75
- * @returns {Promise<void>}
76
- */
77
49
  // @ts-expect-error - Mongoose 7 and below
78
50
  schema.post(remove, { document: true, query: false }, async function (this: HydratedDocument<T>) {
79
51
  const original = this.toObject(toObjectOptions) as HydratedDocument<T>
package/src/ms.ts ADDED
@@ -0,0 +1,66 @@
1
+ const s = 1000
2
+ const m = s * 60
3
+ const h = m * 60
4
+ const d = h * 24
5
+ const w = d * 7
6
+ const y = d * 365.25
7
+ const mo = y / 12
8
+
9
+ export const UNITS = {
10
+ milliseconds: 1,
11
+ millisecond: 1,
12
+ msecs: 1,
13
+ msec: 1,
14
+ ms: 1,
15
+ seconds: s,
16
+ second: s,
17
+ secs: s,
18
+ sec: s,
19
+ s,
20
+ minutes: m,
21
+ minute: m,
22
+ mins: m,
23
+ min: m,
24
+ m,
25
+ hours: h,
26
+ hour: h,
27
+ hrs: h,
28
+ hr: h,
29
+ h,
30
+ days: d,
31
+ day: d,
32
+ d,
33
+ weeks: w,
34
+ week: w,
35
+ w,
36
+ months: mo,
37
+ month: mo,
38
+ mo,
39
+ years: y,
40
+ year: y,
41
+ yrs: y,
42
+ yr: y,
43
+ y,
44
+ } as const satisfies Record<string, number>
45
+
46
+ export type Unit = keyof typeof UNITS
47
+
48
+ export type Duration = number | `${number}` | `${number}${Unit}` | `${number} ${Unit}`
49
+
50
+ const unitPattern = Object.keys(UNITS)
51
+ .sort((a, b) => b.length - a.length)
52
+ .join('|')
53
+
54
+ const RE = new RegExp(String.raw`^(-?(?:\d+)?\.?\d+)\s*(${unitPattern})?$`, 'i')
55
+
56
+ export const ms = (val: Duration): number => {
57
+ const str = String(val)
58
+ if (str.length > 100) return Number.NaN
59
+
60
+ const match = RE.exec(str)
61
+ if (!match) return Number.NaN
62
+
63
+ const n = Number.parseFloat(match[1] ?? '')
64
+ const type = (match[2] ?? 'ms').toLowerCase()
65
+ return n * (UNITS[type as Unit] ?? 0)
66
+ }
@@ -0,0 +1,95 @@
1
+ const isPlainObject = (val: unknown): val is Record<string, unknown> => {
2
+ if (Object.prototype.toString.call(val) !== '[object Object]') return false
3
+ const prot = Object.getPrototypeOf(val) as object | null
4
+ return prot === null || prot === Object.prototype
5
+ }
6
+
7
+ const isUnsafeKey = (key: string): boolean => {
8
+ return key === '__proto__' || key === 'constructor' || key === 'prototype'
9
+ }
10
+
11
+ const getValue = (obj: Record<string, unknown>, path: string): unknown => {
12
+ const segs = path.split('.')
13
+ let current: unknown = obj
14
+ for (const seg of segs) {
15
+ if (current == null || typeof current !== 'object') return undefined
16
+ current = (current as Record<string, unknown>)[seg]
17
+ }
18
+ return current
19
+ }
20
+
21
+ const hasValue = (val: unknown): boolean => {
22
+ if (val == null) return false
23
+ if (typeof val === 'boolean' || typeof val === 'number' || typeof val === 'function') return true
24
+ if (typeof val === 'string') return val.length !== 0
25
+ if (Array.isArray(val)) return val.length !== 0
26
+ if (val instanceof RegExp) return val.source !== '(?:)' && val.source !== ''
27
+ if (val instanceof Error) return val.message !== ''
28
+ if (val instanceof Map || val instanceof Set) return val.size !== 0
29
+ if (typeof val === 'object') {
30
+ for (const key of Object.keys(val)) {
31
+ if (hasValue((val as Record<string, unknown>)[key])) return true
32
+ }
33
+ return false
34
+ }
35
+ return true
36
+ }
37
+
38
+ const has = (obj: unknown, path: string): boolean => {
39
+ if (obj != null && typeof obj === 'object' && typeof path === 'string') {
40
+ return hasValue(getValue(obj as Record<string, unknown>, path))
41
+ }
42
+ return false
43
+ }
44
+
45
+ const unset = (obj: Record<string, unknown>, prop: string): boolean => {
46
+ if (typeof obj !== 'object' || obj === null) return false
47
+
48
+ if (Object.hasOwn(obj, prop)) {
49
+ delete obj[prop]
50
+ return true
51
+ }
52
+
53
+ if (has(obj, prop)) {
54
+ const segs = prop.split('.')
55
+ let last = segs.pop()
56
+ while (segs.length && segs.at(-1)?.slice(-1) === '\\') {
57
+ last = `${(segs.pop() as string).slice(0, -1)}.${last}`
58
+ }
59
+ let target: unknown = obj
60
+ while (segs.length) {
61
+ const seg = segs.shift() as string
62
+ if (isUnsafeKey(seg)) return false
63
+ target = (target as Record<string, unknown>)[seg]
64
+ }
65
+ return delete (target as Record<string, unknown>)[last ?? '']
66
+ }
67
+
68
+ return true
69
+ }
70
+
71
+ export const omitDeep = <T>(value: T, keys: string | string[]): T => {
72
+ if (value === undefined) return {} as T
73
+
74
+ if (Array.isArray(value)) {
75
+ for (let i = 0; i < value.length; i++) {
76
+ value[i] = omitDeep(value[i], keys)
77
+ }
78
+ return value
79
+ }
80
+
81
+ if (!isPlainObject(value)) return value
82
+
83
+ const omitKeys = typeof keys === 'string' ? [keys] : keys
84
+ if (!Array.isArray(omitKeys)) return value
85
+
86
+ for (const key of omitKeys) {
87
+ unset(value, key)
88
+ }
89
+
90
+ for (const key of Object.keys(value)) {
91
+ ;(value as Record<string, unknown>)[key] = omitDeep((value as Record<string, unknown>)[key], omitKeys)
92
+ }
93
+
94
+ return value
95
+ }
package/src/patch.ts CHANGED
@@ -1,20 +1,18 @@
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> {
14
+ export const getJsonOmit = <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> => {
15
+ // NOSONAR — structuredClone cannot handle mongoose documents (they contain non-cloneable methods)
18
16
  const object = JSON.parse(JSON.stringify(doc)) as Partial<T>
19
17
 
20
18
  if (opts.omit) {
@@ -24,7 +22,7 @@ export function getJsonOmit<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>)
24
22
  return object
25
23
  }
26
24
 
27
- export function getObjectOmit<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> {
25
+ export const getObjectOmit = <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> => {
28
26
  if (opts.omit) {
29
27
  return omit(isFunction(doc?.toObject) ? doc.toObject() : doc, opts.omit)
30
28
  }
@@ -32,56 +30,56 @@ export function getObjectOmit<T>(opts: PluginOptions<T>, doc: HydratedDocument<T
32
30
  return doc
33
31
  }
34
32
 
35
- export async function getUser<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<User | undefined> {
33
+ export const getUser = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<User | undefined> => {
36
34
  if (isFunction(opts.getUser)) {
37
35
  return await opts.getUser(doc)
38
36
  }
39
37
  return undefined
40
38
  }
41
39
 
42
- export async function getReason<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<string | undefined> {
40
+ export const getReason = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<string | undefined> => {
43
41
  if (isFunction(opts.getReason)) {
44
42
  return await opts.getReason(doc)
45
43
  }
46
44
  return undefined
47
45
  }
48
46
 
49
- export async function getMetadata<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<Metadata | undefined> {
47
+ export const getMetadata = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<Metadata | undefined> => {
50
48
  if (isFunction(opts.getMetadata)) {
51
49
  return await opts.getMetadata(doc)
52
50
  }
53
51
  return undefined
54
52
  }
55
53
 
56
- export function getValue<T>(item: PromiseSettledResult<T>): T | undefined {
54
+ export const getValue = <T>(item: PromiseSettledResult<T>): T | undefined => {
57
55
  return item.status === 'fulfilled' ? item.value : undefined
58
56
  }
59
57
 
60
- export async function getData<T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<[User | undefined, string | undefined, Metadata | undefined]> {
58
+ export const getData = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<[User | undefined, string | undefined, Metadata | undefined]> => {
61
59
  return Promise.allSettled([getUser(opts, doc), getReason(opts, doc), getMetadata(opts, doc)]).then(([user, reason, metadata]) => {
62
60
  return [getValue(user), getValue(reason), getValue(metadata)]
63
61
  })
64
62
  }
65
63
 
66
- export function emitEvent<T>(context: PatchContext<T>, event: string | undefined, data: PatchEvent<T>): void {
64
+ export const emitEvent = <T>(context: PatchContext<T>, event: string | undefined, data: PatchEvent<T>): void => {
67
65
  if (event && !context.ignoreEvent) {
68
66
  em.emit(event, data)
69
67
  }
70
68
  }
71
69
 
72
- export async function bulkPatch<T>(opts: PluginOptions<T>, context: PatchContext<T>, eventKey: 'eventCreated' | 'eventDeleted', docsKey: 'createdDocs' | 'deletedDocs'): Promise<void> {
70
+ export const bulkPatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>, eventKey: 'eventCreated' | 'eventDeleted', docsKey: 'createdDocs' | 'deletedDocs'): Promise<void> => {
73
71
  const history = isPatchHistoryEnabled(opts, context)
74
72
  const event = opts[eventKey]
75
73
  const docs = context[docsKey]
76
74
  const key = eventKey === 'eventCreated' ? 'doc' : 'oldDoc'
77
75
 
78
- if (isEmpty(docs) || (!event && !history)) return
76
+ if (isEmpty(docs) || !docs || (!event && !history)) return
79
77
 
80
78
  const chunks = chunk(docs, 1000)
81
- for (const chunk of chunks) {
79
+ for (const batch of chunks) {
82
80
  const bulk = []
83
81
 
84
- for (const doc of chunk) {
82
+ for (const doc of batch) {
85
83
  emitEvent(context, event, { [key]: doc })
86
84
 
87
85
  if (history) {
@@ -112,11 +110,11 @@ export async function bulkPatch<T>(opts: PluginOptions<T>, context: PatchContext
112
110
  }
113
111
  }
114
112
 
115
- export async function createPatch<T>(opts: PluginOptions<T>, context: PatchContext<T>): Promise<void> {
113
+ export const createPatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>): Promise<void> => {
116
114
  await bulkPatch(opts, context, 'eventCreated', 'createdDocs')
117
115
  }
118
116
 
119
- export async function updatePatch<T>(opts: PluginOptions<T>, context: PatchContext<T>, current: HydratedDocument<T>, original: HydratedDocument<T>): Promise<void> {
117
+ export const updatePatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>, current: HydratedDocument<T>, original: HydratedDocument<T>): Promise<void> => {
120
118
  const history = isPatchHistoryEnabled(opts, context)
121
119
 
122
120
  const currentObject = getJsonOmit(opts, current)
@@ -154,6 +152,6 @@ export async function updatePatch<T>(opts: PluginOptions<T>, context: PatchConte
154
152
  }
155
153
  }
156
154
 
157
- export async function deletePatch<T>(opts: PluginOptions<T>, context: PatchContext<T>): Promise<void> {
155
+ export const deletePatch = async <T>(opts: PluginOptions<T>, context: PatchContext<T>): Promise<void> => {
158
156
  await bulkPatch(opts, context, 'eventDeleted', 'deletedDocs')
159
157
  }
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
@@ -27,6 +27,7 @@ describe('em', () => {
27
27
  collectionName: 'tests',
28
28
  }
29
29
 
30
+ // @ts-expect-error expected
30
31
  emitEvent(context, 'test', { doc: { name: 'test' } })
31
32
  expect(fn).toHaveBeenCalledTimes(1)
32
33
 
@@ -44,6 +45,7 @@ describe('em', () => {
44
45
  collectionName: 'tests',
45
46
  }
46
47
 
48
+ // @ts-expect-error expected
47
49
  emitEvent(context, 'test', { doc: { name: 'test' } })
48
50
  expect(fn).toHaveBeenCalledTimes(0)
49
51
 
@@ -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,230 @@ 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
+ })