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.
- package/README.md +31 -25
- package/dist/index.cjs +112 -115
- package/dist/index.d.cts +4 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +112 -115
- package/package.json +14 -15
- package/src/helpers.ts +16 -3
- package/src/hooks/delete-hooks.ts +5 -4
- package/src/hooks/update-hooks.ts +47 -19
- package/src/index.ts +8 -5
- package/src/ms.ts +4 -3
- package/src/omit-deep.ts +24 -63
- package/src/patch.ts +30 -33
- package/src/types.ts +3 -0
- package/biome.json +0 -50
- package/tests/constants/events.ts +0 -7
- package/tests/em.test.ts +0 -54
- package/tests/helpers.test.ts +0 -311
- package/tests/mongo/.gitignore +0 -3
- package/tests/mongo/server.ts +0 -31
- package/tests/ms.test.ts +0 -113
- package/tests/omit-deep.test.ts +0 -220
- package/tests/patch.test.ts +0 -199
- package/tests/plugin-all-features.test.ts +0 -741
- package/tests/plugin-complex-data.test.ts +0 -1332
- package/tests/plugin-event-created.test.ts +0 -371
- package/tests/plugin-event-deleted.test.ts +0 -400
- package/tests/plugin-event-updated.test.ts +0 -503
- package/tests/plugin-global.test.ts +0 -545
- package/tests/plugin-omit-all.test.ts +0 -349
- package/tests/plugin-patch-history-disabled.test.ts +0 -162
- package/tests/plugin-pre-delete.test.ts +0 -160
- package/tests/plugin-pre-save.test.ts +0 -54
- package/tests/plugin.test.ts +0 -576
- package/tests/schemas/Description.ts +0 -15
- package/tests/schemas/Product.ts +0 -38
- package/tests/schemas/User.ts +0 -22
- package/tsconfig.json +0 -32
- package/vite.config.mts +0 -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
const
|
|
65
|
-
|
|
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
|
|
12
|
-
const
|
|
13
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
return omit(object, opts.omit)
|
|
20
|
-
}
|
|
18
|
+
const replacer = (_key: string, value: unknown): unknown => (typeof value === 'bigint' ? value.toString() : value)
|
|
21
19
|
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
if (isFunction(
|
|
35
|
-
return await
|
|
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
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
})
|