@toa.io/storages.mongodb 1.0.0-alpha.3 → 1.0.0-alpha.300

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,120 @@
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.300](https://github.com/toa-io/toa/compare/v1.0.0-alpha.299...v1.0.0-alpha.300) (2026-09-11)
7
+
8
+ * feat(core)!: an operation may ask to run once ([dca616c](https://github.com/toa-io/toa/commit/dca616cb380a7a389e277875d3bd5ca55a6e4d45))
9
+
10
+ ### Bug Fixes
11
+
12
+ * **extensions:** what a factory remembers does not outlive the tree it was made for ([dd36631](https://github.com/toa-io/toa/commit/dd3663160f500eadfb701bf840ef3741a2d075f0))
13
+
14
+ ### Features
15
+
16
+ * **core:** an assignment may ask to run once as well ([10fbcbd](https://github.com/toa-io/toa/commit/10fbcbd86a5e82960e65f62c09d0a465ae8d0353))
17
+
18
+ ### BREAKING CHANGES
19
+
20
+ * `Storage.store`, `.upsert` and `.ensure` take one more
21
+ argument, and `Storage` has two more members. A connector that ignores them
22
+ works exactly as it did; a component that asks it for `once` is refused at
23
+ boot. See migrations/299.md.
24
+
25
+
26
+ # [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)
27
+
28
+ ### Features
29
+
30
+ * **core:** a record carries the region that wrote it ([13ed187](https://github.com/toa-io/toa/commit/13ed187604f06dec66feec2e26ccf9dae30bb048))
31
+ * **core:** an outbox row is published to destinations ([cfb77b4](https://github.com/toa-io/toa/commit/cfb77b4826ff0f9560769a0996981e143d6525af))
32
+
33
+
34
+ # [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)
35
+
36
+ **Note:** Version bump only for package @toa.io/storages.mongodb
37
+
38
+
39
+
40
+
41
+
42
+ # [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)
43
+
44
+ **Note:** Version bump only for package @toa.io/storages.mongodb
45
+
46
+
47
+
48
+
49
+
50
+ # [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)
51
+
52
+ **Note:** Version bump only for package @toa.io/storages.mongodb
53
+
54
+
55
+
56
+
57
+
58
+ # [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)
59
+
60
+ **Note:** Version bump only for package @toa.io/storages.mongodb
61
+
62
+
63
+
64
+
65
+
66
+ # [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)
67
+
68
+ **Note:** Version bump only for package @toa.io/storages.mongodb
69
+
70
+
71
+
72
+
73
+
74
+ # [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)
75
+
76
+ **Note:** Version bump only for package @toa.io/storages.mongodb
77
+
78
+
79
+
80
+
81
+
82
+ # [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)
83
+
84
+ ### Features
85
+
86
+ * **definitions:** what a package declares is read from one package ([7052341](https://github.com/toa-io/toa/commit/70523411b5d9e2b204c999aa02365173f3bb518d))
87
+
88
+
89
+ # [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)
90
+
91
+ **Note:** Version bump only for package @toa.io/storages.mongodb
92
+
93
+
94
+
95
+
96
+
97
+ # [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)
98
+
99
+ * 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)
100
+
101
+ ### BREAKING CHANGES
102
+
103
+ * a component that read `process.env.TOA_*` reads `context` instead;
104
+ `echo(input)` no longer substitutes from the environment; a bash operation sees no
105
+ `TOA_*`; images no longer set `USER node` — see migrations/289.md.
106
+
107
+
108
+ # [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)
109
+
110
+ **Note:** Version bump only for package @toa.io/storages.mongodb
111
+
112
+
113
+
114
+
115
+
116
+ # [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)
117
+
118
+ ### Features
119
+
120
+ * **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.3",
3
+ "version": "1.0.0-alpha.300",
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.3",
23
- "@toa.io/conveyor": "1.0.0-alpha.3",
24
- "@toa.io/core": "1.0.0-alpha.3",
25
- "@toa.io/generic": "1.0.0-alpha.3",
26
- "@toa.io/pointer": "1.0.0-alpha.3",
27
- "mongodb": "6.3.0",
28
- "saslprep": "1.0.3"
23
+ "@toa.io/conveyor": "1.0.0-alpha.292",
24
+ "@toa.io/core": "1.0.0-alpha.300",
25
+ "@toa.io/definitions": "1.0.0-alpha.300",
26
+ "@toa.io/generic": "1.0.0-alpha.292",
27
+ "@toa.io/pointer": "1.0.0-alpha.299",
28
+ "mongodb": "7.6.0",
29
+ "openspan": "1.0.0-alpha.293"
29
30
  },
30
- "gitHead": "e36ac7871fc14d15863aaf8f9bbdeace8bdfa9f0"
31
+ "gitHead": "f91131a9aeb29aa271f177c4055ddcf0cda8c75e"
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 ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * @typedef {import('mongodb').MongoClient} MongoClient
3
+ * @typedef {{ count: number, client: MongoClient }} Instance
4
+ * @typedef {import('@toa.io/core').Locator} Locator
5
+ */
6
+
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
+
14
+ /**
15
+ * @type {Record<string, Promise<Instance>>}
16
+ */
17
+ const INSTANCES = {}
18
+
19
+ export class Client extends Connector {
20
+ name
21
+
22
+ /**
23
+ * @public
24
+ * @type {import('mongodb').Collection}
25
+ */
26
+ collection
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
+
65
+ /**
66
+ * @private
67
+ * @type {Locator}
68
+ */
69
+ locator
70
+
71
+ /**
72
+ * @private
73
+ * @type {Instance}
74
+ */
75
+ instance
76
+
77
+ /**
78
+ * @private
79
+ * @type {string}
80
+ */
81
+ key
82
+
83
+ /**
84
+ * @private
85
+ * @type {boolean}
86
+ */
87
+ publishes
88
+
89
+ /**
90
+ * @private
91
+ * @type {boolean}
92
+ */
93
+ claims
94
+
95
+ /**
96
+ * @param {Locator} locator
97
+ * @param {boolean} [publishes] whether this component publishes anything
98
+ * @param {boolean} [claims] whether any of its operations declares `once`
99
+ */
100
+ constructor(locator, publishes = false, claims = false) {
101
+ super()
102
+
103
+ this.locator = locator
104
+ this.name = locator.lowercase
105
+ this.publishes = publishes
106
+ this.claims = claims
107
+ }
108
+
109
+ /**
110
+ * @protected
111
+ * @override
112
+ * @return {Promise<void>}
113
+ */
114
+ async open() {
115
+ const urls = await this.resolveURLs()
116
+ const dbname = this.resolveDB()
117
+
118
+ this.key = getKey(dbname, urls)
119
+
120
+ try {
121
+ INSTANCES[this.key] ??= this.createInstance(urls)
122
+ } catch (error) {
123
+ console.error('Failed to connect to MongoDB', { urls, error })
124
+ }
125
+
126
+ this.instance = await INSTANCES[this.key]
127
+ this.instance.count++
128
+
129
+ const db = this.instance.client.db(dbname)
130
+
131
+ this.db = db
132
+ this.collection = await collection(db, this.name)
133
+ this.transactional = await transactional(db)
134
+
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)
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
+ )
174
+ }
175
+
176
+ /**
177
+ * @protected
178
+ * @override
179
+ * @return {Promise<void>}
180
+ */
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
193
+
194
+ instance.count--
195
+
196
+ if (instance.count === 0) {
197
+ await instance.client.close()
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]
201
+ }
202
+ }
203
+
204
+ /**
205
+ * @private
206
+ * @param {string[]} urls
207
+ * @return {Promise<Instance>}
208
+ */
209
+ async createInstance(urls) {
210
+ const client = new MongoClient(urls.join(','), OPTIONS)
211
+ const hosts = urls.map((str) => new URL(str).host)
212
+
213
+ console.info('Connecting to MongoDB', { address: hosts.join(', ') })
214
+
215
+ await client.connect()
216
+
217
+ return {
218
+ count: 0,
219
+ client
220
+ }
221
+ }
222
+
223
+ /**
224
+ * @private
225
+ * @return {Promise<string[]>}
226
+ */
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']
232
+ } else {
233
+ return await resolve(ID, this.locator.id)
234
+ }
235
+ }
236
+
237
+ /**
238
+ * @private
239
+ * @return {string}
240
+ */
241
+ resolveDB() {
242
+ const context = environment.get('TOA_CONTEXT')
243
+
244
+ if (context !== undefined) return context
245
+
246
+ if (environment.get('TOA_DEV') === '1') return 'toa-dev'
247
+
248
+ throw new Error('Environment variable TOA_CONTEXT is not defined')
249
+ }
250
+ }
251
+
252
+ function getKey(db, urls) {
253
+ return db + ':' + urls.sort().join(' ')
254
+ }
255
+
256
+ /**
257
+ * Concurrent pods race to create the same collection, and losing that race is not an error.
258
+ */
259
+ async function collection(db, name) {
260
+ try {
261
+ return await db.createCollection(name)
262
+ } catch (e) {
263
+ if (e.code !== ALREADY_EXISTS) throw e
264
+
265
+ return db.collection(name)
266
+ }
267
+ }
268
+
269
+ async function transactional(db) {
270
+ try {
271
+ const hello = await db.admin().command({ hello: 1 })
272
+
273
+ return hello.setName !== undefined || hello.msg === 'isdbgrid'
274
+ } catch (e) {
275
+ console.warn('MongoDB transaction support could not be determined', { error: e })
276
+
277
+ return false
278
+ }
279
+ }
280
+
281
+ /**
282
+ * `monitorCommands` is deliberately absent. It makes the driver materialize every reply
283
+ * eagerly to populate the monitoring event (`CommandSucceededEvent`), which defeats the
284
+ * lazy per-document deserialization a cursor exists for — a 100-document batch is then
285
+ * deserialized twice. `Storage` times its own calls instead.
286
+ */
287
+ const OPTIONS = {
288
+ ignoreUndefined: true
289
+ }
290
+
291
+ const ALREADY_EXISTS = 48
292
+ const OUTBOX = '_outbox'
293
+ const INBOX = '_inbox'
package/src/factory.js CHANGED
@@ -1,14 +1,10 @@
1
- 'use strict'
1
+ import { Client } from './client.js'
2
+ import { Storage } from './storage.js'
2
3
 
3
- const { Connection } = require('./connection')
4
- 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)
5
7
 
6
- class Factory {
7
- storage (locator) {
8
- const connection = new Connection(locator)
9
-
10
- return new Storage(connection)
8
+ return new Storage(client, entity)
11
9
  }
12
10
  }
13
-
14
- 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
package/src/index.js CHANGED
@@ -1,7 +1 @@
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 { Factory } from './factory.js'
package/src/indexes.js ADDED
@@ -0,0 +1,84 @@
1
+ import { console } from 'openspan'
2
+
3
+ /**
4
+ * The indexes the runtime keeps on collections of its own — the outbox's and the inbox's. They
5
+ * are declared in code rather than in a migration a component would have to write, so making
6
+ * and pruning them is the same job in both places and is written once.
7
+ */
8
+
9
+ /**
10
+ * The name of one of these is fixed, so changing what it is made of — a retention that changes
11
+ * `expireAfterSeconds`, say — leaves that name held by an index of the old shape, which MongoDB
12
+ * refuses to overwrite. The old one is dropped and the declared one made.
13
+ *
14
+ * @param {import('mongodb').Collection} collection
15
+ * @param {object} fields
16
+ * @param {object} options
17
+ */
18
+ export async function index(collection, fields, options) {
19
+ try {
20
+ await collection.createIndex(fields, options)
21
+ } catch (e) {
22
+ if (!CONFLICTS.includes(e.code))
23
+ return console.warn('MongoDB index creation failed', {
24
+ collection: collection.collectionName,
25
+ name: options.name,
26
+ error: e
27
+ })
28
+
29
+ console.info('Recreating an index whose declaration changed', {
30
+ collection: collection.collectionName,
31
+ name: options.name
32
+ })
33
+
34
+ await drop(collection, options.name)
35
+ await collection.createIndex(fields, options)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Removes what this collection carries that is no longer declared, so that a change to what the
41
+ * runtime wants does not leave the shape it wanted before in place forever.
42
+ *
43
+ * @param {import('mongodb').Collection} collection
44
+ * @param {string[]} desired the names that are declared
45
+ */
46
+ export async function prune(collection, desired) {
47
+ let current
48
+
49
+ try {
50
+ current = await collection.listIndexes().toArray()
51
+ } catch {
52
+ return
53
+ }
54
+
55
+ const obsolete = current
56
+ .map(({ name }) => name)
57
+ .filter((name) => name !== '_id_' && !desired.includes(name))
58
+
59
+ if (obsolete.length === 0) return
60
+
61
+ console.info('Removing obsolete indexes', {
62
+ collection: collection.collectionName,
63
+ indexes: obsolete.join(', ')
64
+ })
65
+
66
+ await Promise.all(obsolete.map((name) => drop(collection, name)))
67
+ }
68
+
69
+ /**
70
+ * Concurrent replicas prune the same index, and losing that race is not an error.
71
+ *
72
+ * @param {import('mongodb').Collection} collection
73
+ * @param {string} name
74
+ */
75
+ async function drop(collection, name) {
76
+ try {
77
+ await collection.dropIndex(name)
78
+ } catch (e) {
79
+ if (e.code !== ERR_INDEX_NOT_FOUND) throw e
80
+ }
81
+ }
82
+
83
+ const ERR_INDEX_NOT_FOUND = 27
84
+ const CONFLICTS = [85, 86] // IndexOptionsConflict, IndexKeySpecsConflict