@toa.io/storages.mongodb 1.0.0-alpha.21 → 1.0.0-alpha.212

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