ts-patch-mongoose 3.0.0 → 3.1.2

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.
Files changed (41) hide show
  1. package/README.md +31 -25
  2. package/dist/index.cjs +112 -115
  3. package/dist/index.d.cts +4 -1
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +4 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +112 -115
  8. package/package.json +14 -15
  9. package/src/helpers.ts +16 -3
  10. package/src/hooks/delete-hooks.ts +5 -4
  11. package/src/hooks/update-hooks.ts +47 -19
  12. package/src/index.ts +8 -5
  13. package/src/ms.ts +4 -3
  14. package/src/omit-deep.ts +24 -63
  15. package/src/patch.ts +30 -33
  16. package/src/types.ts +3 -0
  17. package/biome.json +0 -50
  18. package/tests/constants/events.ts +0 -7
  19. package/tests/em.test.ts +0 -54
  20. package/tests/helpers.test.ts +0 -311
  21. package/tests/mongo/.gitignore +0 -3
  22. package/tests/mongo/server.ts +0 -31
  23. package/tests/ms.test.ts +0 -113
  24. package/tests/omit-deep.test.ts +0 -220
  25. package/tests/patch.test.ts +0 -199
  26. package/tests/plugin-all-features.test.ts +0 -741
  27. package/tests/plugin-complex-data.test.ts +0 -1332
  28. package/tests/plugin-event-created.test.ts +0 -371
  29. package/tests/plugin-event-deleted.test.ts +0 -400
  30. package/tests/plugin-event-updated.test.ts +0 -503
  31. package/tests/plugin-global.test.ts +0 -545
  32. package/tests/plugin-omit-all.test.ts +0 -349
  33. package/tests/plugin-patch-history-disabled.test.ts +0 -162
  34. package/tests/plugin-pre-delete.test.ts +0 -160
  35. package/tests/plugin-pre-save.test.ts +0 -54
  36. package/tests/plugin.test.ts +0 -576
  37. package/tests/schemas/Description.ts +0 -15
  38. package/tests/schemas/Product.ts +0 -38
  39. package/tests/schemas/User.ts +0 -22
  40. package/tsconfig.json +0 -32
  41. package/vite.config.mts +0 -23
package/src/index.ts CHANGED
@@ -36,9 +36,13 @@ export const patchHistoryPlugin = <T>(schema: Schema<T>, opts: PluginOptions<T>)
36
36
  // In Mongoose 7, doc.deleteOne() returned a promise that resolved to doc.
37
37
  // In Mongoose 8, doc.deleteOne() returns a query for easier chaining, as well as consistency with doc.updateOne().
