@toa.io/storages.mongodb 1.0.0-alpha.270 → 1.0.0-alpha.273
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 +31 -0
- package/package.json +7 -7
- package/src/client.js +77 -9
- package/src/factory.js +2 -2
- package/src/outbox.js +129 -0
- package/src/storage.js +109 -26
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,37 @@
|
|
|
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.273](https://github.com/toa-io/toa/compare/v1.0.0-alpha.272...v1.0.0-alpha.273) (2026-09-02)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# [1.0.0-alpha.272](https://github.com/toa-io/toa/compare/v1.0.0-alpha.271...v1.0.0-alpha.272) (2026-09-01)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
* refactor(core)!: pump the outbox in one cycle ([bf590fe](https://github.com/toa-io/toa/commit/bf590fe8ed59c701b5fd1a91ba4874685b79242f))
|
|
18
|
+
* feat(core)!: commit events with the state that produced them ([1eb68cc](https://github.com/toa-io/toa/commit/1eb68cc435dbfa03faa16009fceb866693d22e1a)), closes [#20](https://github.com/toa-io/toa/issues/20)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### BREAKING CHANGES
|
|
22
|
+
|
|
23
|
+
* `Storage.outbox.pending` takes a fourth argument, the id to
|
|
24
|
+
continue from.
|
|
25
|
+
|
|
26
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
27
|
+
* `event.changeset` is removed; use `origin` and `state`. `State`
|
|
28
|
+
takes an `Outbox` in place of an `Emission`. `difference` is dropped from
|
|
29
|
+
`@toa.io/generic` along with its last caller.
|
|
30
|
+
|
|
31
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
6
37
|
# [1.0.0-alpha.270](https://github.com/toa-io/toa/compare/v1.0.0-alpha.269...v1.0.0-alpha.270) (2026-08-31)
|
|
7
38
|
|
|
8
39
|
|
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.273",
|
|
4
4
|
"description": "Toa MongoDB Storage Connector",
|
|
5
5
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/toa-io/toa#readme",
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
"test": "echo \"Error: run tests from root\" && exit 1"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@toa.io/conveyor": "1.0.0-alpha.
|
|
23
|
-
"@toa.io/core": "1.0.0-alpha.
|
|
24
|
-
"@toa.io/generic": "1.0.0-alpha.
|
|
25
|
-
"@toa.io/pointer": "1.0.0-alpha.
|
|
22
|
+
"@toa.io/conveyor": "1.0.0-alpha.273",
|
|
23
|
+
"@toa.io/core": "1.0.0-alpha.273",
|
|
24
|
+
"@toa.io/generic": "1.0.0-alpha.273",
|
|
25
|
+
"@toa.io/pointer": "1.0.0-alpha.273",
|
|
26
26
|
"mongodb": "7.2.0",
|
|
27
|
-
"openspan": "1.0.0-alpha.
|
|
27
|
+
"openspan": "1.0.0-alpha.272",
|
|
28
28
|
"saslprep": "1.0.3"
|
|
29
29
|
},
|
|
30
|
-
"gitHead": "
|
|
30
|
+
"gitHead": "ad3284850ece95ffd5325fd0995707fc144c9c3d"
|
|
31
31
|
}
|
package/src/client.js
CHANGED
|
@@ -26,6 +26,25 @@ class Client extends Connector {
|
|
|
26
26
|
*/
|
|
27
27
|
collection
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The outbox rows of this component, absent unless something consumes its events. Created
|
|
31
|
+
* eagerly beside the entity collection, because a transaction cannot create a collection and
|
|
32
|
+
* an index build cannot run inside one.
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
* @type {import('mongodb').Collection | undefined}
|
|
36
|
+
*/
|
|
37
|
+
outbox
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether this deployment can run transactions at all. A standalone mongod cannot, and an
|
|
41
|
+
* outbox without atomicity is worse than none, so the storage falls back to inline emission.
|
|
42
|
+
*
|
|
43
|
+
* @public
|
|
44
|
+
* @type {boolean}
|
|
45
|
+
*/
|
|
46
|
+
transactional = false
|
|
47
|
+
|
|
29
48
|
/**
|
|
30
49
|
* @private
|
|
31
50
|
* @type {Locator}
|
|
@@ -44,14 +63,22 @@ class Client extends Connector {
|
|
|
44
63
|
*/
|
|
45
64
|
key
|
|
46
65
|
|
|
66
|
+
/**
|
|
67
|
+
* @private
|
|
68
|
+
* @type {boolean}
|
|
69
|
+
*/
|
|
70
|
+
publishes
|
|
71
|
+
|
|
47
72
|
/**
|
|
48
73
|
* @param {Locator} locator
|
|
74
|
+
* @param {boolean} [publishes] whether this component publishes anything
|
|
49
75
|
*/
|
|
50
|
-
constructor (locator) {
|
|
76
|
+
constructor (locator, publishes = false) {
|
|
51
77
|
super()
|
|
52
78
|
|
|
53
79
|
this.locator = locator
|
|
54
80
|
this.name = locator.lowercase
|
|
81
|
+
this.publishes = publishes
|
|
55
82
|
}
|
|
56
83
|
|
|
57
84
|
/**
|
|
@@ -76,15 +103,30 @@ class Client extends Connector {
|
|
|
76
103
|
|
|
77
104
|
const db = this.instance.client.db(dbname)
|
|
78
105
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
} catch (e) {
|
|
82
|
-
if (e.code !== ALREADY_EXISTS) {
|
|
83
|
-
throw e
|
|
84
|
-
}
|
|
106
|
+
this.collection = await collection(db, this.name)
|
|
107
|
+
this.transactional = await transactional(db)
|
|
85
108
|
|
|
86
|
-
|
|
87
|
-
|
|
109
|
+
if (!this.publishes) return
|
|
110
|
+
|
|
111
|
+
if (this.transactional) this.outbox = await collection(db, this.name + OUTBOX)
|
|
112
|
+
else
|
|
113
|
+
console.warn('MongoDB is not a replica set; events are emitted inline, without an outbox',
|
|
114
|
+
{ collection: this.name })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Runs `fn` in a transaction and answers what it returned. The driver may call `fn` more
|
|
119
|
+
* than once, so it must not hold state of its own — an outbox row is built by the caller
|
|
120
|
+
* and reused, and a rolled back attempt leaves nothing behind.
|
|
121
|
+
*
|
|
122
|
+
* @public
|
|
123
|
+
* @template T
|
|
124
|
+
* @param {(session: import('mongodb').ClientSession) => Promise<T>} fn
|
|
125
|
+
* @return {Promise<T>}
|
|
126
|
+
*/
|
|
127
|
+
async transaction (fn) {
|
|
128
|
+
return this.instance.client.withSession(async (session) =>
|
|
129
|
+
session.withTransaction(async () => fn(session)))
|
|
88
130
|
}
|
|
89
131
|
|
|
90
132
|
/**
|
|
@@ -155,6 +197,31 @@ function getKey (db, urls) {
|
|
|
155
197
|
return db + ':' + urls.sort().join(' ')
|
|
156
198
|
}
|
|
157
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Concurrent pods race to create the same collection, and losing that race is not an error.
|
|
202
|
+
*/
|
|
203
|
+
async function collection (db, name) {
|
|
204
|
+
try {
|
|
205
|
+
return await db.createCollection(name)
|
|
206
|
+
} catch (e) {
|
|
207
|
+
if (e.code !== ALREADY_EXISTS) throw e
|
|
208
|
+
|
|
209
|
+
return db.collection(name)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function transactional (db) {
|
|
214
|
+
try {
|
|
215
|
+
const hello = await db.admin().command({ hello: 1 })
|
|
216
|
+
|
|
217
|
+
return hello.setName !== undefined || hello.msg === 'isdbgrid'
|
|
218
|
+
} catch (e) {
|
|
219
|
+
console.warn('MongoDB transaction support could not be determined', { error: e })
|
|
220
|
+
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
158
225
|
/**
|
|
159
226
|
* `monitorCommands` is deliberately absent. It makes the driver materialize every reply
|
|
160
227
|
* eagerly to populate the monitoring event (`CommandSucceededEvent`), which defeats the
|
|
@@ -166,5 +233,6 @@ const OPTIONS = {
|
|
|
166
233
|
}
|
|
167
234
|
|
|
168
235
|
const ALREADY_EXISTS = 48
|
|
236
|
+
const OUTBOX = '_outbox'
|
|
169
237
|
|
|
170
238
|
exports.Client = Client
|
package/src/factory.js
CHANGED
|
@@ -4,8 +4,8 @@ const { Client } = require('./client')
|
|
|
4
4
|
const { Storage } = require('./storage')
|
|
5
5
|
|
|
6
6
|
class Factory {
|
|
7
|
-
storage (locator, entity) {
|
|
8
|
-
const client = new Client(locator)
|
|
7
|
+
storage (locator, entity, options = {}) {
|
|
8
|
+
const client = new Client(locator, options.outbox === true)
|
|
9
9
|
|
|
10
10
|
return new Storage(client, entity)
|
|
11
11
|
}
|
package/src/outbox.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { console } = require('openspan')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The outbox rows of one component. Its lifecycle is the Client's, so it is not a Connector.
|
|
7
|
+
*
|
|
8
|
+
* Core owns what a row means — its id, its lane and when it becomes due. This only writes
|
|
9
|
+
* them, reads back what is due, and marks what has been published.
|
|
10
|
+
*/
|
|
11
|
+
class Outbox {
|
|
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
|
+
/** @param {import('mongodb').ClientSession} session */
|
|
23
|
+
async insert (row, session) {
|
|
24
|
+
await this.#collection.insertOne(to(row), { session })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @param {import('mongodb').ClientSession} session */
|
|
28
|
+
async insertMany (rows, session) {
|
|
29
|
+
if (rows.length === 0) return
|
|
30
|
+
|
|
31
|
+
await this.#collection.insertMany(rows.map(to), { session })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One page of what this replica should publish: due, still unpublished, and in a lane it
|
|
36
|
+
* owns. In steady state the first page is empty.
|
|
37
|
+
*
|
|
38
|
+
* `after` continues from the last id of the page before. Ids are uuid v7, so their order is
|
|
39
|
+
* the order rows were written and a page is never read twice within a cycle — which matters
|
|
40
|
+
* because a row stays unpublished in the database until the cycle that sent it marks it.
|
|
41
|
+
*/
|
|
42
|
+
async pending (lanes, now, limit, after = undefined) {
|
|
43
|
+
const criteria = { lane: { $in: lanes }, published: false, pending: { $lte: now } }
|
|
44
|
+
|
|
45
|
+
if (after !== undefined) criteria._id = { $gt: after }
|
|
46
|
+
|
|
47
|
+
const rows = await this.#collection
|
|
48
|
+
.find(criteria)
|
|
49
|
+
.sort({ _id: 1 })
|
|
50
|
+
.limit(limit)
|
|
51
|
+
.toArray()
|
|
52
|
+
|
|
53
|
+
return rows.map(from)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One batched write for many events, which is why the ids are held in memory until the
|
|
58
|
+
* tick rather than updated one by one.
|
|
59
|
+
*/
|
|
60
|
+
async settle (ids) {
|
|
61
|
+
if (ids.length === 0) return
|
|
62
|
+
|
|
63
|
+
await this.#collection.updateMany({ _id: { $in: ids } },
|
|
64
|
+
{ $set: { published: true, publishedAt: new Date() } })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The entity collection's index management does not reach here, so this collection keeps
|
|
69
|
+
* its own — including pruning, or a later change leaves the old index behind forever.
|
|
70
|
+
*/
|
|
71
|
+
async index () {
|
|
72
|
+
const desired = {
|
|
73
|
+
// holds only what is not published yet, so it stays at in-flight size
|
|
74
|
+
outbox_pending: {
|
|
75
|
+
fields: { lane: 1, pending: 1 },
|
|
76
|
+
options: { name: 'outbox_pending', partialFilterExpression: { published: false } }
|
|
77
|
+
},
|
|
78
|
+
// an unpublished row has no `publishedAt`, and the TTL monitor skips those — so a row
|
|
79
|
+
// that never made it out is never reaped
|
|
80
|
+
outbox_published_at: {
|
|
81
|
+
fields: { publishedAt: 1 },
|
|
82
|
+
options: { name: 'outbox_published_at', expireAfterSeconds: this.#retention }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const { fields, options } of Object.values(desired))
|
|
87
|
+
await this.#collection.createIndex(fields, options)
|
|
88
|
+
.catch((e) => console.warn('MongoDB outbox index creation failed',
|
|
89
|
+
{ collection: this.#collection.collectionName, name: options.name, error: e }))
|
|
90
|
+
|
|
91
|
+
await this.#prune(Object.keys(desired))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @private */
|
|
95
|
+
async #prune (desired) {
|
|
96
|
+
let current
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
current = await this.#collection.listIndexes().toArray()
|
|
100
|
+
} catch {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const obsolete = current
|
|
105
|
+
.map(({ name }) => name)
|
|
106
|
+
.filter((name) => name !== '_id_' && !desired.includes(name))
|
|
107
|
+
|
|
108
|
+
if (obsolete.length === 0) return
|
|
109
|
+
|
|
110
|
+
console.info('Removing obsolete outbox indexes',
|
|
111
|
+
{ collection: this.#collection.collectionName, indexes: obsolete.join(', ') })
|
|
112
|
+
|
|
113
|
+
await Promise.all(obsolete.map((name) => this.#collection.dropIndex(name)))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const to = ({ id, ...rest }) => ({ _id: id, ...rest })
|
|
118
|
+
const from = ({ _id, ...rest }) => ({ id: _id, ...rest })
|
|
119
|
+
|
|
120
|
+
function retention () {
|
|
121
|
+
const value = Number(process.env.TOA_OUTBOX_RETENTION)
|
|
122
|
+
|
|
123
|
+
return Number.isNaN(value) || value < 0 ? RETENTION : value
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** seconds a published row is kept as a change log before the TTL monitor reaps it */
|
|
127
|
+
const RETENTION = 86400
|
|
128
|
+
|
|
129
|
+
exports.Outbox = Outbox
|
package/src/storage.js
CHANGED
|
@@ -4,6 +4,7 @@ const { Connector, exceptions } = require('@toa.io/core')
|
|
|
4
4
|
const { console } = require('openspan')
|
|
5
5
|
const { translate } = require('./translate')
|
|
6
6
|
const { to, from } = require('./record')
|
|
7
|
+
const { Outbox } = require('./outbox')
|
|
7
8
|
const { ReturnDocument } = require('mongodb')
|
|
8
9
|
|
|
9
10
|
class Storage extends Connector {
|
|
@@ -13,6 +14,12 @@ class Storage extends Connector {
|
|
|
13
14
|
#collection
|
|
14
15
|
#entity
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* @type {Outbox | undefined} absent when nothing consumes this component's events, or when
|
|
19
|
+
* the deployment cannot run transactions
|
|
20
|
+
*/
|
|
21
|
+
#outbox
|
|
22
|
+
|
|
16
23
|
/** @type {Map<string, object>} span options per driver method */
|
|
17
24
|
#spans = new Map()
|
|
18
25
|
|
|
@@ -29,12 +36,25 @@ class Storage extends Connector {
|
|
|
29
36
|
return this.#collection
|
|
30
37
|
}
|
|
31
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The outbox is offered only where a row can be committed atomically with the entity.
|
|
41
|
+
* Without that it would be a second write with a window in front of it — worse than the
|
|
42
|
+
* inline emission it replaces — so the storage simply does not advertise it.
|
|
43
|
+
*/
|
|
44
|
+
get outbox () {
|
|
45
|
+
return this.#outbox
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
async open () {
|
|
33
49
|
this.#collection = this.#client.collection
|
|
34
50
|
|
|
51
|
+
if (this.#client.outbox !== undefined)
|
|
52
|
+
this.#outbox = new Outbox(this.#client.outbox)
|
|
53
|
+
|
|
35
54
|
this.#spans.clear()
|
|
36
55
|
|
|
37
56
|
await this.index()
|
|
57
|
+
await this.#outbox?.index()
|
|
38
58
|
}
|
|
39
59
|
|
|
40
60
|
async get (query) {
|
|
@@ -84,16 +104,16 @@ class Storage extends Connector {
|
|
|
84
104
|
return this.#collection.find(criteria, options).stream({ transform: from })
|
|
85
105
|
}
|
|
86
106
|
|
|
87
|
-
async add (entity) {
|
|
107
|
+
async add (entity, session = undefined) {
|
|
88
108
|
const record = to(entity)
|
|
89
109
|
|
|
90
110
|
const result = await this.command('insertOne', { record },
|
|
91
|
-
() => this.#collection.insertOne(record))
|
|
111
|
+
() => this.#collection.insertOne(record, { session }))
|
|
92
112
|
|
|
93
113
|
return result.acknowledged
|
|
94
114
|
}
|
|
95
115
|
|
|
96
|
-
async set (entity) {
|
|
116
|
+
async set (entity, session = undefined) {
|
|
97
117
|
const criteria = {
|
|
98
118
|
_id: entity.id,
|
|
99
119
|
_version: entity._version - 1
|
|
@@ -102,30 +122,52 @@ class Storage extends Connector {
|
|
|
102
122
|
const record = to(entity)
|
|
103
123
|
|
|
104
124
|
const result = await this.command('findOneAndReplace', { criteria, record },
|
|
105
|
-
() => this.#collection.findOneAndReplace(criteria, record))
|
|
125
|
+
() => this.#collection.findOneAndReplace(criteria, record, { session }))
|
|
106
126
|
|
|
107
127
|
return result !== null
|
|
108
128
|
}
|
|
109
129
|
|
|
110
|
-
async store (entity, attempt = 0) {
|
|
130
|
+
async store (entity, row = undefined, attempt = 0) {
|
|
111
131
|
try {
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
132
|
+
if (row === undefined || this.#outbox === undefined) {
|
|
133
|
+
if (entity._version === 1)
|
|
134
|
+
return await this.add(entity)
|
|
135
|
+
else
|
|
136
|
+
return await this.set(entity)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const committed = await this.#client.transaction(async (session) => {
|
|
140
|
+
const ok = entity._version === 1
|
|
141
|
+
? await this.add(entity, session)
|
|
142
|
+
: await this.set(entity, session)
|
|
143
|
+
|
|
144
|
+
// a lost compare-and-swap must take the row down with it, or a retried transition
|
|
145
|
+
// leaves a row for a write that never happened
|
|
146
|
+
if (!ok) {
|
|
147
|
+
await session.abortTransaction()
|
|
148
|
+
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await this.#outbox.insert(row, session)
|
|
153
|
+
|
|
154
|
+
return true
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
return committed === true
|
|
116
158
|
} catch (error) {
|
|
117
159
|
console.error('MongoDB error', error)
|
|
118
160
|
|
|
119
161
|
const retry = await retriable(error, attempt)
|
|
120
162
|
|
|
121
163
|
if (retry)
|
|
122
|
-
return await this.store(entity, attempt + 1)
|
|
164
|
+
return await this.store(entity, row, attempt + 1)
|
|
123
165
|
else
|
|
124
166
|
return false
|
|
125
167
|
}
|
|
126
168
|
}
|
|
127
169
|
|
|
128
|
-
async massStore (entities, attempt = 0) {
|
|
170
|
+
async massStore (entities, rows = undefined, attempt = 0) {
|
|
129
171
|
if (entities.length === 0)
|
|
130
172
|
return true
|
|
131
173
|
|
|
@@ -157,13 +199,13 @@ class Storage extends Connector {
|
|
|
157
199
|
}
|
|
158
200
|
})
|
|
159
201
|
|
|
160
|
-
const client = this.#client.instance.client
|
|
161
|
-
|
|
162
202
|
try {
|
|
163
|
-
await client.
|
|
164
|
-
await
|
|
165
|
-
await this.
|
|
166
|
-
|
|
203
|
+
await this.#client.transaction(async (session) => {
|
|
204
|
+
await this.command('bulkWrite', { operations: operations.length },
|
|
205
|
+
async () => await this.#collection.bulkWrite(operations, { session }))
|
|
206
|
+
|
|
207
|
+
if (rows !== undefined && this.#outbox !== undefined)
|
|
208
|
+
await this.#outbox.insertMany(rows, session)
|
|
167
209
|
})
|
|
168
210
|
|
|
169
211
|
return true
|
|
@@ -173,13 +215,13 @@ class Storage extends Connector {
|
|
|
173
215
|
const retry = await retriable(error, attempt)
|
|
174
216
|
|
|
175
217
|
if (retry)
|
|
176
|
-
return await this.massStore(entities, attempt + 1)
|
|
218
|
+
return await this.massStore(entities, rows, attempt + 1)
|
|
177
219
|
else
|
|
178
220
|
return false
|
|
179
221
|
}
|
|
180
222
|
}
|
|
181
223
|
|
|
182
|
-
async upsert (query, changeset) {
|
|
224
|
+
async upsert (query, changeset, row = undefined) {
|
|
183
225
|
const { criteria, options } = translate(query)
|
|
184
226
|
|
|
185
227
|
if (!('_deleted' in changeset) || changeset._deleted === null) {
|
|
@@ -192,15 +234,45 @@ class Storage extends Connector {
|
|
|
192
234
|
$inc: { _version: 1 }
|
|
193
235
|
}
|
|
194
236
|
|
|
195
|
-
|
|
237
|
+
// BEFORE, so that the filter is applied once and atomically and the pre-image comes back
|
|
238
|
+
// with it — an assignment is the one event whose images are the write's own
|
|
239
|
+
options.returnDocument = ReturnDocument.BEFORE
|
|
240
|
+
|
|
241
|
+
const apply = async (session) => {
|
|
242
|
+
const found = await this.command('findOneAndUpdate', { criteria, update, options },
|
|
243
|
+
() => this.#collection.findOneAndUpdate(criteria, update, { ...options, session }))
|
|
244
|
+
|
|
245
|
+
if (found === null) return null
|
|
246
|
+
|
|
247
|
+
const origin = from(found)
|
|
248
|
+
|
|
249
|
+
/*
|
|
250
|
+
* The post-image is `update` applied to the pre-image, computed rather than read back.
|
|
251
|
+
* That is exact, not approximate: `$set` on top-level keys is a spread (entity property
|
|
252
|
+
* names cannot contain dots, so a changeset never carries a path), and `_version` is
|
|
253
|
+
* incremented by one. It is also a coupling — an operator added to `update` and not
|
|
254
|
+
* mirrored here diverges silently — which `features/events/outbox.feature` guards.
|
|
255
|
+
*/
|
|
256
|
+
const state = { ...origin, ...changeset, _version: origin._version + 1 }
|
|
257
|
+
|
|
258
|
+
// an assignment's event is the write's own images, so they are filled in here whether
|
|
259
|
+
// or not the row is going to be committed
|
|
260
|
+
if (row !== undefined)
|
|
261
|
+
row.event = { origin, state, ...row.event }
|
|
262
|
+
|
|
263
|
+
if (row !== undefined && this.#outbox !== undefined)
|
|
264
|
+
await this.#outbox.insert(row, session)
|
|
265
|
+
|
|
266
|
+
return state
|
|
267
|
+
}
|
|
196
268
|
|
|
197
|
-
|
|
198
|
-
|
|
269
|
+
if (row === undefined || this.#outbox === undefined)
|
|
270
|
+
return apply(undefined)
|
|
199
271
|
|
|
200
|
-
return
|
|
272
|
+
return this.#client.transaction(apply)
|
|
201
273
|
}
|
|
202
274
|
|
|
203
|
-
async ensure (query, properties, state) {
|
|
275
|
+
async ensure (query, properties, state, row = undefined) {
|
|
204
276
|
let { criteria, options } = translate(query)
|
|
205
277
|
|
|
206
278
|
if (query === undefined)
|
|
@@ -212,8 +284,19 @@ class Storage extends Connector {
|
|
|
212
284
|
options.returnDocument = ReturnDocument.AFTER
|
|
213
285
|
|
|
214
286
|
try {
|
|
215
|
-
const result =
|
|
216
|
-
|
|
287
|
+
const result = row === undefined || this.#outbox === undefined
|
|
288
|
+
? await this.command('findOneAndUpdate', { criteria, update, options },
|
|
289
|
+
() => this.#collection.findOneAndUpdate(criteria, update, options))
|
|
290
|
+
: await this.#client.transaction(async (session) => {
|
|
291
|
+
const found = await this.command('findOneAndUpdate', { criteria, update, options },
|
|
292
|
+
() => this.#collection.findOneAndUpdate(criteria, update, { ...options, session }))
|
|
293
|
+
|
|
294
|
+
// only an insert is an event; finding an existing record is not
|
|
295
|
+
if (found !== null && found._id === state.id)
|
|
296
|
+
await this.#outbox.insert(row, session)
|
|
297
|
+
|
|
298
|
+
return found
|
|
299
|
+
})
|
|
217
300
|
|
|
218
301
|
if (result._deleted !== undefined && result._deleted !== null)
|
|
219
302
|
return null
|