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/helpers.ts CHANGED
@@ -1,8 +1,134 @@
1
- import ms from 'ms'
2
1
  import { HistoryModel } from './model'
2
+ import { type Duration, ms } from './ms'
3
3
 
4
4
  import type { QueryOptions, ToObjectOptions } from 'mongoose'
5
5
 
6
+ export const isArray = Array.isArray
7
+
8
+ export const isEmpty = (value: unknown): boolean => {
9
+ if (value == null) return true
10
+ if (Array.isArray(value) || typeof value === 'string') return value.length === 0
11
+ if (value instanceof Map || value instanceof Set) return value.size === 0
12
+ if (typeof value === 'object') {
13
+ for (const key in value) {
14
+ if (Object.hasOwn(value, key)) return false
15
+ }
16
+ return true
17
+ }
18
+ return true
19
+ }
20
+
21
+ export const isFunction = (value: unknown): value is (...args: unknown[]) => unknown => {
22
+ return typeof value === 'function'
23
+ }
24
+
25
+ export const isObjectLike = (value: unknown): value is Record<string, unknown> => {
26
+ return typeof value === 'object' && value !== null
27
+ }
28
+
29
+ const cloneArrayBuffer = (arrayBuffer: ArrayBuffer): ArrayBuffer => {
30
+ const result = new ArrayBuffer(arrayBuffer.byteLength)
31
+ new Uint8Array(result).set(new Uint8Array(arrayBuffer))
32
+ return result
33
+ }
34
+
35
+ const cloneImmutable = <T>(value: T): T | undefined => {
36
+ const tag = Object.prototype.toString.call(value)
37
+
38
+ switch (tag) {
39
+ case '[object Date]':
40
+ return new Date(+(value as unknown as Date)) as T
41
+ case '[object RegExp]': {
42
+ const re = value as unknown as RegExp
43
+ const cloned = new RegExp(re.source, re.flags)
44
+ cloned.lastIndex = re.lastIndex
45
+ return cloned as T
46
+ }
47
+ case '[object Error]': {
48
+ const err = value as unknown as Error
49
+ const cloned = new (err.constructor as ErrorConstructor)(err.message)
50
+ if (err.stack) cloned.stack = err.stack
51
+ return cloned as T
52
+ }
53
+ case '[object ArrayBuffer]':
54
+ return cloneArrayBuffer(value as unknown as ArrayBuffer) as T
55
+ case '[object DataView]': {
56
+ const dv = value as unknown as DataView
57
+ const buffer = cloneArrayBuffer(dv.buffer as ArrayBuffer)
58
+ return new DataView(buffer, dv.byteOffset, dv.byteLength) as T
59
+ }
60
+ }
61
+
62
+ if (ArrayBuffer.isView(value)) {
63
+ const ta = value as unknown as { buffer: ArrayBuffer; byteOffset: number; length: number }
64
+ const buffer = cloneArrayBuffer(ta.buffer)
65
+ return new (value.constructor as new (buffer: ArrayBuffer, byteOffset: number, length: number) => T)(buffer, ta.byteOffset, ta.length)
66
+ }
67
+
68
+ return undefined
69
+ }
70
+
71
+ const cloneCollection = <T extends object>(value: T, seen: WeakMap<object, unknown>): T => {
72
+ if (value instanceof Map) {
73
+ const map = new Map()
74
+ seen.set(value, map)
75
+ for (const [k, v] of value) map.set(k, cloneDeep(v, seen))
76
+ return map as T
77
+ }
78
+
79
+ if (value instanceof Set) {
80
+ const set = new Set()
81
+ seen.set(value, set)
82
+ for (const v of value) set.add(cloneDeep(v, seen))
83
+ return set as T
84
+ }
85
+
86
+ if (Array.isArray(value)) {
87
+ const arr = new Array(value.length) as unknown[]
88
+ seen.set(value, arr)
89
+ for (let i = 0; i < value.length; i++) {
90
+ arr[i] = cloneDeep(value[i], seen)
91
+ }
92
+ return arr as T
93
+ }
94
+
95
+ const result = typeof value.constructor === 'function' ? (Object.create(Object.getPrototypeOf(value) as object) as T) : ({} as T)
96
+ seen.set(value, result)
97
+ for (const key of Object.keys(value)) {
98
+ ;(result as Record<string, unknown>)[key] = cloneDeep((value as Record<string, unknown>)[key], seen)
99
+ }
100
+ return result
101
+ }
102
+
103
+ export const cloneDeep = <T>(value: T, seen = new WeakMap<object, unknown>()): T => {
104
+ if (value === null || typeof value !== 'object') return value
105
+ if (seen.has(value)) return seen.get(value) as T
106
+
107
+ const immutable = cloneImmutable(value)
108
+ if (immutable !== undefined) return immutable
109
+
110
+ const record = value as Record<string, unknown>
111
+
112
+ if (typeof record._bsontype === 'string' && typeof record.toHexString === 'function') {
113
+ return new (value.constructor as new (hex: string) => T)((record.toHexString as () => string)())
114
+ }
115
+
116
+ if (typeof record.toJSON === 'function') {
117
+ // NOSONAR — structuredClone cannot handle objects with non-cloneable methods (e.g. mongoose documents)
118
+ return JSON.parse(JSON.stringify(value)) as T
119
+ }
120
+
121
+ return cloneCollection(value, seen)
122
+ }
123
+
124
+ export const chunk = <T>(array: T[], size: number): T[][] => {
125
+ const result: T[][] = []
126
+ for (let i = 0; i < array.length; i += size) {
127
+ result.push(array.slice(i, i + size))
128
+ }
129
+ return result
130
+ }
131
+
6
132
  export const isHookIgnored = <T>(options: QueryOptions<T>): boolean => {
7
133
  return options.ignoreHook === true || (options.ignoreEvent === true && options.ignorePatchHistory === true)
8
134
  }