38
38
  if (isMongooseLessThan8) {
39
- // @ts-expect-error - Mongoose 7 and below
40
- schema.pre(remove, { document: true, query: false }, async function () {
41
- // @ts-expect-error - Mongoose 7 and below
39
+ type LegacySchema = {
40
+ pre(name: string, options: { document: boolean; query: boolean }, fn: (this: HydratedDocument<T>) => Promise<void>): void
41
+ post(name: string, options: { document: boolean; query: boolean }, fn: (this: HydratedDocument<T>) => Promise<void>): void
42
+ }
43
+ const legacySchema = schema as unknown as LegacySchema
44
+
45
+ legacySchema.pre(remove, { document: true, query: false }, async function (this: HydratedDocument<T>) {
42
46
  const original = this.toObject(toObjectOptions) as HydratedDocument<T>
43
47
 
44
48
  if (opts.preDelete && !isEmpty(original)) {
@@ -46,8 +50,7 @@ export const patchHistoryPlugin = <T>(schema: Schema<T>, opts: PluginOptions<T>)
46
50
  }
47
51
  })
48
52
 
49
- // @ts-expect-error - Mongoose 7 and below
50
- schema.post(remove, { document: true, query: false }, async function (this: HydratedDocument<T>) {
53
+ legacySchema.post(remove, { document: true, query: false }, async function (this: HydratedDocument<T>) {
51
54
  const original = this.toObject(toObjectOptions) as HydratedDocument<T>
52
55
  const model = this.constructor as Model<T>
53
56
 
package/src/ms.ts CHANGED
@@ -60,7 +60,8 @@ export const ms = (val: Duration): number => {
60
60
  const match = RE.exec(str)
61
61
  if (!match) return Number.NaN
62
62
 
63
- const n = Number.parseFloat(match[1] ?? '')
64
- const type = (match[2] ?? 'ms').toLowerCase()
65
- return n * (UNITS[type as Unit] ?? 0)
63
+ const [, numStr, unitStr] = match
64
+ const n = Number.parseFloat(String(numStr))
65
+ const type = (unitStr ?? 'ms').toLowerCase() as Unit
66
+ return n * UNITS[type]
66
67
  }
package/src/omit-deep.ts CHANGED
@@ -8,74 +8,33 @@ const isUnsafeKey = (key: string): boolean => {
8
8
  return key === '__proto__' || key === 'constructor' || key === 'prototype'
9
9
  }
10
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
- }
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[]>()
44
14
 
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]
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
+ }
64
27
  }
65
- return delete (target as Record<string, unknown>)[last ?? '']
66
28
  }
67
29
 
68
- return true
30
+ return { topLevel, nested }
69
31
  }
70
32
 
71
33
  export const omitDeep = <T>(value: T, keys: string | string[]): T => {
72
34
  if (value === undefined) return {} as T
73
35
 
74
36
  if (Array.isArray(value)) {
75
- for (let i = 0; i < value.length; i++) {
76
- value[i] = omitDeep(value[i], keys)
77
- }
78
- return value
37
+ return value.map((item) => omitDeep(item, keys)) as T
79
38
  }
80
39
 
81
40
  if (!isPlainObject(value)) return value
@@ -83,13 +42,15 @@ export const omitDeep = <T>(value: T, keys: string | string[]): T => {
83
42
  const omitKeys = typeof keys === 'string' ? [keys] : keys
84
43
  if (!Array.isArray(omitKeys)) return value
85
44
 
86
- for (const key of omitKeys) {
87
- unset(value, key)
88
- }
45
+ const { topLevel, nested } = classifyKeys(omitKeys)
46
+ const result = {} as Record<string, unknown>
89
47
 
90
48
  for (const key of Object.keys(value)) {
91
- ;(value as Record<string, unknown>)[key] = omitDeep((value as Record<string, unknown>)[key], omitKeys)
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)
92
53
  }
93
54
 
94
- return value
55
+ return result as T
95
56
  }
package/src/patch.ts CHANGED
@@ -11,51 +11,39 @@ const isPatchHistoryEnabled = <T>(opts: PluginOptions<T>, context: PatchContext<
11
11
  return !opts.patchHistoryDisabled && !context.ignorePatchHistory
12
12
  }
13
13
 
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)
16
- const object = JSON.parse(JSON.stringify(doc)) as Partial<T>
14
+ const applyOmit = <T>(object: Partial<T>, opts: PluginOptions<T>): Partial<T> => {
15
+ return opts.omit ? omit(object, opts.omit) : object
16
+ }
17
17
 
18
- if (opts.omit) {
19
- return omit(object, opts.omit)
20
- }
18
+ const replacer = (_key: string, value: unknown): unknown => (typeof value === 'bigint' ? value.toString() : value)
21
19
 
22
- return object
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
23
  }
24
24
 
25
25
  export const getObjectOmit = <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Partial<T> => {
26
- if (opts.omit) {
27
- return omit(isFunction(doc?.toObject) ? doc.toObject() : doc, opts.omit)
28
- }
29
-
30
- return doc
26
+ return applyOmit(isFunction(doc?.toObject) ? (doc.toObject() as Partial<T>) : doc, opts)
31
27
  }
32
28
 
33
- export const getUser = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<User | undefined> => {
34
- if (isFunction(opts.getUser)) {
35
- return await opts.getUser(doc)
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>)
36
32
  }
37
33
  return undefined
38
34
  }
39
35
 
