@toa.io/storages.mongodb 1.0.0-alpha.19 → 1.0.0-alpha.194

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.19",
3
+ "version": "1.0.0-alpha.194",
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.19",
23
- "@toa.io/conveyor": "1.0.0-alpha.19",
24
- "@toa.io/core": "1.0.0-alpha.19",
25
- "@toa.io/generic": "1.0.0-alpha.19",
26
- "@toa.io/pointer": "1.0.0-alpha.19",
27
- "mongodb": "6.3.0",
22
+ "@toa.io/conveyor": "1.0.0-alpha.173",
23
+ "@toa.io/core": "1.0.0-alpha.194",
24
+ "@toa.io/generic": "1.0.0-alpha.173",
25
+ "@toa.io/pointer": "1.0.0-alpha.182",
26
+ "mongodb": "6.7.0",
27
+ "openspan": "1.0.0-alpha.173",
28
28
  "saslprep": "1.0.3"
29
29
  },
30
- "gitHead": "03c7af7e331dd5774ee53c0e9530535357bac8d2"
30
+ "gitHead": "b9f5743c5ee483c2b293db60b22048d3c2cf6107"
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
  /**
@@ -57,17 +61,30 @@ class Client extends Connector {
57
61
  */
58
62
  async open () {
59
63
  const urls = await this.resolveURLs()
60
- const db = this.resolveDB()
61
- const collection = this.locator.lowercase
64
+ const dbname = this.resolveDB()
62
65
 
63
- this.key = getKey(db, urls)
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++
69
76
 
70
- this.collection = this.instance.client.db(db).collection(collection)
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
+ }
71
88
  }
72
89
 