@@ -12,21 +138,19 @@ export const toObjectOptions: ToObjectOptions = {
12
138
  virtuals: false,
13
139
  }
14
140
 
15
- export const setPatchHistoryTTL = async (ttl: number | ms.StringValue): Promise<void> => {
16
- const name = 'createdAt_1_TTL' // To avoid collision with user defined index / manually created index
141
+ export const setPatchHistoryTTL = async (ttl: Duration, onError?: (error: Error) => void): Promise<void> => {
142
+ const name = 'createdAt_1_TTL'
17
143
  try {
18
144
  const indexes = await HistoryModel.collection.indexes()
19
145
  const existingIndex = indexes?.find((index) => index.name === name)
20
146
 
21
- // Drop the index if historyTTL is not set and index exists
22
147
  if (!ttl && existingIndex) {
23
148
  await HistoryModel.collection.dropIndex(name)
24
149
  return
25
150
  }
26
151
 
27
- const milliseconds = typeof ttl === 'string' ? ms(ttl) : ttl
152
+ const milliseconds = ms(ttl)
28
153
 
29
- // Drop the index if historyTTL is less than 1 second and index exists
30
154
  if (milliseconds < 1000 && existingIndex) {
31
155
  await HistoryModel.collection.dropIndex(name)
32
156
  return
@@ -35,18 +159,16 @@ export const setPatchHistoryTTL = async (ttl: number | ms.StringValue): Promise<
35
159
  const expireAfterSeconds = milliseconds / 1000
36
160
 
37
161
  if (existingIndex && existingIndex.expireAfterSeconds === expireAfterSeconds) {
38
- // Index already exists with the correct TTL, no need to recreate
39
162
  return
40
163
  }
41
164
 
42
165
  if (existingIndex) {
43
- // Drop the existing index if it exists and TTL is different
44
166
  await HistoryModel.collection.dropIndex(name)
45
167
  }
46
168
 
47
- // Create a new index with the correct TTL if it doesn't exist or if the TTL is different
48
169
  await HistoryModel.collection.createIndex({ createdAt: 1 }, { expireAfterSeconds, name })
49
170
  } catch (err) {
50
- console.error("Couldn't create or update index for history collection", err)
171
+ const handler = onError ?? console.error
172
+ handler(err as Error)
51
173
  }
52
174
  }
@@ -1,7 +1,4 @@
1
- // Using CJS lodash with .js extensions for ESM compatibility
2
- import isArray from 'lodash/isArray.js'
3
- import isEmpty from 'lodash/isEmpty.js'
4
- import { isHookIgnored } from '../helpers'
1
+ import { cloneDeep, isArray, isEmpty, isHookIgnored } from '../helpers'
5
2
  import { deletePatch } from '../patch'
6
3
 
7
4
  import type { HydratedDocument, Model, MongooseQueryMiddleware, Schema } from 'mongoose'
@@ -19,8 +16,8 @@ export const deleteHooksInitialize = <T>(schema: Schema<T>, opts: PluginOptions<
19
16
 
20
17
  this._context = {
21
18
  op: this.op,
22
- modelName: opts.modelName ?? this.model.modelName,
23
- collectionName: opts.collectionName ?? this.model.collection.collectionName,
19
+ modelName: opts.modelName ?? model.modelName,
20
+ collectionName: opts.collectionName ?? model.collection.collectionName,
24
21
  ignoreEvent: options.ignoreEvent as boolean,
25
22
  ignorePatchHistory: options.ignorePatchHistory as boolean,
26
23
  }
@@ -38,13 +35,14 @@ export const deleteHooksInitialize = <T>(schema: Schema<T>, opts: PluginOptions<
38
35
  }
39
36
 
40
37
  if (opts.preDelete && isArray(this._context.deletedDocs) && !isEmpty(this._context.deletedDocs)) {
41
- await opts.preDelete(this._context.deletedDocs)
38
+ await opts.preDelete(cloneDeep(this._context.deletedDocs))
42
39
  }
43
40
  })
44
41
 
45
42
  schema.post(deleteMethods as MongooseQueryMiddleware[], { document: false, query: true }, async function (this: HookContext<T>) {
46
43
  const options = this.getOptions()
47
44
  if (isHookIgnored(options)) return
45
+ if (!this._context) return
48
46
 
49
47
  await deletePatch(opts, this._context)
50
48
  })
@@ -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'
@@ -14,18 +7,46 @@ import type { HookContext, PluginOptions } from '../types'
14
7
 
15
8
  const updateMethods = ['update', 'updateOne', 'replaceOne', 'updateMany', 'findOneAndUpdate', 'findOneAndReplace', 'findByIdAndUpdate']
16
9
 
10
+ const trackChangedFields = (fields: Record<string, unknown> | undefined, updated: Record<string, unknown>, changed: Map<string, unknown>): void => {
11
+ if (!fields) return
12
+ for (const key of Object.keys(fields)) {
13
+ const root = key.split('.')[0] as string
14
+ changed.set(root, updated[root])
15
+ }
16
+ }
17
+
18
+ const applyPullAll = (updated: Record<string, unknown>, fields: Record<string, unknown[]>, changed: Map<string, unknown>): void => {
19
+ for (const [field, values] of Object.entries(fields)) {
20
+ const arr = updated[field]
21
+ if (Array.isArray(arr)) {
22
+ const filtered = arr.filter((item: unknown) => !values.some((v) => JSON.stringify(v) === JSON.stringify(item)))
23
+ updated[field] = filtered
24
+ changed.set(field, filtered)
25
+ }
26
+ }
27
+ }
28
+
17
29
  export const assignUpdate = <T>(document: HydratedDocument<T>, update: UpdateQuery<T>, commands: Record<string, unknown>[]): HydratedDocument<T> => {
18
- let updated = assign(document.toObject(toObjectOptions), update)
19
- // Try catch not working for of loop, keep it as is
20
- forEach(commands, (command) => {
30
+ let updated = assign(document.toObject(toObjectOptions), update) as Record<string, unknown>
31
+ const changedByCommand = new Map<string, unknown>()
32
+
33
+ for (const command of commands) {
34
+ const op = Object.keys(command)[0] as string
35
+ const fields = command[op] as Record<string, unknown> | undefined
21
36
  try {
22
37
  updated = assign(updated, command)
38
+ trackChangedFields(fields, updated, changedByCommand)
23
39
  } catch {
24
- // we catch assign keys that are not implemented
40
+ if (op === '$pullAll' && fields) {
41
+ applyPullAll(updated, fields as Record<string, unknown[]>, changedByCommand)
42
+ }
25
43
  }
26
- })
44
+ }
27
45
 
28
46
  const doc = document.set(updated).toObject(toObjectOptions) as HydratedDocument<T> & { createdAt?: Date }
47
+ for (const [field, value] of changedByCommand) {
48
+ ;(doc as unknown as Record<string, unknown>)[field] = value
49
+ }
29
50
  if (update.createdAt) doc.createdAt = update.createdAt
30
51
  return doc
31
52
  }
@@ -36,12 +57,12 @@ export const splitUpdateAndCommands = <T>(updateQuery: UpdateWithAggregationPipe
36
57
 
37
58
  if (!isEmpty(updateQuery) && !isArray(updateQuery) && isObjectLike(updateQuery)) {
38
59
  update = cloneDeep(updateQuery)
39
- const keysWithDollarSign = keys(update).filter((key) => key.startsWith('$'))
60
+ const keysWithDollarSign = Object.keys(update).filter((key) => key.startsWith('$'))
40
61
  if (!isEmpty(keysWithDollarSign)) {
41
- forEach(keysWithDollarSign, (key) => {
62
+ for (const key of keysWithDollarSign) {
42
63
  commands.push({ [key]: update[key] as unknown })
43
64
  delete update[key]
44
- })
65
+ }
45
66
  }
46
67
  }
47
68
 
@@ -49,19 +70,18 @@ export const splitUpdateAndCommands = <T>(updateQuery: UpdateWithAggregationPipe
49
70
  }
50
71
 
51
72
  export const updateHooksInitialize = <T>(schema: Schema<T>, opts: PluginOptions<T>): void => {
52
- schema.pre(updateMethods as MongooseQueryMiddleware[], async function (this: HookContext<T>) {
73
+ schema.pre(updateMethods as MongooseQueryMiddleware[], { document: false, query: true }, async function (this: HookContext<T>) {
53
74
  const options = this.getOptions()
54
75
  if (isHookIgnored(options)) return
55
76
 
56
77
  const model = this.model as Model<T>
57
78
  const filter = this.getFilter()
58
- const count = await this.model.countDocuments(filter).exec()
59
79
 
60
80
  this._context = {
61
81
  op: this.op,
62
- modelName: opts.modelName ?? this.model.modelName,
63
- collectionName: opts.collectionName ?? this.model.collection.collectionName,
64
- isNew: Boolean(options.upsert) && count === 0,
82
+ modelName: opts.modelName ?? model.modelName,
83
+ collectionName: opts.collectionName ?? model.collection.collectionName,
84
+ isNew: Boolean(options.upsert) && (await model.countDocuments(filter).exec()) === 0,
65
85
  ignoreEvent: options.ignoreEvent as boolean,
66
86
  ignorePatchHistory: options.ignorePatchHistory as boolean,
67
87
  }
@@ -76,9 +96,10 @@ export const updateHooksInitialize = <T>(schema: Schema<T>, opts: PluginOptions<
76
96
  })
77
97
  })
78
98
 
79
- schema.post(updateMethods as MongooseQueryMiddleware[], async function (this: HookContext<T>) {
99
+ schema.post(updateMethods as MongooseQueryMiddleware[], { document: false, query: true }, async function (this: HookContext<T>) {
80
100
  const options = this.getOptions()
81
101
  if (isHookIgnored(options)) return
102
+ if (!this._context) return
82
103
 
83
104
  if (!this._context.isNew) return
84
105
 
@@ -86,20 +107,13 @@ export const updateHooksInitialize = <T>(schema: Schema<T>, opts: PluginOptions<
86
107
  const updateQuery = this.getUpdate()
87
108
  const { update, commands } = splitUpdateAndCommands(updateQuery)
88
109
 
89
- let current: HydratedDocument<T> | null = null
90
110
  const filter = this.getFilter()
91
- const combined = assignUpdate(model.hydrate({}), update, commands)
92
- if (!isEmpty(update) && !current) {
93
- current = (await model.findOne(update).sort('desc').lean().exec()) as HydratedDocument<T>
94
- }
95
-
96
- if (!isEmpty(combined) && !current) {
97
- current = (await model.findOne(combined).sort('desc').lean().exec()) as HydratedDocument<T>
98
- }
111
+ const candidates = [update, assignUpdate(model.hydrate({}), update, commands), filter]
99
112
 
100
- if (!isEmpty(filter) && !current) {
101
- console.log('filter', filter)
102
- current = (await model.findOne(filter).sort('desc').lean().exec()) as HydratedDocument<T>
113
+ let current: HydratedDocument<T> | null = null
114
+ for (const query of candidates) {
115
+ if (current || isEmpty(query)) continue
116
+ current = (await model.findOne(query).sort({ _id: -1 }).lean().exec()) as HydratedDocument<T>
103
117
  }
104
118
 
105
119
  if (current) {
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,56 @@
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 classifyKeys = (omitKeys: string[]): { topLevel: Set<string>; nested: Map<string, string[]> } => {
12
+ const topLevel = new Set<string>()
13
+ const nested = new Map<string, string[]>()
14
+
15
+ for (const key of omitKeys) {
16
+ const dotIdx = key.indexOf('.')
17
+ if (dotIdx === -1) {
18
+ topLevel.add(key)
19
+ } else {
20
+ const head = key.slice(0, dotIdx)
21
+ const tail = key.slice(dotIdx + 1)
22
+ if (!isUnsafeKey(head)) {
23
+ const existing = nested.get(head) ?? []
24
+ existing.push(tail)
25
+ nested.set(head, existing)
26
+ }
27
+ }
28
+ }
29
+
30
+ return { topLevel, nested }
31
+ }
32
+
33
+ export const omitDeep = <T>(value: T, keys: string | string[]): T => {
34
+ if (value === undefined) return {} as T
35
+
36
+ if (Array.isArray(value)) {
37
+ return value.map((item) => omitDeep(item, keys)) as T
38
+ }
39
+
40
+ if (!isPlainObject(value)) return value
41
+
42
+ const omitKeys = typeof keys === 'string' ? [keys] : keys
43
+ if (!Array.isArray(omitKeys)) return value
44
+
45
+ const { topLevel, nested } = classifyKeys(omitKeys)
46
+ const result = {} as Record<string, unknown>
47
+
48
+ for (const key of Object.keys(value)) {
49
+ if (topLevel.has(key)) continue
50
+
51
+ const nestedKeys = nested.get(key)
52
+ result[key] = omitDeep((value as Record<string, unknown>)[key], nestedKeys ?? omitKeys)
53
+ }
54
+
55
+ return result as T
56
+ }