@ossy/event-store 1.0.1
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/package.json +33 -0
- package/src/aggregate-rebuild.js +71 -0
- package/src/aggregate.js +164 -0
- package/src/event-store.js +131 -0
- package/src/index.js +4 -0
- package/src/mongodb.js +72 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ossy/event-store",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Ossy Event Store — Aggregate, EventStore, and MongoDB client",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/ossy-se/packages.git"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"ossy": {
|
|
11
|
+
"src": "./src"
|
|
12
|
+
},
|
|
13
|
+
"main": "./src/index.js",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.js"
|
|
16
|
+
},
|
|
17
|
+
"private": false,
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"registry": "https://registry.npmjs.org"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [],
|
|
23
|
+
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"mongodb": "^7.2.0",
|
|
27
|
+
"nanoid": "^5.1.11"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src"
|
|
31
|
+
],
|
|
32
|
+
"gitHead": "b8cbdc545a659e96af8948aa08a89316a2c2d193"
|
|
33
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Aggregate } from './aggregate.js'
|
|
2
|
+
import { EventStore } from './event-store.js'
|
|
3
|
+
|
|
4
|
+
const _debug = Boolean(process.env.DEBUG)
|
|
5
|
+
|
|
6
|
+
function _log(message) { console.log(message) }
|
|
7
|
+
function _warn(message) { console.warn(message) }
|
|
8
|
+
function _logDebug(message) {
|
|
9
|
+
if (!_debug) return
|
|
10
|
+
console.log(message)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class AggregateRebuild {
|
|
14
|
+
|
|
15
|
+
/** @type {Record<string, Function>} */
|
|
16
|
+
static _aggregateMap = {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Registers a pre-loaded aggregate module. Called by the platform server at
|
|
20
|
+
* startup for each aggregate entry in the manifest, following the same pattern
|
|
21
|
+
* as task and resource-template registration.
|
|
22
|
+
*
|
|
23
|
+
* @param {{ id: string, Aggregate: Function }} mod - The aggregate bundle module.
|
|
24
|
+
*/
|
|
25
|
+
static registerAggregate(mod) {
|
|
26
|
+
const { id, Aggregate: AggregateClass } = mod
|
|
27
|
+
if (typeof AggregateClass !== 'function' || !id) {
|
|
28
|
+
_warn('[AggregateRebuild] registerAggregate: invalid module — expected { id, Aggregate }')
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
AggregateRebuild._aggregateMap[id] = AggregateClass
|
|
32
|
+
_logDebug(`[AggregateRebuild] Registered aggregate: ${id}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static async BuildAndSave(aggregateType, aggregateId) {
|
|
36
|
+
const AggregateRoot = AggregateRebuild._aggregateMap[aggregateType]
|
|
37
|
+
|
|
38
|
+
if (!AggregateRoot) {
|
|
39
|
+
_logDebug(`[AggregateRebuild][BuildAndSave] Skipping unregistered aggregate type: ${aggregateType}`)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return Aggregate.Of(AggregateRoot, aggregateId)
|
|
44
|
+
.then(Aggregate.Save())
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static async BuildAndSaveAll() {
|
|
48
|
+
_logDebug('[AggregateRebuild][BuildAndSaveAll] Starting building aggregates')
|
|
49
|
+
|
|
50
|
+
const knownTypes = new Set(Object.keys(AggregateRebuild._aggregateMap))
|
|
51
|
+
|
|
52
|
+
if (knownTypes.size === 0) {
|
|
53
|
+
_warn('[AggregateRebuild][BuildAndSaveAll] No aggregates registered — skipping rebuild')
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return EventStore.GetEventStreams()
|
|
58
|
+
.then(streams => Promise.allSettled(
|
|
59
|
+
streams
|
|
60
|
+
.filter(({ aggregateType }) => knownTypes.has(aggregateType))
|
|
61
|
+
.map(({ aggregateType, aggregateId }) => AggregateRebuild.BuildAndSave(aggregateType, aggregateId))
|
|
62
|
+
))
|
|
63
|
+
.then(results => {
|
|
64
|
+
const failed = results.filter(result => result.status === 'rejected')
|
|
65
|
+
const success = results.filter(result => result.status === 'fulfilled')
|
|
66
|
+
_log('[AggregateRebuild][BuildAndSaveAll] Finished building aggregates')
|
|
67
|
+
_log(`[AggregateRebuild][BuildAndSaveAll] Failed: ${failed.length}`)
|
|
68
|
+
_log(`[AggregateRebuild][BuildAndSaveAll] Success: ${success.length}`)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/aggregate.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import { Mongo, withMongoReconnect } from './mongodb.js'
|
|
3
|
+
import { EventStore } from './event-store.js'
|
|
4
|
+
|
|
5
|
+
const _debug = Boolean(process.env.DEBUG)
|
|
6
|
+
|
|
7
|
+
function _log(message) { console.log(message) }
|
|
8
|
+
function _logError(message) { console.error(message) }
|
|
9
|
+
function _logDebug(message) {
|
|
10
|
+
if (!_debug) return
|
|
11
|
+
console.log(message)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Utility class that helps interact with event streams.
|
|
16
|
+
* Use this class if you want to create a new stream, add events to an existing stream or view streams.
|
|
17
|
+
* @class
|
|
18
|
+
*/
|
|
19
|
+
export class Aggregate {
|
|
20
|
+
|
|
21
|
+
static get Collection() {
|
|
22
|
+
return Mongo.db.collection('aggregates')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Start interacting with an event stream
|
|
27
|
+
*
|
|
28
|
+
* @param {Class} AggregateRoot - Root aggregate (class that aggregates the events)
|
|
29
|
+
* @param {string} id - Id of aggregate - aggregateId
|
|
30
|
+
* @return {Aggregate} - Instance of Aggregate
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* Aggregate.Of(User, userId)
|
|
34
|
+
* Aggregate.Of(Workspace, workspaceId)
|
|
35
|
+
*/
|
|
36
|
+
static Of(AggregateRoot, identifier) {
|
|
37
|
+
|
|
38
|
+
_log(`[Aggregate][Of()][${AggregateRoot?.AggregateType}] Creating aggregate root`)
|
|
39
|
+
|
|
40
|
+
if (typeof identifier === 'string') {
|
|
41
|
+
|
|
42
|
+
return Aggregate.Find(identifier)
|
|
43
|
+
.then(aggregate => {
|
|
44
|
+
return EventStore.GetEventStream({ aggregateId: identifier, fromVersion: aggregate?.version })
|
|
45
|
+
.then(events => new Aggregate(AggregateRoot, identifier, events, aggregate))
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof identifier === 'object') {
|
|
50
|
+
_log('[Aggregate] Creating new aggregate event stream')
|
|
51
|
+
return Aggregate.Add(identifier)(new Aggregate(AggregateRoot, identifier.aggregateId || nanoid(), []))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_logError('[Aggregate] No recognizable identifier provided')
|
|
55
|
+
return Promise.reject()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static Find(identifier) {
|
|
59
|
+
_log(`[Aggregate][Find()] Fetching aggregate for ${identifier}`)
|
|
60
|
+
return withMongoReconnect(() => Aggregate.Collection.findOne({ id: identifier }))
|
|
61
|
+
.then(aggregate => {
|
|
62
|
+
if (!aggregate) {
|
|
63
|
+
_logDebug(`[Aggregate][Find()] No aggregate found for ${identifier}`)
|
|
64
|
+
}
|
|
65
|
+
return aggregate
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static Add(event) {
|
|
70
|
+
return aggregate => {
|
|
71
|
+
|
|
72
|
+
return EventStore.AppendEvent({
|
|
73
|
+
...event,
|
|
74
|
+
id: nanoid(),
|
|
75
|
+
created: Date.now(),
|
|
76
|
+
createdBy: event.createdBy,
|
|
77
|
+
aggregateType: aggregate.type,
|
|
78
|
+
aggregateId: aggregate.id,
|
|
79
|
+
aggregateVersion: aggregate.version + 1,
|
|
80
|
+
})
|
|
81
|
+
.then(savedEvent => {
|
|
82
|
+
aggregate.version = savedEvent.aggregateVersion,
|
|
83
|
+
aggregate.events = [...aggregate.events, savedEvent]
|
|
84
|
+
return aggregate
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static Save() {
|
|
91
|
+
return aggregate => {
|
|
92
|
+
_logDebug(`[Aggregate][Save] Saving state for ${aggregate.type} ${aggregate.id}`)
|
|
93
|
+
|
|
94
|
+
if (!aggregate.events || aggregate.events.length === 0) {
|
|
95
|
+
_logDebug(`[Aggregate][Save] No events to save for ${aggregate.type} ${aggregate.id}`)
|
|
96
|
+
return Promise.resolve()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const latestEventVersion = [...aggregate.events].sort((a, b) => a.aggregateVersion - b.aggregateVersion).pop()?.aggregateVersion
|
|
100
|
+
|
|
101
|
+
if (latestEventVersion < aggregate.version) {
|
|
102
|
+
_logDebug(`[Aggregate][Save] No new events to save for ${aggregate.type} ${aggregate.id}`)
|
|
103
|
+
return Promise.resolve()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const state = aggregate.View(aggregate.events, aggregate.state)
|
|
107
|
+
|
|
108
|
+
return withMongoReconnect(() =>
|
|
109
|
+
Aggregate.Collection.updateOne({ id: aggregate.id }, {
|
|
110
|
+
$set: {
|
|
111
|
+
id: aggregate.id,
|
|
112
|
+
version: latestEventVersion,
|
|
113
|
+
type: aggregate.type,
|
|
114
|
+
state: state
|
|
115
|
+
}
|
|
116
|
+
}, { upsert: true })
|
|
117
|
+
)
|
|
118
|
+
.catch(error => {
|
|
119
|
+
_logError(`[Aggregate][Save] Failed to save state for ${aggregate.type} ${aggregate.id}`)
|
|
120
|
+
console.error(error)
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static Validate(validator) {
|
|
126
|
+
return aggregate => Promise.resolve()
|
|
127
|
+
.then(() => validator(aggregate.events, aggregate.state))
|
|
128
|
+
.then(() => aggregate)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
static View(view) {
|
|
132
|
+
return aggregate => {
|
|
133
|
+
_logDebug(`[Aggregate][View] Building view for ${aggregate.type} ${aggregate.id}`)
|
|
134
|
+
|
|
135
|
+
if (typeof view === 'function') {
|
|
136
|
+
return view(aggregate.events, aggregate.state)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return aggregate.View(aggregate.events, aggregate.state)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Use the Aggregate.Of(type, id) static method instead
|
|
146
|
+
*
|
|
147
|
+
* @param {string} id - Id of aggregate - aggregateId
|
|
148
|
+
* @param {string} root - root aggregate (class that aggregates the events)
|
|
149
|
+
* @param {Event[]} events - The events that make up the stream
|
|
150
|
+
* @param {Object} aggregate - The object representing the aggregate of all the events
|
|
151
|
+
*/
|
|
152
|
+
constructor(AggregateRoot, id, events, aggregate) {
|
|
153
|
+
_logDebug(`[Aggregate][Constructor] Assembling ${AggregateRoot.AggregateType} ${id}`)
|
|
154
|
+
|
|
155
|
+
this.id = id;
|
|
156
|
+
this.version = aggregate?.version || 0
|
|
157
|
+
this.type = AggregateRoot.AggregateType
|
|
158
|
+
this.events = events || []
|
|
159
|
+
this.state = aggregate?.state
|
|
160
|
+
|
|
161
|
+
this.View = AggregateRoot.View
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Mongo, withMongoReconnect } from './mongodb.js'
|
|
2
|
+
|
|
3
|
+
const _debug = Boolean(process.env.DEBUG)
|
|
4
|
+
|
|
5
|
+
function _log(message) { console.log(message) }
|
|
6
|
+
function _logError(message, error) {
|
|
7
|
+
console.error(message)
|
|
8
|
+
if (error) console.error('[Reason]:', error)
|
|
9
|
+
}
|
|
10
|
+
function _logDebug(message, data) {
|
|
11
|
+
if (!_debug) return
|
|
12
|
+
console.log(message)
|
|
13
|
+
if (data !== undefined) console.log('[DEBUG DATA]:', data)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Database queries related to workspace events
|
|
18
|
+
* @class
|
|
19
|
+
*/
|
|
20
|
+
export class EventStore {
|
|
21
|
+
|
|
22
|
+
static get Collection() {
|
|
23
|
+
return Mongo.db.collection('eventstore')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static AppendEvent(event) {
|
|
27
|
+
_log('[EventStore] Appending event')
|
|
28
|
+
_logDebug('[EventStore] Event', event)
|
|
29
|
+
|
|
30
|
+
return withMongoReconnect(() => EventStore.Collection.insertOne(event))
|
|
31
|
+
.then(insertResult => {
|
|
32
|
+
return insertResult.acknowledged
|
|
33
|
+
? Promise.resolve(event)
|
|
34
|
+
: Promise.reject()
|
|
35
|
+
})
|
|
36
|
+
.catch(error => {
|
|
37
|
+
_logError('[EventStore] Could not append event', error)
|
|
38
|
+
return Promise.reject()
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static FindEvent(query) {
|
|
43
|
+
_log('[EventStore] Searching for event')
|
|
44
|
+
_logDebug('[EventStore] Query', query)
|
|
45
|
+
|
|
46
|
+
return withMongoReconnect(() => EventStore.Collection.findOne(query))
|
|
47
|
+
.then(event => !!event ? event : Promise.reject())
|
|
48
|
+
.catch(error => {
|
|
49
|
+
_logError('[EventStore] No event found', error)
|
|
50
|
+
return Promise.reject()
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static FindEvents(query) {
|
|
55
|
+
_log('[EventStore] Searching for events')
|
|
56
|
+
_logDebug('[EventStore] Query', query)
|
|
57
|
+
|
|
58
|
+
return withMongoReconnect(() =>
|
|
59
|
+
EventStore.Collection.find(query, { sort: { aggregateVersion: 1 } }).toArray()
|
|
60
|
+
)
|
|
61
|
+
.then(events => !!events?.length ? events : Promise.reject())
|
|
62
|
+
.catch(error => {
|
|
63
|
+
_logError('[EventStore] No events found', error)
|
|
64
|
+
return Promise.reject()
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static GetEventStream({
|
|
69
|
+
aggregateId,
|
|
70
|
+
fromVersion = 0
|
|
71
|
+
}) {
|
|
72
|
+
_logDebug(`[EventStore][GetEventStream()] Fetching stream ${aggregateId} from version ${fromVersion}`)
|
|
73
|
+
|
|
74
|
+
const query = {
|
|
75
|
+
aggregateId: aggregateId,
|
|
76
|
+
aggregateVersion: { '$gt': fromVersion }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const options = {
|
|
80
|
+
sort: { aggregateVersion: 1 }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return withMongoReconnect(() => EventStore.Collection.find(query, options).toArray())
|
|
84
|
+
.then(events => {
|
|
85
|
+
_logDebug(`[EventStore][GetEventStream()] Found ${events.length} events for ${aggregateId}`)
|
|
86
|
+
return events
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static GetEventStreams() {
|
|
91
|
+
_log('[EventStore][GetEventStreams()] Fetching event streams')
|
|
92
|
+
|
|
93
|
+
return EventStore.Aggregate([
|
|
94
|
+
{
|
|
95
|
+
'$group': {
|
|
96
|
+
'_id': {
|
|
97
|
+
'aggregateId': '$aggregateId',
|
|
98
|
+
'aggregateType': '$aggregateType'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
'$project': {
|
|
104
|
+
'_id': 0,
|
|
105
|
+
'aggregateId': '$_id.aggregateId',
|
|
106
|
+
'aggregateType': '$_id.aggregateType'
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
])
|
|
110
|
+
.then(result => {
|
|
111
|
+
_logDebug(`[EventStore][GetEventStreams()] found ${result.length} streams`)
|
|
112
|
+
return result
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static Aggregate(pipeline) {
|
|
117
|
+
_log('[EventStore] Running aggregation pipeline')
|
|
118
|
+
|
|
119
|
+
return withMongoReconnect(() => EventStore.Collection.aggregate(pipeline).toArray())
|
|
120
|
+
.then(events => (Array.isArray(events) ? events : []))
|
|
121
|
+
.catch(error => {
|
|
122
|
+
_logError('[EventStore] Aggregation failed', error)
|
|
123
|
+
return Promise.reject(error)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static CloseDbConnection() {
|
|
128
|
+
return Mongo.closeConnection()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
}
|
package/src/index.js
ADDED
package/src/mongodb.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createRequire } from 'module'
|
|
2
|
+
import { MongoClient } from 'mongodb'
|
|
3
|
+
|
|
4
|
+
// When bundled as ESM by @ossy/app (Rollup + @rollup/plugin-commonjs), the
|
|
5
|
+
// MongoDB driver's resolveRuntimeAdapters references `require` as a free
|
|
6
|
+
// variable to detect the Node.js runtime. Provide it on globalThis so the
|
|
7
|
+
// bundled CJS code can find it without changing the build configuration.
|
|
8
|
+
if (!globalThis.require) {
|
|
9
|
+
globalThis.require = createRequire(import.meta.url)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MONGO_URL = process.env.DB_URL || 'mongodb://mongodb:27017/'
|
|
13
|
+
const DB_NAME = process.env.DB_NAME || 'test'
|
|
14
|
+
|
|
15
|
+
let mongoClient = null
|
|
16
|
+
|
|
17
|
+
function createClient() {
|
|
18
|
+
return new MongoClient(MONGO_URL, {
|
|
19
|
+
serverSelectionTimeoutMS: 30_000,
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isMongoTopologyClosedError(error) {
|
|
24
|
+
if (!error || typeof error !== 'object') return false
|
|
25
|
+
if (error.name === 'MongoTopologyClosedError') return true
|
|
26
|
+
if (typeof error.message === 'string' && error.message.includes('Topology is closed')) return true
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run a DB operation; if the driver closed the topology after a prior failure, reset the client and retry once.
|
|
32
|
+
*/
|
|
33
|
+
export function withMongoReconnect(run) {
|
|
34
|
+
return run().catch((err) => {
|
|
35
|
+
if (isMongoTopologyClosedError(err)) {
|
|
36
|
+
console.log('[Mongo] Topology closed — resetting client and retrying once')
|
|
37
|
+
Mongo.resetClient()
|
|
38
|
+
return run()
|
|
39
|
+
}
|
|
40
|
+
return Promise.reject(err)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class Mongo {
|
|
45
|
+
static get Client() {
|
|
46
|
+
if (!mongoClient) {
|
|
47
|
+
mongoClient = createClient()
|
|
48
|
+
}
|
|
49
|
+
return mongoClient
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static get db() {
|
|
53
|
+
return Mongo.Client.db(DB_NAME)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static resetClient() {
|
|
57
|
+
const prev = mongoClient
|
|
58
|
+
mongoClient = createClient()
|
|
59
|
+
if (prev) {
|
|
60
|
+
prev.close().catch(() => {})
|
|
61
|
+
}
|
|
62
|
+
console.log('[Mongo] New MongoClient instance created')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Used by tests / graceful shutdown; resets singleton so a later operation opens a new client. */
|
|
66
|
+
static closeConnection() {
|
|
67
|
+
if (!mongoClient) return Promise.resolve()
|
|
68
|
+
const c = mongoClient
|
|
69
|
+
mongoClient = null
|
|
70
|
+
return c.close().catch(() => {})
|
|
71
|
+
}
|
|
72
|
+
}
|