@toa.io/storages.mongodb 1.0.0-alpha.15 → 1.0.0-alpha.156

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.15",
3
+ "version": "1.0.0-alpha.156",
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.15",
23
- "@toa.io/conveyor": "1.0.0-alpha.15",
24
- "@toa.io/core": "1.0.0-alpha.15",
25
- "@toa.io/generic": "1.0.0-alpha.15",
26
- "@toa.io/pointer": "1.0.0-alpha.15",
27
- "mongodb": "6.3.0",
22
+ "@toa.io/conveyor": "1.0.0-alpha.93",
23
+ "@toa.io/core": "1.0.0-alpha.156",
24
+ "@toa.io/generic": "1.0.0-alpha.93",
25
+ "@toa.io/pointer": "1.0.0-alpha.143",
26
+ "mongodb": "6.7.0",
27
+ "openspan": "1.0.0-alpha.93",
28
28
  "saslprep": "1.0.3"
29
29
  },
30
- "gitHead": "8c42f0b136162a579859d6baa7f940bd16c66821"
30
+ "gitHead": "687ebdf64b8fbc177e3a802440ecd6c96d6b068f"
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,90 @@
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
+ #logs
17
+
18
+ constructor (client, entity) {
19
19
  super()
20
20
 
21
- this.#connection = connection
21
+ this.#client = client
22
22
  this.#entity = entity
23
23
 
24
- this.depends(connection)
24
+ this.depends(client)
25
+
26
+ this.#logs = console.fork({ collection: client.name })
27
+ }
28
+
29
+ get raw () {
30
+ return this.#collection
25
31
  }
26
32
 
27
33
  async open () {
34
+ this.#collection = this.#client.collection
35
+
28
36
  await this.index()
29
37
  }
30
38
 
31
39
  async get (query) {
32
- const {
33
- criteria,
34
- options
35
- } = translate(query)
40
+ const { criteria, options } = translate(query)
41
+
42
+ this.debug('findOne', { criteria, options })
36
43
 
37
- const record = await this.#connection.get(criteria, options)
44
+ const record = await this.#collection.findOne(criteria, options)
38
45
 
39
46
  return from(record)
40
47
  }
41
48
 
42
49
  async find (query) {
43
- const {
44
- criteria,
45
- options
46
- } = translate(query)
50
+ debugger
51
+ const { criteria, options, sample } = translate(query)
52
+
53
+ criteria._deleted = null
54
+
55
+ let cursor
56
+
57
+ if (sample === undefined) {
58
+ this.debug('find', { criteria, options })
59
+
60
+ cursor = this.#collection.find(criteria, options)
61
+ } else {
62
+ const pipeline = toPipeline(criteria, options, sample)
63
+
64
+ this.debug('aggregate', { pipeline })
65
+
66
+ cursor = this.#collection.aggregate(pipeline)
67
+ }
47
68
 
48
- const recordset = await this.#connection.find(criteria, options)
69
+ const recordset = await cursor.toArray()
49
70
 
50
71
  return recordset.map((item) => from(item))
51
72
  }
52
73
 
74
+ async stream (query = undefined) {
75
+ const { criteria, options } = translate(query)
76
+
77
+ this.debug('find (stream)', { criteria, options })
78
+
79
+ return this.#collection.find(criteria, options).stream({ transform: from })
80
+ }
81
+
53
82
  async add (entity) {
54
83
  const record = to(entity)
55
- const result = await this.#connection.add(record)
84
+
85
+ this.debug('insertOne', { record })
86
+
87
+ const result = await this.#collection.insertOne(record)
56
88
 
57
89
  return result.acknowledged
58
90
  }
@@ -62,41 +94,47 @@ class Storage extends Connector {
62
94
  _id: entity.id,
63
95
  _version: entity._version - 1
64
96
  }
65
- const result = await this.#connection.replace(criteria, to(entity))
97
+
98
+ const record = to(entity)
99
+
100
+ this.debug('findOneAndReplace', { criteria, record })
101
+
102
+ const result = await this.#collection.findOneAndReplace(criteria, record)
66
103
 
67
104
  return result !== null
68
105
  }
69
106
 
