@toa.io/storages.mongodb 1.0.0-alpha.27 → 1.0.0-alpha.270

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/CHANGELOG.md ADDED
@@ -0,0 +1,68 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
+
6
+ # [1.0.0-alpha.270](https://github.com/toa-io/toa/compare/v1.0.0-alpha.269...v1.0.0-alpha.270) (2026-08-31)
7
+
8
+
9
+ ### Performance Improvements
10
+
11
+ * **boot:** stop paying for what a composition does not need to start ([1619c27](https://github.com/toa-io/toa/commit/1619c2743072f4706939ce72f3074e615d26a91e))
12
+ * **storages.mongodb:** time the call instead of monitoring the command ([135f1a9](https://github.com/toa-io/toa/commit/135f1a97c753b1caed9291f85cea0521ef68b0e3))
13
+
14
+
15
+
16
+
17
+
18
+ # [1.0.0-alpha.266](https://github.com/toa-io/toa/compare/v1.0.0-alpha.265...v1.0.0-alpha.266) (2026-08-29)
19
+
20
+ **Note:** Version bump only for package @toa.io/storages.mongodb
21
+
22
+
23
+
24
+
25
+
26
+ # [1.0.0-alpha.265](https://github.com/toa-io/toa/compare/v1.0.0-alpha.264...v1.0.0-alpha.265) (2026-08-29)
27
+
28
+ **Note:** Version bump only for package @toa.io/storages.mongodb
29
+
30
+
31
+
32
+
33
+
34
+ # [1.0.0-alpha.264](https://github.com/toa-io/toa/compare/v1.0.0-alpha.263...v1.0.0-alpha.264) (2026-08-29)
35
+
36
+ **Note:** Version bump only for package @toa.io/storages.mongodb
37
+
38
+
39
+
40
+
41
+
42
+ # [1.0.0-alpha.263](https://github.com/toa-io/toa/compare/v1.0.0-alpha.262...v1.0.0-alpha.263) (2026-08-29)
43
+
44
+ **Note:** Version bump only for package @toa.io/storages.mongodb
45
+
46
+
47
+
48
+
49
+
50
+ # [1.0.0-alpha.262](https://github.com/toa-io/toa/compare/v1.0.0-alpha.261...v1.0.0-alpha.262) (2026-08-28)
51
+
52
+ **Note:** Version bump only for package @toa.io/storages.mongodb
53
+
54
+
55
+
56
+
57
+
58
+ # [1.0.0-alpha.259](https://github.com/toa-io/toa/compare/v1.0.0-alpha.258...v1.0.0-alpha.259) (2026-08-24)
59
+
60
+ **Note:** Version bump only for package @toa.io/storages.mongodb
61
+
62
+
63
+
64
+
65
+
66
+ # [1.0.0-alpha.257](https://github.com/toa-io/toa/compare/v1.0.0-alpha.256...v1.0.0-alpha.257) (2026-08-24)
67
+
68
+ **Note:** Version bump only for package @toa.io/storages.mongodb
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/storages.mongodb",
3
- "version": "1.0.0-alpha.27",
3
+ "version": "1.0.0-alpha.270",
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.27",
23
- "@toa.io/conveyor": "1.0.0-alpha.27",
24
- "@toa.io/core": "1.0.0-alpha.27",
25
- "@toa.io/generic": "1.0.0-alpha.27",
26
- "@toa.io/pointer": "1.0.0-alpha.27",
27
- "mongodb": "6.3.0",
22
+ "@toa.io/conveyor": "1.0.0-alpha.254",
23
+ "@toa.io/core": "1.0.0-alpha.270",
24
+ "@toa.io/generic": "1.0.0-alpha.254",
25
+ "@toa.io/pointer": "1.0.0-alpha.270",
26
+ "mongodb": "7.2.0",
27
+ "openspan": "1.0.0-alpha.270",
28
28
  "saslprep": "1.0.3"
29
29
  },
30
- "gitHead": "9bc4bdb919688c5272791020746f70d51a520750"
30
+ "gitHead": "94400bdd411b4c25074ffcf58184580ddd060268"
31
31
  }
package/readme.md ADDED
@@ -0,0 +1,16 @@
1
+ # MongoDB Storage
2
+
3
+ ## Tracing
4
+
5
+ Commands are recorded as `client` spans within the trace of the current invocation,
6
+ using the [driver command monitoring events](https://www.mongodb.com/docs/drivers/node/current/monitoring/command-monitoring/).
7
+
8
+ Spans are named `{command} {collection}` and carry `db.*` attributes following the
9
+ [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/database/mongodb/):
10
+ `db.system`, `db.namespace`, `db.collection.name`, `db.operation.name`.
11
+
12
+ Commands executed outside of a sampled trace context (e.g. index management on startup)
13
+ and internal driver commands (`hello`, `ping`, authentication) are not recorded.
14
+
15
+ Monitoring is client-side only and does not affect the MongoDB server. Span recording
16
+ adds no waiting to the query path: exporting is buffered and happens in the background.
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
 
@@ -148,10 +155,14 @@ function getKey (db, urls) {
148
155
  return db + ':' + urls.sort().join(' ')
149
156
  }
150
157
 
158
+ /**
159
+ * `monitorCommands` is deliberately absent. It makes the driver materialize every reply
160
+ * eagerly to populate the monitoring event (`CommandSucceededEvent`), which defeats the
161
+ * lazy per-document deserialization a cursor exists for — a 100-document batch is then
162
+ * deserialized twice. `Storage` times its own calls instead.
163
+ */
151
164
  const OPTIONS = {
152
- ignoreUndefined: true,
153
- connectTimeoutMS: 0,
154
- serverSelectionTimeoutMS: 0
165
+ ignoreUndefined: true
155
166
  }
156
167
 
157
168
  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,94 @@
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
+ /** @type {Map<string, object>} span options per driver method */
17
+ #spans = new Map()
18
+
19
+ constructor (client, entity) {
19
20
  super()
20
21
 
21
- this.#connection = connection
22
+ this.#client = client
22
23
  this.#entity = entity
23
24
 
24
- this.depends(connection)
25
+ this.depends(client)
26
+ }
27
+
28
+ get raw () {
29
+ return this.#collection
25
30
  }
26
31
 
27
32
  async open () {
33
+ this.#collection = this.#client.collection
34
+
35
+ this.#spans.clear()
36
+
28
37
  await this.index()
29
38
  }
30
39
 
31
40
  async get (query) {
32
- const {
33
- criteria,
34
- options
35
- } = translate(query)
41
+ const { criteria, options } = translate(query)
42
+
43
+ // identity lookups must return deleted records, so that callers
44
+ // can tell a deleted entity from a missing one
45
+ if (query?.id === undefined && query?.options?.deleted !== true)
46
+ criteria._deleted = null
36
47
 
37
- const record = await this.#connection.get(criteria, options)
48
+ const record = await this.command('findOne', { criteria, options },
49
+ () => this.#collection.findOne(criteria, options))
38
50
 
39
51
  return from(record)
40
52
  }
41
53
 
42
54
  async find (query) {
43
- const {
44
- criteria,
45
- options
46
- } = translate(query)
55
+ const { criteria, options, sample } = translate(query)
56
+
57
+ if (query?.options?.deleted !== true)
58
+ criteria._deleted = null
47
59
 
48
- const recordset = await this.#connection.find(criteria, options)
60
+ const recordset = sample === undefined
61
+ ? await this.command('find', { criteria, options },
62
+ async () => await this.#collection.find(criteria, options).toArray())
63
+ : await this.aggregate(criteria, options, sample)
49
64
 
50
65
  return recordset.map((item) => from(item))
51
66
  }
52
67
 
68
+ /** @private */
69
+ async aggregate (criteria, options, sample) {
70
+ const pipeline = toPipeline(criteria, options, sample)
71
+
72
+ return await this.command('aggregate', { pipeline },
73
+ async () => await this.#collection.aggregate(pipeline).toArray())
74
+ }
75
+
76
+ async stream (query = undefined) {
77
+ const { criteria, options } = translate(query)
78
+
79
+ if (query?.options?.deleted !== true)
80
+ criteria._deleted = null
81
+
82
+ this.debug('find (stream)', { criteria, options })
83
+
84
+ return this.#collection.find(criteria, options).stream({ transform: from })
85
+ }
86
+
53
87
  async add (entity) {
54
88
  const record = to(entity)
55
- const result = await this.#connection.add(record)
89
+
90
+ const result = await this.command('insertOne', { record },
91
+ () => this.#collection.insertOne(record))
56
92
 
57
93
  return result.acknowledged
58
94
  }
@@ -62,41 +98,89 @@ class Storage extends Connector {
62
98
  _id: entity.id,
63
99
  _version: entity._version - 1
64
100
  }
65
- const result = await this.#connection.replace(criteria, to(entity))
101
+
102
+ const record = to(entity)
103
+
104
+ const result = await this.command('findOneAndReplace', { criteria, record },
105
+ () => this.#collection.findOneAndReplace(criteria, record))
66
106
 
67
107
  return result !== null
68
108
  }
69
109
 
70
- async store (entity) {
110
+ async store (entity, attempt = 0) {
71
111
  try {
72
- if (entity._version === 1) {
112
+ if (entity._version === 1)
73
113
  return await this.add(entity)
74
- } else {
114
+ else
75
115
  return await this.set(entity)
76
- }
77
116
  } catch (error) {
78
- if (error.code === ERR_DUPLICATE_KEY) {
117
+ console.error('MongoDB error', error)
79
118
 
80
- const id = error.keyPattern === undefined
81
- ? error.message.includes(' index: _id_ ') // AWS DocumentDB
82
- : error.keyPattern._id === 1
119
+ const retry = await retriable(error, attempt)
83
120
 
84
- if (id) {
85
- return false
86
- } else {
87
- throw new exceptions.DuplicateException()
121
+ if (retry)
122
+ return await this.store(entity, attempt + 1)
123
+ else
124
+ return false
125
+ }
126
+ }
127
+
128
+ async massStore (entities, attempt = 0) {
129
+ if (entities.length === 0)
130
+ return true
131
+
132
+ const operations = entities.map((entity) => {
133
+ const record = to(entity)
134
+
135
+ if (entity._version === 1) {
136
+ const { _version, ...rest } = record
137
+
138
+ return { // upsert in required when document is deleted
139
+ updateOne: {
140
+ filter: { _id: entity.id },
141
+ update: {
142
+ $set: {
143
+ ...rest,
144
+ _deleted: null
145
+ },
146
+ $inc: { _version: 1 },
147
+ },
148
+ upsert: true
149
+ }
88
150
  }
89
- } else {
90
- throw error
91
- }
151
+ } else
152
+ return {
153
+ replaceOne: {
154
+ filter: { _id: entity.id, _version: entity._version - 1 },
155
+ replacement: record
156
+ }
157
+ }
158
+ })
159
+
160
+ const client = this.#client.instance.client
161
+
162
+ try {
163
+ await client.withSession(async (session) => {
164
+ await session.withTransaction(async () =>
165
+ await this.command('bulkWrite', { operations: operations.length },
166
+ async () => await this.#collection.bulkWrite(operations, { session })))
167
+ })
168
+
169
+ return true
170
+ } catch (error) {
171
+ console.error('MongoDB error', error)
172
+
173
+ const retry = await retriable(error, attempt)
174
+
175
+ if (retry)
176
+ return await this.massStore(entities, attempt + 1)
177
+ else
178
+ return false
92
179
  }
93
180
  }
94
181
 
95
182
  async upsert (query, changeset) {
96
- const {
97
- criteria,
98
- options
99
- } = translate(query)
183
+ const { criteria, options } = translate(query)
100
184
 
101
185
  if (!('_deleted' in changeset) || changeset._deleted === null) {
102
186
  delete criteria._deleted
@@ -108,46 +192,74 @@ class Storage extends Connector {
108
192
  $inc: { _version: 1 }
109
193
  }
110
194
 
111
- options.returnDocument = 'after'
195
+ options.returnDocument = ReturnDocument.AFTER
112
196
 
113
- const result = await this.#connection.update(criteria, update, options)
197
+ const result = await this.command('findOneAndUpdate', { criteria, update, options },
198
+ () => this.#collection.findOneAndUpdate(criteria, update, options))
114
199
 
115
200
  return from(result)
116
201
  }
117
202
 
203
+ async ensure (query, properties, state) {
204
+ let { criteria, options } = translate(query)
205
+
206
+ if (query === undefined)
207
+ criteria = properties
208
+
209
+ const update = { $setOnInsert: to(state) }
210
+
211
+ options.upsert = true
212
+ options.returnDocument = ReturnDocument.AFTER
213
+
214
+ try {
215
+ const result = await this.command('findOneAndUpdate', { criteria, update, options },
216
+ () => this.#collection.findOneAndUpdate(criteria, update, options))
217
+
218
+ if (result._deleted !== undefined && result._deleted !== null)
219
+ return null
220
+ else
221
+ return from(result)
222
+ } catch (error) {
223
+ if (error.code === ERR_DUPLICATE_KEY)
224
+ throw new exceptions.DuplicateException(this.#client.name)
225
+ else
226
+ throw error
227
+ }
228
+ }
229
+
230
+ /** A component does not start before this returns, so the indexes are created at once. */
118
231
  async index () {
119
- const indexes = []
232
+ const pending = []
120
233
 
121
- if (this.#entity.unique !== undefined) {
234
+ if (this.#entity.unique !== undefined)
122
235
  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)
236
+ const optional = this.getOptional(fields)
125
237
 
126
- indexes.push(unique)
238
+ pending.push(this.uniqueIndex(name, fields, optional))
127
239
  }
128
- }
129
240
 
130
- if (this.#entity.index !== undefined) {
241
+ if (this.#entity.index !== undefined)
131
242
  for (const [suffix, declaration] of Object.entries(this.#entity.index)) {
132
243
  const name = 'index_' + suffix
133
244
  const fields = Object.fromEntries(Object.entries(declaration)
134
- .map(([name, type]) => [name, INDEX_TYPES[type]]))
245
+ .map(([name, type]) => [name, INDEX_TYPES[type] ?? type]))
135
246
 
136
- const sparse = this.checkFields(Object.keys(fields))
247
+ const optional = this.getOptional(Object.keys(fields))
248
+ const options = { name, sparse: optional.length > 0 }
137
249
 
138
- await this.#connection.index(fields, {
139
- name,
140
- sparse
141
- })
250
+ console.info('Creating index', { fields, options })
142
251
 
143
- indexes.push(name)
252
+ pending.push(this.#collection.createIndex(fields, options)
253
+ .catch((e) => console.warn('MongoDB index creation failed', { collection: this.#collection.collectionName, name, fields, error: e }))
254
+ .then(() => name))
144
255
  }
145
- }
146
256
 
147
- await this.removeObsolete(indexes)
257
+ const indexes = await Promise.all(pending)
258
+
259
+ await this.removeObsoleteIndexes(indexes)
148
260
  }
149
261
 
150
- async uniqueIndex (name, properties, sparse = false) {
262
+ async uniqueIndex (name, properties, optional) {
151
263
  const fields = properties.reduce((acc, property) => {
152
264
  acc[property] = 1
153
265
  return acc
@@ -155,48 +267,122 @@ class Storage extends Connector {
155
267
 
156
268
  name = 'unique_' + name
157
269
 
158
- await this.#connection.index(fields, {
159
- name,
160
- unique: true,
161
- sparse
162
- })
270
+ const options = { name, unique: true }
271
+
272
+ if (optional.length > 0)
273
+ options.partialFilterExpression = Object.fromEntries(optional.map((field) => [field, { $exists: true }]))
274
+
275
+ console.info('Creating unique index', { name, fields, options })
276
+
277
+ await this.#collection.createIndex(fields, options)
278
+ .catch((e) => console.warn('MongoDB unique index creation failed',
279
+ { collection: this.#collection.collectionName, name, fields, error: e }))
163
280
 
164
281
  return name
165
282
  }
166
283
 
167
- async removeObsolete (desired) {
168
- const current = await this.#connection.indexes()
284
+ async removeObsoleteIndexes (desired) {
285
+ const current = await this.getCurrentIndexes()
169
286
  const obsolete = current.filter((name) => !desired.includes(name))
170
287
 
171
288
  if (obsolete.length > 0) {
172
- console.info(`Remove obsolete indexes: [${obsolete.join(', ')}]`)
289
+ console.info('Removing obsolete indexes', { collection: this.#collection.collectionName, indexes: obsolete.join(', ') })
290
+
291
+ await Promise.all(obsolete.map((name) => this.#collection.dropIndex(name)))
292
+ }
293
+ }
294
+
295
+ async getCurrentIndexes () {
296
+ try {
297
+ const array = await this.#collection.listIndexes().toArray()
173
298
 
174
- await this.#connection.dropIndexes(obsolete)
299
+ return array.map(({ name }) => name).filter((name) => name !== '_id_')
300
+ } catch {
301
+ return []
175
302
  }
176
303
  }
177
304
 
178
- checkFields (fields) {
305
+ getOptional (fields) {
179
306
  const optional = []
180
307
 
181
- for (const field of fields) {
182
- if (!(field in this.#entity.schema.properties)) {
308
+ for (const field of fields) {
309
+ if (!field.includes('.') && !(field in this.#entity.schema.properties))
183
310
  throw new Error(`Index field '${field}' is not defined.`)
184
- }
185
311
 
186
- if (!this.#entity.schema.required?.includes(field)) {
312
+ if (!this.#entity.schema.required?.includes(field))
187
313
  optional.push(field)
188
- }
189
314
  }
190
315
 
191
- if (optional.length > 0) {
192
- console.info(`Index fields [${optional.join(', ')}] are optional, creating sparse index.`)
316
+ return optional
317
+ }
318
+
319
+ /**
320
+ * Names, logs and times a call into the driver. The driver's own command monitoring is
321
+ * off (see `client.js`), so this is where a query becomes a span.
322
+ *
323
+ * @private
324
+ */
325
+ async command (method, attributes, task) {
326
+ this.debug(method, attributes)
193
327
 
194
- return true
195
- } else {
196
- return false
328
+ return console.span(this.span(method), task)
329
+ }
330
+
331
+ /**
332
+ * The span of a driver method is the same object every time: the collection is fixed
333
+ * for a storage, and nothing downstream writes to what it is given.
334
+ *
335
+ * @private
336
+ */
337
+ span (method) {
338
+ let options = this.#spans.get(method)
339
+
340
+ if (options === undefined) {
341
+ const collection = this.#collection.collectionName
342
+
343
+ options = {
344
+ name: `${method} ${collection}`,
345
+ kind: 'client',
346
+ // https://opentelemetry.io/docs/specs/semconv/database/mongodb/
347
+ attributes: {
348
+ 'db.system': 'mongodb',
349
+ 'db.namespace': this.#collection.dbName,
350
+ 'db.operation.name': method,
351
+ 'db.collection.name': collection
352
+ }
353
+ }
354
+
355
+ this.#spans.set(method, options)
197
356
  }
357
+
358
+ return options
198
359
  }
199
360
 
361
+ debug (method, attributes) {
362
+ console.debug('MongoDB query', {
363
+ collection: this.#collection.collectionName,
364
+ method,
365
+ ...attributes
366
+ })
367
+ }
368
+ }
369
+
370
+ function toPipeline (criteria, options, sample) {
371
+ const pipeline = []
372
+
373
+ if (criteria !== undefined)
374
+ pipeline.push({ $match: criteria })
375
+
376
+ if (sample !== undefined)
377
+ pipeline.push({ $sample: { size: sample } })
378
+
379
+ if (options?.sort !== undefined)
380
+ pipeline.push({ $sort: options.sort })
381
+
382
+ if (options?.projection !== undefined)
383
+ pipeline.push({ $project: options.projection })
384
+
385
+ return pipeline
200
386
  }
201
387
 
202
388
  const INDEX_TYPES = {
@@ -207,4 +393,29 @@ const INDEX_TYPES = {
207
393
 
208
394
  const ERR_DUPLICATE_KEY = 11000
209
395
 
396
+ async function retriable (error, attempt) {
397
+ if (error.code === ERR_DUPLICATE_KEY) {
398
+ const id = error.keyPattern === undefined
399
+ ? error.message.includes(' index: _id_ ') // AWS DocumentDB
400
+ : error.keyPattern._id === 1
401
+
402
+ if (id)
403
+ return false
404
+ else
405
+ throw new exceptions.DuplicateException()
406
+ } else if (error.cause?.code === 'ECONNREFUSED') {
407
+ if (attempt === LAST_ATTEMPT)
408
+ throw error
409
+
410
+ const timeout = 1000 + 500 * attempt
411
+
412
+ await new Promise((resolve) => setTimeout(resolve, timeout))
413
+
414
+ return true
415
+ } else
416
+ throw error
417
+ }
418
+
419
+ const LAST_ATTEMPT = 9
420
+
210
421
  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
  })
@@ -0,0 +1,73 @@
1
+ 'use strict'
2
+
3
+ const { Storage } = require('../src/storage')
4
+
5
+ let collection
6
+ let storage
7
+
8
+ beforeEach(async () => {
9
+ collection = {
10
+ collectionName: 'test',
11
+ findOne: jest.fn(async () => null),
12
+ find: jest.fn(() => ({ stream: () => null }))
13
+ }
14
+
15
+ const client = {
16
+ collection,
17
+ link: () => null
18
+ }
19
+
20
+ storage = new Storage(client, { schema: { properties: {} } })
21
+
22
+ await storage.open()
23
+ })
24
+
25
+ describe('get', () => {
26
+ it('should filter deleted', async () => {
27
+ await storage.get({})
28
+
29
+ expect(collection.findOne).toHaveBeenCalledWith({ _deleted: null }, {})
30
+ })
31
+
32
+ it('should filter deleted with sort', async () => {
33
+ await storage.get({ options: { sort: [['_created', 'desc']] } })
34
+
35
+ expect(collection.findOne)
36
+ .toHaveBeenCalledWith({ _deleted: null }, { sort: [['_created', -1]] })
37
+ })
38
+
39
+ it('should not filter deleted if queried by id', async () => {
40
+ const id = 'bcb6780f50e243348cad40ed6b5ef575'
41
+
42
+ await storage.get({ id })
43
+
44
+ expect(collection.findOne).toHaveBeenCalledWith({ _id: id }, {})
45
+ })
46
+
47
+ it('should not filter deleted if requested', async () => {
48
+ await storage.get({ options: { deleted: true } })
49
+
50
+ expect(collection.findOne).toHaveBeenCalledWith({}, {})
51
+ })
52
+ })
53
+
54
+ describe('stream', () => {
55
+ it('should filter deleted', async () => {
56
+ await storage.stream()
57
+
58
+ expect(collection.find).toHaveBeenCalledWith({ _deleted: null }, {})
59
+ })
60
+
61
+ it('should filter deleted with sort', async () => {
62
+ await storage.stream({ options: { sort: [['_created', 'desc']] } })
63
+
64
+ expect(collection.find)
65
+ .toHaveBeenCalledWith({ _deleted: null }, { sort: [['_created', -1]] })
66
+ })
67
+
68
+ it('should not filter deleted if requested', async () => {
69
+ await storage.stream({ options: { deleted: true } })
70
+
71
+ expect(collection.find).toHaveBeenCalledWith({}, {})
72
+ })
73
+ })
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