40
- export const getReason = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<string | undefined> => {
41
- if (isFunction(opts.getReason)) {
42
- return await opts.getReason(doc)
43
- }
44
- return undefined
45
- }
36
+ export const getUser = async <T>(opts: PluginOptions<T>, doc?: HydratedDocument<T>): Promise<User | undefined> => getOptionalField(opts.getUser, doc)
46
37
 
47
- export const getMetadata = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<Metadata | undefined> => {
48
- if (isFunction(opts.getMetadata)) {
49
- return await opts.getMetadata(doc)
50
- }
51
- return undefined
52
- }
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)
53
41
 
54
42
  export const getValue = <T>(item: PromiseSettledResult<T>): T | undefined => {
55
43
  return item.status === 'fulfilled' ? item.value : undefined
56
44
  }
57
45
 
58
- export const getData = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T>): Promise<[User | undefined, string | undefined, Metadata | undefined]> => {
46
+ export const getData = async <T>(opts: PluginOptions<T>, doc?: HydratedDocument<T>): Promise<[User | undefined, string | undefined, Metadata | undefined]> => {
59
47
  return Promise.allSettled([getUser(opts, doc), getReason(opts, doc), getMetadata(opts, doc)]).then(([user, reason, metadata]) => {
60
48
  return [getValue(user), getValue(reason), getValue(metadata)]
61
49
  })
@@ -63,7 +51,11 @@ export const getData = async <T>(opts: PluginOptions<T>, doc: HydratedDocument<T
63
51
 
64
52
  export const emitEvent = <T>(context: PatchContext<T>, event: string | undefined, data: PatchEvent<T>): void => {
65
53
  if (event && !context.ignoreEvent) {
66
- em.emit(event, data)
54
+ try {
55
+ em.emit(event, data)
56
+ } catch {
57
+ // Listener errors must not crash patch history recording
58
+ }
67
59
  }
68
60
  }
69
61
 
@@ -80,7 +72,8 @@ export const bulkPatch = async <T>(opts: PluginOptions<T>, context: PatchContext
80
72
  const bulk = []
81
73
 
82
74
  for (const doc of batch) {
83
- emitEvent(context, event, { [key]: doc })
75
+ const omitted = getObjectOmit(opts, doc)
76
+ emitEvent(context, event, { [key]: omitted })
84
77
 
85
78
  if (history) {
86
79
  const [user, reason, metadata] = await getData(opts, doc)
@@ -91,7 +84,7 @@ export const bulkPatch = async <T>(opts: PluginOptions<T>, context: PatchContext
91
84
  modelName: context.modelName,
92
85
  collectionName: context.collectionName,
93
86
  collectionId: doc._id as Types.ObjectId,
94
- doc: getObjectOmit(opts, doc),
87
+ doc: omitted,
95
88
  version: 0,
96
89
  ...(user !== undefined && { user }),
97
90
  ...(reason !== undefined && { reason }),
@@ -103,8 +96,9 @@ export const bulkPatch = async <T>(opts: PluginOptions<T>, context: PatchContext
103
96
  }
104
97
 
105
98
  if (history && !isEmpty(bulk)) {
99
+ const onError = opts.onError ?? console.error
106
100
  await HistoryModel.bulkWrite(bulk, { ordered: false }).catch((error: MongooseError) => {
107
- console.error(error.message)
101
+ onError(error)
108
102
  })
109
103
  }
110
104
  }
@@ -133,11 +127,12 @@ export const updatePatch = async <T>(opts: PluginOptions<T>, context: PatchConte
133
127
  .sort('-version')
134
128
  .exec()
135
129
 
136
- if (lastHistory && lastHistory.version >= 0) {
130
+ if (lastHistory) {
137
131
  version = lastHistory.version + 1
138
132
  }
139
133
 
140
134
  const [user, reason, metadata] = await getData(opts, current)
135
+ const onError = opts.onError ?? console.error
141
136
  await HistoryModel.create({
142
137
  op: context.op,
143
138
  modelName: context.modelName,
@@ -148,6 +143,8 @@ export const updatePatch = async <T>(opts: PluginOptions<T>, context: PatchConte
148
143
  ...(user !== undefined && { user }),
149
144
  ...(reason !== undefined && { reason }),
150
145
  ...(metadata !== undefined && { metadata }),
146
+ }).catch((error: MongooseError) => {
147
+ onError(error)
151
148
  })
152
149
  }
153
150
  }
package/src/types.ts CHANGED
@@ -12,6 +12,8 @@ export interface History {
12
12
  reason?: string
13
13
  metadata?: object
14
14
  patch?: Operation[]
15
+ createdAt?: Date
16
+ updatedAt?: Date
15
17
  }
16
18
 
17
19
  export interface PatchEvent<T> {
@@ -49,4 +51,5 @@ export interface PluginOptions<T> {
49
51
  omit?: string[]
50
52
  patchHistoryDisabled?: boolean
51
53
  preDelete?: (docs: HydratedDocument<T>[]) => Promise<void>
54
+ onError?: (error: Error) => void
52
55
  }
package/biome.json DELETED
@@ -1,50 +0,0 @@
1
- {
2
- "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
3
- "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
4
- "files": {
5
- "ignoreUnknown": false,
6
- "includes": ["src/**/*.ts", "tests/**/*.ts"]
7
- },
8
- "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 },
9
- "assist": {
10
- "actions": {
11
- "source": {
12
- "organizeImports": {
13
- "level": "on",
14
- "options": {
15
- "groups": [
16
- "vitest",
17
- ":BLANK_LINE:",
18
- ":NODE:",
19
- { "type": false },
20
- ":BLANK_LINE:"
21
- ]
22
- }
23
- }
24
- }
25
- }
26
- },
27
- "linter": {
28
- "enabled": true,
29
- "rules": {
30
- "recommended": true,
31
- "correctness": {
32
- "noUnusedVariables": "off"
33
- }
34
- }
35
- },
36
- "javascript": {
37
- "formatter": {
38
- "trailingCommas": "all",
39
- "quoteStyle": "single",
40
- "semicolons": "asNeeded",
41
- "lineWidth": 320
42
- },
43
- "globals": ["Atomics", "SharedArrayBuffer"]
44
- },
45
- "json": {
46
- "formatter": {
47
- "trailingCommas": "none"
48
- }
49
- }
50
- }
@@ -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,54 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest'
2
-
3
- import { patchEventEmitter } from '../src/index'
4
- import { emitEvent } from '../src/patch'
5
-
6
- describe('em', () => {
7
- it('should subscribe and count', async () => {
8
- let count = 0
9
- const fn = () => {
10
- count++
11
- }
12
- patchEventEmitter.on('test', fn)
13
- patchEventEmitter.emit('test')
14
- expect(count).toBe(1)
15
- patchEventEmitter.off('test', fn)
16
- patchEventEmitter.emit('test')
17
- expect(count).toBe(1)
18
- })
19
-
20
- it('emitEvent', async () => {
21
- const fn = vi.fn()
22
- patchEventEmitter.on('test', fn)
23
-
24
- const context = {
25
- op: 'test',
26
- modelName: 'Test',
27
- collectionName: 'tests',
28
- }
29
-
30
- // @ts-expect-error expected
31
- emitEvent(context, 'test', { doc: { name: 'test' } })
32
- expect(fn).toHaveBeenCalledTimes(1)
33
-
34
- patchEventEmitter.off('test', fn)
35
- })
36
-
37
- it('emitEvent ignore', async () => {
38
- const fn = vi.fn()
39
- patchEventEmitter.on('test', fn)
40
-
41
- const context = {
42
- ignoreEvent: true,
43
- op: 'test',
44
- modelName: 'Test',
45
- collectionName: 'tests',
46
- }
47
-
48
- // @ts-expect-error expected
49
- emitEvent(context, 'test', { doc: { name: 'test' } })
50
- expect(fn).toHaveBeenCalledTimes(0)
51
-
52
- patchEventEmitter.off('test', fn)
53
- })
54
- })