@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 +15 -0
- package/package.json +4 -4
- package/src/storage.js +50 -16
- package/test/storage.test.js +60 -2
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.
|
|
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.
|
|
25
|
-
"@toa.io/definitions": "1.0.0-alpha.
|
|
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": "
|
|
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
|
-
//
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/test/storage.test.js
CHANGED
|
@@ -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
|
-
|
|
23
|
+
session = {
|
|
24
|
+
endSession: async () => undefined,
|
|
25
|
+
abortTransaction: mock.fn(async () => undefined)
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
const instance = { client: { startSession: () => session } }
|
|
24
|
-
const
|
|
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
|
+
})
|