@toa.io/storages.mongodb 1.0.0-alpha.331 → 1.0.0-alpha.333

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,20 @@
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.333](https://github.com/toa-io/toa/compare/v1.0.0-alpha.332...v1.0.0-alpha.333) (2026-10-26)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **storages.mongodb:** run a set again where it met a commit in flight ([2e293fe](https://github.com/toa-io/toa/commit/2e293fe1b72c3eac65f659f0061e312945829b6e))
11
+
12
+
13
+ # [1.0.0-alpha.332](https://github.com/toa-io/toa/compare/v1.0.0-alpha.331...v1.0.0-alpha.332) (2026-10-23)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **storages.mongodb:** commit a set where the URL sets timeoutMS ([0b51f02](https://github.com/toa-io/toa/commit/0b51f0281de218dbcb677b75d6dd58685c4e2958))
18
+
19
+
6
20
  # [1.0.0-alpha.331](https://github.com/toa-io/toa/compare/v1.0.0-alpha.330...v1.0.0-alpha.331) (2026-10-16)
7
21
 
8
22
  **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.331",
3
+ "version": "1.0.0-alpha.333",
4
4
  "type": "module",
5
5
  "description": "Toa MongoDB Storage Connector",
6
6
  "author": "temich <tema.gurtovoy@gmail.com>",
@@ -20,13 +20,13 @@
20
20
  "test": "echo \"Error: run tests from root\" && exit 1"
21
21
  },
22
22
  "dependencies": {
23
- "@toa.io/conveyor": "1.0.0-alpha.330",
24
- "@toa.io/core": "1.0.0-alpha.330",
25
- "@toa.io/definitions": "1.0.0-alpha.331",
26
- "@toa.io/generic": "1.0.0-alpha.330",
27
- "@toa.io/pointer": "1.0.0-alpha.330",
23
+ "@toa.io/conveyor": "1.0.0-alpha.332",
24
+ "@toa.io/core": "1.0.0-alpha.333",
25
+ "@toa.io/definitions": "1.0.0-alpha.333",
26
+ "@toa.io/generic": "1.0.0-alpha.332",
27
+ "@toa.io/pointer": "1.0.0-alpha.332",
28
28
  "mongodb": "7.7.0",
29
29
  "openspan": "1.0.0-alpha.330"
30
30
  },
31
- "gitHead": "e7020d160910ca4f0c2ff9040b3cbb4fca8ea41f"
31
+ "gitHead": "ddb74b2f99026fae540a8a8e0a49c26cbd22886a"
32
32
  }
package/readme.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # MongoDB Storage
2
2
 
