@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 +68 -0
- package/package.json +8 -8
- package/readme.md +16 -0
- package/src/client.js +19 -8
- package/src/factory.js +1 -3
- package/src/record.js +6 -19
- package/src/storage.js +294 -83
- package/src/translate.js +9 -6
- package/test/record.test.js +0 -15
- package/test/storage.test.js +73 -0
- package/src/collection.js +0 -69
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.
|
|
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/
|
|
23
|
-
"@toa.io/
|
|
24
|
-
"@toa.io/
|
|
25
|
-
"@toa.io/
|
|
26
|
-
"
|
|
27
|
-
"
|
|
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": "
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
10
|
+
#client
|
|
11
|
+
|
|
12
|
+
/** @type {import('mongodb').Collection} */
|
|
13
|
+
#collection
|
|
16
14
|
#entity
|
|
17
15
|
|
|
18
|
-
|
|
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.#
|
|
22
|
+
this.#client = client
|
|
22
23
|
this.#entity = entity
|
|
23
24
|
|
|
24
|
-
this.depends(
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
const { criteria, options, sample } = translate(query)
|
|
56
|
+
|
|
57
|
+
if (query?.options?.deleted !== true)
|
|
58
|
+
criteria._deleted = null
|
|
47
59
|
|
|
48
|
-
const recordset =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
+
else
|
|
75
115
|
return await this.set(entity)
|
|
76
|
-
}
|
|
77
116
|
} catch (error) {
|
|
78
|
-
|
|
117
|
+
console.error('MongoDB error', error)
|
|
79
118
|
|
|
80
|
-
|
|
81
|
-
? error.message.includes(' index: _id_ ') // AWS DocumentDB
|
|
82
|
-
: error.keyPattern._id === 1
|
|
119
|
+
const retry = await retriable(error, attempt)
|
|
83
120
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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 =
|
|
195
|
+
options.returnDocument = ReturnDocument.AFTER
|
|
112
196
|
|
|
113
|
-
const result = await this
|
|
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
|
|
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
|
|
124
|
-
const unique = await this.uniqueIndex(name, fields, sparse)
|
|
236
|
+
const optional = this.getOptional(fields)
|
|
125
237
|
|
|
126
|
-
|
|
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
|
|
247
|
+
const optional = this.getOptional(Object.keys(fields))
|
|
248
|
+
const options = { name, sparse: optional.length > 0 }
|
|
137
249
|
|
|
138
|
-
|
|
139
|
-
name,
|
|
140
|
-
sparse
|
|
141
|
-
})
|
|
250
|
+
console.info('Creating index', { fields, options })
|
|
142
251
|
|
|
143
|
-
|
|
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
|
|
257
|
+
const indexes = await Promise.all(pending)
|
|
258
|
+
|
|
259
|
+
await this.removeObsoleteIndexes(indexes)
|
|
148
260
|
}
|
|
149
261
|
|
|
150
|
-
async uniqueIndex (name, properties,
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
168
|
-
const current = await this
|
|
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(
|
|
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
|
-
|
|
299
|
+
return array.map(({ name }) => name).filter((name) => name !== '_id_')
|
|
300
|
+
} catch {
|
|
301
|
+
return []
|
|
175
302
|
}
|
|
176
303
|
}
|
|
177
304
|
|
|
178
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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?.
|
|
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
|
-
|
|
25
|
+
if (query?.search !== undefined)
|
|
26
|
+
result.criteria.$text = { $search: query.search }
|
|
24
27
|
|
|
25
28
|
return result
|
|
26
29
|
}
|
package/test/record.test.js
CHANGED
|
@@ -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
|