@toa.io/storages.mongodb 1.0.0-alpha.2 → 1.0.0-alpha.200

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/storages.mongodb",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.200",
4
4
  "description": "Toa MongoDB Storage Connector",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -19,13 +19,13 @@
19
19
  "test": "echo \"Error: run tests from root\" && exit 1"
20
20
  },
21
21
  "dependencies": {
22
- "@toa.io/console": "1.0.0-alpha.2",
23
- "@toa.io/conveyor": "1.0.0-alpha.2",
24
- "@toa.io/core": "1.0.0-alpha.2",
25
- "@toa.io/generic": "1.0.0-alpha.2",
26
- "@toa.io/pointer": "1.0.0-alpha.2",
27
- "mongodb": "6.3.0",
22
+ "@toa.io/conveyor": "1.0.0-alpha.173",
23
+ "@toa.io/core": "1.0.0-alpha.200",
24
+ "@toa.io/generic": "1.0.0-alpha.173",
25
+ "@toa.io/pointer": "1.0.0-alpha.200",
26
+ "mongodb": "7.1.0",
27
+ "openspan": "1.0.0-alpha.173",
28
28
  "saslprep": "1.0.3"
29
29
  },
30
- "gitHead": "7688e6e980a65c82ac2e459be4e355eebf406cd0"
30
+ "gitHead": "35b4adeedae2e9450ef44a74f42a53eff20ee203"
31
31
  }
