@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 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.27",
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/console": "1.0.0-alpha.27",
23
- "@toa.io/conveyor": "1.0.0-alpha.27",
24
- "@toa.io/core": "1.0.0-alpha.27",
25
- "@toa.io/generic": "1.0.0-alpha.27",
26
- "@toa.io/pointer": "1.0.0-alpha.27",
27
- "mongodb": "6.3.0",
22
+ "@toa.io/conveyor": "1.0.0-alpha.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": "9bc4bdb919688c5272791020746f70d51a520750"
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
- INSTANCES[this.key] ??= this.createInstance(urls)
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
- try {
73
- this.collection = await db.createCollection(collname)
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
- this.collection = db.collection(collname)
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:', hosts.join(', '))
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(connection, entity)
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
- * @param {toa.core.storages.Record} entity
5
- * @returns {toa.mongodb.Record}
6
- */
7
- const to = (entity) => {
8
- const {
9
- id,
10
- ...rest
11
- } = entity
3
+ function to (entity) {
4
+ const { id, ...rest } = entity
12
5
 
13
6
  return /** @type {toa.mongodb.Record} */ { _id: id, ...rest }
14
7
  }
15
8
 
16
- /**
17
- * @param {toa.mongodb.Record} record
18
- * @returns {toa.core.storages.Record}
19
- */
20
- const from = (record) => {
21
- if (record === undefined || record === null) return null
9
+ function from (record) {
10
+ if (record === undefined || record === null)
11
+ return null
22
12
 
23
- const {
24
- _id,
25
- ...rest
26
- } = record
13
+ const { _id, ...rest } = record
27
14
 
28
15
  return { id: _id, ...rest }
29
16
  }
package/src/storage.js CHANGED
@@ -1,102 +1,228 @@
1
1
  'use strict'
2
2
 
3
- const {
4
- Connector,
5
- exceptions
6
- } = require('@toa.io/core')
7
-
3
+ const { Connector, exceptions } = require('@toa.io/core')
4
+ const { console } = require('openspan')
8
5
  const { translate } = require('./translate')
9
- const {
10
- to,
11
- from
12
- } = require('./record')
6
+ const { to, from } = require('./record')
7
+ const { Outbox } = require('./outbox')
8
+ const { ReturnDocument } = require('mongodb')
13
9
 
14
10
  class Storage extends Connector {
15
- #connection
11
+ #client
12
+
13
+ /** @type {import('mongodb').Collection} */
14
+ #collection
16
15
  #entity
17
16
 
18
- constructor (connection, entity) {
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.#connection = connection
29
+ this.#client = client
22
30
  this.#entity = entity
23
31
 
24
- this.depends(connection)
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
- criteria,
34
- options
35
- } = translate(query)
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.#connection.get(criteria, options)
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
- const recordset = await this.#connection.find(criteria, options)
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
- async add (entity) {
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
- const result = await this.#connection.add(record)
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
- const result = await this.#connection.replace(criteria, to(entity))
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 (entity._version === 1) {
73
- return await this.add(entity)
74
- } else {
75
- return await this.set(entity)
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
- const id = error.keyPattern === undefined
81
- ? error.message.includes(' index: _id_ ') // AWS DocumentDB
82
- : error.keyPattern._id === 1
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
- } else {
90
- throw error
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 upsert (query, changeset) {
96
- const {
97
- criteria,
98
- options
99
- } = translate(query)
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
- options.returnDocument = 'after'
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
- const result = await this.#connection.update(criteria, update, options)
278
+ if (query === undefined)
279
+ criteria = properties
114
280
 
115
- return from(result)
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 indexes = []
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 sparse = this.checkFields(fields)
124
- const unique = await this.uniqueIndex(name, fields, sparse)
319
+ const optional = this.getOptional(fields)
125
320
 
126
- indexes.push(unique)
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 sparse = this.checkFields(Object.keys(fields))
330
+ const optional = this.getOptional(Object.keys(fields))
331
+ const options = { name, sparse: optional.length > 0 }
137
332
 
138
- await this.#connection.index(fields, {
139
- name,
140
- sparse
141
- })
333
+ console.info('Creating index', { fields, options })
142
334
 
143
- indexes.push(name)
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 this.removeObsolete(indexes)
340
+ const indexes = await Promise.all(pending)
341
+
342
+ await this.removeObsoleteIndexes(indexes)
148
343
  }
149
344
 
150
- async uniqueIndex (name, properties, sparse = false) {
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
- await this.#connection.index(fields, {
159
- name,
160
- unique: true,
161
- sparse
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 removeObsolete (desired) {
168
- const current = await this.#connection.indexes()
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(`Remove obsolete indexes: [${obsolete.join(', ')}]`)
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
- await this.#connection.dropIndexes(obsolete)
382
+ return array.map(({ name }) => name).filter((name) => name !== '_id_')
383
+ } catch {
384
+ return []
175
385
  }
176
386
  }
177
387
 
178
- checkFields (fields) {
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
- if (optional.length > 0) {
192
- console.info(`Index fields [${optional.join(', ')}] are optional, creating sparse index.`)
399
+ return optional
400
+ }
193
401
 
194
- return true
195
- } else {
196
- return false
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?.version !== undefined) {
19
+ if (query?.ids !== undefined)
20
+ result.criteria._id = { $in: query.ids }
21
+
22
+ if (query?.version !== undefined)
20
23
  result.criteria._version = query.version
21
- }
22
24
 
23
- result.criteria._deleted = null
25
+ if (query?.search !== undefined)
26
+ result.criteria.$text = { $search: query.search }
24
27
 
25
28
  return result
26
29
  }
@@ -47,19 +47,4 @@ describe('from', () => {
47
47
  _version: 0
48
48
  })
49
49
  })
50
-
51
- it('should not modify argument', () => {
52
- /** @type {toa.mongodb.Record} */
53
- const record = {
54
- _id: '1',
55
- _version: 0
56
- }
57
-
58
- from(record)
59
-
60
- expect(record).toStrictEqual({
61
- _id: '1',
62
- _version: 0
63
- })
64
- })
65
50
  })
