@toa.io/storages.mongodb 1.0.0-alpha.297 → 1.0.0-alpha.299
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 +16 -0
- package/package.json +5 -5
- package/src/outbox.js +34 -8
- package/src/storage.js +54 -0
- package/test/storage.test.js +89 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
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.299](https://github.com/toa-io/toa/compare/v1.0.0-alpha.298...v1.0.0-alpha.299) (2026-09-10)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **core:** a record carries the region that wrote it ([13ed187](https://github.com/toa-io/toa/commit/13ed187604f06dec66feec2e26ccf9dae30bb048))
|
|
11
|
+
* **core:** an outbox row is published to destinations ([cfb77b4](https://github.com/toa-io/toa/commit/cfb77b4826ff0f9560769a0996981e143d6525af))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# [1.0.0-alpha.298](https://github.com/toa-io/toa/compare/v1.0.0-alpha.297...v1.0.0-alpha.298) (2026-09-08)
|
|
15
|
+
|
|
16
|
+
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
# [1.0.0-alpha.297](https://github.com/toa-io/toa/compare/v1.0.0-alpha.296...v1.0.0-alpha.297) (2026-09-08)
|
|
7
23
|
|
|
8
24
|
**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.299",
|
|
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.292",
|
|
24
|
-
"@toa.io/core": "1.0.0-alpha.
|
|
25
|
-
"@toa.io/definitions": "1.0.0-alpha.
|
|
24
|
+
"@toa.io/core": "1.0.0-alpha.299",
|
|
25
|
+
"@toa.io/definitions": "1.0.0-alpha.299",
|
|
26
26
|
"@toa.io/generic": "1.0.0-alpha.292",
|
|
27
|
-
"@toa.io/pointer": "1.0.0-alpha.
|
|
27
|
+
"@toa.io/pointer": "1.0.0-alpha.299",
|
|
28
28
|
"mongodb": "7.6.0",
|
|
29
29
|
"openspan": "1.0.0-alpha.293"
|
|
30
30
|
},
|
|
31
|
-
"gitHead": "
|
|
31
|
+
"gitHead": "2ba95b16f2cf06f82d6ff8d436761e843e5e4b43"
|
|
32
32
|
}
|
package/src/outbox.js
CHANGED
|
@@ -53,16 +53,36 @@ export class Outbox {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* Takes those destinations out of what those rows are outstanding for, and marks published
|
|
57
|
+
* the ones left outstanding for nothing — which is what `pending` selects on and what the
|
|
58
|
+
* TTL reaps by, so neither changes.
|
|
59
|
+
*
|
|
60
|
+
* One batched write for many rows, which is why the ids are held in memory until the tick
|
|
61
|
+
* rather than updated one by one. A row written before this property existed reads as
|
|
62
|
+
* outstanding for `events` alone, which is all there was.
|
|
58
63
|
*/
|
|
59
|
-
async settle(ids) {
|
|
64
|
+
async settle(ids, destinations) {
|
|
60
65
|
if (ids.length === 0) return
|
|
61
66
|
|
|
62
|
-
await this.#collection.updateMany(
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
await this.#collection.updateMany({ _id: { $in: ids } }, [
|
|
68
|
+
{
|
|
69
|
+
$set: {
|
|
70
|
+
outstanding: {
|
|
71
|
+
$setDifference: [{ $ifNull: ['$outstanding', [EVENTS]] }, destinations]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
$set: {
|
|
77
|
+
published: { $eq: [{ $size: '$outstanding' }, 0] },
|
|
78
|
+
// written once, when the last destination lands: an unpublished row has none, and
|
|
79
|
+
// the TTL monitor skips a document that lacks the field it expires by
|
|
80
|
+
publishedAt: {
|
|
81
|
+
$cond: [{ $eq: [{ $size: '$outstanding' }, 0] }, '$$NOW', '$publishedAt']
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
])
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
/**
|
|
@@ -158,7 +178,10 @@ export class Outbox {
|
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
const to = ({ id, ...rest }) => ({ _id: id, ...rest })
|
|
161
|
-
|
|
181
|
+
|
|
182
|
+
// a row written before destinations existed is outstanding for the events, which is all a row
|
|
183
|
+
// was ever published to then
|
|
184
|
+
const from = ({ _id, ...rest }) => ({ id: _id, outstanding: [EVENTS], ...rest })
|
|
162
185
|
|
|
163
186
|
function retention() {
|
|
164
187
|
const value = Number(environment.get('TOA_OUTBOX_RETENTION'))
|
|
@@ -166,6 +189,9 @@ function retention() {
|
|
|
166
189
|
return Number.isNaN(value) || value < 0 ? RETENTION : value
|
|
167
190
|
}
|
|
168
191
|
|
|
192
|
+
/** what a component's own events are outstanding for; `Emission.name` */
|
|
193
|
+
const EVENTS = 'events'
|
|
194
|
+
|
|
169
195
|
/** seconds a published row is kept as a change log before the TTL monitor reaps it */
|
|
170
196
|
const RETENTION = 86400
|
|
171
197
|
|
package/src/storage.js
CHANGED
|
@@ -65,6 +65,11 @@ export class Storage extends Connector {
|
|
|
65
65
|
return true
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** This storage converges. */
|
|
69
|
+
get converges() {
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
|
|
68
73
|
async open() {
|
|
69
74
|
this.#collection = this.#client.collection
|
|
70
75
|
|
|
@@ -255,6 +260,52 @@ export class Storage extends Connector {
|
|
|
255
260
|
}
|
|
256
261
|
}
|
|
257
262
|
|
|
263
|
+
/**
|
|
264
|
+
* The filter selects by `_id` alone, so it always matches what is there and upserts what is
|
|
265
|
+
* not, and the rule is the pipeline: either the incoming document replaces the stored one, or
|
|
266
|
+
* `$$ROOT` stays where it is and nothing changes. That keeps the three answers apart — a
|
|
267
|
+
* record never seen is an upsert, a superseded one is a modification, and a stale one is
|
|
268
|
+
* neither — where putting the rule in the filter would make absence and staleness one and the
|
|
269
|
+
* same miss, and then have the upsert collide on `_id` to say so.
|
|
270
|
+
*
|
|
271
|
+
* `$literal` because a value in a pipeline is an expression, so a property holding a string
|
|
272
|
+
* that begins with `$` would otherwise be read as a field path.
|
|
273
|
+
*
|
|
274
|
+
* `$ifNull` twice, and for two reasons. On the upsert path the pipeline runs over the base
|
|
275
|
+
* document the filter builds, which is `{ _id }`, so a missing `VERSION` reads as `0` — the
|
|
276
|
+
* version an entity holds before its first write. And a record that somehow reached here
|
|
277
|
+
* without a `REGION` reads as the first region, which is what the prototype's migration
|
|
278
|
+
* writes into one: without that, such a record would lose no tie and would beat every
|
|
279
|
+
* equal-version write from anywhere, silently and for good.
|
|
280
|
+
*/
|
|
281
|
+
async converge(record) {
|
|
282
|
+
const document = this.#to(record)
|
|
283
|
+
|
|
284
|
+
const supersedes = {
|
|
285
|
+
$or: [
|
|
286
|
+
{ $lt: [{ $ifNull: ['$VERSION', 0] }, document.VERSION] },
|
|
287
|
+
{
|
|
288
|
+
$and: [
|
|
289
|
+
{ $eq: ['$VERSION', document.VERSION] },
|
|
290
|
+
{ $gt: [{ $ifNull: ['$REGION', FIRST] }, document.REGION] }
|
|
291
|
+
]
|
|
292
|
+
}
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const pipeline = [
|
|
297
|
+
{ $replaceWith: { $cond: [supersedes, { $literal: document }, '$$ROOT'] } }
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
const result = await this.command(
|
|
301
|
+
'updateOne',
|
|
302
|
+
{ criteria: { _id: document._id }, pipeline },
|
|
303
|
+
() => this.#collection.updateOne({ _id: document._id }, pipeline, { upsert: true })
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
return result.upsertedCount === 1 || result.modifiedCount === 1
|
|
307
|
+
}
|
|
308
|
+
|
|
258
309
|
async upsert(query, changeset, row = undefined) {
|
|
259
310
|
const { criteria, options } = translate(query, this.#dates)
|
|
260
311
|
|
|
@@ -415,6 +466,9 @@ function toPipeline(criteria, options, sample) {
|
|
|
415
466
|
return pipeline
|
|
416
467
|
}
|
|
417
468
|
|
|
469
|
+
/** the rank of the first region, which is what a record written before regions reads as */
|
|
470
|
+
const FIRST = 0
|
|
471
|
+
|
|
418
472
|
const ERR_DUPLICATE_KEY = 11000
|
|
419
473
|
|
|
420
474
|
async function retriable(error, attempt) {
|
package/test/storage.test.js
CHANGED
|
@@ -13,7 +13,8 @@ beforeEach(async () => {
|
|
|
13
13
|
collectionName: 'test',
|
|
14
14
|
findOne: mock.fn(async () => null),
|
|
15
15
|
find: mock.fn(() => ({ stream: () => null })),
|
|
16
|
-
updateMany: mock.fn(async () => ({ modifiedCount: 0 }))
|
|
16
|
+
updateMany: mock.fn(async () => ({ modifiedCount: 0 })),
|
|
17
|
+
updateOne: mock.fn(async () => ({ upsertedCount: 0, modifiedCount: 0 }))
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
db = { collection: mock.fn(() => null) }
|
|
@@ -127,3 +128,90 @@ describe('stream', () => {
|
|
|
127
128
|
)
|
|
128
129
|
})
|
|
129
130
|
})
|
|
131
|
+
|
|
132
|
+
describe('converge', () => {
|
|
133
|
+
const record = { id: 'a1', VERSION: 4, REGION: 1, status: 'paid' }
|
|
134
|
+
|
|
135
|
+
const call = () => collection.updateOne.mock.calls[0].arguments
|
|
136
|
+
|
|
137
|
+
it('should select by id alone, so that what is absent is upserted', async () => {
|
|
138
|
+
await storage.converge(record)
|
|
139
|
+
|
|
140
|
+
const [criteria, , options] = call()
|
|
141
|
+
|
|
142
|
+
assert.deepEqual(criteria, { _id: 'a1' })
|
|
143
|
+
assert.equal(options.upsert, true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should supersede a lower version', async () => {
|
|
147
|
+
await storage.converge(record)
|
|
148
|
+
|
|
149
|
+
const [, pipeline] = call()
|
|
150
|
+
const [older] = pipeline[0].$replaceWith.$cond[0].$or
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(older, { $lt: [{ $ifNull: ['$VERSION', 0] }, 4] })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should supersede an equal version written by an outranked region', async () => {
|
|
156
|
+
await storage.converge(record)
|
|
157
|
+
|
|
158
|
+
const [, pipeline] = call()
|
|
159
|
+
const [, tied] = pipeline[0].$replaceWith.$cond[0].$or
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(tied.$and, [
|
|
162
|
+
{ $eq: ['$VERSION', 4] },
|
|
163
|
+
{ $gt: [{ $ifNull: ['$REGION', 0] }, 1] }
|
|
164
|
+
])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('should leave what is stored where it does not', async () => {
|
|
168
|
+
await storage.converge(record)
|
|
169
|
+
|
|
170
|
+
const [, pipeline] = call()
|
|
171
|
+
|
|
172
|
+
assert.equal(pipeline[0].$replaceWith.$cond[2], '$$ROOT')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should write the record as a literal, so that a `$` value is not a field path', async () => {
|
|
176
|
+
await storage.converge({ ...record, status: '$paid' })
|
|
177
|
+
|
|
178
|
+
const [, pipeline] = call()
|
|
179
|
+
const written = pipeline[0].$replaceWith.$cond[1].$literal
|
|
180
|
+
|
|
181
|
+
assert.equal(written._id, 'a1')
|
|
182
|
+
assert.equal(written.status, '$paid')
|
|
183
|
+
assert.equal(written.id, undefined)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('should answer true where it inserted', async () => {
|
|
187
|
+
collection.updateOne.mock.mockImplementationOnce(async () => ({
|
|
188
|
+
upsertedCount: 1,
|
|
189
|
+
modifiedCount: 0
|
|
190
|
+
}))
|
|
191
|
+
|
|
192
|
+
assert.equal(await storage.converge(record), true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should answer true where it superseded', async () => {
|
|
196
|
+
collection.updateOne.mock.mockImplementationOnce(async () => ({
|
|
197
|
+
upsertedCount: 0,
|
|
198
|
+
modifiedCount: 1
|
|
199
|
+
}))
|
|
200
|
+
|
|
201
|
+
assert.equal(await storage.converge(record), true)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('should read a record that lacks a region as the first one', async () => {
|
|
205
|
+
await storage.converge(record)
|
|
206
|
+
|
|
207
|
+
const [, pipeline] = call()
|
|
208
|
+
const [, tied] = pipeline[0].$replaceWith.$cond[0].$or
|
|
209
|
+
|
|
210
|
+
// the migration writes it, and one that reached here without it would win every tie
|
|
211
|
+
assert.deepEqual(tied.$and[1].$gt[0], { $ifNull: ['$REGION', 0] })
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('should answer false where it changed nothing, and not throw', async () => {
|
|
215
|
+
assert.equal(await storage.converge(record), false)
|
|
216
|
+
})
|
|
217
|
+
})
|