@toa.io/storages.mongodb 1.0.0-alpha.270 → 1.0.0-alpha.273

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 CHANGED
@@ -3,6 +3,37 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [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)
7
+
8
+ **Note:** Version bump only for package @toa.io/storages.mongodb
9
+
10
+
11
+
12
+
13
+
14
+ # [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)
15
+
16
+
17
+ * refactor(core)!: pump the outbox in one cycle ([bf590fe](https://github.com/toa-io/toa/commit/bf590fe8ed59c701b5fd1a91ba4874685b79242f))
18
+ * 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)
19
+
20
+
21
+ ### BREAKING CHANGES
22
+
23
+ * `Storage.outbox.pending` takes a fourth argument, the id to
24
+ continue from.
25
+
26
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
27
+ * `event.changeset` is removed; use `origin` and `state`. `State`
28
+ takes an `Outbox` in place of an `Emission`. `difference` is dropped from
29
+ `@toa.io/generic` along with its last caller.
30
+
31
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
32
+
33
+
34
+
35
+
36
+
6
37
  # [1.0.0-alpha.270](https://github.com/toa-io/toa/compare/v1.0.0-alpha.269...v1.0.0-alpha.270) (2026-08-31)
7
38
 
8
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/storages.mongodb",
3
- "version": "1.0.0-alpha.270",
3
+ "version": "1.0.0-alpha.273",
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/conveyor": "1.0.0-alpha.254",
23
- "@toa.io/core": "1.0.0-alpha.270",
24
- "@toa.io/generic": "1.0.0-alpha.254",
25
- "@toa.io/pointer": "1.0.0-alpha.270",
22
+ "@toa.io/conveyor": "1.0.0-alpha.273",
23
+ "@toa.io/core": "1.0.0-alpha.273",
24
+ "@toa.io/generic": "1.0.0-alpha.273",
25
+ "@toa.io/pointer": "1.0.0-alpha.273",
26
26
  "mongodb": "7.2.0",
27
- "openspan": "1.0.0-alpha.270",
27
+ "openspan": "1.0.0-alpha.272",
28
28
  "saslprep": "1.0.3"
29
29
  },
30
- "gitHead": "94400bdd411b4c25074ffcf58184580ddd060268"
30
+ "gitHead": "ad3284850ece95ffd5325fd0995707fc144c9c3d"
31
31
  }