3
+ ## Server
4
+
5
+ MongoDB 8.0 or later. An earlier server refuses a `transition` of scope `entries`
6
+ ([Operations](/documentation/component/declaration.md#operations)).
7
+
3
8
  ## Tracing
4
9
 
5
10
  Commands are recorded as `client` spans within the trace of the current invocation.
package/src/bulk.js ADDED
@@ -0,0 +1,26 @@
1
+ import { MongoClientBulkWriteError, MongoServerError } from 'mongodb'
2
+
3
+ /**
4
+ * The client's bulk write, where a command the server refused is thrown as the server sent it.
5
+ *
6
+ * The driver wraps that refusal in an error that carries none of its labels, and a transaction
7
+ * runs again only what is labelled as worth running again: a write conflict with a commit in
8
+ * flight would end the transaction as a failure of its own. A write the server refused one by
9
+ * one is left as it is, read off `writeErrors`.
10
+ *
11
+ * @param {import('mongodb').MongoClient} client
12
+ * @param {import('mongodb').AnyClientBulkWriteModel[]} operations
13
+ * @param {import('mongodb').ClientBulkWriteOptions} options
14
+ */
15
+ export async function bulkWrite(client, operations, options) {
16
+ try {
17
+ return await client.bulkWrite(operations, options)
18
+ } catch (error) {
19
+ if (
20
+ error instanceof MongoClientBulkWriteError &&
21
+ error.cause instanceof MongoServerError
22
+ )
23
+ throw error.cause
24
+ else throw error
25
+ }
26
+ }
package/src/outbox.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { environment } from '@toa.io/generic'
2
2
  import { index, prune } from './indexes.js'
3
+ import { bulkWrite } from './bulk.js'
3
4
 
4
5
  /**
5
6
  * The outbox rows of one component. Its lifecycle is the Client's, so it is not a Connector.
@@ -27,7 +28,14 @@ export class Outbox {
27
28
  async insertMany(rows, session) {
28
29
  if (rows.length === 0) return
29
30
 
30
- await this.#collection.insertMany(rows.map(to), { session })
31
+ const namespace = this.#collection.namespace
32
+
33
+ // the client's bulk write, not `insertMany`: see `massStore` in ./storage.js
34
+ await bulkWrite(
35
+ this.#collection.client,
36
+ rows.map((row) => ({ name: 'insertOne', namespace, document: to(row) })),
37
+ { session }
38
+ )
31
39
  }
32
40
 
33
41
  /**
package/src/storage.js CHANGED
@@ -4,6 +4,7 @@ import { translate } from './translate.js'
4
4
  import { codec } from './record.js'
5
5
  import { Inbox } from './inbox.js'
6
6
  import { Outbox } from './outbox.js'
7
+ import { bulkWrite } from './bulk.js'
7
8
  import { Migrations } from './migrations.js'
8
9
  import { Streams } from './streams.js'
9
10
  import { conflicted, query } from './measurements.js'
@@ -287,33 +288,35 @@ export class Storage extends Connector {
287
288
  async massStore(entities, rows = undefined, attempt = 0) {
288
289
  if (entities.length === 0) return true
289
290
 
291
+ const namespace = this.#collection.namespace
292
+
290
293
  const operations = entities.map((entity) => {
291
294
  const record = this.#to(entity)
292
295
 
293
296
  if (entity.VERSION === 1) {
294
297
  const { VERSION, ...rest } = record
295
298
 
299
+ // one the read did not find: absent, and inserted, or deleted, and revived — a live
300
+ // one makes the upsert collide on `_id`, which is the swap lost
296
301
  return {
297
- // one the read did not find: absent, and inserted, or deleted, and revived — a live
298
- // one makes the upsert collide on `_id`, which is the swap lost
299
- updateOne: {
300
- filter: { _id: entity.id, DELETED: { $ne: null } },
301
- update: {
302
- $set: {
303
- ...rest,
304
- DELETED: null
305
- },
306
- $inc: { VERSION: 1 }
302
+ name: 'updateOne',
303
+ namespace,
304
+ filter: { _id: entity.id, DELETED: { $ne: null } },
305
+ update: {
306
+ $set: {
307
+ ...rest,
308
+ DELETED: null
307
309
  },
308
- upsert: true
309
- }
310
+ $inc: { VERSION: 1 }
311
+ },
312
+ upsert: true
310
313
  }
311
314
  } else
312
315
  return {
313
- replaceOne: {
314
- filter: { _id: entity.id, VERSION: entity.VERSION - 1 },
315
- replacement: record
316
- }
316
+ name: 'replaceOne',
317
+ namespace,
318
+ filter: { _id: entity.id, VERSION: entity.VERSION - 1 },
319
+ replacement: record
317
320
  }
318
321
  })
319
322
 
@@ -327,10 +330,13 @@ export class Storage extends Connector {
327
330
  result = await this.command(
328
331
  'bulkWrite',
329
332
  { operations: operations.length },
330
- async () => await this.#collection.bulkWrite(operations, { session })
333
+ // the client's bulk write, not the collection's: the latter takes a `timeoutMS` the
334
+ // URL sets for one the call passed, and refuses it inside a transaction that has
335
+ // its own
336
+ async () => await bulkWrite(this.#collection.client, operations, { session })
331
337
  )
332
338
  } catch (error) {
333
- if (!collidedOnId(error)) throw error
339
+ if (!collidedOnId(firstWriteError(error))) throw error
334
340
 
335
341
  await session.abortTransaction()
336
342
 
@@ -353,7 +359,7 @@ export class Storage extends Connector {
353
359
  } catch (error) {
354
360
  console.error('MongoDB error', error)
355
361
 
356
- const retry = await retriable(error, attempt)
362
+ const retry = await retriable(firstWriteError(error), attempt)
357
363
 
358
364
  if (retry) return await this.massStore(entities, rows, attempt + 1)
359
365
  else return false
@@ -608,6 +614,13 @@ const ERR_DUPLICATE_KEY = 11000
608
614
  /** what the transaction answers where the call it carries has already been made */
609
615
  const MADE = Symbol('made')
610
616
 
617
+ /** What an ordered bulk write stopped at, or the error itself where no write failed. */
618
+ function firstWriteError(error) {
619
+ return error?.writeErrors instanceof Map && error.writeErrors.size > 0
620
+ ? error.writeErrors.values().next().value
621
+ : error
622
+ }
623
+
611
624
  /** whether the key a write collided on is `_id` */
612
625
  function collidedOnId(error) {
613
626
  if (error?.code !== ERR_DUPLICATE_KEY) return false
@@ -0,0 +1,49 @@
1
+ import { describe, it, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { MongoClientBulkWriteError, MongoServerError } from 'mongodb'
4
+
5
+ import { bulkWrite } from '../src/bulk.js'
6
+
7
+ describe('bulkWrite', () => {
8
+ it('should throw what the server refused the command with, labels and all', async () => {
9
+ const conflict = new MongoServerError({
10
+ message: 'Write conflict during plan execution and yielding is disabled.',
11
+ code: 112,
12
+ errorLabels: ['TransientTransactionError']
13
+ })
14
+
15
+ const wrapped = new MongoClientBulkWriteError({
16
+ message: 'Mongo client bulk write encountered an error during execution'
17
+ })
18
+
19
+ wrapped.cause = conflict
20
+
21
+ const client = { bulkWrite: mock.fn(async () => Promise.reject(wrapped)) }
22
+
23
+ await assert.rejects(bulkWrite(client, [], {}), (error) => {
24
+ assert.equal(error, conflict)
25
+ assert.equal(error.hasErrorLabel('TransientTransactionError'), true)
26
+
27
+ return true
28
+ })
29
+ })
30
+
31
+ it('should throw what refused a write one by one as it is', async () => {
32
+ const refused = new MongoClientBulkWriteError({
33
+ message: 'Mongo client bulk write encountered errors during execution'
34
+ })
35
+
36
+ refused.writeErrors.set(0, { code: 11000, message: 'E11000 duplicate key error' })
37
+
38
+ const client = { bulkWrite: mock.fn(async () => Promise.reject(refused)) }
39
+
40
+ await assert.rejects(bulkWrite(client, [], {}), (error) => error === refused)
41
+ })
42
+
43
+ it('should answer what the client answers', async () => {
44
+ const result = { matchedCount: 1, upsertedCount: 0 }
45
+ const client = { bulkWrite: mock.fn(async () => result) }
46
+
47
+ assert.equal(await bulkWrite(client, [], {}), result)
48
+ })
49
+ })
@@ -12,6 +12,8 @@ let storage
12
12
  beforeEach(async () => {
13
13
  collection = {
14
14
  collectionName: 'test',
15
+ namespace: 'db.test',
16
+ client: {},
15
17
  findOne: mock.fn(async () => null),
16
18
  find: mock.fn(() => cursor()),
17
19
  updateMany: mock.fn(async () => ({ modifiedCount: 0 })),
@@ -270,10 +272,10 @@ describe('massStore', () => {
270
272
  const stored = { id: 'a1', VERSION: 3, foo: 1 }
271
273
  const created = { id: 'b2', VERSION: 1, foo: 1 }
272
274
 
273
- const operations = () => collection.bulkWrite.mock.calls[0].arguments[0]
275
+ const operations = () => collection.client.bulkWrite.mock.calls[0].arguments[0]
274
276
 
275
277
  beforeEach(() => {
276
- collection.bulkWrite = mock.fn(async (operations) => ({
278
+ collection.client.bulkWrite = mock.fn(async (operations) => ({
277
279
  matchedCount: operations.length,
278
280
  upsertedCount: 0
279
281
  }))
@@ -282,14 +284,19 @@ describe('massStore', () => {
282
284
  it('should replace a record only at the version it was read at', async () => {
283
285
  await storage.massStore([stored])
284
286
 
285
- assert.deepEqual(operations()[0].replaceOne.filter, { _id: 'a1', VERSION: 2 })
287
+ const { name, namespace, filter } = operations()[0]
288
+
289
+ assert.equal(name, 'replaceOne')
290
+ assert.equal(namespace, 'db.test')
291
+ assert.deepEqual(filter, { _id: 'a1', VERSION: 2 })
286
292
  })
287
293
 
288
294
  it('should create a record only where none but a deleted one holds its id', async () => {
289
295
  await storage.massStore([created])
290
296
 
291
- const { filter, upsert } = operations()[0].updateOne
297
+ const { name, filter, upsert } = operations()[0]
292
298
 
299
+ assert.equal(name, 'updateOne')
293
300
  assert.deepEqual(filter, { _id: 'b2', DELETED: { $ne: null } })
294
301
  assert.equal(upsert, true)
295
302
  })
@@ -300,20 +307,48 @@ describe('massStore', () => {
300
307
  })
301
308
 
302
309
  it('should abort and answer false where a record was not matched', async () => {
303
- collection.bulkWrite = mock.fn(async () => ({ matchedCount: 1, upsertedCount: 0 }))
310
+ collection.client.bulkWrite = mock.fn(async () => ({
311
+ matchedCount: 1,
312
+ upsertedCount: 0
313
+ }))
304
314
 
305
315
  assert.equal(await storage.massStore([stored, { ...stored, id: 'c3' }]), false)
306
316
  assert.equal(session.abortTransaction.mock.callCount(), 1)
307
317
  })
308
318
 
309
319
  it('should abort and answer false where a record being created exists', async () => {
310
- collection.bulkWrite = mock.fn(async () => {
311
- throw Object.assign(new Error('E11000 duplicate key error index: _id_ dup key'), {
312
- code: 11000
320
+ collection.client.bulkWrite = mock.fn(async () => {
321
+ const collision = Object.assign(
322
+ new Error('E11000 duplicate key error index: _id_ dup key'),
323
+ {
324
+ code: 11000
325
+ }
326
+ )
327
+
328
+ throw Object.assign(new Error('ordered bulk write encountered a write error'), {
329
+ writeErrors: new Map([[0, collision]])
313
330
  })
314
331
  })
315
332
 
316
333
  assert.equal(await storage.massStore([created]), false)
317
334
  assert.equal(session.abortTransaction.mock.callCount(), 1)
318
335
  })
336
+
337
+ it('should throw a duplicate where a record collides on a key other than its id', async () => {
338
+ collection.client.bulkWrite = mock.fn(async () => {
339
+ const collision = Object.assign(
340
+ new Error('E11000 duplicate key error index: foo_1'),
341
+ {
342
+ code: 11000,
343
+ keyPattern: { foo: 1 }
344
+ }
345
+ )
346
+
347
+ throw Object.assign(new Error('ordered bulk write encountered a write error'), {
348
+ writeErrors: new Map([[0, collision]])
349
+ })
350
+ })
351
+
352
+ await assert.rejects(storage.massStore([created]), { code: 306 })
353
+ })
319
354
  })