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/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:
|
|
16
|
-
const name = 'createdAt_1_TTL'
|
|
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 =
|
|
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
|
-
|
|
171
|
+
const handler = onError ?? console.error
|
|
172
|
+
handler(err as Error)
|
|
51
173
|
}
|
|
52
174
|
}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
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 ??
|
|
23
|
-
collectionName: opts.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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
63
|
-
collectionName: opts.collectionName ??
|
|
64
|
-
isNew: Boolean(options.upsert) &&
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
current
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
+
}
|
package/src/omit-deep.ts
ADDED
|
@@ -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
|
+
}
|