@toa.io/storages.mongodb 1.0.0-alpha.27 → 1.0.0-alpha.272
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 +91 -0
- package/package.json +8 -8
- package/readme.md +16 -0
- package/src/client.js +94 -15
- package/src/factory.js +3 -5
- package/src/outbox.js +129 -0
- package/src/record.js +6 -19
- package/src/storage.js +382 -88
- 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,91 @@
|
|
|
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.272](https://github.com/toa-io/toa/compare/v1.0.0-alpha.271...v1.0.0-alpha.272) (2026-09-01)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
* refactor(core)!: pump the outbox in one cycle ([bf590fe](https://github.com/toa-io/toa/commit/bf590fe8ed59c701b5fd1a91ba4874685b79242f))
|
|
10
|
+
* feat(core)!: commit events with the state that produced them ([1eb68cc](https://github.com/toa-io/toa/commit/1eb68cc435dbfa03faa16009fceb866693d22e1a)), closes [#20](https://github.com/toa-io/toa/issues/20)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### BREAKING CHANGES
|
|
14
|
+
|
|
15
|
+
* `Storage.outbox.pending` takes a fourth argument, the id to
|
|
16
|
+
continue from.
|
|
17
|
+
|
|
18
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
19
|
+
* `event.changeset` is removed; use `origin` and `state`. `State`
|
|
20
|
+
takes an `Outbox` in place of an `Emission`. `difference` is dropped from
|
|
21
|
+
`@toa.io/generic` along with its last caller.
|
|
22
|
+
|
|
23
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# [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)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Performance Improvements
|
|
33
|
+
|
|
34
|
+
* **boot:** stop paying for what a composition does not need to start ([1619c27](https://github.com/toa-io/toa/commit/1619c2743072f4706939ce72f3074e615d26a91e))
|
|
35
|
+
* **storages.mongodb:** time the call instead of monitoring the command ([135f1a9](https://github.com/toa-io/toa/commit/135f1a97c753b1caed9291f85cea0521ef68b0e3))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# [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)
|
|
42
|
+
|
|
43
|
+
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# [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)
|
|
50
|
+
|
|
51
|
+
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# [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)
|
|
58
|
+
|
|
59
|
+
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# [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)
|
|
66
|
+
|
|
67
|
+
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# [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)
|
|
74
|
+
|
|
75
|
+
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# [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)
|
|
82
|
+
|
|
83
|
+
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# [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)
|
|
90
|
+
|
|
91
|
+
**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.272",
|
|
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.272",
|
|
23
|
+
"@toa.io/core": "1.0.0-alpha.272",
|
|
24
|
+
"@toa.io/generic": "1.0.0-alpha.272",
|
|
25
|
+
"@toa.io/pointer": "1.0.0-alpha.272",
|
|
26
|
+
"mongodb": "7.2.0",
|
|
27
|
+
"openspan": "1.0.0-alpha.272",
|
|
28
28
|
"saslprep": "1.0.3"
|
|
29
29
|
},
|
|
30
|
-
"gitHead": "
|
|
30
|
+
"gitHead": "1a451755445935c04cf5b373bcc73942cac0bb10"
|
|
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,12 +18,33 @@ 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}
|
|
23
26
|
*/
|
|
24
27
|
collection
|
|
25
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The outbox rows of this component, absent unless something consumes its events. Created
|
|
31
|
+
* eagerly beside the entity collection, because a transaction cannot create a collection and
|
|
32
|
+
* an index build cannot run inside one.
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
* @type {import('mongodb').Collection | undefined}
|
|
36
|
+
*/
|
|
37
|
+
outbox
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether this deployment can run transactions at all. A standalone mongod cannot, and an
|
|
41
|
+
* outbox without atomicity is worse than none, so the storage falls back to inline emission.
|
|
42
|
+
*
|
|
43
|
+
* @public
|
|
44
|
+
* @type {boolean}
|
|
45
|
+
*/
|
|
46
|
+
transactional = false
|
|
47
|
+
|
|
26
48
|
/**
|
|
27
49
|
* @private
|
|
28
50
|
* @type {Locator}
|
|
@@ -41,13 +63,22 @@ class Client extends Connector {
|
|
|
41
63
|
*/
|
|
42
64
|
key
|
|
43
65
|
|
|
66
|
+
/**
|
|
67
|
+
* @private
|
|
68
|
+
* @type {boolean}
|
|
69
|
+
*/
|
|
70
|
+
publishes
|
|
71
|
+
|
|
44
72
|
/**
|
|
45
73
|
* @param {Locator} locator
|
|
74
|
+
* @param {boolean} [publishes] whether this component publishes anything
|
|
46
75
|
*/
|
|
47
|
-
constructor (locator) {
|
|
76
|
+
constructor (locator, publishes = false) {
|
|
48
77
|
super()
|
|
49
78
|
|
|
50
79
|
this.locator = locator
|
|
80
|
+
this.name = locator.lowercase
|
|
81
|
+
this.publishes = publishes
|
|
51
82
|
}
|
|
52
83
|
|
|
53
84
|
/**
|
|
@@ -58,26 +89,44 @@ class Client extends Connector {
|
|
|
58
89
|
async open () {
|
|
59
90
|
const urls = await this.resolveURLs()
|
|
60
91
|
const dbname = this.resolveDB()
|
|
61
|
-
const collname = this.locator.lowercase
|
|
62
92
|
|
|
63
93
|
this.key = getKey(dbname, urls)
|
|
64
94
|
|
|
65
|
-
|
|
95
|
+
try {
|
|
96
|
+
INSTANCES[this.key] ??= this.createInstance(urls)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error('Failed to connect to MongoDB', { urls, error })
|
|
99
|
+
}
|
|
66
100
|
|
|
67
101
|
this.instance = await INSTANCES[this.key]
|
|
68
102
|
this.instance.count++
|
|
69
103
|
|
|
70
104
|
const db = this.instance.client.db(dbname)
|
|
71
105
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
} catch (e) {
|
|
75
|
-
if (e.code !== ALREADY_EXISTS) {
|
|
76
|
-
throw e
|
|
77
|
-
}
|
|
106
|
+
this.collection = await collection(db, this.name)
|
|
107
|
+
this.transactional = await transactional(db)
|
|
78
108
|
|
|
79
|
-
|
|
80
|
-
|
|
109
|
+
if (!this.publishes) return
|
|
110
|
+
|
|
111
|
+
if (this.transactional) this.outbox = await collection(db, this.name + OUTBOX)
|
|
112
|
+
else
|
|
113
|
+
console.warn('MongoDB is not a replica set; events are emitted inline, without an outbox',
|
|
114
|
+
{ collection: this.name })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Runs `fn` in a transaction and answers what it returned. The driver may call `fn` more
|
|
119
|
+
* than once, so it must not hold state of its own — an outbox row is built by the caller
|
|
120
|
+
* and reused, and a rolled back attempt leaves nothing behind.
|
|
121
|
+
*
|
|
122
|
+
* @public
|
|
123
|
+
* @template T
|
|
124
|
+
* @param {(session: import('mongodb').ClientSession) => Promise<T>} fn
|
|
125
|
+
* @return {Promise<T>}
|
|
126
|
+
*/
|
|
127
|
+
async transaction (fn) {
|
|
128
|
+
return this.instance.client.withSession(async (session) =>
|
|
129
|
+
session.withTransaction(async () => fn(session)))
|
|
81
130
|
}
|
|
82
131
|
|
|
83
132
|
/**
|
|
@@ -105,7 +154,7 @@ class Client extends Connector {
|
|
|
105
154
|
const client = new MongoClient(urls.join(','), OPTIONS)
|
|
106
155
|
const hosts = urls.map((str) => new URL(str).host)
|
|
107
156
|
|
|
108
|
-
console.info('Connecting to MongoDB
|
|
157
|
+
console.info('Connecting to MongoDB', { address: hosts.join(', ') })
|
|
109
158
|
|
|
110
159
|
await client.connect()
|
|
111
160
|
|
|
@@ -148,12 +197,42 @@ function getKey (db, urls) {
|
|
|
148
197
|
return db + ':' + urls.sort().join(' ')
|
|
149
198
|
}
|
|
150
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Concurrent pods race to create the same collection, and losing that race is not an error.
|
|
202
|
+
*/
|
|
203
|
+
async function collection (db, name) {
|
|
204
|
+
try {
|
|
205
|
+
return await db.createCollection(name)
|
|
206
|
+
} catch (e) {
|
|
207
|
+
if (e.code !== ALREADY_EXISTS) throw e
|
|
208
|
+
|
|
209
|
+
return db.collection(name)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function transactional (db) {
|
|
214
|
+
try {
|
|
215
|
+
const hello = await db.admin().command({ hello: 1 })
|
|
216
|
+
|
|
217
|
+
return hello.setName !== undefined || hello.msg === 'isdbgrid'
|
|
218
|
+
} catch (e) {
|
|
219
|
+
console.warn('MongoDB transaction support could not be determined', { error: e })
|
|
220
|
+
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `monitorCommands` is deliberately absent. It makes the driver materialize every reply
|
|
227
|
+
* eagerly to populate the monitoring event (`CommandSucceededEvent`), which defeats the
|
|
228
|
+
* lazy per-document deserialization a cursor exists for — a 100-document batch is then
|
|
229
|
+
* deserialized twice. `Storage` times its own calls instead.
|
|
230
|
+
*/
|
|
151
231
|
const OPTIONS = {
|
|
152
|
-
ignoreUndefined: true
|
|
153
|
-
connectTimeoutMS: 0,
|
|
154
|
-
serverSelectionTimeoutMS: 0
|
|
232
|
+
ignoreUndefined: true
|
|
155
233
|
}
|
|
156
234
|
|
|
157
235
|
const ALREADY_EXISTS = 48
|
|
236
|
+
const OUTBOX = '_outbox'
|
|
158
237
|
|
|
159
238
|
exports.Client = Client
|
package/src/factory.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { Client } = require('./client')
|
|
4
|
-
const { Collection } = require('./collection')
|
|
5
4
|
const { Storage } = require('./storage')
|
|
6
5
|
|
|
7
6
|
class Factory {
|
|
8
|
-
storage (locator, entity) {
|
|
9
|
-
const client = new Client(locator)
|
|
10
|
-
const connection = new Collection(client)
|
|
7
|
+
storage (locator, entity, options = {}) {
|
|
8
|
+
const client = new Client(locator, options.outbox === true)
|
|
11
9
|
|
|
12
|
-
return new Storage(
|
|
10
|
+
return new Storage(client, entity)
|
|
13
11
|
}
|
|
14
12
|
}
|
|
15
13
|
|
package/src/outbox.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { console } = require('openspan')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The outbox rows of one component. Its lifecycle is the Client's, so it is not a Connector.
|
|
7
|
+
*
|
|
8
|
+
* Core owns what a row means — its id, its lane and when it becomes due. This only writes
|
|
9
|
+
* them, reads back what is due, and marks what has been published.
|
|
10
|
+
*/
|
|
11
|
+
class Outbox {
|
|
12
|
+
/** @type {import('mongodb').Collection} */
|
|
13
|
+
#collection
|
|
14
|
+
|
|
15
|
+
#retention
|
|
16
|
+
|
|
17
|
+
constructor (collection) {
|
|
18
|
+
this.#collection = collection
|
|
19
|
+
this.#retention = retention()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {import('mongodb').ClientSession} session */
|
|
23
|
+
async insert (row, session) {
|
|
24
|
+
await this.#collection.insertOne(to(row), { session })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @param {import('mongodb').ClientSession} session */
|
|
28
|
+
async insertMany (rows, session) {
|
|
29
|
+
if (rows.length === 0) return
|
|
30
|
+
|
|
31
|
+
await this.#collection.insertMany(rows.map(to), { session })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One page of what this replica should publish: due, still unpublished, and in a lane it
|
|
36
|
+
* owns. In steady state the first page is empty.
|
|
37
|
+
*
|
|
38
|
+
* `after` continues from the last id of the page before. Ids are uuid v7, so their order is
|
|
39
|
+
* the order rows were written and a page is never read twice within a cycle — which matters
|
|
40
|
+
* because a row stays unpublished in the database until the cycle that sent it marks it.
|
|
41
|
+
*/
|
|
42
|
+
async pending (lanes, now, limit, after = undefined) {
|
|
43
|
+
const criteria = { lane: { $in: lanes }, published: false, pending: { $lte: now } }
|
|
44
|
+
|
|
45
|
+
if (after !== undefined) criteria._id = { $gt: after }
|
|
46
|
+
|
|
47
|
+
const rows = await this.#collection
|
|
48
|
+
.find(criteria)
|
|
49
|
+
.sort({ _id: 1 })
|
|
50
|
+
.limit(limit)
|
|
51
|
+
.toArray()
|
|
52
|
+
|
|
53
|
+
return rows.map(from)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One batched write for many events, which is why the ids are held in memory until the
|
|
58
|
+
* tick rather than updated one by one.
|
|
59
|
+
*/
|
|
60
|
+
async settle (ids) {
|
|
61
|
+
if (ids.length === 0) return
|
|
62
|
+
|
|
63
|
+
await this.#collection.updateMany({ _id: { $in: ids } },
|
|
64
|
+
{ $set: { published: true, publishedAt: new Date() } })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The entity collection's index management does not reach here, so this collection keeps
|
|
69
|
+
* its own — including pruning, or a later change leaves the old index behind forever.
|
|
70
|
+
*/
|
|
71
|
+
async index () {
|
|
72
|
+
const desired = {
|
|
73
|
+
// holds only what is not published yet, so it stays at in-flight size
|
|
74
|
+
outbox_pending: {
|
|
75
|
+
fields: { lane: 1, pending: 1 },
|
|
76
|
+
options: { name: 'outbox_pending', partialFilterExpression: { published: false } }
|
|
77
|
+
},
|
|
78
|
+
// an unpublished row has no `publishedAt`, and the TTL monitor skips those — so a row
|
|
79
|
+
// that never made it out is never reaped
|
|
80
|
+
outbox_published_at: {
|
|
81
|
+
fields: { publishedAt: 1 },
|
|
82
|
+
options: { name: 'outbox_published_at', expireAfterSeconds: this.#retention }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const { fields, options } of Object.values(desired))
|
|
87
|
+
await this.#collection.createIndex(fields, options)
|
|
88
|
+
.catch((e) => console.warn('MongoDB outbox index creation failed',
|
|
89
|
+
{ collection: this.#collection.collectionName, name: options.name, error: e }))
|
|
90
|
+
|
|
91
|
+
await this.#prune(Object.keys(desired))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @private */
|
|
95
|
+
async #prune (desired) {
|
|
96
|
+
let current
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
current = await this.#collection.listIndexes().toArray()
|
|
100
|
+
} catch {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const obsolete = current
|
|
105
|
+
.map(({ name }) => name)
|
|
106
|
+
.filter((name) => name !== '_id_' && !desired.includes(name))
|
|
107
|
+
|
|
108
|
+
if (obsolete.length === 0) return
|
|
109
|
+
|
|
110
|
+
console.info('Removing obsolete outbox indexes',
|
|
111
|
+
{ collection: this.#collection.collectionName, indexes: obsolete.join(', ') })
|
|
112
|
+
|
|
113
|
+
await Promise.all(obsolete.map((name) => this.#collection.dropIndex(name)))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const to = ({ id, ...rest }) => ({ _id: id, ...rest })
|
|
118
|
+
const from = ({ _id, ...rest }) => ({ id: _id, ...rest })
|
|
119
|
+
|
|
120
|
+
function retention () {
|
|
121
|
+
const value = Number(process.env.TOA_OUTBOX_RETENTION)
|
|
122
|
+
|
|
123
|
+
return Number.isNaN(value) || value < 0 ? RETENTION : value
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** seconds a published row is kept as a change log before the TTL monitor reaps it */
|
|
127
|
+
const RETENTION = 86400
|
|
128
|
+
|
|
129
|
+
exports.Outbox = Outbox
|
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,102 +1,228 @@
|
|
|
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
|
-
|
|
12
|
-
} = require('./record')
|
|
6
|
+
const { to, from } = require('./record')
|
|
7
|
+
const { Outbox } = require('./outbox')
|
|
8
|
+
const { ReturnDocument } = require('mongodb')
|
|
13
9
|
|
|
14
10
|
class Storage extends Connector {
|
|
15
|
-
#
|
|
11
|
+
#client
|
|
12
|
+
|
|
13
|
+
/** @type {import('mongodb').Collection} */
|
|
14
|
+
#collection
|
|
16
15
|
#entity
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* @type {Outbox | undefined} absent when nothing consumes this component's events, or when
|
|
19
|
+
* the deployment cannot run transactions
|
|
20
|
+
*/
|
|
21
|
+
#outbox
|
|
22
|
+
|
|
23
|
+
/** @type {Map<string, object>} span options per driver method */
|
|
24
|
+
#spans = new Map()
|
|
25
|
+
|
|
26
|
+
constructor (client, entity) {
|
|
19
27
|
super()
|
|
20
28
|
|
|
21
|
-
this.#
|
|
29
|
+
this.#client = client
|
|
22
30
|
this.#entity = entity
|
|
23
31
|
|
|
24
|
-
this.depends(
|
|
32
|
+
this.depends(client)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get raw () {
|
|
36
|
+
return this.#collection
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The outbox is offered only where a row can be committed atomically with the entity.
|
|
41
|
+
* Without that it would be a second write with a window in front of it — worse than the
|
|
42
|
+
* inline emission it replaces — so the storage simply does not advertise it.
|
|
43
|
+
*/
|
|
44
|
+
get outbox () {
|
|
45
|
+
return this.#outbox
|
|
25
46
|
}
|
|
26
47
|
|
|
27
48
|
async open () {
|
|
49
|
+
this.#collection = this.#client.collection
|
|
50
|
+
|
|
51
|
+
if (this.#client.outbox !== undefined)
|
|
52
|
+
this.#outbox = new Outbox(this.#client.outbox)
|
|
53
|
+
|
|
54
|
+
this.#spans.clear()
|
|
55
|
+
|
|
28
56
|
await this.index()
|
|
57
|
+
await this.#outbox?.index()
|
|
29
58
|
}
|
|
30
59
|
|
|
31
60
|
async get (query) {
|
|
32
|
-
const {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
61
|
+
const { criteria, options } = translate(query)
|
|
62
|
+
|
|
63
|
+
// identity lookups must return deleted records, so that callers
|
|
64
|
+
// can tell a deleted entity from a missing one
|
|
65
|
+
if (query?.id === undefined && query?.options?.deleted !== true)
|
|
66
|
+
criteria._deleted = null
|
|
36
67
|
|
|
37
|
-
const record = await this
|
|
68
|
+
const record = await this.command('findOne', { criteria, options },
|
|
69
|
+
() => this.#collection.findOne(criteria, options))
|
|
38
70
|
|
|
39
71
|
return from(record)
|
|
40
72
|
}
|
|
41
73
|
|
|
42
74
|
async find (query) {
|
|
43
|
-
const {
|
|
44
|
-
criteria,
|
|
45
|
-
options
|
|
46
|
-
} = translate(query)
|
|
75
|
+
const { criteria, options, sample } = translate(query)
|
|
47
76
|
|
|
48
|
-
|
|
77
|
+
if (query?.options?.deleted !== true)
|
|
78
|
+
criteria._deleted = null
|
|
79
|
+
|
|
80
|
+
const recordset = sample === undefined
|
|
81
|
+
? await this.command('find', { criteria, options },
|
|
82
|
+
async () => await this.#collection.find(criteria, options).toArray())
|
|
83
|
+
: await this.aggregate(criteria, options, sample)
|
|
49
84
|
|
|
50
85
|
return recordset.map((item) => from(item))
|
|
51
86
|
}
|
|
52
87
|
|
|
53
|
-
|
|
88
|
+
/** @private */
|
|
89
|
+
async aggregate (criteria, options, sample) {
|
|
90
|
+
const pipeline = toPipeline(criteria, options, sample)
|
|
91
|
+
|
|
92
|
+
return await this.command('aggregate', { pipeline },
|
|
93
|
+
async () => await this.#collection.aggregate(pipeline).toArray())
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async stream (query = undefined) {
|
|
97
|
+
const { criteria, options } = translate(query)
|
|
98
|
+
|
|
99
|
+
if (query?.options?.deleted !== true)
|
|
100
|
+
criteria._deleted = null
|
|
101
|
+
|
|
102
|
+
this.debug('find (stream)', { criteria, options })
|
|
103
|
+
|
|
104
|
+
return this.#collection.find(criteria, options).stream({ transform: from })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async add (entity, session = undefined) {
|
|
54
108
|
const record = to(entity)
|
|
55
|
-
|
|
109
|
+
|
|
110
|
+
const result = await this.command('insertOne', { record },
|
|
111
|
+
() => this.#collection.insertOne(record, { session }))
|
|
56
112
|
|
|
57
113
|
return result.acknowledged
|
|
58
114
|
}
|
|
59
115
|
|
|
60
|
-
async set (entity) {
|
|
116
|
+
async set (entity, session = undefined) {
|
|
61
117
|
const criteria = {
|
|
62
118
|
_id: entity.id,
|
|
63
119
|
_version: entity._version - 1
|
|
64
120
|
}
|
|
65
|
-
|
|
121
|
+
|
|
122
|
+
const record = to(entity)
|
|
123
|
+
|
|
124
|
+
const result = await this.command('findOneAndReplace', { criteria, record },
|
|
125
|
+
() => this.#collection.findOneAndReplace(criteria, record, { session }))
|
|
66
126
|
|
|
67
127
|
return result !== null
|
|
68
128
|
}
|
|
69
129
|
|
|
70
|
-
async store (entity) {
|
|
130
|
+
async store (entity, row = undefined, attempt = 0) {
|
|
71
131
|
try {
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
132
|
+
if (row === undefined || this.#outbox === undefined) {
|
|
133
|
+
if (entity._version === 1)
|
|
134
|
+
return await this.add(entity)
|
|
135
|
+
else
|
|
136
|
+
return await this.set(entity)
|
|
76
137
|
}
|
|
77
|
-
} catch (error) {
|
|
78
|
-
if (error.code === ERR_DUPLICATE_KEY) {
|
|
79
138
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
139
|
+
const committed = await this.#client.transaction(async (session) => {
|
|
140
|
+
const ok = entity._version === 1
|
|
141
|
+
? await this.add(entity, session)
|
|
142
|
+
: await this.set(entity, session)
|
|
143
|
+
|
|
144
|
+
// a lost compare-and-swap must take the row down with it, or a retried transition
|
|
145
|
+
// leaves a row for a write that never happened
|
|
146
|
+
if (!ok) {
|
|
147
|
+
await session.abortTransaction()
|
|
83
148
|
|
|
84
|
-
if (id) {
|
|
85
149
|
return false
|
|
86
|
-
} else {
|
|
87
|
-
throw new exceptions.DuplicateException()
|
|
88
150
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
151
|
+
|
|
152
|
+
await this.#outbox.insert(row, session)
|
|
153
|
+
|
|
154
|
+
return true
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
return committed === true
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error('MongoDB error', error)
|
|
160
|
+
|
|
161
|
+
const retry = await retriable(error, attempt)
|
|
162
|
+
|
|
163
|
+
if (retry)
|
|
164
|
+
return await this.store(entity, row, attempt + 1)
|
|
165
|
+
else
|
|
166
|
+
return false
|
|
92
167
|
}
|
|
93
168
|
}
|
|
94
169
|
|
|
95
|
-
async
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
170
|
+
async massStore (entities, rows = undefined, attempt = 0) {
|
|
171
|
+
if (entities.length === 0)
|
|
172
|
+
return true
|
|
173
|
+
|
|
174
|
+
const operations = entities.map((entity) => {
|
|
175
|
+
const record = to(entity)
|
|
176
|
+
|
|
177
|
+
if (entity._version === 1) {
|
|
178
|
+
const { _version, ...rest } = record
|
|
179
|
+
|
|
180
|
+
return { // upsert in required when document is deleted
|
|
181
|
+
updateOne: {
|
|
182
|
+
filter: { _id: entity.id },
|
|
183
|
+
update: {
|
|
184
|
+
$set: {
|
|
185
|
+
...rest,
|
|
186
|
+
_deleted: null
|
|
187
|
+
},
|
|
188
|
+
$inc: { _version: 1 },
|
|
189
|
+
},
|
|
190
|
+
upsert: true
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else
|
|
194
|
+
return {
|
|
195
|
+
replaceOne: {
|
|
196
|
+
filter: { _id: entity.id, _version: entity._version - 1 },
|
|
197
|
+
replacement: record
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await this.#client.transaction(async (session) => {
|
|
204
|
+
await this.command('bulkWrite', { operations: operations.length },
|
|
205
|
+
async () => await this.#collection.bulkWrite(operations, { session }))
|
|
206
|
+
|
|
207
|
+
if (rows !== undefined && this.#outbox !== undefined)
|
|
208
|
+
await this.#outbox.insertMany(rows, session)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
return true
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error('MongoDB error', error)
|
|
214
|
+
|
|
215
|
+
const retry = await retriable(error, attempt)
|
|
216
|
+
|
|
217
|
+
if (retry)
|
|
218
|
+
return await this.massStore(entities, rows, attempt + 1)
|
|
219
|
+
else
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async upsert (query, changeset, row = undefined) {
|
|
225
|
+
const { criteria, options } = translate(query)
|
|
100
226
|
|
|
101
227
|
if (!('_deleted' in changeset) || changeset._deleted === null) {
|
|
102
228
|
delete criteria._deleted
|
|
@@ -108,46 +234,115 @@ class Storage extends Connector {
|
|
|
108
234
|
$inc: { _version: 1 }
|
|
109
235
|
}
|
|
110
236
|
|
|
111
|
-
|
|
237
|
+
// BEFORE, so that the filter is applied once and atomically and the pre-image comes back
|
|
238
|
+
// with it — an assignment is the one event whose images are the write's own
|
|
239
|
+
options.returnDocument = ReturnDocument.BEFORE
|
|
240
|
+
|
|
241
|
+
const apply = async (session) => {
|
|
242
|
+
const found = await this.command('findOneAndUpdate', { criteria, update, options },
|
|
243
|
+
() => this.#collection.findOneAndUpdate(criteria, update, { ...options, session }))
|
|
244
|
+
|
|
245
|
+
if (found === null) return null
|
|
246
|
+
|
|
247
|
+
const origin = from(found)
|
|
248
|
+
|
|
249
|
+
/*
|
|
250
|
+
* The post-image is `update` applied to the pre-image, computed rather than read back.
|
|
251
|
+
* That is exact, not approximate: `$set` on top-level keys is a spread (entity property
|
|
252
|
+
* names cannot contain dots, so a changeset never carries a path), and `_version` is
|
|
253
|
+
* incremented by one. It is also a coupling — an operator added to `update` and not
|
|
254
|
+
* mirrored here diverges silently — which `features/events/outbox.feature` guards.
|
|
255
|
+
*/
|
|
256
|
+
const state = { ...origin, ...changeset, _version: origin._version + 1 }
|
|
257
|
+
|
|
258
|
+
// an assignment's event is the write's own images, so they are filled in here whether
|
|
259
|
+
// or not the row is going to be committed
|
|
260
|
+
if (row !== undefined)
|
|
261
|
+
row.event = { origin, state, ...row.event }
|
|
262
|
+
|
|
263
|
+
if (row !== undefined && this.#outbox !== undefined)
|
|
264
|
+
await this.#outbox.insert(row, session)
|
|
265
|
+
|
|
266
|
+
return state
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (row === undefined || this.#outbox === undefined)
|
|
270
|
+
return apply(undefined)
|
|
271
|
+
|
|
272
|
+
return this.#client.transaction(apply)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async ensure (query, properties, state, row = undefined) {
|
|
276
|
+
let { criteria, options } = translate(query)
|
|
112
277
|
|
|
113
|
-
|
|
278
|
+
if (query === undefined)
|
|
279
|
+
criteria = properties
|
|
114
280
|
|
|
115
|
-
|
|
281
|
+
const update = { $setOnInsert: to(state) }
|
|
282
|
+
|
|
283
|
+
options.upsert = true
|
|
284
|
+
options.returnDocument = ReturnDocument.AFTER
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const result = row === undefined || this.#outbox === undefined
|
|
288
|
+
? await this.command('findOneAndUpdate', { criteria, update, options },
|
|
289
|
+
() => this.#collection.findOneAndUpdate(criteria, update, options))
|
|
290
|
+
: await this.#client.transaction(async (session) => {
|
|
291
|
+
const found = await this.command('findOneAndUpdate', { criteria, update, options },
|
|
292
|
+
() => this.#collection.findOneAndUpdate(criteria, update, { ...options, session }))
|
|
293
|
+
|
|
294
|
+
// only an insert is an event; finding an existing record is not
|
|
295
|
+
if (found !== null && found._id === state.id)
|
|
296
|
+
await this.#outbox.insert(row, session)
|
|
297
|
+
|
|
298
|
+
return found
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
if (result._deleted !== undefined && result._deleted !== null)
|
|
302
|
+
return null
|
|
303
|
+
else
|
|
304
|
+
return from(result)
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error.code === ERR_DUPLICATE_KEY)
|
|
307
|
+
throw new exceptions.DuplicateException(this.#client.name)
|
|
308
|
+
else
|
|
309
|
+
throw error
|
|
310
|
+
}
|
|
116
311
|
}
|
|
117
312
|
|
|
313
|
+
/** A component does not start before this returns, so the indexes are created at once. */
|
|
118
314
|
async index () {
|
|
119
|
-
const
|
|
315
|
+
const pending = []
|
|
120
316
|
|
|
121
|
-
if (this.#entity.unique !== undefined)
|
|
317
|
+
if (this.#entity.unique !== undefined)
|
|
122
318
|
for (const [name, fields] of Object.entries(this.#entity.unique)) {
|
|
123
|
-
const
|
|
124
|
-
const unique = await this.uniqueIndex(name, fields, sparse)
|
|
319
|
+
const optional = this.getOptional(fields)
|
|
125
320
|
|
|
126
|
-
|
|
321
|
+
pending.push(this.uniqueIndex(name, fields, optional))
|
|
127
322
|
}
|
|
128
|
-
}
|
|
129
323
|
|
|
130
|
-
if (this.#entity.index !== undefined)
|
|
324
|
+
if (this.#entity.index !== undefined)
|
|
131
325
|
for (const [suffix, declaration] of Object.entries(this.#entity.index)) {
|
|
132
326
|
const name = 'index_' + suffix
|
|
133
327
|
const fields = Object.fromEntries(Object.entries(declaration)
|
|
134
|
-
.map(([name, type]) => [name, INDEX_TYPES[type]]))
|
|
328
|
+
.map(([name, type]) => [name, INDEX_TYPES[type] ?? type]))
|
|
135
329
|
|
|
136
|
-
const
|
|
330
|
+
const optional = this.getOptional(Object.keys(fields))
|
|
331
|
+
const options = { name, sparse: optional.length > 0 }
|
|
137
332
|
|
|
138
|
-
|
|
139
|
-
name,
|
|
140
|
-
sparse
|
|
141
|
-
})
|
|
333
|
+
console.info('Creating index', { fields, options })
|
|
142
334
|
|
|
143
|
-
|
|
335
|
+
pending.push(this.#collection.createIndex(fields, options)
|
|
336
|
+
.catch((e) => console.warn('MongoDB index creation failed', { collection: this.#collection.collectionName, name, fields, error: e }))
|
|
337
|
+
.then(() => name))
|
|
144
338
|
}
|
|
145
|
-
}
|
|
146
339
|
|
|
147
|
-
await
|
|
340
|
+
const indexes = await Promise.all(pending)
|
|
341
|
+
|
|
342
|
+
await this.removeObsoleteIndexes(indexes)
|
|
148
343
|
}
|
|
149
344
|
|
|
150
|
-
async uniqueIndex (name, properties,
|
|
345
|
+
async uniqueIndex (name, properties, optional) {
|
|
151
346
|
const fields = properties.reduce((acc, property) => {
|
|
152
347
|
acc[property] = 1
|
|
153
348
|
return acc
|
|
@@ -155,48 +350,122 @@ class Storage extends Connector {
|
|
|
155
350
|
|
|
156
351
|
name = 'unique_' + name
|
|
157
352
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
353
|
+
const options = { name, unique: true }
|
|
354
|
+
|
|
355
|
+
if (optional.length > 0)
|
|
356
|
+
options.partialFilterExpression = Object.fromEntries(optional.map((field) => [field, { $exists: true }]))
|
|
357
|
+
|
|
358
|
+
console.info('Creating unique index', { name, fields, options })
|
|
359
|
+
|
|
360
|
+
await this.#collection.createIndex(fields, options)
|
|
361
|
+
.catch((e) => console.warn('MongoDB unique index creation failed',
|
|
362
|
+
{ collection: this.#collection.collectionName, name, fields, error: e }))
|
|
163
363
|
|
|
164
364
|
return name
|
|
165
365
|
}
|
|
166
366
|
|
|
167
|
-
async
|
|
168
|
-
const current = await this
|
|
367
|
+
async removeObsoleteIndexes (desired) {
|
|
368
|
+
const current = await this.getCurrentIndexes()
|
|
169
369
|
const obsolete = current.filter((name) => !desired.includes(name))
|
|
170
370
|
|
|
171
371
|
if (obsolete.length > 0) {
|
|
172
|
-
console.info(
|
|
372
|
+
console.info('Removing obsolete indexes', { collection: this.#collection.collectionName, indexes: obsolete.join(', ') })
|
|
373
|
+
|
|
374
|
+
await Promise.all(obsolete.map((name) => this.#collection.dropIndex(name)))
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async getCurrentIndexes () {
|
|
379
|
+
try {
|
|
380
|
+
const array = await this.#collection.listIndexes().toArray()
|
|
173
381
|
|
|
174
|
-
|
|
382
|
+
return array.map(({ name }) => name).filter((name) => name !== '_id_')
|
|
383
|
+
} catch {
|
|
384
|
+
return []
|
|
175
385
|
}
|
|
176
386
|
}
|
|
177
387
|
|
|
178
|
-
|
|
388
|
+
getOptional (fields) {
|
|
179
389
|
const optional = []
|
|
180
390
|
|
|
181
|
-
for (const field of fields) {
|
|
182
|
-
if (!(field in this.#entity.schema.properties))
|
|
391
|
+
for (const field of fields) {
|
|
392
|
+
if (!field.includes('.') && !(field in this.#entity.schema.properties))
|
|
183
393
|
throw new Error(`Index field '${field}' is not defined.`)
|
|
184
|
-
}
|
|
185
394
|
|
|
186
|
-
if (!this.#entity.schema.required?.includes(field))
|
|
395
|
+
if (!this.#entity.schema.required?.includes(field))
|
|
187
396
|
optional.push(field)
|
|
188
|
-
}
|
|
189
397
|
}
|
|
190
398
|
|
|
191
|
-
|
|
192
|
-
|
|
399
|
+
return optional
|
|
400
|
+
}
|
|
193
401
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
402
|
+
/**
|
|
403
|
+
* Names, logs and times a call into the driver. The driver's own command monitoring is
|
|
404
|
+
* off (see `client.js`), so this is where a query becomes a span.
|
|
405
|
+
*
|
|
406
|
+
* @private
|
|
407
|
+
*/
|
|
408
|
+
async command (method, attributes, task) {
|
|
409
|
+
this.debug(method, attributes)
|
|
410
|
+
|
|
411
|
+
return console.span(this.span(method), task)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The span of a driver method is the same object every time: the collection is fixed
|
|
416
|
+
* for a storage, and nothing downstream writes to what it is given.
|
|
417
|
+
*
|
|
418
|
+
* @private
|
|
419
|
+
*/
|
|
420
|
+
span (method) {
|
|
421
|
+
let options = this.#spans.get(method)
|
|
422
|
+
|
|
423
|
+
if (options === undefined) {
|
|
424
|
+
const collection = this.#collection.collectionName
|
|
425
|
+
|
|
426
|
+
options = {
|
|
427
|
+
name: `${method} ${collection}`,
|
|
428
|
+
kind: 'client',
|
|
429
|
+
// https://opentelemetry.io/docs/specs/semconv/database/mongodb/
|
|
430
|
+
attributes: {
|
|
431
|
+
'db.system': 'mongodb',
|
|
432
|
+
'db.namespace': this.#collection.dbName,
|
|
433
|
+
'db.operation.name': method,
|
|
434
|
+
'db.collection.name': collection
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
this.#spans.set(method, options)
|
|
197
439
|
}
|
|
440
|
+
|
|
441
|
+
return options
|
|
198
442
|
}
|
|
199
443
|
|
|
444
|
+
debug (method, attributes) {
|
|
445
|
+
console.debug('MongoDB query', {
|
|
446
|
+
collection: this.#collection.collectionName,
|
|
447
|
+
method,
|
|
448
|
+
...attributes
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function toPipeline (criteria, options, sample) {
|
|
454
|
+
const pipeline = []
|
|
455
|
+
|
|
456
|
+
if (criteria !== undefined)
|
|
457
|
+
pipeline.push({ $match: criteria })
|
|
458
|
+
|
|
459
|
+
if (sample !== undefined)
|
|
460
|
+
pipeline.push({ $sample: { size: sample } })
|
|
461
|
+
|
|
462
|
+
if (options?.sort !== undefined)
|
|
463
|
+
pipeline.push({ $sort: options.sort })
|
|
464
|
+
|
|
465
|
+
if (options?.projection !== undefined)
|
|
466
|
+
pipeline.push({ $project: options.projection })
|
|
467
|
+
|
|
468
|
+
return pipeline
|
|
200
469
|
}
|
|
201
470
|
|
|
202
471
|
const INDEX_TYPES = {
|
|
@@ -207,4 +476,29 @@ const INDEX_TYPES = {
|
|
|
207
476
|
|
|
208
477
|
const ERR_DUPLICATE_KEY = 11000
|
|
209
478
|
|
|
479
|
+
async function retriable (error, attempt) {
|
|
480
|
+
if (error.code === ERR_DUPLICATE_KEY) {
|
|
481
|
+
const id = error.keyPattern === undefined
|
|
482
|
+
? error.message.includes(' index: _id_ ') // AWS DocumentDB
|
|
483
|
+
: error.keyPattern._id === 1
|
|
484
|
+
|
|
485
|
+
if (id)
|
|
486
|
+
return false
|
|
487
|
+
else
|
|
488
|
+
throw new exceptions.DuplicateException()
|
|
489
|
+
} else if (error.cause?.code === 'ECONNREFUSED') {
|
|
490
|
+
if (attempt === LAST_ATTEMPT)
|
|
491
|
+
throw error
|
|
492
|
+
|
|
493
|
+
const timeout = 1000 + 500 * attempt
|
|
494
|
+
|
|
495
|
+
await new Promise((resolve) => setTimeout(resolve, timeout))
|
|
496
|
+
|
|
497
|
+
return true
|
|
498
|
+
} else
|
|
499
|
+
throw error
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const LAST_ATTEMPT = 9
|
|
503
|
+
|
|
210
504
|
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
|