70
- async store (entity) {
107
+ async store (entity, attempt = 0) {
71
108
  try {
72
- if (entity._version === 1) {
109
+ if (entity._version === 1)
73
110
  return await this.add(entity)
74
- } else {
111
+ else
75
112
  return await this.set(entity)
76
- }
77
113
  } catch (error) {
78
114
  if (error.code === ERR_DUPLICATE_KEY) {
79
-
80
115
  const id = error.keyPattern === undefined
81
116
  ? error.message.includes(' index: _id_ ') // AWS DocumentDB
82
117
  : error.keyPattern._id === 1
83
118
 
84
- if (id) {
119
+ if (id)
85
120
  return false
86
- } else {
87
- throw new exceptions.DuplicateException()
88
- }
89
- } else {
121
+ else
122
+ throw new exceptions.DuplicateException(this.#client.name, entity)
123
+ } else if (error.cause?.code === 'ECONNREFUSED') {
124
+ // This is temporary and should be replaced with a class decorator.
125
+ if (attempt > 10)
126
+ throw error
127
+
128
+ await new Promise((resolve) => setTimeout(resolve, 1000))
129
+
130
+ return this.store(entity)
131
+ } else
90
132
  throw error
91
- }
92
133
  }
93
134
  }
94
135
 
95
136
  async upsert (query, changeset) {
96
- const {
97
- criteria,
98
- options
99
- } = translate(query)
137
+ const { criteria, options } = translate(query)
100
138
 
101
139
  if (!('_deleted' in changeset) || changeset._deleted === null) {
102
140
  delete criteria._deleted
@@ -108,13 +146,36 @@ class Storage extends Connector {
108
146
  $inc: { _version: 1 }
109
147
  }
110
148
 
111
- options.returnDocument = 'after'
149
+ options.returnDocument = ReturnDocument.AFTER
150
+
151
+ this.debug('findOneAndUpdate', { criteria, update, options })
112
152
 
113
- const result = await this.#connection.update(criteria, update, options)
153
+ const result = await this.#collection.findOneAndUpdate(criteria, update, options)
114
154
 
115
155
  return from(result)
116
156
  }
117
157
 
158
+ async ensure (query, properties, state) {
159
+ let { criteria, options } = translate(query)
160
+
161
+ if (query === undefined)
162
+ criteria = properties
163
+
164
+ const update = { $setOnInsert: to(state) }
165
+
166
+ options.upsert = true
167
+ options.returnDocument = ReturnDocument.AFTER
168
+
169
+ this.#logs.debug('Database query', { method: 'findOneAndUpdate', criteria, update, options })
170
+
171
+ const result = await this.#collection.findOneAndUpdate(criteria, update, options)
172
+
173
+ if (result._deleted !== undefined && result._deleted !== null)
174
+ return null
175
+ else
176
+ return from(result)
177
+ }
178
+
118
179
  async index () {
119
180
  const indexes = []
120
181
 
@@ -131,20 +192,18 @@ class Storage extends Connector {
131
192
  for (const [suffix, declaration] of Object.entries(this.#entity.index)) {
132
193
  const name = 'index_' + suffix
133
194
  const fields = Object.fromEntries(Object.entries(declaration)
134
- .map(([name, type]) => [name, INDEX_TYPES[type]]))
195
+ .map(([name, type]) => [name, INDEX_TYPES[type] ?? type]))
135
196
 
136
197
  const sparse = this.checkFields(Object.keys(fields))
137
198
 
138
- await this.#connection.index(fields, {
139
- name,
140
- sparse
141
- })
199
+ await this.#collection.createIndex(fields, { name, sparse })
200
+ .catch((e) => this.#logs.warn('Index creation failed', { name, fields, error: e }))
142
201
 
143
202
  indexes.push(name)
144
203
  }
145
204
  }
146
205
 
147
- await this.removeObsolete(indexes)
206
+ await this.removeObsoleteIndexes(indexes)
148
207
  }
149
208
 
150
209
  async uniqueIndex (name, properties, sparse = false) {
@@ -155,23 +214,30 @@ class Storage extends Connector {
155
214
 
156
215
  name = 'unique_' + name
157
216
 
158
- await this.#connection.index(fields, {
159
- name,
160
- unique: true,
161
- sparse
162
- })
217
+ await this.#collection.createIndex(fields, { name, unique: true, sparse })
218
+ .catch((e) => this.#logs.warn('Unique index creation failed', { name, fields, error: e }))
163
219
 
164
220
  return name
165
221
  }
166
222
 
167
- async removeObsolete (desired) {
168
- const current = await this.#connection.indexes()
223
+ async removeObsoleteIndexes (desired) {
224
+ const current = await this.getCurrentIndexes()
169
225
  const obsolete = current.filter((name) => !desired.includes(name))
170
226
 
171
227
  if (obsolete.length > 0) {
172
- console.info(`Remove obsolete indexes: [${obsolete.join(', ')}]`)
228
+ this.#logs.info('Removing obsolete indexes', { indexes: obsolete.join(', ') })
173
229
 
174
- await this.#connection.dropIndexes(obsolete)
230
+ await Promise.all(obsolete.map((name) => this.#collection.dropIndex(name)))
231
+ }
232
+ }
233
+
234
+ async getCurrentIndexes () {
235
+ try {
236
+ const array = await this.#collection.listIndexes().toArray()
237
+
238
+ return array.map(({ name }) => name).filter((name) => name !== '_id_')
239
+ } catch {
240
+ return []
175
241
  }
176
242
  }
177
243
 
@@ -179,24 +245,45 @@ class Storage extends Connector {
179
245
  const optional = []
180
246
 
181
247
  for (const field of fields) {
182
- if (!(field in this.#entity.schema.properties)) {
248
+ if (!(field in this.#entity.schema.properties))
183
249
  throw new Error(`Index field '${field}' is not defined.`)
184
- }
185
250
 
186
- if (!this.#entity.schema.required?.includes(field)) {
251
+ if (!this.#entity.schema.required?.includes(field))
187
252
  optional.push(field)
188
- }
189
253
  }
190
254
 
191
255
  if (optional.length > 0) {
192
- console.info(`Index fields [${optional.join(', ')}] are optional, creating sparse index.`)
256
+ this.#logs.info('Index fields are optional, creating sparse index', { fields: optional })
193
257
 
194
258
  return true
195
- } else {
259
+ } else
196
260
  return false
197
- }
198
261
  }
199
262
 
263
+ debug (method, attributes) {
264
+ this.#logs.debug('Database query', {
265
+ method,
266
+ ...attributes
267
+ })
268
+ }
269
+ }
270
+
271
+ function toPipeline (criteria, options, sample) {
272
+ const pipeline = []
273
+
274
+ if (criteria !== undefined)
275
+ pipeline.push({ $match: criteria })
276
+
277
+ if (sample !== undefined)
278
+ pipeline.push({ $sample: { size: sample } })
279
+
280
+ if (options?.sort !== undefined)
281
+ pipeline.push({ $sort: options.sort })
282
+
283
+ if (options?.projection !== undefined)
284
+ pipeline.push({ $project: options.projection })
285
+
286
+ return pipeline
200
287
  }
201
288
 
202
289
  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