package/src/client.js ADDED
@@ -0,0 +1,164 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * @typedef {import('mongodb').MongoClient} MongoClient
5
+ * @typedef {{ count: number, client: MongoClient }} Instance
6
+ * @typedef {import('@toa.io/core').Locator} Locator
7
+ */
8
+
9
+ const { console } = require('openspan')
10
+ const { Connector } = require('@toa.io/core')
11
+ const { resolve } = require('@toa.io/pointer')
12
+ const { ID } = require('./deployment')
13
+ const { MongoClient } = require('mongodb')
14
+
15
+ /**
16
+ * @type {Record<string, Promise<Instance>>}
17
+ */
18
+ const INSTANCES = {}
19
+
20
+ class Client extends Connector {
21
+ name
22
+
23
+ /**
24
+ * @public
25
+ * @type {import('mongodb').Collection}
26
+ */
27
+ collection
28
+
29
+ /**
30
+ * @private
31
+ * @type {Locator}
32
+ */
33
+ locator
34
+
35
+ /**
36
+ * @private
37
+ * @type {Instance}
38
+ */
39
+ instance
40
+
41
+ /**
42
+ * @private
43
+ * @type {string}
44
+ */
45
+ key
46
+
47
+ /**
48
+ * @param {Locator} locator
49
+ */
50
+ constructor (locator) {
51
+ super()
52
+
53
+ this.locator = locator
54
+ this.name = locator.lowercase
55
+ }
56
+
57
+ /**
58
+ * @protected
59
+ * @override
60
+ * @return {Promise<void>}
61
+ */
62
+ async open () {
63
+ const urls = await this.resolveURLs()
64
+ const dbname = this.resolveDB()
65
+
66
+ this.key = getKey(dbname, urls)
67
+
68
+ try {
69
+ INSTANCES[this.key] ??= this.createInstance(urls)
70
+ } catch (error) {
71
+ console.error('Failed to connect to MongoDB', { urls, error })
72
+ }
73
+
74
+ this.instance = await INSTANCES[this.key]
75
+ this.instance.count++
76
+
77
+ const db = this.instance.client.db(dbname)
78
+
79
+ try {
80
+ this.collection = await db.createCollection(this.name)
81
+ } catch (e) {
82
+ if (e.code !== ALREADY_EXISTS) {
83
+ throw e
84
+ }
85
+
86
+ this.collection = db.collection(this.name)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * @protected
92
+ * @override
93
+ * @return {Promise<void>}
94
+ */
95
+ async close () {
96
+ const instance = await INSTANCES[this.key]
97
+
98
+ instance.count--
99
+
100
+ if (instance.count === 0) {
101
+ await instance.client.close()
102
+ delete INSTANCES[this.key]
103
+ }
104
+ }
105
+
106
+ /**
107
+ * @private
108
+ * @param {string[]} urls
109
+ * @return {Promise<Instance>}
110
+ */
111
+ async createInstance (urls) {
112
+ const client = new MongoClient(urls.join(','), OPTIONS)
113
+ const hosts = urls.map((str) => new URL(str).host)
114
+
115
+ console.info('Connecting to MongoDB', { address: hosts.join(', ') })
116
+
117
+ await client.connect()
118
+
119
+ return {
120
+ count: 0,
121
+ client
122
+ }
123
+ }
124
+
125
+ /**
126
+ * @private
127
+ * @return {Promise<string[]>}
128
+ */
129
+ async resolveURLs () {
130
+ if (process.env.TOA_DEV === '1') {
131
+ return ['mongodb://developer:secret@localhost']
132
+ } else {
133
+ return await resolve(ID, this.locator.id)
134
+ }
135
+ }
136
+
137
+ /**
138
+ * @private
139
+ * @return {string}
140
+ */
141
+ resolveDB () {
142
+ if (process.env.TOA_CONTEXT !== undefined) {
143
+ return process.env.TOA_CONTEXT
144
+ }
145
+
146
+ if (process.env.TOA_DEV === '1') {
147
+ return 'toa-dev'
148
+ }
149
+
150
+ throw new Error('Environment variable TOA_CONTEXT is not defined')
151
+ }
152
+ }
153
+
154
+ function getKey (db, urls) {
155
+ return db + ':' + urls.sort().join(' ')
156
+ }
157
+
158
+ const OPTIONS = {
159
+ ignoreUndefined: true
160
+ }
161
+
162
+ const ALREADY_EXISTS = 48
163
+
164
+ exports.Client = Client
package/src/factory.js CHANGED
@@ -1,13 +1,13 @@
1
1
  'use strict'
2
2
 
3
- const { Connection } = require('./connection')
3
+ const { Client } = require('./client')
4
4
  const { Storage } = require('./storage')
5
5
 
6
6
  class Factory {
7
- storage (locator) {
8
- const connection = new Connection(locator)
7
+ storage (locator, entity) {
8
+ const client = new Client(locator)
9
9
 
10
- return new Storage(connection)
10
+ return new Storage(client, entity)
11
11
  }
12
12
  }
13
13
 
package/src/record.js CHANGED
@@ -1,21 +1,14 @@
1
1
  'use strict'
2
2
 
3
- /**
4
- * @param {toa.core.storages.Record} entity
5
- * @returns {toa.mongodb.Record}
6
- */
7
- const to = (entity) => {
8
- const { id, _version, ...rest } = entity
3
+ function to (entity) {
4
+ const { id, ...rest } = entity
9
5
 
10
- return /** @type {toa.mongodb.Record} */ { _id: id, _version: _version + 1, ...rest }
6
+ return /** @type {toa.mongodb.Record} */ { _id: id, ...rest }
11
7
  }
12
8
 
13
- /**
14
- * @param {toa.mongodb.Record} record
15
- * @returns {toa.core.storages.Record}
16
- */
17
- const from = (record) => {
18
- if (record === undefined || record === null) return null
9
+ function from (record) {
10
+ if (record === undefined || record === null)
11
+ return null
19
12
 
20
13
  const { _id, ...rest } = record
21
14
 
package/src/storage.js CHANGED
@@ -1,82 +1,354 @@
1
1
  'use strict'
2
2
 
3
- const { Connector } = require('@toa.io/core')
4
-
3
+ const { Connector, exceptions } = require('@toa.io/core')
4
+ const { console } = require('openspan')
5
5
  const { translate } = require('./translate')
6
6
  const { to, from } = require('./record')
7
+ const { ReturnDocument } = require('mongodb')
7
8
 
8
- /**
9
- * @implements {toa.core.Storage}
10
- */
11
9
  class Storage extends Connector {
12
- /** @type {toa.mongodb.Connection} */
13
- #connection
10
+ #client
11
+
12
+ /** @type {import('mongodb').Collection} */
13
+ #collection
14
+ #entity
14
15
 
15
- /**
16
- * @param {toa.mongodb.Connection} connection
17
- */
18
- constructor (connection) {
16
+ constructor (client, entity) {
19
17
  super()
20
18
 
21
- this.#connection = connection
19
+ this.#client = client
20
+ this.#entity = entity
21
+
22
+ this.depends(client)
23
+ }
24
+
25
+ get raw () {
26
+ return this.#collection
27
+ }
28
+
29
+ async open () {
30
+ this.#collection = this.#client.collection
22
31
 
23
- this.depends(connection)
32
+ await this.index()
24
33
  }
25
34
 
26
35
  async get (query) {
27
36
  const { criteria, options } = translate(query)
28
37
 
29
- const record = await this.#connection.get(criteria, options)
38
+ this.debug('findOne', { criteria, options })
39
+
40
+ const record = await this.#collection.findOne(criteria, options)
30
41
 
31
42
  return from(record)
32
43
  }
33
44
 
34
45
  async find (query) {
35
- const { criteria, options } = translate(query)
36
- const recordset = await this.#connection.find(criteria, options)
46
+ const { criteria, options, sample } = translate(query)
47
+
48
+ if (query?.options?.deleted !== true)
49
+ criteria._deleted = null
50
+
51
+ let cursor
52
+
53
+ if (sample === undefined) {
54
+ this.debug('find', { criteria, options })
55
+
56
+ cursor = this.#collection.find(criteria, options)
57
+ } else {
58
+ const pipeline = toPipeline(criteria, options, sample)
59
+
60
+ this.debug('aggregate', { pipeline })
61
+
62
+ cursor = this.#collection.aggregate(pipeline)
63
+ }
64
+
65
+ const recordset = await cursor.toArray()
37
66
 
38
67
  return recordset.map((item) => from(item))
39
68
  }
40
69
 
70
+ async stream (query = undefined) {
71
+ const { criteria, options } = translate(query)
72
+
73
+ this.debug('find (stream)', { criteria, options })
74
+
75
+ return this.#collection.find(criteria, options).stream({ transform: from })
76
+ }
77
+
41
78
  async add (entity) {
42
79
  const record = to(entity)
43
80
 
44
- return await this.#connection.add(record)
81
+ this.debug('insertOne', { record })
82
+
83
+ const result = await this.#collection.insertOne(record)
84
+
85
+ return result.acknowledged
45
86
  }
46
87
 
47
88
  async set (entity) {
48
- const criteria = { _id: entity.id, _version: entity._version }
49
- const result = await this.#connection.replace(criteria, to(entity))
89
+ const criteria = {
90
+ _id: entity.id,
91
+ _version: entity._version - 1
92
+ }
93
+
94
+ const record = to(entity)
95
+
96
+ this.debug('findOneAndReplace', { criteria, record })
97
+
98
+ const result = await this.#collection.findOneAndReplace(criteria, record)
50
99
 
51
100
  return result !== null
52
101
  }
53
102
 
54
- async store (entity) {
55
- if (entity._version === 0) return this.add(entity)
56
- else return this.set(entity)
103
+ async store (entity, attempt = 0) {
104
+ try {
105
+ if (entity._version === 1)
106
+ return await this.add(entity)
107
+ else
108
+ return await this.set(entity)
109
+ } catch (error) {
110
+ const retry = await retriable(error, attempt)
111
+
112
+ if (retry)
113
+ return await this.store(entity, attempt + 1)
114
+ else
115
+ return false
116
+ }
57
117
  }
58
118
 
59
- async upsert (query, changeset, insert) {
60
- const { criteria, options } = translate(query)
61
- const update = { $set: { ...changeset }, $inc: { _version: 1 } }
119
+ async massStore (entities, attempt = 0) {
120
+ if (entities.length === 0)
121
+ return true
122
+
123
+ const operations = entities.map((entity) => {
124
+ const record = to(entity)
125
+
126
+ if (entity._version === 1)
127
+ return { insertOne: { document: record } }
128
+ else
129
+ return { replaceOne: { filter: { _id: entity.id, _version: entity._version - 1 }, replacement: record } }
130
+ })
62
131
 
63
- if (insert !== undefined) {
64
- delete insert._version
132
+ const client = this.#client.instance.client
133
+
134
+ try {
135
+ await client.withSession(async (session) => {
136
+ await session.withTransaction(async () => {
137
+ this.debug('bulkWrite', { operations: operations.length })
138
+
139
+ await this.#collection.bulkWrite(operations, { session })
140
+ })
141
+ })
142
+
143
+ return true
144
+ } catch (error) {
145
+ const retry = await retriable(error, attempt)
146
+
147
+ if (retry)
148
+ return await this.massStore(entities, attempt + 1)
149
+ else
150
+ return false
151
+ }
152
+ }
65
153
 
66
- options.upsert = true
154
+ async upsert (query, changeset) {
155
+ const { criteria, options } = translate(query)
67
156
 
68
- if (criteria._id !== undefined) insert._id = criteria._id
69
- else return null // this shouldn't ever happen
157
+ if (!('_deleted' in changeset) || changeset._deleted === null) {
158
+ delete criteria._deleted
159
+ changeset._deleted = null
160
+ }
70
161
 
71
- if (Object.keys(insert) > 0) update.$setOnInsert = insert
162
+ const update = {
163
+ $set: { ...changeset },
164
+ $inc: { _version: 1 }
72
165
  }
73
166
 
74
- options.returnDocument = 'after'
167
+ options.returnDocument = ReturnDocument.AFTER
75
168
 
76
- const result = await this.#connection.update(criteria, update, options)
169
+ this.debug('findOneAndUpdate', { criteria, update, options })
170
+
171
+ const result = await this.#collection.findOneAndUpdate(criteria, update, options)
77
172
 
78
173
  return from(result)
79
174
  }
175
+
176
+ async ensure (query, properties, state) {
177
+ let { criteria, options } = translate(query)
178
+
179
+ if (query === undefined)
180
+ criteria = properties
181
+
182
+ const update = { $setOnInsert: to(state) }
183
+
184
+ options.upsert = true
185
+ options.returnDocument = ReturnDocument.AFTER
186
+
187
+ console.debug('Database query', { collection: this.#collection.collectionName, method: 'findOneAndUpdate', criteria, update, options })
188
+
189
+ try {
190
+ const result = await this.#collection.findOneAndUpdate(criteria, update, options)
191
+
192
+ if (result._deleted !== undefined && result._deleted !== null)
193
+ return null
194
+ else
195
+ return from(result)
196
+ } catch (error) {
197
+ if (error.code === ERR_DUPLICATE_KEY)
198
+ throw new exceptions.DuplicateException(this.#client.name)
199
+ else
200
+ throw error
201
+ }
202
+ }
203
+
204
+ async index () {
205
+ const indexes = []
206
+
207
+ if (this.#entity.unique !== undefined) {
208
+ for (const [name, fields] of Object.entries(this.#entity.unique)) {
209
+ const optional = this.getOptional(fields)
210
+ const unique = await this.uniqueIndex(name, fields, optional)
211
+
212
+ indexes.push(unique)
213
+ }
214
+ }
215
+
216
+ if (this.#entity.index !== undefined) {
217
+ for (const [suffix, declaration] of Object.entries(this.#entity.index)) {
218
+ const name = 'index_' + suffix
219
+ const fields = Object.fromEntries(Object.entries(declaration)
220
+ .map(([name, type]) => [name, INDEX_TYPES[type] ?? type]))
221
+
222
+ const optional = this.getOptional(Object.keys(fields))
223
+ const options = { name, sparse: optional.length > 0 }
224
+
225
+ console.info('Creating index', { fields, options })
226
+
227
+ await this.#collection.createIndex(fields, options)
228
+ .catch((e) => console.warn('MongoDB index creation failed', { collection: this.#collection.collectionName, name, fields, error: e }))
229
+
230
+ indexes.push(name)
231
+ }
232
+ }
233
+
234
+ await this.removeObsoleteIndexes(indexes)
235
+ }
236
+
237
+ async uniqueIndex (name, properties, optional) {
238
+ const fields = properties.reduce((acc, property) => {
239
+ acc[property] = 1
240
+ return acc
241
+ }, {})
242
+
243
+ name = 'unique_' + name
244
+
245
+ const options = { name, unique: true }
246
+
247
+ if (optional.length > 0)
248
+ options.partialFilterExpression = Object.fromEntries(optional.map((field) => [field, { $exists: true }]))
249
+
250
+ console.info('Creating unique index', { name, fields, options })
251
+
252
+ await this.#collection.createIndex(fields, options)
253
+ .catch((e) => console.warn('MongoDB unique index creation failed',
254
+ { collection: this.#collection.collectionName, name, fields, error: e }))
255
+
256
+ return name
257
+ }
258
+
259
+ async removeObsoleteIndexes (desired) {
260
+ const current = await this.getCurrentIndexes()
261
+ const obsolete = current.filter((name) => !desired.includes(name))
262
+
263
+ if (obsolete.length > 0) {
264
+ console.info('Removing obsolete indexes', { collection: this.#collection.collectionName, indexes: obsolete.join(', ') })
265
+
266
+ await Promise.all(obsolete.map((name) => this.#collection.dropIndex(name)))
267
+ }
268
+ }
269
+
270
+ async getCurrentIndexes () {
271
+ try {
272
+ const array = await this.#collection.listIndexes().toArray()
273
+
274
+ return array.map(({ name }) => name).filter((name) => name !== '_id_')
275
+ } catch {
276
+ return []
277
+ }
278
+ }
279
+
280
+ getOptional (fields) {
281
+ const optional = []
282
+
283
+ for (const field of fields) {
284
+ if (!field.includes('.') && !(field in this.#entity.schema.properties))
285
+ throw new Error(`Index field '${field}' is not defined.`)
286
+
287
+ if (!this.#entity.schema.required?.includes(field))
288
+ optional.push(field)
289
+ }
290
+
291
+ return optional
292
+ }
293
+
294
+ debug (method, attributes) {
295
+ console.debug('MongoDB query', {
296
+ collection: this.#collection.collectionName,
297
+ method,
298
+ ...attributes
299
+ })
300
+ }
301
+ }
302
+
303
+ function toPipeline (criteria, options, sample) {
304
+ const pipeline = []
305
+
306
+ if (criteria !== undefined)
307
+ pipeline.push({ $match: criteria })
308
+
309
+ if (sample !== undefined)
310
+ pipeline.push({ $sample: { size: sample } })
311
+
312
+ if (options?.sort !== undefined)
313
+ pipeline.push({ $sort: options.sort })
314
+
315
+ if (options?.projection !== undefined)
316
+ pipeline.push({ $project: options.projection })
317
+
318
+ return pipeline
80
319
  }
81
320
 
321
+ const INDEX_TYPES = {
322
+ 'asc': 1,
323
+ 'desc': -1,
324
+ 'hash': 'hashed'
325
+ }
326
+
327
+ const ERR_DUPLICATE_KEY = 11000
328
+
329
+ async function retriable (error, attempt) {
330
+ if (error.code === ERR_DUPLICATE_KEY) {
331
+ const id = error.keyPattern === undefined
332
+ ? error.message.includes(' index: _id_ ') // AWS DocumentDB
333
+ : error.keyPattern._id === 1
334
+
335
+ if (id)
336
+ return false
337
+ else
338
+ throw new exceptions.DuplicateException()
339
+ } else if (error.cause?.code === 'ECONNREFUSED') {
340
+ if (attempt === LAST_ATTEMPT)
341
+ throw error
342
+
343
+ const timeout = 1000 + 500 * attempt
344
+
345
+ await new Promise((resolve) => setTimeout(resolve, timeout))
346
+
347
+ return true
348
+ } else
349
+ throw error
350
+ }
351
+
352
+ const LAST_ATTEMPT = 9
353
+
82
354
  exports.Storage = Storage
package/src/translate.js CHANGED
@@ -7,12 +7,23 @@ const parse = { ...require('./translate/criteria'), ...require('./translate/opti
7
7
  * @returns {{criteria: Object, options: Object}}
8
8
  */
9
9
  const translate = (query) => {
10
- const result = { criteria: {}, options: {} }
10
+ const result = {
11
+ criteria: query?.criteria === undefined ? {} : parse.criteria(query.criteria),
12
+ options: query?.options === undefined ? {} : parse.options(query.options),
13
+ sample: query?.options?.sample
14
+ }
11
15
 
12
- if (query.criteria !== undefined) result.criteria = parse.criteria(query.criteria)
13
- if (query.options !== undefined) result.options = parse.options(query.options)
14
- if (query.id !== undefined) result.criteria._id = query.id
15
- if (query.version !== undefined) result.criteria._version = query.version
16
+ if (query?.id !== undefined)
17
+ result.criteria._id = query.id
18
+
19
+ if (query?.ids !== undefined)
20
+ result.criteria._id = { $in: query.ids }
21
+
22
+ if (query?.version !== undefined)
23
+ result.criteria._version = query.version
24
+
25
+ if (query?.search !== undefined)
26
+ result.criteria.$text = { $search: query.search }
16
27
 
17
28
  return result
18
29
  }
@@ -1,12 +1,17 @@
1
1
  'use strict'
2
2
 
3
- const { to, from } = require('../src/record')
4
- const { random } = require('@toa.io/generic')
3
+ const {
4
+ to,
5
+ from
6
+ } = require('../src/record')
5
7
 
6
8
  describe('to', () => {
7
9
  it('should rename id to _id', () => {
8
10
  /** @type {toa.core.storages.Record} */
9
- const entity = { id: '1', _version: 0 }
11
+ const entity = {
12
+ id: '1',
13
+ _version: 0
14
+ }
10
15
  const record = to(entity)
11
16
 
12
17
  expect(record).toMatchObject({ _id: '1' })
@@ -14,37 +19,32 @@ describe('to', () => {
14
19
 
15
20
  it('should not modify argument', () => {
16
21
  /** @type {toa.core.storages.Record} */
17
- const entity = { id: '1', _version: 0 }
22
+ const entity = {
23
+ id: '1',
24
+ _version: 0
25
+ }
18
26
 
19
27
  to(entity)
20
28
 
21
- expect(entity).toStrictEqual({ id: '1', _version: 0 })
22
- })
23
-
24
- it('should increment _version', () => {
25
- /** @type {toa.core.storages.Record} */
26
- const entity = { id: '1', _version: random() }
27
- const record = to(entity)
28
-
29
- expect(record).toMatchObject({ _version: entity._version + 1 })
29
+ expect(entity).toStrictEqual({
30
+ id: '1',
31
+ _version: 0
32
+ })
30
33
  })
31
34
  })
32
35
 
33
36
  describe('from', () => {
34
37
  it('should rename _id to id', () => {
35
38
  /** @type {toa.mongodb.Record} */
36
- const record = { _id: '1', _version: 0 }
39
+ const record = {
40
+ _id: '1',
41
+ _version: 0
42
+ }
37
43
  const entity = from(record)
38
44
 
39
- expect(entity).toStrictEqual({ id: '1', _version: 0 })
40
- })
41
-
42
- it('should not modify argument', () => {
43
- /** @type {toa.mongodb.Record} */
44
- const record = { _id: '1', _version: 0 }
45
-
46
- from(record)
47
-
48
- expect(record).toStrictEqual({ _id: '1', _version: 0 })
45
+ expect(entity).toStrictEqual({
46
+ id: '1',
47
+ _version: 0
48
+ })
49
49
  })
50
50
  })
package/src/connection.js DELETED
@@ -1,103 +0,0 @@
1
- // noinspection JSCheckFunctionSignatures
2
-
3
- 'use strict'
4
-
5
- const { MongoClient } = require('mongodb')
6
- const { Connector } = require('@toa.io/core')
7
- const { resolve } = require('@toa.io/pointer')
8
- const { Conveyor } = require('@toa.io/conveyor')
9
- const { ID } = require('./deployment')
10
-
11
- class Connection extends Connector {
12
- #locator
13
- /** @type {import('mongodb').MongoClient} */
14
- #client
15
- /** @type {import('mongodb').Collection<toa.mongodb.Record>} */
16
- #collection
17
- /** @type {toa.conveyor.Conveyor<toa.core.storages.Record, boolean>} */
18
- #conveyor
19
-
20
- constructor (locator) {
21
- super()
22
-
23
- this.#locator = locator
24
- }
25
-
26
- async open () {
27
- const urls = await this.#resolveURLs()
28
- const db = this.#locator.namespace
29
- const collection = this.#locator.name
30
-
31
- this.#client = new MongoClient(urls[0], OPTIONS)
32
-
33
- await this.#client.connect()
34
-
35
- this.#collection = this.#client.db(db).collection(collection)
36
- this.#conveyor = new Conveyor((objects) => this.addMany(objects))
37
-
38
- console.info(`Storage Mongo '${this.#locator.id}' connected`)
39
- }
40
-
41
- async close () {
42
- await this.#client?.close()
43
-
44
- console.info(`Storage Mongo '${this.#locator.id}' disconnected`)
45
- }
46
-
47
- /** @hot */
48
- async get (query, options) {
49
- return /** @type {toa.mongodb.Record} */ this.#collection.findOne(query, options)
50
- }
51
-
52
- /** @hot */
53
- async find (query, options) {
54
- const cursor = this.#collection.find(query, options)
55
-
56
- return cursor.toArray()
57
- }
58
-
59
- /** @hot */
60
- async add (record) {
61
- return this.#conveyor.process(record)
62
- }
63
-
64
- async addMany (records) {
65
- let result
66
-
67
- try {
68
- const response = await this.#collection.insertMany(records, { ordered: false })
69
-
70
- result = response.acknowledged
71
- } catch (e) {
72
- if (e.code === ERR_DUPLICATE_KEY) result = false
73
- else throw e
74
- }
75
-
76
- return result
77
- }
78
-
79
- /** @hot */
80
- async replace (query, record, options) {
81
- return await this.#collection.findOneAndReplace(query, record, options)
82
- }
83
-
84
- /** @hot */
85
- async update (query, update, options) {
86
- return this.#collection.findOneAndUpdate(query, update, options)
87
- }
88
-
89
- async #resolveURLs () {
90
- if (process.env.TOA_DEV === '1') return ['mongodb://developer:secret@localhost']
91
- else return await resolve(ID, this.#locator.id)
92
- }
93
- }
94
-
95
- const OPTIONS = {
96
- ignoreUndefined: true,
97
- connectTimeoutMS: 0,
98
- serverSelectionTimeoutMS: 0
99
- }
100
-
101
- const ERR_DUPLICATE_KEY = 11000
102
-
103
- exports.Connection = Connection
@@ -1,58 +0,0 @@
1
- 'use strict'
2
-
3
- const insertManyMock = jest.fn(() => ({ acknowledged: true }))
4
- jest.mock('mongodb', () => ({
5
- __esModule: true,
6
- MongoClient: function () {
7
- this.connect = () => {},
8
- this.db = () => ({
9
- collection: () => ({
10
- insertMany: insertManyMock
11
- })
12
- })
13
- return this
14
- },
15
- }))
16
- jest.mock('@toa.io/pointer', () => ({
17
- __esModule: true,
18
- resolve: () => ['url'],
19
- }))
20
- const { generate } = require('randomstring')
21
- const { Connection } = require('../src/connection')
22
-
23
-
24
- let connection
25
-
26
- beforeEach(async () => {
27
- jest.clearAllMocks()
28
- connection = new Connection({ id: 1 })
29
- await connection.open()
30
- })
31
-
32
- it('should be', () => {
33
- expect(Connection).toBeDefined()
34
- })
35
-
36
- it('should insert', async () => {
37
- const object = generate()
38
-
39
- await connection.add(object)
40
-
41
- expect(insertManyMock).toHaveBeenCalledWith([object], { ordered: false })
42
- })
43
-
44
- it('should batch insert', async () => {
45
- const a = generate()
46
- const b = generate()
47
- const c = generate()
48
-
49
- await Promise.all([
50
- connection.add(a),
51
- connection.add(b),
52
- connection.add(c)
53
- ])
54
-
55
- expect(insertManyMock).toHaveBeenCalledTimes(2)
56
- expect(insertManyMock).toHaveBeenNthCalledWith(1, [a], { ordered: false })
57
- expect(insertManyMock).toHaveBeenNthCalledWith(2, [b, c], { ordered: false })
58
- })