@toa.io/storages.mongodb 1.0.0-alpha.320 → 1.0.0-alpha.322

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,21 @@
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.322](https://github.com/toa-io/toa/compare/v1.0.0-alpha.321...v1.0.0-alpha.322) (2026-09-29)
7
+
8
+ **Note:** Version bump only for package @toa.io/storages.mongodb
9
+
10
+
11
+
12
+
13
+
14
+ # [1.0.0-alpha.321](https://github.com/toa-io/toa/compare/v1.0.0-alpha.320...v1.0.0-alpha.321) (2026-09-28)
15
+
16
+ ### Bug Fixes
17
+
18
+ * **storages.mongodb:** commit a set of entries all or nothing ([dafecf0](https://github.com/toa-io/toa/commit/dafecf096bac99c4aca5b697a0d7d9f6f6d82daf))
19
+
20
+
6
21
  # [1.0.0-alpha.320](https://github.com/toa-io/toa/compare/v1.0.0-alpha.319...v1.0.0-alpha.320) (2026-09-28)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/storages.mongodb",
3
- "version": "1.0.0-alpha.320",
3
+ "version": "1.0.0-alpha.322",
4
4
  "type": "module",
5
5
  "description": "Toa MongoDB Storage Connector",
6
6
  "author": "temich <tema.gurtovoy@gmail.com>",
@@ -21,12 +21,12 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@toa.io/conveyor": "1.0.0-alpha.311",
24
- "@toa.io/core": "1.0.0-alpha.320",
25
- "@toa.io/definitions": "1.0.0-alpha.320",
24
+ "@toa.io/core": "1.0.0-alpha.322",
25
+ "@toa.io/definitions": "1.0.0-alpha.322",
26
26
  "@toa.io/generic": "1.0.0-alpha.311",
27
27
  "@toa.io/pointer": "1.0.0-alpha.311",
28
28
  "mongodb": "7.6.0",
29
29
  "openspan": "1.0.0-alpha.305"
30
30
  },
31
- "gitHead": "5ef92ab939a596e4ac820965c59c015820eba7b2"
31
+ "gitHead": "ef65352a8bd7bc0ef400fcc8cde0e19a3bb92199"
32
32
  }
package/src/storage.js CHANGED
@@ -278,6 +278,10 @@ export class Storage extends Connector {
278
278
  return committed === true
279
279
  }
280
280
 
281
+ /**
282
+ * A transition over a set, committed as one compare-and-swap: every record is written at the
283
+ * version it was read at, and its rows with it, or nothing is and the answer is `false`.
284
+ */
281
285
  async massStore(entities, rows = undefined, attempt = 0) {
282
286
  if (entities.length === 0) return true
283
287
 
@@ -288,9 +292,10 @@ export class Storage extends Connector {
288
292
  const { VERSION, ...rest } = record
289
293
 
290
294
  return {
291
- // upsert in required when document is deleted
295
+ // one the read did not find: absent, and inserted, or deleted, and revived — a live
296
+ // one makes the upsert collide on `_id`, which is the swap lost
292
297
  updateOne: {
293
- filter: { _id: entity.id },
298
+ filter: { _id: entity.id, DELETED: { $ne: null } },
294
299
  update: {
295
300
  $set: {
296
301
  ...rest,
@@ -310,19 +315,39 @@ export class Storage extends Connector {
310
315
  }
311
316
  })
312
317
 
318
+ let committed
319
+
313
320
  try {
314
- await this.#client.transaction(async (session) => {
315
- await this.command(
316
- 'bulkWrite',
317
- { operations: operations.length },
318
- async () => await this.#collection.bulkWrite(operations, { session })
319
- )
321
+ committed = await this.#client.transaction(async (session) => {
322
+ let result
323
+
324
+ try {
325
+ result = await this.command(
326
+ 'bulkWrite',
327
+ { operations: operations.length },
328
+ async () => await this.#collection.bulkWrite(operations, { session })
329
+ )
330
+ } catch (error) {
331
+ if (!collidedOnId(error)) throw error
332
+
333
+ await session.abortTransaction()
334
+
335
+ return false
336
+ }
337
+
338
+ // a replacement whose version moved matches nothing, which is no error to a bulk
339
+ // write: without this the rest of the set would be committed around it
340
+ if (result.matchedCount + result.upsertedCount < operations.length) {
341
+ await session.abortTransaction()
342
+
343
+ return false
344
+ }
320
345
 
321
346
  if (rows !== undefined && this.#outbox !== undefined)
322
347
  await this.#outbox.insertMany(rows, session)
323
- })
324
348
 
325
- return true
349
+ return true
350
+ })
326
351
  } catch (error) {
327
352
  console.error('MongoDB error', error)
328
353
 
@@ -331,6 +356,11 @@ export class Storage extends Connector {
331
356
  if (retry) return await this.massStore(entities, rows, attempt + 1)
332
357
  else return false
333
358
  }
359
+
360
+ // counted once per set: one commit lost, and one retry
361
+ if (committed !== true) conflicted(this.#collection.collectionName)
362
+
363
+ return committed === true
334
364
  }
335
365
 
336
366
  /**
@@ -574,14 +604,18 @@ const ERR_DUPLICATE_KEY = 11000
574
604
  /** what the transaction answers where the call it carries has already been made */
575
605
  const MADE = Symbol('made')
576
606
 
607
+ /** whether the key a write collided on is `_id` */
608
+ function collidedOnId(error) {
609
+ if (error?.code !== ERR_DUPLICATE_KEY) return false
610
+
611
+ return error.keyPattern === undefined
612
+ ? error.message.includes(' index: _id_ ') // AWS DocumentDB, and a bulk write
613
+ : error.keyPattern._id === 1
614
+ }
615
+
577
616
  async function retriable(error, attempt) {
578
617
  if (error.code === ERR_DUPLICATE_KEY) {
579
- const id =
580
- error.keyPattern === undefined
581
- ? error.message.includes(' index: _id_ ') // AWS DocumentDB
582
- : error.keyPattern._id === 1
583
-
584
- if (id) return false
618
+ if (collidedOnId(error)) return false
585
619
  else throw new exceptions.DuplicateException()
586
620
  } else if (error.cause?.code === 'ECONNREFUSED') {
587
621
  if (attempt === LAST_ATTEMPT) throw error
@@ -6,6 +6,7 @@ import { Storage } from '../src/storage.js'
6
6
 
7
7
  let collection
8
8
  let db
9
+ let session
9
10
  let storage
10
11
 
11
12
  beforeEach(async () => {
@@ -19,9 +20,14 @@ beforeEach(async () => {
19
20
 
20
21
  db = { collection: mock.fn(() => null) }
21
22
 
22
- const session = { endSession: async () => undefined }
23
+ session = {
24
+ endSession: async () => undefined,
25
+ abortTransaction: mock.fn(async () => undefined)
26
+ }
27
+
23
28
  const instance = { client: { startSession: () => session } }
24
- const client = { collection, db, instance, link: () => null }
29
+ const transaction = async (fn) => fn(session)
30
+ const client = { collection, db, instance, transaction, link: () => null }
25
31
 
26
32
  storage = new Storage(client, { schema: { properties: {} } })
27
33
 
@@ -256,3 +262,55 @@ describe('converge', () => {
256
262
  assert.equal(await storage.converge(record), false)
257
263
  })
258
264
  })
265
+
266
+ describe('massStore', () => {
267
+ const stored = { id: 'a1', VERSION: 3, foo: 1 }
268
+ const created = { id: 'b2', VERSION: 1, foo: 1 }
269
+
270
+ const operations = () => collection.bulkWrite.mock.calls[0].arguments[0]
271
+
272
+ beforeEach(() => {
273
+ collection.bulkWrite = mock.fn(async (operations) => ({
274
+ matchedCount: operations.length,
275
+ upsertedCount: 0
276
+ }))
277
+ })
278
+
279
+ it('should replace a record only at the version it was read at', async () => {
280
+ await storage.massStore([stored])
281
+
282
+ assert.deepEqual(operations()[0].replaceOne.filter, { _id: 'a1', VERSION: 2 })
283
+ })
284
+
285
+ it('should create a record only where none but a deleted one holds its id', async () => {
286
+ await storage.massStore([created])
287
+
288
+ const { filter, upsert } = operations()[0].updateOne
289
+
290
+ assert.deepEqual(filter, { _id: 'b2', DELETED: { $ne: null } })
291
+ assert.equal(upsert, true)
292
+ })
293
+
294
+ it('should answer true where every record was written', async () => {
295
+ assert.equal(await storage.massStore([stored, created]), true)
296
+ assert.equal(session.abortTransaction.mock.callCount(), 0)
297
+ })
298
+
299
+ it('should abort and answer false where a record was not matched', async () => {
300
+ collection.bulkWrite = mock.fn(async () => ({ matchedCount: 1, upsertedCount: 0 }))
301
+
302
+ assert.equal(await storage.massStore([stored, { ...stored, id: 'c3' }]), false)
303
+ assert.equal(session.abortTransaction.mock.callCount(), 1)
304
+ })
305
+
306
+ it('should abort and answer false where a record being created exists', async () => {
307
+ collection.bulkWrite = mock.fn(async () => {
308
+ throw Object.assign(new Error('E11000 duplicate key error index: _id_ dup key'), {
309
+ code: 11000
310
+ })
311
+ })
312
+
313
+ assert.equal(await storage.massStore([created]), false)
314
+ assert.equal(session.abortTransaction.mock.callCount(), 1)
315
+ })
316
+ })