package/src/client.js CHANGED
@@ -26,6 +26,25 @@ class Client extends Connector {
26
26
  */
27
27
  collection
28
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
+
29
48
  /**
30
49
  * @private
31
50
  * @type {Locator}
@@ -44,14 +63,22 @@ class Client extends Connector {
44
63
  */
45
64
  key
46
65
 
66
+ /**
67
+ * @private
68
+ * @type {boolean}
69
+ */
70
+ publishes
71
+
47
72
  /**
48
73
  * @param {Locator} locator
74
+ * @param {boolean} [publishes] whether this component publishes anything
49
75
  */
50
- constructor (locator) {
76
+ constructor (locator, publishes = false) {
51
77
  super()
52
78
 
53
79
  this.locator = locator
54
80
  this.name = locator.lowercase
81
+ this.publishes = publishes
55
82
  }
56
83
 
57
84
  /**
@@ -76,15 +103,30 @@ class Client extends Connector {
76
103
 
77
104
  const db = this.instance.client.db(dbname)
78
105
 
79
- try {
80
- this.collection = await db.createCollection(this.name)
81
- } catch (e) {
82
- if (e.code !== ALREADY_EXISTS) {
83
- throw e
84
- }
106
+ this.collection = await collection(db, this.name)
107
+ this.transactional = await transactional(db)
85
108
 
86
- this.collection = db.collection(this.name)
87
- }
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)))
88
130
  }
89
131
 
90
132
  /**
@@ -155,6 +197,31 @@ function getKey (db, urls) {
155
197
  return db + ':' + urls.sort().join(' ')
156
198
  }
157
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
+
158
225
  /**
159
226
  * `monitorCommands` is deliberately absent. It makes the driver materialize every reply
160
227
  * eagerly to populate the monitoring event (`CommandSucceededEvent`), which defeats the
@@ -166,5 +233,6 @@ const OPTIONS = {
166
233
  }
167
234
 
168
235
  const ALREADY_EXISTS = 48
236
+ const OUTBOX = '_outbox'
169
237
 
170
238
  exports.Client = Client
package/src/factory.js CHANGED
@@ -4,8 +4,8 @@ const { Client } = require('./client')
4
4
  const { Storage } = require('./storage')
5
5
 
6
6
  class Factory {
7
- storage (locator, entity) {
8
- const client = new Client(locator)
7
+ storage (locator, entity, options = {}) {
8
+ const client = new Client(locator, options.outbox === true)
9
9
 
10
10
  return new Storage(client, entity)
11
11
  }
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/storage.js CHANGED
@@ -4,6 +4,7 @@ const { Connector, exceptions } = require('@toa.io/core')
4
4
  const { console } = require('openspan')
5
5
  const { translate } = require('./translate')
6
6
  const { to, from } = require('./record')
7
+ const { Outbox } = require('./outbox')
7
8
  const { ReturnDocument } = require('mongodb')
8
9
 
9
10
  class Storage extends Connector {
@@ -13,6 +14,12 @@ class Storage extends Connector {
13
14
  #collection
14
15
  #entity
15
16
 
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
+
16
23
  /** @type {Map<string, object>} span options per driver method */
17
24
  #spans = new Map()
18
25
 
@@ -29,12 +36,25 @@ class Storage extends Connector {
29
36
  return this.#collection
30
37
  }
31
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
46
+ }
47
+
32
48
  async open () {
33
49
  this.#collection = this.#client.collection
34
50
 
51
+ if (this.#client.outbox !== undefined)
52
+ this.#outbox = new Outbox(this.#client.outbox)
53
+
35
54
  this.#spans.clear()
36
55
 
37
56
  await this.index()
57
+ await this.#outbox?.index()
38
58
  }
39
59
 
40
60
  async get (query) {
@@ -84,16 +104,16 @@ class Storage extends Connector {
84
104
  return this.#collection.find(criteria, options).stream({ transform: from })
85
105
  }
86
106
 
87
- async add (entity) {
107
+ async add (entity, session = undefined) {
88
108
  const record = to(entity)
89
109
 
90
110
  const result = await this.command('insertOne', { record },
91
- () => this.#collection.insertOne(record))
111
+ () => this.#collection.insertOne(record, { session }))
92
112
 
93
113
  return result.acknowledged
94
114
  }
95
115
 
96
- async set (entity) {
116
+ async set (entity, session = undefined) {
97
117
  const criteria = {
98
118
  _id: entity.id,
99
119
  _version: entity._version - 1
@@ -102,30 +122,52 @@ class Storage extends Connector {
102
122
  const record = to(entity)
103
123
 
104
124
  const result = await this.command('findOneAndReplace', { criteria, record },
105
- () => this.#collection.findOneAndReplace(criteria, record))
125
+ () => this.#collection.findOneAndReplace(criteria, record, { session }))
106
126
 
107
127
  return result !== null
108
128
  }
109
129
 
110
- async store (entity, attempt = 0) {
130
+ async store (entity, row = undefined, attempt = 0) {
111
131
  try {
112
- if (entity._version === 1)
113
- return await this.add(entity)
114
- else
115
- 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)
137
+ }
138
+
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()
148
+
149
+ return false
150
+ }
151
+
152
+ await this.#outbox.insert(row, session)
153
+
154
+ return true
155
+ })
156
+
157
+ return committed === true
116
158
  } catch (error) {
117
159
  console.error('MongoDB error', error)
118
160
 
119
161
  const retry = await retriable(error, attempt)
120
162
 
121
163
  if (retry)
122
- return await this.store(entity, attempt + 1)
164
+ return await this.store(entity, row, attempt + 1)
123
165
  else
124
166
  return false
125
167
  }
126
168
  }
127
169
 
128
- async massStore (entities, attempt = 0) {
170
+ async massStore (entities, rows = undefined, attempt = 0) {
129
171
  if (entities.length === 0)
130
172
  return true
131
173
 
@@ -157,13 +199,13 @@ class Storage extends Connector {
157
199
  }
158
200
  })
159
201
 
160
- const client = this.#client.instance.client
161
-
162
202
  try {
163
- await client.withSession(async (session) => {
164
- await session.withTransaction(async () =>
165
- await this.command('bulkWrite', { operations: operations.length },
166
- async () => await this.#collection.bulkWrite(operations, { session })))
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)
167
209
  })
168
210
 
169
211
  return true
@@ -173,13 +215,13 @@ class Storage extends Connector {
173
215
  const retry = await retriable(error, attempt)
174
216
 
175
217
  if (retry)
176
- return await this.massStore(entities, attempt + 1)
218
+ return await this.massStore(entities, rows, attempt + 1)
177
219
  else
178
220
  return false
179
221
  }
180
222
  }
181
223
 
182
- async upsert (query, changeset) {
224
+ async upsert (query, changeset, row = undefined) {
183
225
  const { criteria, options } = translate(query)
184
226
 
185
227
  if (!('_deleted' in changeset) || changeset._deleted === null) {
@@ -192,15 +234,45 @@ class Storage extends Connector {
192
234
  $inc: { _version: 1 }
193
235
  }
194
236
 
195
- options.returnDocument = 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
+ }
196
268
 
197
- const result = await this.command('findOneAndUpdate', { criteria, update, options },
198
- () => this.#collection.findOneAndUpdate(criteria, update, options))
269
+ if (row === undefined || this.#outbox === undefined)
270
+ return apply(undefined)
199
271
 
200
- return from(result)
272
+ return this.#client.transaction(apply)
201
273
  }
202
274
 
203
- async ensure (query, properties, state) {
275
+ async ensure (query, properties, state, row = undefined) {
204
276
  let { criteria, options } = translate(query)
205
277
 
206
278
  if (query === undefined)
@@ -212,8 +284,19 @@ class Storage extends Connector {
212
284
  options.returnDocument = ReturnDocument.AFTER
213
285
 
214
286
  try {
215
- const result = await this.command('findOneAndUpdate', { criteria, update, options },
216
- () => this.#collection.findOneAndUpdate(criteria, update, options))
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
+ })
217
300
 
218
301
  if (result._deleted !== undefined && result._deleted !== null)
219
302
  return null