@@ -0,0 +1,73 @@
1
+ 'use strict'
2
+
3
+ const { Storage } = require('../src/storage')
4
+
5
+ let collection
6
+ let storage
7
+
8
+ beforeEach(async () => {
9
+ collection = {
10
+ collectionName: 'test',
11
+ findOne: jest.fn(async () => null),
12
+ find: jest.fn(() => ({ stream: () => null }))
13
+ }
14
+
15
+ const client = {
16
+ collection,
17
+ link: () => null
18
+ }
19
+
20
+ storage = new Storage(client, { schema: { properties: {} } })
21
+
22
+ await storage.open()
23
+ })
24
+
25
+ describe('get', () => {
26
+ it('should filter deleted', async () => {
27
+ await storage.get({})
28
+
29
+ expect(collection.findOne).toHaveBeenCalledWith({ _deleted: null }, {})
30
+ })
31
+
32
+ it('should filter deleted with sort', async () => {
33
+ await storage.get({ options: { sort: [['_created', 'desc']] } })
34
+
35
+ expect(collection.findOne)
36
+ .toHaveBeenCalledWith({ _deleted: null }, { sort: [['_created', -1]] })
37
+ })
38
+
39
+ it('should not filter deleted if queried by id', async () => {
40
+ const id = 'bcb6780f50e243348cad40ed6b5ef575'
41
+
42
+ await storage.get({ id })
43
+
44
+ expect(collection.findOne).toHaveBeenCalledWith({ _id: id }, {})
45
+ })
46
+
47
+ it('should not filter deleted if requested', async () => {
48
+ await storage.get({ options: { deleted: true } })
49
+
50
+ expect(collection.findOne).toHaveBeenCalledWith({}, {})
51
+ })
52
+ })
53
+
54
+ describe('stream', () => {
55
+ it('should filter deleted', async () => {
56
+ await storage.stream()
57
+
58
+ expect(collection.find).toHaveBeenCalledWith({ _deleted: null }, {})
59
+ })
60
+
61
+ it('should filter deleted with sort', async () => {
62
+ await storage.stream({ options: { sort: [['_created', 'desc']] } })
63
+
64
+ expect(collection.find)
65
+ .toHaveBeenCalledWith({ _deleted: null }, { sort: [['_created', -1]] })
66
+ })
67
+
68
+ it('should not filter deleted if requested', async () => {
69
+ await storage.stream({ options: { deleted: true } })
70
+
71
+ expect(collection.find).toHaveBeenCalledWith({}, {})
72
+ })
73
+ })
package/src/collection.js DELETED
@@ -1,69 +0,0 @@
1
- 'use strict'
2
-
3
- const { Connector } = require('@toa.io/core')
4
-
5
- class Collection extends Connector {
6
- #client
7
- #collection
8
-
9
- constructor (client) {
10
- super()
11
-
12
- this.#client = client
13
-
14
- this.depends(client)
15
- }
16
-
17
- async open () {
18
- this.#collection = this.#client.collection
19
- }
20
-
21
- /** @hot */
22
- async get (query, options) {
23
- return /** @type {toa.mongodb.Record} */ this.#collection.findOne(query, options)
24
- }
25
-
26
- /** @hot */
27
- async find (query, options) {
28
- const cursor = this.#collection.find(query, options)
29
-
30
- return cursor.toArray()
31
- }
32
-
33
- /** @hot */
34
- async add (record) {
35
- return await this.#collection.insertOne(record)
36
- }
37
-
38
- /** @hot */
39
- async replace (query, record, options) {
40
- return await this.#collection.findOneAndReplace(query, record, options)
41
- }
42
-
43
- /** @hot */
44
- async update (query, update, options) {
45
- return this.#collection.findOneAndUpdate(query, update, options)
46
- }
47
-
48
- async index (keys, options) {
49
- return this.#collection.createIndex(keys, options)
50
- }
51
-
52
- async indexes () {
53
- try {
54
- const array = await this.#collection.listIndexes().toArray()
55
-
56
- return array.map(({ name }) => name).filter((name) => name !== '_id_')
57
- } catch {
58
- return []
59
- }
60
- }
61
-
62
- async dropIndexes (names) {
63
- const all = names.map((name) => this.#collection.dropIndex(name))
64
-
65
- return Promise.all(all)
66
- }
67
- }
68
-
69
- exports.Collection = Collection