@toa.io/storages.mongodb 1.0.0-alpha.0 → 1.0.0-alpha.11

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.0",
3
+ "version": "1.0.0-alpha.11",
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,12 +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.0",
23
- "@toa.io/core": "1.0.0-alpha.0",
24
- "@toa.io/generic": "1.0.0-alpha.0",
25
- "@toa.io/pointer": "1.0.0-alpha.0",
26
- "mongodb": "5.9.1",
22
+ "@toa.io/console": "1.0.0-alpha.11",
23
+ "@toa.io/conveyor": "1.0.0-alpha.11",
24
+ "@toa.io/core": "1.0.0-alpha.11",
25
+ "@toa.io/generic": "1.0.0-alpha.11",
26
+ "@toa.io/pointer": "1.0.0-alpha.11",
27
+ "mongodb": "6.3.0",
27
28
  "saslprep": "1.0.3"
28
29
  },
29
- "gitHead": "06c64546f6292cc07c52f74b31415101037f7616"
30
+ "gitHead": "e343ac81eef12957cfa5e520119b1276b8ec0ad2"
30
31
  }
package/src/client.js ADDED
@@ -0,0 +1,131 @@
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 { Connector } = require('@toa.io/core')
10
+ const { resolve } = require('@toa.io/pointer')
11
+ const { ID } = require('./deployment')
12
+ const { MongoClient } = require('mongodb')
13
+
14
+ /**
15
+ * @type {Record<string, Promise<Instance>>}
16
+ */
17
+ const INSTANCES = {}
18
+
19
+ class Client extends Connector {
20
+ /**
21
+ * @public
22
+ * @type {import('mongodb').Collection}
23
+ */
24
+ collection
25
+
26
+ /**
27
+ * @private
28
+ * @type {Locator}
29
+ */
30
+ locator
31
+
32
+ /**
33
+ * @private
34
+ * @type {Instance}
35
+ */
36
+ instance
37
+
38
+ /**
39
+ * @private
40
+ * @type {string}
41
+ */
42
+ key
43
+
44
+ /**
45
+ * @param {Locator} locator
46
+ */
47
+ constructor (locator) {
48
+ super()
49
+
50
+ this.locator = locator
51
+ }
52
+
53
+ /**
54
+ * @protected
55
+ * @override
56
+ * @return {Promise<void>}
57
+ */
58
+ async open () {
59
+ const urls = await this.resolveURLs()
60
+
61
+ this.key = getKey(urls)
62
+
63
+ INSTANCES[this.key] ??= this.createInstance(urls)
64
+
65
+ this.instance = await INSTANCES[this.key]
66
+ this.instance.count++
67
+
68
+ this.collection = this.instance.client
69
+ .db(this.locator.namespace)
70
+ .collection(this.locator.name)
71
+ }
72
+
73
+ /**
74
+ * @protected
75
+ * @override
76
+ * @return {Promise<void>}
77
+ */
78
+ async close () {
79
+ const instance = await INSTANCES[this.key]
80
+
81
+ instance.count--
82
+
83
+ if (instance.count === 0) {
84
+ await instance.client.close()
85
+ delete INSTANCES[this.key]
86
+ }
87
+ }
88
+
89
+ /**
90
+ * @private
91
+ * @param {string[]} urls
92
+ * @return {Promise<Instance>}
93
+ */
94
+ async createInstance (urls) {
95
+ const client = new MongoClient(urls.join(','), OPTIONS)
96
+ const hosts = urls.map((str) => new URL(str).host)
97
+
98
+ console.info('Connecting to MongoDB:', hosts.join(', '))
99
+
100
+ await client.connect()
101
+
102
+ return {
103
+ count: 0,
104
+ client
105
+ }
106
+ }
107
+
108
+ /**
109
+ * @private
110
+ * @return {Promise<string[]>}
111
+ */
112
+ async resolveURLs () {
113
+ if (process.env.TOA_DEV === '1') {
114
+ return ['mongodb://developer:secret@localhost']
115
+ } else {
116
+ return await resolve(ID, this.locator.id)
117
+ }
118
+ }
119
+ }
120
+
121
+ function getKey (urls) {
122
+ return urls.sort().join(' ')
123
+ }
124
+
125
+ const OPTIONS = {
126
+ ignoreUndefined: true,
127
+ connectTimeoutMS: 0,
128
+ serverSelectionTimeoutMS: 0
129
+ }
130
+
131
+ exports.Client = Client
@@ -0,0 +1,69 @@
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
package/src/factory.js CHANGED
@@ -1,13 +1,15 @@
1
1
  'use strict'
