@toa.io/storages.mongodb 1.0.0-alpha.31 → 1.0.0-alpha.311

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,210 @@
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.311](https://github.com/toa-io/toa/compare/v1.0.0-alpha.310...v1.0.0-alpha.311) (2026-09-17)
7
+
8
+ ### Features
9
+
10
+ * name the database after the scope ([4d7203f](https://github.com/toa-io/toa/commit/4d7203fd18828895272a9dae76e351d92b49277f))
11
+
12
+
13
+ # [1.0.0-alpha.310](https://github.com/toa-io/toa/compare/v1.0.0-alpha.309...v1.0.0-alpha.310) (2026-09-16)
14
+
15
+ **Note:** Version bump only for package @toa.io/storages.mongodb
16
+
17
+
18
+
19
+
20
+
21
+ # [1.0.0-alpha.309](https://github.com/toa-io/toa/compare/v1.0.0-alpha.308...v1.0.0-alpha.309) (2026-09-16)
22
+
23
+ **Note:** Version bump only for package @toa.io/storages.mongodb
24
+
25
+
26
+
27
+
28
+
29
+ # [1.0.0-alpha.308](https://github.com/toa-io/toa/compare/v1.0.0-alpha.307...v1.0.0-alpha.308) (2026-09-15)
30
+
31
+ **Note:** Version bump only for package @toa.io/storages.mongodb
32
+
33
+
34
+
35
+
36
+
37
+ # [1.0.0-alpha.307](https://github.com/toa-io/toa/compare/v1.0.0-alpha.306...v1.0.0-alpha.307) (2026-09-15)
38
+
39
+ **Note:** Version bump only for package @toa.io/storages.mongodb
40
+
41
+
42
+
43
+
44
+
45
+ # [1.0.0-alpha.306](https://github.com/toa-io/toa/compare/v1.0.0-alpha.305...v1.0.0-alpha.306) (2026-09-14)
46
+
47
+ **Note:** Version bump only for package @toa.io/storages.mongodb
48
+
49
+
50
+
51
+
52
+
53
+ # [1.0.0-alpha.305](https://github.com/toa-io/toa/compare/v1.0.0-alpha.304...v1.0.0-alpha.305) (2026-09-13)
54
+
55
+ **Note:** Version bump only for package @toa.io/storages.mongodb
56
+
57
+
58
+
59
+
60
+
61
+ # [1.0.0-alpha.304](https://github.com/toa-io/toa/compare/v1.0.0-alpha.303...v1.0.0-alpha.304) (2026-09-13)
62
+
63
+ **Note:** Version bump only for package @toa.io/storages.mongodb
64
+
65
+
66
+
67
+
68
+
69
+ # [1.0.0-alpha.303](https://github.com/toa-io/toa/compare/v1.0.0-alpha.302...v1.0.0-alpha.303) (2026-09-12)
70
+
71
+ ### Features
72
+
73
+ * **metrics:** measure storage, stash, blob storages and fetch ([252298b](https://github.com/toa-io/toa/commit/252298bb9f34421694da4ee89742d62b47c39dfb))
74
+
75
+ ### Performance Improvements
76
+
77
+ * **storages.mongodb:** rename a record's _id to id in place ([4aeb2a1](https://github.com/toa-io/toa/commit/4aeb2a1d61f7f2b0ddab203b5887152e8917ef7f))
78
+
79
+
80
+ # [1.0.0-alpha.302](https://github.com/toa-io/toa/compare/v1.0.0-alpha.301...v1.0.0-alpha.302) (2026-09-11)
81
+
82
+ **Note:** Version bump only for package @toa.io/storages.mongodb
83
+
84
+
85
+
86
+
87
+
88
+ # [1.0.0-alpha.301](https://github.com/toa-io/toa/compare/v1.0.0-alpha.300...v1.0.0-alpha.301) (2026-09-11)
89
+
90
+ **Note:** Version bump only for package @toa.io/storages.mongodb
91
+
92
+
93
+
94
+
95
+
96
+ # [1.0.0-alpha.300](https://github.com/toa-io/toa/compare/v1.0.0-alpha.299...v1.0.0-alpha.300) (2026-09-11)
97
+
98
+ * feat(core)!: an operation may ask to run once ([dca616c](https://github.com/toa-io/toa/commit/dca616cb380a7a389e277875d3bd5ca55a6e4d45))
99
+
100
+ ### Bug Fixes
101
+
102
+ * **extensions:** what a factory remembers does not outlive the tree it was made for ([dd36631](https://github.com/toa-io/toa/commit/dd3663160f500eadfb701bf840ef3741a2d075f0))
103
+
104
+ ### Features
105
+
106
+ * **core:** an assignment may ask to run once as well ([10fbcbd](https://github.com/toa-io/toa/commit/10fbcbd86a5e82960e65f62c09d0a465ae8d0353))
107
+
108
+ ### BREAKING CHANGES
109
+
110
+ * `Storage.store`, `.upsert` and `.ensure` take one more
111
+ argument, and `Storage` has two more members. A connector that ignores them
112
+ works exactly as it did; a component that asks it for `once` is refused at
113
+ boot. See migrations/299.md.
114
+
115
+
116
+ # [1.0.0-alpha.299](https://github.com/toa-io/toa/compare/v1.0.0-alpha.298...v1.0.0-alpha.299) (2026-09-10)
117
+
118
+ ### Features
119
+
120
+ * **core:** a record carries the region that wrote it ([13ed187](https://github.com/toa-io/toa/commit/13ed187604f06dec66feec2e26ccf9dae30bb048))
121
+ * **core:** an outbox row is published to destinations ([cfb77b4](https://github.com/toa-io/toa/commit/cfb77b4826ff0f9560769a0996981e143d6525af))
122
+
123
+
124
+ # [1.0.0-alpha.298](https://github.com/toa-io/toa/compare/v1.0.0-alpha.297...v1.0.0-alpha.298) (2026-09-08)
125
+
126
+ **Note:** Version bump only for package @toa.io/storages.mongodb
127
+
128
+
129
+
130
+
131
+
132
+ # [1.0.0-alpha.297](https://github.com/toa-io/toa/compare/v1.0.0-alpha.296...v1.0.0-alpha.297) (2026-09-08)
133
+
134
+ **Note:** Version bump only for package @toa.io/storages.mongodb
135
+
136
+
137
+
138
+
139
+
140
+ # [1.0.0-alpha.296](https://github.com/toa-io/toa/compare/v1.0.0-alpha.295...v1.0.0-alpha.296) (2026-09-08)
141
+
142
+ **Note:** Version bump only for package @toa.io/storages.mongodb
143
+
144
+
145
+
146
+
147
+
148
+ # [1.0.0-alpha.295](https://github.com/toa-io/toa/compare/v1.0.0-alpha.294...v1.0.0-alpha.295) (2026-09-07)
149
+
150
+ **Note:** Version bump only for package @toa.io/storages.mongodb
151
+
152
+
153
+
154
+
155
+
156
+ # [1.0.0-alpha.294](https://github.com/toa-io/toa/compare/v1.0.0-alpha.293...v1.0.0-alpha.294) (2026-09-07)
157
+
158
+ **Note:** Version bump only for package @toa.io/storages.mongodb
159
+
160
+
161
+
162
+
163
+
164
+ # [1.0.0-alpha.293](https://github.com/toa-io/toa/compare/v1.0.0-alpha.292...v1.0.0-alpha.293) (2026-09-07)
165
+
166
+ **Note:** Version bump only for package @toa.io/storages.mongodb
167
+
168
+
169
+
170
+
171
+
172
+ # [1.0.0-alpha.292](https://github.com/toa-io/toa/compare/v1.0.0-alpha.291...v1.0.0-alpha.292) (2026-09-07)
173
+
174
+ ### Features
175
+
176
+ * **definitions:** what a package declares is read from one package ([7052341](https://github.com/toa-io/toa/commit/70523411b5d9e2b204c999aa02365173f3bb518d))
177
+
178
+
179
+ # [1.0.0-alpha.291](https://github.com/toa-io/toa/compare/v1.0.0-alpha.290...v1.0.0-alpha.291) (2026-09-07)
180
+
181
+ **Note:** Version bump only for package @toa.io/storages.mongodb
182
+
183
+
184
+
185
+
186
+
187
+ # [1.0.0-alpha.289](https://github.com/toa-io/toa/compare/v1.0.0-alpha.288...v1.0.0-alpha.289) (2026-09-07)
188
+
189
+ * A deploy moves only what changed, and a component sees none of the runtime's environment (#1073) ([f38e3db](https://github.com/toa-io/toa/commit/f38e3db533f24db866c57bfa1ef294eff1eaf499)), closes [#1073](https://github.com/toa-io/toa/issues/1073) [#1064](https://github.com/toa-io/toa/issues/1064) [#1066](https://github.com/toa-io/toa/issues/1066) [#1067](https://github.com/toa-io/toa/issues/1067) [#1068](https://github.com/toa-io/toa/issues/1068) [#1069](https://github.com/toa-io/toa/issues/1069) [#1071](https://github.com/toa-io/toa/issues/1071) [#1070](https://github.com/toa-io/toa/issues/1070) [#1072](https://github.com/toa-io/toa/issues/1072)
190
+
191
+ ### BREAKING CHANGES
192
+
193
+ * a component that read `process.env.TOA_*` reads `context` instead;
194
+ `echo(input)` no longer substitutes from the environment; a bash operation sees no
195
+ `TOA_*`; images no longer set `USER node` — see migrations/289.md.
196
+
197
+
198
+ # [1.0.0-alpha.288](https://github.com/toa-io/toa/compare/v1.0.0-alpha.287...v1.0.0-alpha.288) (2026-09-06)
199
+
200
+ **Note:** Version bump only for package @toa.io/storages.mongodb
201
+
202
+
203
+
204
+
205
+
206
+ # [1.0.0-alpha.287](https://github.com/toa-io/toa/compare/v1.0.0-alpha.286...v1.0.0-alpha.287) (2026-09-06)
207
+
208
+ ### Features
209
+
210
+ * **mongodb:** a migration says what it is doing ([a917a81](https://github.com/toa-io/toa/commit/a917a81fdc94eb23fd73182c0edf891e6b2df843))
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@toa.io/storages.mongodb",
3
- "version": "1.0.0-alpha.31",
3
+ "version": "1.0.0-alpha.311",
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.31",
23
- "@toa.io/conveyor": "1.0.0-alpha.31",
24
- "@toa.io/core": "1.0.0-alpha.31",
25
- "@toa.io/generic": "1.0.0-alpha.31",
26
- "@toa.io/pointer": "1.0.0-alpha.31",
27
- "mongodb": "6.3.0",
28
- "saslprep": "1.0.3"
23
+ "@toa.io/conveyor": "1.0.0-alpha.311",
24
+ "@toa.io/core": "1.0.0-alpha.311",
25
+ "@toa.io/definitions": "1.0.0-alpha.311",
26
+ "@toa.io/generic": "1.0.0-alpha.311",
27
+ "@toa.io/pointer": "1.0.0-alpha.311",
28
+ "mongodb": "7.6.0",
29
+ "openspan": "1.0.0-alpha.305"
29
30
  },
30
- "gitHead": "91839a488176853a0731f49abc70dc7103ed7cca"
31
+ "gitHead": "4f45061964e275296a27ff8ff39f757ce27d4008"
31
32
  }
package/readme.md ADDED
@@ -0,0 +1,15 @@
1
+ # MongoDB Storage
2
+
3
+ ## Tracing
4
+
5
+ Commands are recorded as `client` spans within the trace of the current invocation.
6
+
7
+ Spans are named `{command} {collection}` and carry `db.*` attributes following the
8
+ [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/database/mongodb/):
9
+ `db.system`, `db.namespace`, `db.collection.name`, `db.operation.name`.
10
+
11
+ Commands executed outside of a sampled trace context (e.g. index management on startup)
12
+ and internal driver commands (`hello`, `ping`, authentication) are not recorded.
13
+
14
+ Monitoring is client-side only and does not affect the MongoDB server. Span recording
15
+ adds no waiting to the query path: exporting is buffered and happens in the background.
package/src/client.js CHANGED
@@ -1,28 +1,67 @@
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 { environment } from '@toa.io/generic'
9
+ import { Connector } from '@toa.io/core'
10
+ import { resolve } from '@toa.io/pointer'
11
+ import { ID } from '@toa.io/definitions/storages.mongodb'
12
+ import { MongoClient } from 'mongodb'
13
13
 
14
14
  /**
15
15
  * @type {Record<string, Promise<Instance>>}
16
16
  */
17
17
  const INSTANCES = {}
18
18
 
19
- class Client extends Connector {
19
+ export class Client extends Connector {
20
+ name
21
+
20
22
  /**
21
23
  * @public
22
24
  * @type {import('mongodb').Collection}
23
25
  */
24
26
  collection
25
27
 
28
+ /**
29
+ * The outbox rows of this component, absent unless something consumes its events. Created
30
+ * eagerly beside the entity collection, because a transaction cannot create a collection and
31
+ * an index build cannot run inside one.
32
+ *
33
+ * @public
34
+ * @type {import('mongodb').Collection | undefined}
35
+ */
36
+ outbox
37
+
38
+ /**
39
+ * The calls this component has made good on, absent unless it declares `once` anywhere.
40
+ * Created eagerly beside the entity collection, for the reason the outbox's is.
41
+ *
42
+ * @public
43
+ * @type {import('mongodb').Collection | undefined}
44
+ */
45
+ inbox
46
+
47
+ /**
48
+ * Whether this deployment can run transactions at all. A standalone mongod cannot, and an
49
+ * outbox without atomicity is worse than none, so the storage falls back to inline emission.
50
+ *
51
+ * @public
52
+ * @type {boolean}
53
+ */
54
+ transactional = false
55
+
56
+ /**
57
+ * The database this component's collections live in, which is where the migration state
58
+ * is kept as well.
59
+ *
60
+ * @public
61
+ * @type {import('mongodb').Db}
62
+ */
63
+ db
64
+
26
65
  /**
27
66
  * @private
28
67
  * @type {Locator}
@@ -41,13 +80,30 @@ class Client extends Connector {
41
80
  */
42
81
  key
43
82
 
83
+ /**
84
+ * @private
85
+ * @type {boolean}
86
+ */
87
+ publishes
88
+
89
+ /**
90
+ * @private
91
+ * @type {boolean}
92
+ */
93
+ claims
94
+
44
95
  /**
45
96
  * @param {Locator} locator
97
+ * @param {boolean} [publishes] whether this component publishes anything
98
+ * @param {boolean} [claims] whether any of its operations declares `once`
46
99
  */
47
- constructor (locator) {
100
+ constructor(locator, publishes = false, claims = false) {
48
101
  super()
49
102
 
50
103
  this.locator = locator
104
+ this.name = locator.lowercase
105
+ this.publishes = publishes
106
+ this.claims = claims
51
107
  }
52
108
 
53
109
  /**
@@ -55,29 +111,66 @@ class Client extends Connector {
55
111
  * @override
56
112
  * @return {Promise<void>}
57
113
  */
58
- async open () {
114
+ async open() {
59
115
  const urls = await this.resolveURLs()
60
116
  const dbname = this.resolveDB()
61
- const collname = this.locator.lowercase
62
117
 
63
118
  this.key = getKey(dbname, urls)
64
119
 
65
- INSTANCES[this.key] ??= this.createInstance(urls)
120
+ try {
121
+ INSTANCES[this.key] ??= this.createInstance(urls)
122
+ } catch (error) {
123
+ console.error('Failed to connect to MongoDB', { urls, error })
124
+ }
66
125
 
67
126
  this.instance = await INSTANCES[this.key]
68
127
  this.instance.count++
69
128
 
70
129
  const db = this.instance.client.db(dbname)
71
130
 
72
- try {
73
- this.collection = await db.createCollection(collname)
74
- } catch (e) {
75
- if (e.code !== ALREADY_EXISTS) {
76
- throw e
77
- }
131
+ this.db = db
132
+ this.collection = await collection(db, this.name)
133
+ this.transactional = await transactional(db)
78
134
 
79
- this.collection = db.collection(collname)
135
+ /*
136
+ * The outbox may fall back and this may not: inline emission still delivers, where a call
137
+ * that is not recorded is a call that will be made twice, which is the opposite of what was
138
+ * asked for. So this refuses rather than warns.
139
+ */
140
+ if (this.claims) {
141
+ if (!this.transactional)
142
+ throw new Error(
143
+ `Component '${this.name}' declares 'once', which needs a MongoDB replica set ` +
144
+ 'or a sharded cluster to commit a call with the entity it changed'
145
+ )
146
+
147
+ this.inbox = await collection(db, this.name + INBOX)
80
148
  }
149
+
150
+ if (!this.publishes) return
151
+
152
+ if (this.transactional) this.outbox = await collection(db, this.name + OUTBOX)
153
+ else
154
+ console.warn(
155
+ 'MongoDB is not a replica set; events are emitted inline, without an outbox',
156
+ { collection: this.name }
157
+ )
158
+ }
159
+
160
+ /**
161
+ * Runs `fn` in a transaction and answers what it returned. The driver may call `fn` more
162
+ * than once, so it must not hold state of its own — an outbox row is built by the caller
163
+ * and reused, and a rolled back attempt leaves nothing behind.
164
+ *
165
+ * @public
166
+ * @template T
167
+ * @param {(session: import('mongodb').ClientSession) => Promise<T>} fn
168
+ * @return {Promise<T>}
169
+ */
170
+ async transaction(fn) {
171
+ return this.instance.client.withSession(async (session) =>
172
+ session.withTransaction(async () => fn(session))
173
+ )
81
174
  }
82
175
 
83
176
  /**
@@ -85,14 +178,26 @@ class Client extends Connector {
85
178
  * @override
86
179
  * @return {Promise<void>}
87
180
  */
88
- async close () {
89
- const instance = await INSTANCES[this.key]
181
+ async close() {
182
+ /*
183
+ * What was never counted is not discounted. An `open` that threw between taking the
184
+ * instance and incrementing it leaves the count one high, and a client nothing ever
185
+ * closes — which a process that is taken down and built again, as a halt does, would
186
+ * otherwise leak once per cycle.
187
+ */
188
+ if (this.instance === undefined) return
189
+
190
+ const instance = this.instance
191
+
192
+ this.instance = undefined
90
193
 
91
194
  instance.count--
92
195
 
93
196
  if (instance.count === 0) {
94
197
  await instance.client.close()
95
- delete INSTANCES[this.key]
198
+
199
+ // another `open` may have taken it in the meantime, and that one is not this one
200
+ if ((await INSTANCES[this.key]) === instance) delete INSTANCES[this.key]
96
201
  }
97
202
  }
98
203
 
@@ -101,11 +206,11 @@ class Client extends Connector {
101
206
  * @param {string[]} urls
102
207
  * @return {Promise<Instance>}
103
208
  */
104
- async createInstance (urls) {
209
+ async createInstance(urls) {
105
210
  const client = new MongoClient(urls.join(','), OPTIONS)
106
211
  const hosts = urls.map((str) => new URL(str).host)
107
212
 
108
- console.info('Connecting to MongoDB:', hosts.join(', '))
213
+ console.info('Connecting to MongoDB', { address: hosts.join(', ') })
109
214
 
110
215
  await client.connect()
111
216
 
@@ -119,9 +224,11 @@ class Client extends Connector {
119
224
  * @private
120
225
  * @return {Promise<string[]>}
121
226
  */
122
- async resolveURLs () {
123
- if (process.env.TOA_DEV === '1') {
124
- return ['mongodb://developer:secret@localhost']
227
+ async resolveURLs() {
228
+ // Toa's own development stack is not on the conventional ports: the applications built on
229
+ // Toa are, and they share the machine. See CONTRIBUTING.md.
230
+ if (environment.get('TOA_DEV') === '1') {
231
+ return ['mongodb://developer:secret@localhost:31020']
125
232
  } else {
126
233
  return await resolve(ID, this.locator.id)
127
234
  }
@@ -131,29 +238,63 @@ class Client extends Connector {
131
238
  * @private
132
239
  * @return {string}
133
240
  */
134
- resolveDB () {
135
- if (process.env.TOA_CONTEXT !== undefined) {
136
- return process.env.TOA_CONTEXT
137
- }
241
+ resolveDB() {
242
+ const scope = environment.scope()
243
+ const length = Buffer.byteLength(scope)
138
244
 
139
- if (process.env.TOA_DEV === '1') {
140
- return 'toa-dev'
141
- }
245
+ // MongoDB refuses the name only when something is first written, far from what caused it
246
+ if (length > MAX_DB_LENGTH)
247
+ throw new Error(
248
+ `Database name '${scope}' is ${length} bytes, and MongoDB takes no more than ` +
249
+ `${MAX_DB_LENGTH}: shorten TOA_CONTEXT or TOA_SUFFIX`
250
+ )
142
251
 
143
- throw new Error('Environment variable TOA_CONTEXT is not defined')
252
+ return scope
144
253
  }
145
254
  }
146
255
 
147
- function getKey (db, urls) {
256
+ /** what MongoDB takes for a database name, in bytes */
257
+ const MAX_DB_LENGTH = 63
258
+
259
+ function getKey(db, urls) {
148
260
  return db + ':' + urls.sort().join(' ')
149
261
  }
150
262
 
263
+ /**
264
+ * Concurrent pods race to create the same collection, and losing that race is not an error.
265
+ */
266
+ async function collection(db, name) {
267
+ try {
268
+ return await db.createCollection(name)
269
+ } catch (e) {
270
+ if (e.code !== ALREADY_EXISTS) throw e
271
+
272
+ return db.collection(name)
273
+ }
274
+ }
275
+
276
+ async function transactional(db) {
277
+ try {
278
+ const hello = await db.admin().command({ hello: 1 })
279
+
280
+ return hello.setName !== undefined || hello.msg === 'isdbgrid'
281
+ } catch (e) {
282
+ console.warn('MongoDB transaction support could not be determined', { error: e })
283
+
284
+ return false
285
+ }
286
+ }
287
+
288
+ /**
289
+ * `monitorCommands` is deliberately absent. It makes the driver materialize every reply
290
+ * eagerly to populate the monitoring event (`CommandSucceededEvent`), which defeats the
291
+ * lazy per-document deserialization a cursor exists for — a 100-document batch is then
292
+ * deserialized twice. `Storage` times its own calls instead.
293
+ */
151
294
  const OPTIONS = {
152
- ignoreUndefined: true,
153
- connectTimeoutMS: 0,
154
- serverSelectionTimeoutMS: 0
295
+ ignoreUndefined: true
155
296
  }
156
297
 
157
298
  const ALREADY_EXISTS = 48
158
-
159
- exports.Client = Client
299
+ const OUTBOX = '_outbox'
300
+ const INBOX = '_inbox'
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, options.inbox === 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/inbox.js ADDED
@@ -0,0 +1,85 @@
1
+ import { environment } from '@toa.io/generic'
2
+ import { index, prune } from './indexes.js'
3
+
4
+ /**
5
+ * The calls a component has already made good on. Its lifecycle is the Client's, so it is not
6
+ * a Connector.
7
+ *
8
+ * Core owns what a record means — its identity and the reply it holds. This only writes it,
9
+ * inside the transaction the entity is written in, and reads one back.
10
+ */
11
+ export class Inbox {
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
+ /**
23
+ * What the call under this identity answered, or `null` where it was never made.
24
+ *
25
+ * @param {string} id
26
+ * @returns {Promise<object | null>}
27
+ */
28
+ async recall(id) {
29
+ const record = await this.#collection.findOne(
30
+ { _id: id },
31
+ { projection: { reply: 1 } }
32
+ )
33
+
34
+ return record === null ? null : (record.reply ?? {})
35
+ }
36
+
37
+ /**
38
+ * Writes the call down, in the session the entity is written in. A duplicate key is the
39
+ * whole mechanism: the call has been made, this transaction changes nothing, and the caller
40
+ * is answered from what is already there.
41
+ *
42
+ * @param {{ id: string, reply: object }} call
43
+ * @param {import('mongodb').ClientSession} session
44
+ */
45
+ async insert(call, session) {
46
+ await this.#collection.insertOne(
47
+ { _id: call.id, reply: call.reply ?? {}, at: new Date() },
48
+ { session }
49
+ )
50
+ }
51
+
52
+ /**
53
+ * The runtime's own index, not the component's, so it is declared here rather than in a
54
+ * migration a component would have to write — including pruning, or a later change leaves
55
+ * the old index behind forever.
56
+ */
57
+ async index() {
58
+ const desired = {
59
+ // how long a call is remembered is how long a duplicate of it is caught
60
+ inbox_at: {
61
+ fields: { at: 1 },
62
+ options: { name: 'inbox_at', expireAfterSeconds: this.#retention }
63
+ }
64
+ }
65
+
66
+ for (const { fields, options } of Object.values(desired))
67
+ await index(this.#collection, fields, options)
68
+
69
+ await prune(this.#collection, Object.keys(desired))
70
+ }
71
+ }
72
+
73
+ function retention() {
74
+ const value = Number(environment.get('TOA_INBOX_RETENTION'))
75
+
76
+ return Number.isNaN(value) || value <= 0 ? RETENTION : value
77
+ }
78
+
79
+ /**
80
+ * Seconds a call is remembered. An hour, and not the outbox's day: what a duplicate arrives
81
+ * within is the broker's redelivery, the five attempts `comq` makes of a message, and whatever
82
+ * a client retries on — minutes. Every call of an operation that declares `once` is a document
83
+ * here for this long.
84
+ */
85
+ const RETENTION = 3600