@toa.io/storages.mongodb 1.0.0-alpha.28 → 1.0.0-alpha.283

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,139 @@
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.283](https://github.com/toa-io/toa/compare/v1.0.0-alpha.282...v1.0.0-alpha.283) (2026-09-04)
7
+
8
+ **Note:** Version bump only for package @toa.io/storages.mongodb
9
+
10
+
11
+
12
+
13
+
14
+ # [1.0.0-alpha.282](https://github.com/toa-io/toa/compare/v1.0.0-alpha.281...v1.0.0-alpha.282) (2026-09-03)
15
+
16
+ **Note:** Version bump only for package @toa.io/storages.mongodb
17
+
18
+
19
+
20
+
21
+
22
+ # [1.0.0-alpha.278](https://github.com/toa-io/toa/compare/v1.0.0-alpha.277...v1.0.0-alpha.278) (2026-09-03)
23
+
24
+ **Note:** Version bump only for package @toa.io/storages.mongodb
25
+
26
+
27
+
28
+
29
+
30
+ # [1.0.0-alpha.277](https://github.com/toa-io/toa/compare/v1.0.0-alpha.276...v1.0.0-alpha.277) (2026-09-03)
31
+
32
+ **Note:** Version bump only for package @toa.io/storages.mongodb
33
+
34
+
35
+
36
+
37
+
38
+ # [1.0.0-alpha.274](https://github.com/toa-io/toa/compare/v1.0.0-alpha.273...v1.0.0-alpha.274) (2026-09-02)
39
+
40
+ **Note:** Version bump only for package @toa.io/storages.mongodb
41
+
42
+
43
+
44
+
45
+
46
+ # [1.0.0-alpha.273](https://github.com/toa-io/toa/compare/v1.0.0-alpha.272...v1.0.0-alpha.273) (2026-09-02)
47
+
48
+ **Note:** Version bump only for package @toa.io/storages.mongodb
49
+
50
+
51
+
52
+
53
+
54
+ # [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)
55
+
56
+
57
+ * refactor(core)!: pump the outbox in one cycle ([bf590fe](https://github.com/toa-io/toa/commit/bf590fe8ed59c701b5fd1a91ba4874685b79242f))
58
+ * 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)
59
+
60
+
61
+ ### BREAKING CHANGES
62
+
63
+ * `Storage.outbox.pending` takes a fourth argument, the id to
64
+ continue from.
65
+
66
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
67
+ * `event.changeset` is removed; use `origin` and `state`. `State`
68
+ takes an `Outbox` in place of an `Emission`. `difference` is dropped from
69
+ `@toa.io/generic` along with its last caller.
70
+
71
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
72
+
73
+
74
+
75
+
76
+
77
+ # [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)
78
+
79
+
80
+ ### Performance Improvements
81
+
82
+ * **boot:** stop paying for what a composition does not need to start ([1619c27](https://github.com/toa-io/toa/commit/1619c2743072f4706939ce72f3074e615d26a91e))
83
+ * **storages.mongodb:** time the call instead of monitoring the command ([135f1a9](https://github.com/toa-io/toa/commit/135f1a97c753b1caed9291f85cea0521ef68b0e3))
84
+
85
+
86
+
87
+
88
+
89
+ # [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)
90
+
91
+ **Note:** Version bump only for package @toa.io/storages.mongodb
92
+
93
+
94
+
95
+
96
+
97
+ # [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)
98
+
99
+ **Note:** Version bump only for package @toa.io/storages.mongodb
100
+
101
+
102
+
103
+
104
+
105
+ # [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)
106
+
107
+ **Note:** Version bump only for package @toa.io/storages.mongodb
108
+
109
+
110
+
111
+
112
+
113
+ # [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)
114
+
115
+ **Note:** Version bump only for package @toa.io/storages.mongodb
116
+
117
+
118
+
119
+
120
+
121
+ # [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)
122
+
123
+ **Note:** Version bump only for package @toa.io/storages.mongodb
124
+
125
+
126
+
127
+
128
+
129
+ # [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)
130
+
131
+ **Note:** Version bump only for package @toa.io/storages.mongodb
132
+
133
+
134
+
135
+
136
+
137
+ # [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)
138
+
139
+ **Note:** Version bump only for package @toa.io/storages.mongodb
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@toa.io/storages.mongodb",
3
- "version": "1.0.0-alpha.28",
3
+ "version": "1.0.0-alpha.283",
4
+ "type": "module",
4
5
  "description": "Toa MongoDB Storage Connector",
5
6
  "author": "temich <tema.gurtovoy@gmail.com>",
6
7
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -19,13 +20,13 @@
19
20
  "test": "echo \"Error: run tests from root\" && exit 1"
20
21
  },
21
22
  "dependencies": {
22
- "@toa.io/console": "1.0.0-alpha.28",
23
- "@toa.io/conveyor": "1.0.0-alpha.28",
24
- "@toa.io/core": "1.0.0-alpha.28",
25
- "@toa.io/generic": "1.0.0-alpha.28",
26
- "@toa.io/pointer": "1.0.0-alpha.28",
27
- "mongodb": "6.3.0",
23
+ "@toa.io/conveyor": "1.0.0-alpha.283",
24
+ "@toa.io/core": "1.0.0-alpha.283",
25
+ "@toa.io/generic": "1.0.0-alpha.283",
26
+ "@toa.io/pointer": "1.0.0-alpha.283",
27
+ "mongodb": "7.6.0",
28
+ "openspan": "1.0.0-alpha.277",
28
29
  "saslprep": "1.0.3"
29
30
  },
30
- "gitHead": "89c3b7d1314e357c9e33df8d95f8d46954cd3bcf"
31
+ "gitHead": "2f52b023bed0ccb2ecdb2b82fe59fbe38bf282e1"
31
32
  }
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
@@ -1,28 +1,48 @@
1
- 'use strict'
2
-
3
1
  /**
4
2
  * @typedef {import('mongodb').MongoClient} MongoClient
5
3
  * @typedef {{ count: number, client: MongoClient }} Instance
6
4
  * @typedef {import('@toa.io/core').Locator} Locator
7
5
  */
8
6
 
9
- const { Connector } = require('@toa.io/core')
10
- const { resolve } = require('@toa.io/pointer')
11
- const { ID } = require('./deployment')
12
- const { MongoClient } = require('mongodb')
7
+ import { console } from 'openspan'
8
+ import { Connector } from '@toa.io/core'
9
+ import { resolve } from '@toa.io/pointer'
10
+ import { ID } from './deployment.js'
11
+ import { MongoClient } from 'mongodb'
13
12
 
14
13
  /**
15
14
  * @type {Record<string, Promise<Instance>>}
16
15
  */
17
16
  const INSTANCES = {}
18
17
 
19
- class Client extends Connector {
18
+ export class Client extends Connector {
19
+ name
20
+
20
21
  /**
21
22
  * @public
22
23
  * @type {import('mongodb').Collection}
23
24
  */
24
25
  collection
25
26
 
27
+ /**
28
+ * The outbox rows of this component, absent unless something consumes its events. Created
29
+ * eagerly beside the entity collection, because a transaction cannot create a collection and
30
+ * an index build cannot run inside one.
31
+ *
32
+ * @public
33
+ * @type {import('mongodb').Collection | undefined}
34
+ */
35
+ outbox
36
+
37
+ /**
38
+ * Whether this deployment can run transactions at all. A standalone mongod cannot, and an
39
+ * outbox without atomicity is worse than none, so the storage falls back to inline emission.
40
+ *
41
+ * @public
42
+ * @type {boolean}
43
+ */
44
+ transactional = false
45
+
26
46
  /**
27
47
  * @private
28
48
  * @type {Locator}
@@ -41,13 +61,22 @@ class Client extends Connector {
41
61
  */
42
62
  key
43
63
 
64
+ /**
65
+ * @private
66
+ * @type {boolean}
67
+ */
68
+ publishes
69
+
44
70
  /**
45
71
  * @param {Locator} locator
72
+ * @param {boolean} [publishes] whether this component publishes anything
46
73
  */
47
- constructor (locator) {
74
+ constructor (locator, publishes = false) {
48
75
  super()
49
76
 
50
77
  this.locator = locator
78
+ this.name = locator.lowercase
79
+ this.publishes = publishes
51
80
  }
52
81
 
53
82
  /**
@@ -58,26 +87,44 @@ class Client extends Connector {
58
87
  async open () {
59
88
  const urls = await this.resolveURLs()
60
89
  const dbname = this.resolveDB()
61
- const collname = this.locator.lowercase
62
90
 
63
91
  this.key = getKey(dbname, urls)
64
92
 
65
- INSTANCES[this.key] ??= this.createInstance(urls)
93
+ try {
94
+ INSTANCES[this.key] ??= this.createInstance(urls)
95
+ } catch (error) {
96
+ console.error('Failed to connect to MongoDB', { urls, error })
97
+ }
66
98
 
67
99
  this.instance = await INSTANCES[this.key]
68
100
  this.instance.count++
69
101
 
70
102
  const db = this.instance.client.db(dbname)
71
103
 
72
- try {
73
- this.collection = await db.createCollection(collname)
74
- } catch (e) {
75
- if (e.code !== ALREADY_EXISTS) {
76
- throw e
77
- }
104
+ this.collection = await collection(db, this.name)
105
+ this.transactional = await transactional(db)
78
106
 
79
- this.collection = db.collection(collname)
80
- }
107
+ if (!this.publishes) return
108
+
109
+ if (this.transactional) this.outbox = await collection(db, this.name + OUTBOX)
110
+ else
111
+ console.warn('MongoDB is not a replica set; events are emitted inline, without an outbox',
112
+ { collection: this.name })
113
+ }
114
+
115
+ /**
116
+ * Runs `fn` in a transaction and answers what it returned. The driver may call `fn` more
117
+ * than once, so it must not hold state of its own — an outbox row is built by the caller
118
+ * and reused, and a rolled back attempt leaves nothing behind.
119
+ *
120
+ * @public
121
+ * @template T
122
+ * @param {(session: import('mongodb').ClientSession) => Promise<T>} fn
123
+ * @return {Promise<T>}
124
+ */
125
+ async transaction (fn) {
126
+ return this.instance.client.withSession(async (session) =>
127
+ session.withTransaction(async () => fn(session)))
81
128
  }
82
129
 
83
130
  /**
@@ -105,7 +152,7 @@ class Client extends Connector {
105
152
  const client = new MongoClient(urls.join(','), OPTIONS)
106
153
  const hosts = urls.map((str) => new URL(str).host)
107
154
 
108
- console.info('Connecting to MongoDB:', hosts.join(', '))
155
+ console.info('Connecting to MongoDB', { address: hosts.join(', ') })
109
156
 
110
157
  await client.connect()
111
158
 
@@ -148,12 +195,40 @@ function getKey (db, urls) {
148
195
  return db + ':' + urls.sort().join(' ')
149
196
  }
150
197
 
198
+ /**
199
+ * Concurrent pods race to create the same collection, and losing that race is not an error.
200
+ */
201
+ async function collection (db, name) {
202
+ try {
203
+ return await db.createCollection(name)
204
+ } catch (e) {
205
+ if (e.code !== ALREADY_EXISTS) throw e
206
+
207
+ return db.collection(name)
208
+ }
209
+ }
210
+
211
+ async function transactional (db) {
212
+ try {
213
+ const hello = await db.admin().command({ hello: 1 })
214
+
215
+ return hello.setName !== undefined || hello.msg === 'isdbgrid'
216
+ } catch (e) {
217
+ console.warn('MongoDB transaction support could not be determined', { error: e })
218
+
219
+ return false
220
+ }
221
+ }
222
+
223
+ /**
224
+ * `monitorCommands` is deliberately absent. It makes the driver materialize every reply
225
+ * eagerly to populate the monitoring event (`CommandSucceededEvent`), which defeats the
226
+ * lazy per-document deserialization a cursor exists for — a 100-document batch is then
227
+ * deserialized twice. `Storage` times its own calls instead.
228
+ */
151
229
  const OPTIONS = {
152
- ignoreUndefined: true,
153
- connectTimeoutMS: 0,
154
- serverSelectionTimeoutMS: 0
230
+ ignoreUndefined: true
155
231
  }
156
232
 
157
233
  const ALREADY_EXISTS = 48
158
-
159
- exports.Client = Client
234
+ const OUTBOX = '_outbox'
package/src/deployment.js CHANGED
@@ -1,8 +1,6 @@
1
- 'use strict'
1
+ import { createVariables } from '@toa.io/pointer'
2
2
 
3
- const { createVariables } = require('@toa.io/pointer')
4
-
5
- const deployment = (instances, annotation) => {
3
+ export const deployment = (instances, annotation) => {
6
4
  const requests = instances.map((instance) => createRequest(instance))
7
5
  const variables = createVariables(ID, annotation, requests)
8
6
 
@@ -16,7 +14,4 @@ function createRequest (instance) {
16
14
  }
17
15
  }
18
16
 
19
- const ID = 'mongodb'
20
-
21
- exports.ID = ID
22
- exports.deployment = deployment
17
+ export const ID = 'mongodb'
package/src/factory.js CHANGED
@@ -1,16 +1,10 @@
1
- 'use strict'
1
+ import { Client } from './client.js'
2
+ import { Storage } from './storage.js'
2
3
 
3
- const { Client } = require('./client')
4
- const { Collection } = require('./collection')
5
- const { Storage } = require('./storage')
4
+ export class Factory {
5
+ storage (locator, entity, options = {}) {
6
+ const client = new Client(locator, options.outbox === true)
6
7
 
7
- class Factory {
8
- storage (locator, entity) {
9
- const client = new Client(locator)
10
- const connection = new Collection(client)
11
-
12
- return new Storage(connection, entity)
8
+ return new Storage(client, entity)
13
9
  }
14
10
  }
15
-
16
- exports.Factory = Factory
package/src/index.js CHANGED
@@ -1,7 +1,2 @@
1
- 'use strict'
2
-
3
- const { deployment } = require('./deployment')
4
- const { Factory } = require('./factory')
5
-
6
- exports.deployment = deployment
7
- exports.Factory = Factory
1
+ export { deployment } from './deployment.js'
2
+ export { Factory } from './factory.js'
package/src/outbox.js ADDED
@@ -0,0 +1,125 @@
1
+ import { console } from 'openspan'
2
+
3
+ /**
4
+ * The outbox rows of one component. Its lifecycle is the Client's, so it is not a Connector.
5
+ *
6
+ * Core owns what a row means — its id, its lane and when it becomes due. This only writes
7
+ * them, reads back what is due, and marks what has been published.
8
+ */
9
+ export class Outbox {
10
+ /** @type {import('mongodb').Collection} */
11
+ #collection
12
+
13
+ #retention
14
+
15
+ constructor (collection) {
16
+ this.#collection = collection
17
+ this.#retention = retention()
18
+ }
19
+
20
+ /** @param {import('mongodb').ClientSession} session */
21
+ async insert (row, session) {
22
+ await this.#collection.insertOne(to(row), { session })
23
+ }
24
+
25
+ /** @param {import('mongodb').ClientSession} session */
26
+ async insertMany (rows, session) {
27
+ if (rows.length === 0) return
28
+
29
+ await this.#collection.insertMany(rows.map(to), { session })
30
+ }
31
+
32
+ /**
33
+ * One page of what this replica should publish: due, still unpublished, and in a lane it
34
+ * owns. In steady state the first page is empty.
35
+ *
36
+ * `after` continues from the last id of the page before. Ids are uuid v7, so their order is
37
+ * the order rows were written and a page is never read twice within a cycle — which matters
38
+ * because a row stays unpublished in the database until the cycle that sent it marks it.
39
+ */
40
+ async pending (lanes, now, limit, after = undefined) {
41
+ const criteria = { lane: { $in: lanes }, published: false, pending: { $lte: now } }
42
+
43
+ if (after !== undefined) criteria._id = { $gt: after }
44
+
45
+ const rows = await this.#collection
46
+ .find(criteria)
47
+ .sort({ _id: 1 })
48
+ .limit(limit)
49
+ .toArray()
50
+
51
+ return rows.map(from)
52
+ }
53
+
54
+ /**
55
+ * One batched write for many events, which is why the ids are held in memory until the
56
+ * tick rather than updated one by one.
57
+ */
58
+ async settle (ids) {
59
+ if (ids.length === 0) return
60
+
61
+ await this.#collection.updateMany({ _id: { $in: ids } },
62
+ { $set: { published: true, publishedAt: new Date() } })
63
+ }
64
+
65
+ /**
66
+ * The entity collection's index management does not reach here, so this collection keeps
67
+ * its own — including pruning, or a later change leaves the old index behind forever.
68
+ */
69
+ async index () {
70
+ const desired = {
71
+ // holds only what is not published yet, so it stays at in-flight size
72
+ outbox_pending: {
73
+ fields: { lane: 1, pending: 1 },
74
+ options: { name: 'outbox_pending', partialFilterExpression: { published: false } }
75
+ },
76
+ // an unpublished row has no `publishedAt`, and the TTL monitor skips those — so a row
77
+ // that never made it out is never reaped
78
+ outbox_published_at: {
79
+ fields: { publishedAt: 1 },
80
+ options: { name: 'outbox_published_at', expireAfterSeconds: this.#retention }
81
+ }
82
+ }
83
+
84
+ for (const { fields, options } of Object.values(desired))
85
+ await this.#collection.createIndex(fields, options)
86
+ .catch((e) => console.warn('MongoDB outbox index creation failed',
87
+ { collection: this.#collection.collectionName, name: options.name, error: e }))
88
+
89
+ await this.#prune(Object.keys(desired))
90
+ }
91
+
92
+ /** @private */
93
+ async #prune (desired) {
94
+ let current
95
+
96
+ try {
97
+ current = await this.#collection.listIndexes().toArray()
98
+ } catch {
99
+ return
100
+ }
101
+
102
+ const obsolete = current
103
+ .map(({ name }) => name)
104
+ .filter((name) => name !== '_id_' && !desired.includes(name))
105
+
106
+ if (obsolete.length === 0) return
107
+
108
+ console.info('Removing obsolete outbox indexes',
109
+ { collection: this.#collection.collectionName, indexes: obsolete.join(', ') })
110
+
111
+ await Promise.all(obsolete.map((name) => this.#collection.dropIndex(name)))
112
+ }
113
+ }
114
+
115
+ const to = ({ id, ...rest }) => ({ _id: id, ...rest })
116
+ const from = ({ _id, ...rest }) => ({ id: _id, ...rest })
117
+
118
+ function retention () {
119
+ const value = Number(process.env.TOA_OUTBOX_RETENTION)
120
+
121
+ return Number.isNaN(value) || value < 0 ? RETENTION : value
122
+ }
123
+
124
+ /** seconds a published row is kept as a change log before the TTL monitor reaps it */
125
+ const RETENTION = 86400
package/src/record.js CHANGED
@@ -1,32 +1,14 @@
1
- 'use strict'
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
1
+ export function to (entity) {
2
+ const { id, ...rest } = entity
12
3
 
13
4
  return /** @type {toa.mongodb.Record} */ { _id: id, ...rest }
14
5
  }
15
6
 
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
7
+ export function from (record) {
8
+ if (record === undefined || record === null)
9
+ return null
22
10
 
23
- const {
24
- _id,
25
- ...rest
26
- } = record
11
+ const { _id, ...rest } = record
27
12
 
28
13
  return { id: _id, ...rest }
29
14
  }
30
-
31
- exports.to = to
32
- exports.from = from