2
2
 
3
- const { Connection } = require('./connection')
3
+ const { Client } = require('./client')
4
+ const { Collection } = require('./collection')
4
5
  const { Storage } = require('./storage')
5
6
 
6
7
  class Factory {
7
- storage (locator) {
8
- const connection = new Connection(locator)
8
+ storage (locator, entity) {
9
+ const client = new Client(locator)
10
+ const connection = new Collection(client)
9
11
 
10
- return new Storage(connection)
12
+ return new Storage(connection, entity)
11
13
  }
12
14
  }
13
15
 
package/src/record.js CHANGED
@@ -5,9 +5,12 @@
5
5
  * @returns {toa.mongodb.Record}
6
6
  */
7
7
  const to = (entity) => {
8
- const { id, _version, ...rest } = entity
8
+ const {
9
+ id,
10
+ ...rest
11
+ } = entity
9
12
 
10
- return /** @type {toa.mongodb.Record} */ { _id: id, _version: _version + 1, ...rest }
13
+ return /** @type {toa.mongodb.Record} */ { _id: id, ...rest }
11
14
  }
12
15
 
13
16
  /**
@@ -17,7 +20,10 @@ const to = (entity) => {
17
20
  const from = (record) => {
18
21
  if (record === undefined || record === null) return null
19
22
 
20
- const { _id, ...rest } = record
23
+ const {
24
+ _id,
25
+ ...rest
26
+ } = record
21
27
 
22
28
  return { id: _id, ...rest }
23
29
  }
package/src/storage.js CHANGED
@@ -1,30 +1,38 @@
1
1
  'use strict'
2
2
 
3
- const { Connector } = require('@toa.io/core')
3
+ const {
4
+ Connector,
5
+ exceptions
6
+ } = require('@toa.io/core')
4
7
 
5
8
  const { translate } = require('./translate')
6
- const { to, from } = require('./record')
9
+ const {
10
+ to,
11
+ from
12
+ } = require('./record')
7
13
 
8
- /**
9
- * @implements {toa.core.Storage}
10
- */
11
14
  class Storage extends Connector {
12
- /** @type {toa.mongodb.Connection} */
13
15
  #connection
16
+ #entity
14
17
 
15
- /**
16
- * @param {toa.mongodb.Connection} connection
17
- */
18
- constructor (connection) {
18
+ constructor (connection, entity) {
19
19
  super()
20
20
 
21
21
  this.#connection = connection
22
+ this.#entity = entity
22
23
 
23
24
  this.depends(connection)
24
25
  }
25
26
 
27
+ async open () {
28
+ await this.index()
29
+ }
30
+
26
31
  async get (query) {
27
- const { criteria, options } = translate(query)
32
+ const {
33
+ criteria,
34
+ options
35
+ } = translate(query)
28
36
 
29
37
  const record = await this.#connection.get(criteria, options)
30
38
 
@@ -32,7 +40,10 @@ class Storage extends Connector {
32
40
  }
33
41
 
34
42
  async find (query) {
35
- const { criteria, options } = translate(query)
43
+ const {
44
+ criteria,
45
+ options
46
+ } = translate(query)
36
47
  const recordset = await this.#connection.find(criteria, options)
37
48
 
38
49
  return recordset.map((item) => from(item))
@@ -40,33 +51,58 @@ class Storage extends Connector {
40
51
 
41
52
  async add (entity) {
42
53
  const record = to(entity)
54
+ const result = await this.#connection.add(record)
43
55
 
44
- return await this.#connection.add(record)
56
+ return result.acknowledged
45
57
  }
46
58
 
47
59
  async set (entity) {
48
- const criteria = { _id: entity.id, _version: entity._version }
60
+ const criteria = {
61
+ _id: entity.id,
62
+ _version: entity._version - 1
63
+ }
49
64
  const result = await this.#connection.replace(criteria, to(entity))
50
65
 
51
- return result.value !== null
66
+ return result !== null
52
67
  }
53
68
 
54
69
  async store (entity) {
55
- if (entity._version === 0) return this.add(entity)
56
- else return this.set(entity)
70
+ try {
71
+ if (entity._version === 1) {
72
+ return await this.add(entity)
73
+ } else {
74
+ return await this.set(entity)
75
+ }
76
+ } catch (error) {
77
+ if (error.code === ERR_DUPLICATE_KEY) {
78
+ return new exceptions.DuplicateException(Object.keys(error.keyValue))
79
+ } else {
80
+ throw error
81
+ }
82
+ }
57
83
  }
58
84
 
59
85
  async upsert (query, changeset, insert) {
60
- const { criteria, options } = translate(query)
61
- const update = { $set: { ...changeset }, $inc: { _version: 1 } }
86
+ const {
87
+ criteria,
88
+ options
89
+ } = translate(query)
90
+
91
+ const update = {
92
+ $set: { ...changeset },
93
+ $inc: { _version: 1 }
94
+ }
62
95
 
63
96
  if (insert !== undefined) {
64
97
  delete insert._version
65
98
 
66
99
  options.upsert = true
67
100
 
68
- if (criteria._id !== undefined) insert._id = criteria._id
69
- else return null // this shouldn't ever happen
101
+ if (criteria._id !== undefined) {
102
+ insert._id = criteria._id
103
+ } else {
104
+ return null
105
+ } // this shouldn't ever happen
70
106
 
71
107
  if (Object.keys(insert) > 0) update.$setOnInsert = insert
72
108
  }
@@ -75,8 +111,99 @@ class Storage extends Connector {
75
111
 
76
112
  const result = await this.#connection.update(criteria, update, options)
77
113
 
78
- return from(result.value)
114
+ return from(result)
115
+ }
116
+
117
+ async index () {
118
+ const indexes = []
119
+
120
+ if (this.#entity.unique !== undefined) {
121
+ for (const [name, fields] of Object.entries(this.#entity.unique)) {
122
+ const sparse = this.checkFields(fields)
123
+ const unique = await this.uniqueIndex(name, fields, sparse)
124
+
125
+ indexes.push(unique)
126
+ }
127
+ }
128
+
129
+ if (this.#entity.index !== undefined) {
130
+ for (const [suffix, declaration] of Object.entries(this.#entity.index)) {
131
+ const name = 'index_' + suffix
132
+ const fields = Object.fromEntries(Object.entries(declaration)
133
+ .map(([name, type]) => [name, INDEX_TYPES[type]]))
134
+
135
+ const sparse = this.checkFields(Object.keys(fields))
136
+
137
+ await this.#connection.index(fields, {
138
+ name,
139
+ sparse
140
+ })
141
+
142
+ indexes.push(name)
143
+ }
144
+ }
145
+
146
+ await this.removeObsolete(indexes)
147
+ }
148
+
149
+ async uniqueIndex (name, properties, sparse = false) {
150
+ const fields = properties.reduce((acc, property) => {
151
+ acc[property] = 1
152
+ return acc
153
+ }, {})
154
+
155
+ name = 'unique_' + name
156
+
157
+ await this.#connection.index(fields, {
158
+ name,
159
+ unique: true,
160
+ sparse
161
+ })
162
+
163
+ return name
164
+ }
165
+
166
+ async removeObsolete (desired) {
167
+ const current = await this.#connection.indexes()
168
+ const obsolete = current.filter((name) => !desired.includes(name))
169
+
170
+ if (obsolete.length > 0) {
171
+ console.info(`Remove obsolete indexes: [${obsolete.join(', ')}]`)
172
+
173
+ await this.#connection.dropIndexes(obsolete)
174
+ }
79
175
  }
176
+
177
+ checkFields (fields) {
178
+ const optional = []
179
+
180
+ for (const field of fields) {
181
+ if (!(field in this.#entity.schema.properties)) {
182
+ throw new Error(`Index field '${field}' is not defined.`)
183
+ }
184
+
185
+ if (!this.#entity.schema.required?.includes(field)) {
186
+ optional.push(field)
187
+ }
188
+ }
189
+
190
+ if (optional.length > 0) {
191
+ console.info(`Index fields [${optional.join(', ')}] are optional, creating sparse index.`)
192
+
193
+ return true
194
+ } else {
195
+ return false
196
+ }
197
+ }
198
+
199
+ }
200
+
201
+ const INDEX_TYPES = {
202
+ 'asc': 1,
203
+ 'desc': -1,
204
+ 'hash': 'hashed'
80
205
  }
81
206
 
207
+ const ERR_DUPLICATE_KEY = 11000
208
+
82
209
  exports.Storage = Storage
@@ -1,12 +1,17 @@
1
1
  'use strict'
2
2
 
3
- const { to, from } = require('../src/record')
4
- const { random } = require('@toa.io/generic')
3
+ const {
4
+ to,
5
+ from
6
+ } = require('../src/record')
5
7
 
6
8
  describe('to', () => {
7
9
  it('should rename id to _id', () => {
8
10
  /** @type {toa.core.storages.Record} */
9
- const entity = { id: '1', _version: 0 }
11
+ const entity = {
12
+ id: '1',
13
+ _version: 0
14
+ }
10
15
  const record = to(entity)
11
16
 
12
17
  expect(record).toMatchObject({ _id: '1' })
@@ -14,37 +19,47 @@ describe('to', () => {
14
19
 
15
20
  it('should not modify argument', () => {
16
21
  /** @type {toa.core.storages.Record} */
17
- const entity = { id: '1', _version: 0 }
22
+ const entity = {
23
+ id: '1',
24
+ _version: 0
25
+ }
18
26
 
19
27
  to(entity)
20
28
 
21
- expect(entity).toStrictEqual({ id: '1', _version: 0 })
22
- })
23
-
24
- it('should increment _version', () => {
25
- /** @type {toa.core.storages.Record} */
26
- const entity = { id: '1', _version: random() }
27
- const record = to(entity)
28
-
29
- expect(record).toMatchObject({ _version: entity._version + 1 })
29
+ expect(entity).toStrictEqual({
30
+ id: '1',
31
+ _version: 0
32
+ })
30
33
  })
31
34
  })
32
35
 
33
36
  describe('from', () => {
34
37
  it('should rename _id to id', () => {
35
38
  /** @type {toa.mongodb.Record} */
36
- const record = { _id: '1', _version: 0 }
39
+ const record = {
40
+ _id: '1',
41
+ _version: 0
42
+ }
37
43
  const entity = from(record)
38
44
 
39
- expect(entity).toStrictEqual({ id: '1', _version: 0 })
45
+ expect(entity).toStrictEqual({
46
+ id: '1',
47
+ _version: 0
48
+ })
40
49
  })
41
50
 
42
51
  it('should not modify argument', () => {
43
52
  /** @type {toa.mongodb.Record} */
44
- const record = { _id: '1', _version: 0 }
53
+ const record = {
54
+ _id: '1',
55
+ _version: 0
56
+ }
45
57
 
46
58
  from(record)
47
59
 
48
- expect(record).toStrictEqual({ _id: '1', _version: 0 })
60
+ expect(record).toStrictEqual({
61
+ _id: '1',
62
+ _version: 0
63
+ })
49
64
  })
50
65
  })
@@ -1,29 +1,31 @@
1
1
  // noinspection ES6UnusedImports
2
2
 
3
3
  import type {
4
- Document,
5
- Filter,
6
- FindOneAndReplaceOptions,
7
- FindOneAndUpdateOptions,
8
- FindOptions,
9
- UpdateFilter
4
+ Document,
5
+ Filter,
6
+ FindOneAndReplaceOptions,
7
+ FindOneAndUpdateOptions,
8
+ FindOptions,
9
+ UpdateFilter,
10
10
  } from 'mongodb'
11
11
 
12
12
  import type { Connector } from '@toa.io/core'
13
13
  import type { Record } from './record'
14
14
 
15
- declare namespace toa.mongodb {
15
+ declare namespace toa.mongodb{
16
16
 
17
- interface Connection extends Connector {
18
- get(query: Filter<Record>, options?: FindOptions<Record>): Promise<Record>
17
+ interface Connection extends Connector{
18
+ get (query: Filter<Record>, options?: FindOptions<Record>): Promise<Record>
19
19
 
20
- find(query: Filter<Record>, options?: FindOptions<Record>): Promise<Record[]>
20
+ find (query: Filter<Record>, options?: FindOptions<Record>): Promise<Record[]>
21
21
 
22
- add(record: Record): Promise<boolean>
22
+ add (record: Record): Promise<boolean>
23
23
 
24
- replace(query: Filter<Record>, record: UpdateFilter<Record>, options?: FindOneAndReplaceOptions): Promise<any>
24
+ addMany (records: Record[]): Promise<boolean>
25
25
 
26
- update(query: Filter<Record>, update: UpdateFilter<Record>, options?: FindOneAndUpdateOptions): Promise<any>
27
- }
26
+ replace (query: Filter<Record>, record: UpdateFilter<Record>, options?: FindOneAndReplaceOptions): Promise<any>
27
+
28
+ update (query: Filter<Record>, update: UpdateFilter<Record>, options?: FindOneAndUpdateOptions): Promise<any>
29
+ }
28
30
 
29
31
  }
package/src/connection.js DELETED
@@ -1,95 +0,0 @@
1
- // noinspection JSCheckFunctionSignatures
2
-
3
- 'use strict'
4
-
5
- const { MongoClient } = require('mongodb')
6
- const { Connector } = require('@toa.io/core')
7
- const { console } = require('@toa.io/console')
8
- const { resolve } = require('@toa.io/pointer')
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
-
18
- constructor (locator) {
19
- super()
20
-
21
- this.#locator = locator
22
- }
23
-
24
- async open () {
25
- const urls = await this.#resolveURLs()
26
- const db = this.#locator.namespace
27
- const collection = this.#locator.name
28
-
29
- this.#client = new MongoClient(urls[0], OPTIONS)
30
-
31
- await this.#client.connect()
32
-
33
- this.#collection = this.#client.db(db).collection(collection)
34
-
35
- console.info('Storage Mongo connected')
36
- }
37
-
38
- async close () {
39
- await this.#client?.close()
40
-
41
- console.info('Storage Mongo disconnected')
42
- }
43
-
44
- /** @hot */
45
- async get (query, options) {
46
- return /** @type {toa.mongodb.Record} */ this.#collection.findOne(query, options)
47
- }
48
-
49
- /** @hot */
50
- async find (query, options) {
51
- const cursor = await this.#collection.find(query, options)
52
-
53
- return cursor.toArray()
54
- }
55
-
56
- /** @hot */
57
- async add (record) {
58
- /** @type {boolean} */
59
- let result
60
-
61
- try {
62
- const response = await this.#collection.insertOne(record)
63
-
64
- result = response.acknowledged
65
- } catch (e) {
66
- if (e.code === 11000) result = false // duplicate id
67
- else throw e
68
- }
69
-
70
- return result
71
- }
72
-
73
- /** @hot */
74
- async replace (query, record, options) {
75
- return await this.#collection.findOneAndReplace(query, record, options)
76
- }
77
-
78
- /** @hot */
79
- async update (query, update, options) {
80
- return this.#collection.findOneAndUpdate(query, update, options)
81
- }
82
-
83
- async #resolveURLs () {
84
- if (process.env.TOA_DEV === '1') return ['mongodb://developer:secret@localhost']
85
- else return await resolve(ID, this.#locator.id)
86
- }
87
- }
88
-
89
- const OPTIONS = {
90
- useNewUrlParser: true,
91
- useUnifiedTopology: true,
92
- ignoreUndefined: true
93
- }
94
-
95
- exports.Connection = Connection