@toa.io/storages.mongodb 1.0.0-alpha.298 → 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 CHANGED
@@ -3,6 +3,14 @@
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
+
6
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)
7
15
 
8
16
  **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.298",
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.293",
25
- "@toa.io/definitions": "1.0.0-alpha.298",
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.293",
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": "334fad8726adfa7507462c33b509ce7db9db853f"
31
+ "gitHead": "2ba95b16f2cf06f82d6ff8d436761e843e5e4b43"
32
32
  }
package/src/outbox.js CHANGED
@@ -53,16 +53,36 @@ export class Outbox {
53
53
  }
54
54
 
55
55
  /**
56
- * One batched write for many events, which is why the ids are held in memory until the
57
- * tick rather than updated one by one.
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
- { _id: { $in: ids } },
64
- { $set: { published: true, publishedAt: new Date() } }
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
- const from = ({ _id, ...rest }) => ({ id: _id, ...rest })
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) {
@@ -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
+ })