@toa.io/storages.mongodb 1.0.0-alpha.7 → 1.0.0-alpha.72

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.7",
3
+ "version": "1.0.0-alpha.72",
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.7",
23
- "@toa.io/conveyor": "1.0.0-alpha.7",
24
- "@toa.io/core": "1.0.0-alpha.7",
25
- "@toa.io/generic": "1.0.0-alpha.7",
26
- "@toa.io/pointer": "1.0.0-alpha.7",
27
- "mongodb": "6.3.0",
22
+ "@toa.io/conveyor": "1.0.0-alpha.63",
23
+ "@toa.io/core": "1.0.0-alpha.67",
24
+ "@toa.io/generic": "1.0.0-alpha.63",
25
+ "@toa.io/pointer": "1.0.0-alpha.63",
26
+ "mongodb": "6.7.0",
27
+ "openspan": "1.0.0-alpha.67",
28
28
  "saslprep": "1.0.3"
29
29
  },
30
- "gitHead": "4f5ac0bc342d4b7bd469fbe5c74266f050b55c9f"
30
+ "gitHead": "919b587dfcdfab40d18f0f8bd24b34d8107f811a"
31
31
  }
package/src/client.js ADDED
@@ -0,0 +1,160 @@
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
+ }
55
+
56
+ /**
57
+ * @protected
58
+ * @override
59
+ * @return {Promise<void>}
60
+ */
61
+ async open () {
62
+ const urls = await this.resolveURLs()
63
+ const dbname = this.resolveDB()
64
+
65
+ this.name = this.locator.lowercase
66
+ this.key = getKey(dbname, urls)
67
+
68
+ INSTANCES[this.key] ??= this.createInstance(urls)
69
+
70
+ this.instance = await INSTANCES[this.key]
71
+ this.instance.count++
72
+
73
+ const db = this.instance.client.db(dbname)
74
+
75
+ try {
76
+ this.collection = await db.createCollection(this.name)
77
+ } catch (e) {
78
+ if (e.code !== ALREADY_EXISTS) {
79
+ throw e
80
+ }
81
+
82
+ this.collection = db.collection(this.name)
83
+ }
84
+ }
85
+
86
+ /**
87
+ * @protected
88
+ * @override
89
+ * @return {Promise<void>}
90
+ */
91
+ async close () {
92
+ const instance = await INSTANCES[this.key]
93
+
94
+ instance.count--
95
+
96
+ if (instance.count === 0) {
97
+ await instance.client.close()
98
+ delete INSTANCES[this.key]
99
+ }
100
+ }
101
+
102
+ /**
103
+ * @private
104
+ * @param {string[]} urls
105
+ * @return {Promise<Instance>}
106
+ */
107
+ async createInstance (urls) {
108
+ const client = new MongoClient(urls.join(','), OPTIONS)
109
+ const hosts = urls.map((str) => new URL(str).host)
110
+
111
+ console.info('Connecting to MongoDB', { address: hosts.join(', ') })
112
+
113
+ await client.connect()
114
+
115
+ return {
116
+ count: 0,
117
+ client
118
+ }
119
+ }
120
+
121
+ /**
122
+ * @private
123
+ * @return {Promise<string[]>}
124
+ */
125
+ async resolveURLs () {
126
+ if (process.env.TOA_DEV === '1') {
127
+ return ['mongodb://developer:secret@localhost']
128
+ } else {
129
+ return await resolve(ID, this.locator.id)
130
+ }
131
+ }
132
+
133
+ /**
134
+ * @private
135
+ * @return {string}
136
+ */
137
+ resolveDB () {
138
+ if (process.env.TOA_CONTEXT !== undefined) {
139
+ return process.env.TOA_CONTEXT
140
+ }
141
+
142
+ if (process.env.TOA_DEV === '1') {
143
+ return 'toa-dev'
144
+ }
145
+
146
+ throw new Error('Environment variable TOA_CONTEXT is not defined')
147
+ }
148
+ }
149
+
150
+ function getKey (db, urls) {
151
+ return db + ':' + urls.sort().join(' ')
152
+ }
153
+
154
+ const OPTIONS = {
155
+ ignoreUndefined: true
156
+ }
157
+
158
+ const ALREADY_EXISTS = 48
159
+
160
+ 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,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,56 +1,69 @@
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
- const {
7
- to,
8
- from
9
- } = require('./record')
10
-
11
- /**
12
- * @implements {toa.core.Storage}
13
- */
6
+ const { to, from } = require('./record')
7
+ const { ReturnDocument } = require('mongodb')
8
+
14
9
  class Storage extends Connector {
15
- /** @type {toa.mongodb.Connection} */
16
- #connection
10
+ #client
17
11
 
18
- /**
19
- * @param {toa.mongodb.Connection} connection
20
- */
21
- constructor (connection) {
12
+ /** @type {import('mongodb').Collection} */
13
+ #collection
14
+ #entity
15
+
16
+ constructor (client, entity) {
22
17
  super()
23
18
 
24
- this.#connection = connection
19
+ this.#client = client
20
+ this.#entity = entity
21
+
22
+ this.depends(client)
23
+ }
24
+
25
+ async open () {
26
+ this.#collection = this.#client.collection
25
27
 
26
- this.depends(connection)
28
+ await this.index()
27
29
  }
28
30
 
29
31
  async get (query) {
30
- const {
31
- criteria,
32
- options
33
- } = translate(query)
32
+ const { criteria, options } = translate(query)
34
33
 
35
- const record = await this.#connection.get(criteria, options)
34
+ this.debug('findOne', { criteria, options })
35
+
36
+ const record = await this.#collection.findOne(criteria, options)
36
37
 
37
38
  return from(record)
38
39
  }
39
40
 
40
41
  async find (query) {
41
- const {
42
- criteria,
43
- options
44
- } = translate(query)
45
- const recordset = await this.#connection.find(criteria, options)
42
+ const { criteria, options } = translate(query)
43
+
44
+ this.debug('find', { criteria, options })
45
+
46
+ const recordset = await this.#collection.find(criteria, options).toArray()
46
47
 
47
48
  return recordset.map((item) => from(item))
48
49
  }
49
50
 
51
+ async stream (query = undefined) {
52
+ const { criteria, options } = translate(query)
53
+
54
+ this.debug('find (stream)', { criteria, options })
55
+
56
+ return this.#collection.find(criteria, options).stream({ transform: from })
57
+ }
58
+
50
59
  async add (entity) {
51
60
  const record = to(entity)
52
61
 
53
- return await this.#connection.add(record)
62
+ this.debug('insertOne', { record })
63
+
64
+ const result = await this.#collection.insertOne(record)
65
+
66
+ return result.acknowledged
54
67
  }
55
68
 
56
69
  async set (entity) {
@@ -58,50 +71,185 @@ class Storage extends Connector {
58
71
  _id: entity.id,
59
72
  _version: entity._version - 1
60
73
  }
61
- const result = await this.#connection.replace(criteria, to(entity))
74
+
75
+ const record = to(entity)
76
+
77
+ this.debug('findOneAndReplace', { criteria, record })
78
+
79
+ const result = await this.#collection.findOneAndReplace(criteria, record)
62
80
 
63
81
  return result !== null
64
82
  }
65
83
 
66
- async store (entity) {
67
- if (entity._version === 1) {
68
- return this.add(entity)
69
- } else {
70
- return this.set(entity)
84
+ async store (entity, attempt = 0) {
85
+ try {
86
+ if (entity._version === 1)
87
+ return await this.add(entity)
88
+ else
89
+ return await this.set(entity)
90
+ } catch (error) {
91
+ if (error.code === ERR_DUPLICATE_KEY) {
92
+ const id = error.keyPattern === undefined
93
+ ? error.message.includes(' index: _id_ ') // AWS DocumentDB
94
+ : error.keyPattern._id === 1
95
+
96
+ if (id)
97
+ return false
98
+ else
99
+ throw new exceptions.DuplicateException(this.#client.name, entity)
100
+ } else if (error.cause?.code === 'ECONNREFUSED') {
101
+ // This is temporary and should be replaced with a class decorator.
102
+ if (attempt > 10)
103
+ throw error
104
+
105
+ await new Promise((resolve) => setTimeout(resolve, 1000))
106
+
107
+ return this.store(entity)
108
+ } else
109
+ throw error
71
110
  }
72
111
  }
73
112
 
74
- async upsert (query, changeset, insert) {
75
- const {
76
- criteria,
77
- options
78
- } = translate(query)
113
+ async upsert (query, changeset) {
114
+ const { criteria, options } = translate(query)
115
+
116
+ if (!('_deleted' in changeset) || changeset._deleted === null) {
117
+ delete criteria._deleted
118
+ changeset._deleted = null
119
+ }
79
120
 
80
121
  const update = {
81
122
  $set: { ...changeset },
82
123
  $inc: { _version: 1 }
83
124
  }
84
125
 
85
- if (insert !== undefined) {
86
- delete insert._version
126
+ options.returnDocument = ReturnDocument.AFTER
127
+
128
+ this.debug('findOneAndUpdate', { criteria, update, options })
129
+
130
+ const result = await this.#collection.findOneAndUpdate(criteria, update, options)
131
+
132
+ return from(result)
133
+ }
134
+
135
+ async ensure (query, properties, state) {
136
+ let { criteria, options } = translate(query)
137
+
138
+ if (query === undefined)
139
+ criteria = properties
140
+
141
+ const update = { $setOnInsert: to(state) }
142
+
143
+ options.upsert = true
144
+ options.returnDocument = ReturnDocument.AFTER
145
+
146
+ console.debug('Database query', { method: 'findOneAndUpdate', criteria, update, options })
147
+
148
+ const result = await this.#collection.findOneAndUpdate(criteria, update, options)
149
+
150
+ if (result._deleted !== undefined && result._deleted !== null)
151
+ return null
152
+ else
153
+ return from(result)
154
+ }
155
+
156
+ async index () {
157
+ const indexes = []
158
+
159
+ if (this.#entity.unique !== undefined) {
160
+ for (const [name, fields] of Object.entries(this.#entity.unique)) {
161
+ const sparse = this.checkFields(fields)
162
+ const unique = await this.uniqueIndex(name, fields, sparse)
163
+
164
+ indexes.push(unique)
165
+ }
166
+ }
167
+
168
+ if (this.#entity.index !== undefined) {
169
+ for (const [suffix, declaration] of Object.entries(this.#entity.index)) {
170
+ const name = 'index_' + suffix
171
+ const fields = Object.fromEntries(Object.entries(declaration)
172
+ .map(([name, type]) => [name, INDEX_TYPES[type]]))
87
173
 
88
- options.upsert = true
174
+ const sparse = this.checkFields(Object.keys(fields))
89
175
 
90
- if (criteria._id !== undefined) {
91
- insert._id = criteria._id
92
- } else {
93
- return null
94
- } // this shouldn't ever happen
176
+ await this.#collection.createIndex(fields, { name, sparse })
95
177
 
96
- if (Object.keys(insert) > 0) update.$setOnInsert = insert
178
+ indexes.push(name)
179
+ }
97
180
  }
98
181
 
99
- options.returnDocument = 'after'
182
+ await this.removeObsoleteIndexes(indexes)
183
+ }
184
+
185
+ async uniqueIndex (name, properties, sparse = false) {
186
+ const fields = properties.reduce((acc, property) => {
187
+ acc[property] = 1
188
+ return acc
189
+ }, {})
100
190
 
101
- const result = await this.#connection.update(criteria, update, options)
191
+ name = 'unique_' + name
102
192
 
103
- return from(result)
193
+ await this.#collection.createIndex(fields, { name, unique: true, sparse })
194
+
195
+ return name
196
+ }
197
+
198
+ async removeObsoleteIndexes (desired) {
199
+ const current = await this.getCurrentIndexes()
200
+ const obsolete = current.filter((name) => !desired.includes(name))
201
+
202
+ if (obsolete.length > 0) {
203
+ console.info('Removing obsolete indexes', { indexes: obsolete.join(', ') })
204
+
205
+ await Promise.all(obsolete.map((name) => this.#collection.dropIndex(name)))
206
+ }
104
207
  }
208
+
209
+ async getCurrentIndexes () {
210
+ try {
211
+ const array = await this.#collection.listIndexes().toArray()
212
+
213
+ return array.map(({ name }) => name).filter((name) => name !== '_id_')
214
+ } catch {
215
+ return []
216
+ }
217
+ }
218
+
219
+ checkFields (fields) {
220
+ const optional = []
221
+
222
+ for (const field of fields) {
223
+ if (!(field in this.#entity.schema.properties))
224
+ throw new Error(`Index field '${field}' is not defined.`)
225
+
226
+ if (!this.#entity.schema.required?.includes(field))
227
+ optional.push(field)
228
+ }
229
+
230
+ if (optional.length > 0) {
231
+ console.info('Index fields are optional, creating sparse index', { fields: optional.join(', ') })
232
+
233
+ return true
234
+ } else
235
+ return false
236
+ }
237
+
238
+ debug (method, attributes) {
239
+ console.debug('Database query', {
240
+ collection: this.#client.name,
241
+ method,
242
+ ...attributes
243
+ })
244
+ }
245
+ }
246
+
247
+ const INDEX_TYPES = {
248
+ 'asc': 1,
249
+ 'desc': -1,
250
+ 'hash': 'hashed'
105
251
  }
106
252
 
253
+ const ERR_DUPLICATE_KEY = 11000
254
+
107
255
  exports.Storage = Storage
package/src/translate.js CHANGED
@@ -7,12 +7,20 @@ 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
+ }
11
14
 
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
15
+ if (query?.id !== undefined) {
16
+ result.criteria._id = query.id
17
+ }
18
+
19
+ if (query?.version !== undefined) {
20
+ result.criteria._version = query.version
21
+ }
22
+
23
+ result.criteria._deleted = null
16
24
 
17
25
  return result
18
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/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
- })