73
90
  /**
@@ -95,7 +112,7 @@ class Client extends Connector {
95
112
  const client = new MongoClient(urls.join(','), OPTIONS)
96
113
  const hosts = urls.map((str) => new URL(str).host)
97
114
 
98
- console.info('Connecting to MongoDB:', hosts.join(', '))
115
+ console.info('Connecting to MongoDB', { address: hosts.join(', ') })
99
116
 
100
117
  await client.connect()
101
118
 
@@ -139,9 +156,9 @@ function getKey (db, urls) {
139
156
  }
140
157
 
141
158
  const OPTIONS = {
142
- ignoreUndefined: true,
143
- connectTimeoutMS: 0,
144
- serverSelectionTimeoutMS: 0
159
+ ignoreUndefined: true
145
160
  }
146
161
 
162
+ const ALREADY_EXISTS = 48
163
+
147
164
  exports.Client = Client
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
47
52
 
48
- const recordset = await this.#connection.find(criteria, options)
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()
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,47 @@ 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
110
  if (error.code === ERR_DUPLICATE_KEY) {
79
-
80
111
  const id = error.keyPattern === undefined
81
112
  ? error.message.includes(' index: _id_ ') // AWS DocumentDB
82
113
  : error.keyPattern._id === 1
83
114
 
84
- if (id) {
115
+ if (id)
85
116
  return false
86
- } else {
87
- throw new exceptions.DuplicateException()
88
- }
89
- } else {
117
+ else
118
+ throw new exceptions.DuplicateException(this.#client.name, entity)
119
+ } else if (error.cause?.code === 'ECONNREFUSED') {
120
+ // This is temporary and should be replaced with a class decorator.
121
+ if (attempt > 10)
122
+ throw error
123
+
124
+ await new Promise((resolve) => setTimeout(resolve, 1000))
125
+
126
+ return this.store(entity)
127
+ } else
90
128
  throw error
91
- }
92
129
  }
93
130
  }
94
131
 
95
132
  async upsert (query, changeset) {
96
- const {
97
- criteria,
98
- options
99
- } = translate(query)
133
+ const { criteria, options } = translate(query)
100
134
 
101
135
  if (!('_deleted' in changeset) || changeset._deleted === null) {
102
136
  delete criteria._deleted
@@ -108,20 +142,43 @@ class Storage extends Connector {
108
142
  $inc: { _version: 1 }
109
143
  }
110
144
 
111
- options.returnDocument = 'after'
145
+ options.returnDocument = ReturnDocument.AFTER
146
+
147
+ this.debug('findOneAndUpdate', { criteria, update, options })
112
148
 
113
- const result = await this.#connection.update(criteria, update, options)
149
+ const result = await this.#collection.findOneAndUpdate(criteria, update, options)
114
150
 
115
151
  return from(result)
116
152
  }
117
153
 
154
+ async ensure (query, properties, state) {
155
+ let { criteria, options } = translate(query)
156
+
157
+ if (query === undefined)
158
+ criteria = properties
159
+
160
+ const update = { $setOnInsert: to(state) }
161
+
162
+ options.upsert = true
163
+ options.returnDocument = ReturnDocument.AFTER
164
+
165
+ console.debug('Database query', { collection: this.#collection.name, method: 'findOneAndUpdate', criteria, update, options })
166
+
167
+ const result = await this.#collection.findOneAndUpdate(criteria, update, options)
168
+
169
+ if (result._deleted !== undefined && result._deleted !== null)
170
+ return null
171
+ else
172
+ return from(result)
173
+ }
174
+
118
175
  async index () {
119
176
  const indexes = []
120
177
 
121
178
  if (this.#entity.unique !== undefined) {
122
179
  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)
180
+ const optional = this.getOptional(fields)
181
+ const unique = await this.uniqueIndex(name, fields, optional)
125
182
 
126
183
  indexes.push(unique)
127
184
  }
@@ -131,23 +188,24 @@ class Storage extends Connector {
131
188
  for (const [suffix, declaration] of Object.entries(this.#entity.index)) {
132
189
  const name = 'index_' + suffix
133
190
  const fields = Object.fromEntries(Object.entries(declaration)
134
- .map(([name, type]) => [name, INDEX_TYPES[type]]))
191
+ .map(([name, type]) => [name, INDEX_TYPES[type] ?? type]))
135
192
 
136
- const sparse = this.checkFields(Object.keys(fields))
193
+ const optional = this.getOptional(Object.keys(fields))
194
+ const options = { name, sparse: optional.length > 0 }
137
195
 
138
- await this.#connection.index(fields, {
139
- name,
140
- sparse
141
- })
196
+ console.info('Creating index', { fields, options })
197
+
198
+ await this.#collection.createIndex(fields, options)
199
+ .catch((e) => console.warn('MongoDB index creation failed', { collection: this.#collection.name, name, fields, error: e }))
142
200
 
143
201
  indexes.push(name)
144
202
  }
145
203
  }
146
204
 
147
- await this.removeObsolete(indexes)
205
+ await this.removeObsoleteIndexes(indexes)
148
206
  }
149
207
 
150
- async uniqueIndex (name, properties, sparse = false) {
208
+ async uniqueIndex (name, properties, optional) {
151
209
  const fields = properties.reduce((acc, property) => {
152
210
  acc[property] = 1
153
211
  return acc
@@ -155,48 +213,80 @@ class Storage extends Connector {
155
213
 
156
214
  name = 'unique_' + name
157
215
 
158
- await this.#connection.index(fields, {
159
- name,
160
- unique: true,
161
- sparse
162
- })
216
+ const options = { name, unique: true }
217
+
218
+ if (optional.length > 0)
219
+ options.partialFilterExpression = Object.fromEntries(optional.map((field) => [field, { $exists: true }]))
220
+
221
+ console.info('Creating unique index', { name, fields, options })
222
+
223
+ await this.#collection.createIndex(fields, options)
224
+ .catch((e) => console.warn('MongoDB unique index creation failed',
225
+ { collection: this.#collection.name, name, fields, error: e }))
163
226
 
164
227
  return name
165
228
  }
166
229
 
167
- async removeObsolete (desired) {
168
- const current = await this.#connection.indexes()
230
+ async removeObsoleteIndexes (desired) {
231
+ const current = await this.getCurrentIndexes()
169
232
  const obsolete = current.filter((name) => !desired.includes(name))
170
233
 
171
234
  if (obsolete.length > 0) {
172
- console.info(`Remove obsolete indexes: [${obsolete.join(', ')}]`)
235
+ console.info('Removing obsolete indexes', { collection: this.#collection.name, indexes: obsolete.join(', ') })
236
+
237
+ await Promise.all(obsolete.map((name) => this.#collection.dropIndex(name)))
238
+ }
239
+ }
240
+
241
+ async getCurrentIndexes () {
242
+ try {
243
+ const array = await this.#collection.listIndexes().toArray()
173
244
 
174
- await this.#connection.dropIndexes(obsolete)
245
+ return array.map(({ name }) => name).filter((name) => name !== '_id_')
246
+ } catch {
247
+ return []
175
248
  }
176
249
  }
177
250
 
178
- checkFields (fields) {
251
+ getOptional (fields) {
179
252
  const optional = []
180
253
 
181
- for (const field of fields) {
182
- if (!(field in this.#entity.schema.properties)) {
254
+ for (const field of fields) {
255
+ if (!field.includes('.') && !(field in this.#entity.schema.properties))
183
256
  throw new Error(`Index field '${field}' is not defined.`)
184
- }
185
257
 
186
- if (!this.#entity.schema.required?.includes(field)) {
258
+ if (!this.#entity.schema.required?.includes(field))
187
259
  optional.push(field)
188
- }
189
260
  }
190
261
 
191
- if (optional.length > 0) {
192
- console.info(`Index fields [${optional.join(', ')}] are optional, creating sparse index.`)
262
+ return optional
263
+ }
193
264
 
194
- return true
195
- } else {
196
- return false
197
- }
265
+ debug (method, attributes) {
266
+ console.debug('Database query', {
267
+ collection: this.#collection.name,
268
+ method,
269
+ ...attributes
270
+ })
198
271
  }
272
+ }
273
+
274
+ function toPipeline (criteria, options, sample) {
275
+ const pipeline = []
276
+
277
+ if (criteria !== undefined)
278
+ pipeline.push({ $match: criteria })
279
+
280
+ if (sample !== undefined)
281
+ pipeline.push({ $sample: { size: sample } })
282
+
283
+ if (options?.sort !== undefined)
284
+ pipeline.push({ $sort: options.sort })
285
+
286
+ if (options?.projection !== undefined)
287
+ pipeline.push({ $project: options.projection })
199
288
 
289
+ return pipeline
200
290
  }
201
291
 
202
292
  const INDEX_TYPES = {
package/src/translate.js CHANGED
@@ -9,18 +9,18 @@ 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?.version !== undefined)
20
20
  result.criteria._version = query.version
21
- }
22
21
 
23
- result.criteria._deleted = null
22
+ if (query?.search !== undefined)
23
+ result.criteria.$text = { $search: query.search }
24
24
 
25
25
  return result
26
26
  }
@@ -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