@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 +14 -0
- package/package.json +7 -7
- package/readme.md +5 -0
- package/src/bulk.js +26 -0
- package/src/outbox.js +9 -1
- package/src/storage.js +32 -19
- package/test/bulk.test.js +49 -0
- package/test/storage.test.js +43 -8
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.
|
|
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.
|
|
24
|
-
"@toa.io/core": "1.0.0-alpha.
|
|
25
|
-
"@toa.io/definitions": "1.0.0-alpha.
|
|
26
|
-
"@toa.io/generic": "1.0.0-alpha.
|
|
27
|
-
"@toa.io/pointer": "1.0.0-alpha.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
309
|
-
}
|
|
310
|
+
$inc: { VERSION: 1 }
|
|
311
|
+
},
|
|
312
|
+
upsert: true
|
|
310
313
|
}
|
|
311
314
|
} else
|
|
312
315
|
return {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
+
})
|
package/test/storage.test.js
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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 () => ({
|
|
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
|
-
|
|
312
|
-
|
|
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
|
})
|