@toa.io/core 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 CHANGED
@@ -3,6 +3,70 @@
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/core
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
+ ### Bug Fixes
18
+
19
+ * **core:** stop the reply contract from relaxing the declaration ([6222893](https://github.com/toa-io/toa/commit/622289326630b94deff93499d6a851bfaa57c69a))
20
+
21
+
22
+ * feat(atomicity)!: take a quorum of independent servers ([862562f](https://github.com/toa-io/toa/commit/862562f24d77ec00127dbbd88d43802b208c643b))
23
+ * refactor(atomicity)!: take the lock manager from the stash ([4fd91eb](https://github.com/toa-io/toa/commit/4fd91eb2fdb68f7b87aaf16d182794bbc567ba57))
24
+ * refactor(core)!: pump the outbox in one cycle ([bf590fe](https://github.com/toa-io/toa/commit/bf590fe8ed59c701b5fd1a91ba4874685b79242f))
25
+ * refactor(atomicity)!: make the connector a family, not a partitioner ([06fd63a](https://github.com/toa-io/toa/commit/06fd63ad6296b724ef83afccf4e4e25d0bcaf080))
26
+ * refactor(atomicity)!: rename the connector and free it of the outbox ([21f1a41](https://github.com/toa-io/toa/commit/21f1a41aceafcfd35c7620014062fe46b54afa95))
27
+ * 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)
28
+
29
+
30
+ ### Features
31
+
32
+ * **atomicity:** meter what the group has spent ([bb1118a](https://github.com/toa-io/toa/commit/bb1118aa60bfeb43d3212f2495b7797445f003c3))
33
+ * **core:** give every component an atom aspect ([b501b9c](https://github.com/toa-io/toa/commit/b501b9cd3d5f5408d9faa71213e953fe2792cf9f))
34
+
35
+
36
+ ### BREAKING CHANGES
37
+
38
+ * `atomicity.redis` accepts a list again, of independent servers rather
39
+ than cluster nodes, and refuses an even number of them.
40
+
41
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
42
+ * `context.stash.lock` is gone; lock through `context.atom` instead.
43
+ A stash pointer resolving to several addresses now uses the first.
44
+
45
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
46
+ * `Storage.outbox.pending` takes a fourth argument, the id to
47
+ continue from.
48
+
49
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
50
+ * `Factory.partition(group)` is `Factory.atom(group)`, and the
51
+ `Partition` it returned is an `Atom`. `slots(total)` is unchanged.
52
+
53
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
54
+ * `@toa.io/partitions.redis` is now `@toa.io/atomicity`, and its
55
+ `lanes(total)` is `slots(total)`. Redis is declared as `atomicity` in the context
56
+ rather than `outbox.redis`, and read from `TOA_ATOMICITY_REDIS`.
57
+ `TOA_OUTBOX_PARTITION_INTERVAL` is `TOA_ATOMICITY_INTERVAL`.
58
+
59
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
60
+ * `event.changeset` is removed; use `origin` and `state`. `State`
61
+ takes an `Outbox` in place of an `Emission`. `difference` is dropped from
62
+ `@toa.io/generic` along with its last caller.
63
+
64
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
65
+
66
+
67
+
68
+
69
+
6
70
  # [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
71
 
8
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/core",
3
- "version": "1.0.0-alpha.270",
3
+ "version": "1.0.0-alpha.273",
4
4
  "description": "Toa Core",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -21,11 +21,10 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@rsql/parser": "1.2.4",
24
- "@toa.io/generic": "1.0.0-alpha.254",
25
- "@toa.io/yaml": "1.0.0-alpha.254",
26
- "error-value": "0.3.0",
27
- "openspan": "1.0.0-alpha.270",
24
+ "@toa.io/generic": "1.0.0-alpha.273",
25
+ "js-yaml": "4.3.1",
26
+ "openspan": "1.0.0-alpha.272",
28
27
  "uuid": "11.1.1"
29
28
  },
30
- "gitHead": "94400bdd411b4c25074ffcf58184580ddd060268"
29
+ "gitHead": "ad3284850ece95ffd5325fd0995707fc144c9c3d"
31
30
  }
package/src/assignment.js CHANGED
@@ -12,14 +12,15 @@ class Assignment extends Operation {
12
12
  const {
13
13
  scope,
14
14
  state,
15
- reply
15
+ reply,
16
+ request
16
17
  } = store
17
18
 
18
19
  if (reply.error !== undefined) return
19
20
 
20
21
  scope.set(state)
21
22
 
22
- const output = await this.scope.apply(scope)
23
+ const output = await this.scope.apply(scope, request.input)
23
24
 
24
25
  // assignment returns new state by default
25
26
  if (store.reply.output === undefined) {
package/src/call.js CHANGED
@@ -3,7 +3,6 @@
3
3
  const { Readable } = require('node:stream')
4
4
  const { current, encode } = require('openspan')
5
5
  const { Connector } = require('./connector')
6
- const { Err } = require('error-value')
7
6
 
8
7
  class Call extends Connector {
9
8
  #transmitter
@@ -48,7 +47,7 @@ class Call extends Connector {
48
47
  throw reply.exception
49
48
 
50
49
  if (reply.error !== undefined)
51
- return Err(reply.error.code, reply.error)
50
+ return new RemoteError(reply.error)
52
51
  else
53
52
  return reply.output
54
53
  }
@@ -60,3 +59,12 @@ class Call extends Connector {
60
59
  }
61
60
 
62
61
  exports.Call = Call
62
+
63
+ // the remote error as a value: every property it carries, and nothing else enumerable
64
+ class RemoteError extends Error {
65
+ constructor (error) {
66
+ super()
67
+
68
+ Object.assign(this, error)
69
+ }
70
+ }
@@ -11,6 +11,14 @@ class Reply extends Contract {
11
11
  const schema = { type: 'object', properties: {}, additionalProperties: false }
12
12
 
13
13
  if (output !== undefined) {
14
+ /*
15
+ * A reply carries more than the operation declares — `_version` and the rest of the
16
+ * record's own fields — so what it is validated against is the declaration relaxed.
17
+ * On a copy: the declaration itself is what a component publishes when asked to
18
+ * explain, and relaxing that would publish a contract nobody wrote.
19
+ */
20
+ output = structuredClone(output)
21
+
14
22
  if (output.type === 'object')
15
23
  output.additionalProperties = true
16
24
  else if (output.type === 'array' && output.items?.type === 'object')
@@ -1,8 +1,13 @@
1
1
  'use strict'
2
2
 
3
3
  const { resolve } = require('path')
4
- const { load } = require('@toa.io/yaml')
4
+ const { readFileSync } = require('node:fs')
5
+ const { load: parseYAML } = require('js-yaml')
5
6
 
6
- exports.query = load.sync(resolve(__dirname, './query.yaml'))
7
- exports.error = load.sync(resolve(__dirname, './error.yaml'))
8
- exports.source = load.sync(resolve(__dirname, './source.yaml'))
7
+ exports.query = read(resolve(__dirname, './query.yaml'))
8
+ exports.error = read(resolve(__dirname, './error.yaml'))
9
+ exports.source = read(resolve(__dirname, './source.yaml'))
10
+
11
+ function read (path) {
12
+ return parseYAML(readFileSync(path, 'utf8'))
13
+ }
@@ -1,6 +1,5 @@
1
1
  'use strict'
2
2
 
3
- const { difference } = require('@toa.io/generic')
4
3
  const { EntityContractException, EntityGuardException } = require('../exceptions')
5
4
  const { newid } = require('./newid')
6
5
 
@@ -62,7 +61,6 @@ class Entity {
62
61
  return {
63
62
  origin: this.#origin,
64
63
  state: this.#state,
65
- changeset: this.#origin === null ? this.#state : difference(this.#origin, this.#state),
66
64
  trailers: this.#state._trailers,
67
65
  input
68
66
  }
package/src/index.js CHANGED
@@ -11,6 +11,7 @@ const { Event } = require('./event')
11
11
  const { Exposition } = require('./exposition')
12
12
  const { Locator } = require('./locator')
13
13
  const { Observation } = require('./observation')
14
+ const { Outbox } = require('./outbox')
14
15
  const { Operation } = require('./operation')
15
16
  const { Query } = require('./query')
16
17
  const { Receiver } = require('./receiver')
@@ -41,6 +42,7 @@ exports.Event = Event
41
42
  exports.Exposition = Exposition
42
43
  exports.Locator = Locator
43
44
  exports.Observation = Observation
45
+ exports.Outbox = Outbox
44
46
  exports.Operation = Operation
45
47
  exports.Query = Query
46
48
  exports.Receiver = Receiver
@@ -0,0 +1,6 @@
1
+ 'use strict'
2
+
3
+ const { Outbox, LANES } = require('./outbox')
4
+
5
+ exports.Outbox = Outbox
6
+ exports.LANES = LANES
@@ -0,0 +1,301 @@
1
+ 'use strict'
2
+
3
+ const { console } = require('openspan')
4
+ const { Connector } = require('../connector')
5
+ const { newid } = require('../entities/newid')
6
+
7
+ /**
8
+ * Owns the intent to publish. A row is built before the write so that the storage can commit
9
+ * it in the same transaction as the entity; publication then happens off the operation's path,
10
+ * and anything that fails to publish is recovered from the row.
11
+ *
12
+ * The mechanism is a safety net: in a healthy system the row is written, published within
13
+ * milliseconds by the same process, and marked published on that process's next tick.
14
+ *
15
+ * A storage that cannot commit a row atomically has no outbox, and this degrades to the
16
+ * inline emission it replaces.
17
+ */
18
+ class Outbox extends Connector {
19
+ #emission
20
+ #storage
21
+ #atom
22
+
23
+ #gap
24
+ #interval
25
+ #batch
26
+ #defer
27
+
28
+ /** ids this process has published, held until a cycle marks them */
29
+ #published = new Set()
30
+
31
+ /** in-flight publications, awaited (with a bound) on close */
32
+ #inflight = new Set()
33
+
34
+ /** rows this replica is publishing right now, so a cycle does not pick them up again */
35
+ #publishing = new Set()
36
+
37
+ #timer
38
+ #pumping = false
39
+ #closing = false
40
+
41
+ constructor (emission, storage, atom, options = {}) {
42
+ super()
43
+
44
+ this.#emission = emission
45
+ this.#storage = storage
46
+ this.#atom = atom
47
+
48
+ this.#interval = number('TOA_OUTBOX_INTERVAL', options.interval, INTERVAL)
49
+ this.#batch = number('TOA_OUTBOX_BATCH', options.batch, BATCH)
50
+ this.#gap = options.gap ?? this.#interval * K
51
+ this.#defer = process.env.TOA_OUTBOX_DEFER === '1'
52
+
53
+ this.depends(emission)
54
+ this.depends(atom)
55
+
56
+ if (storage !== undefined) this.depends(storage)
57
+ }
58
+
59
+ /** whether the storage can commit a row atomically with the entity */
60
+ get durable () {
61
+ return this.#storage?.outbox !== undefined
62
+ }
63
+
64
+ /**
65
+ * @param event {toa.core.transition.Event}
66
+ * @returns {object}
67
+ */
68
+ row (event) {
69
+ return {
70
+ id: newid(),
71
+ lane: this.#lane(),
72
+ published: false,
73
+ pending: Date.now() + this.#gap,
74
+ event
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Hands a committed row over. Awaited by the caller only on the legacy path — with an
80
+ * outbox this returns at once and the broker leaves the operation's path.
81
+ */
82
+ publish (row) {
83
+ if (!this.durable)
84
+ return this.#emission.emit(row.event)
85
+
86
+ /*
87
+ * A publication started while the pump is closing would outlive the emitters it needs,
88
+ * and `comq` waits on a connection that is going rather than failing. The row is already
89
+ * durable, so leaving it is exactly what it is for.
90
+ */
91
+ if (this.#closing || this.#defer ||
92
+ this.#inflight.size >= INFLIGHT || this.#published.size >= PUBLISHED)
93
+ return
94
+
95
+ void this.#publish(row)
96
+ }
97
+
98
+ async open () {
99
+ if (!this.durable) return
100
+
101
+ if (this.#defer)
102
+ console.warn('Outbox immediate publication is deferred; events are published by the pump only')
103
+
104
+ this.#timer = setInterval(() => this.#tick(), this.#interval)
105
+ this.#timer.unref()
106
+ }
107
+
108
+ async close () {
109
+ this.#closing = true
110
+
111
+ if (this.#timer !== undefined) clearInterval(this.#timer)
112
+
113
+ await this.#drain()
114
+ await this.#mark()
115
+ }
116
+
117
+ /**
118
+ * Publishes one row and swallows the failure: the row stays unpublished and comes back on a
119
+ * later cycle, which is the whole point of having written it.
120
+ *
121
+ * There is no timeout here on purpose. A publication is a confirmed write to a durable
122
+ * exchange, and `comq` waits for the broker to come back rather than failing — abandoning
123
+ * it would not stop it, it would only mean the row is published twice once it lands. What
124
+ * bounds this instead is the in-flight cap and the drain on close.
125
+ *
126
+ * @private
127
+ */
128
+ async #publish (row) {
129
+ this.#publishing.add(row.id)
130
+
131
+ const publishing = this.#emission.emit(row.event)
132
+
133
+ this.#inflight.add(publishing)
134
+
135
+ try {
136
+ await publishing
137
+
138
+ this.#published.add(row.id)
139
+ } catch (error) {
140
+ console.warn('Event publication failed', { row: row.id, error })
141
+ } finally {
142
+ this.#inflight.delete(publishing)
143
+ this.#publishing.delete(row.id)
144
+ }
145
+ }
146
+
147
+ /**
148
+ * `comq` retries a publish for as long as the broker is down rather than rejecting, so an
149
+ * unbounded drain outlives any grace period.
150
+ *
151
+ * @private
152
+ */
153
+ async #drain () {
154
+ if (this.#inflight.size === 0) return
155
+
156
+ await Promise.race([Promise.allSettled([...this.#inflight]), delay(DRAIN)])
157
+ }
158
+
159
+ /**
160
+ * Reads what is due, publishes it, and marks everything this process has sent — what it just
161
+ * published and what the immediate path published since the last cycle. One cycle at a time.
162
+ *
163
+ * @private
164
+ */
165
+ #tick () {
166
+ if (this.#pumping) return
167
+
168
+ this.#pumping = true
169
+
170
+ void this.#pump().finally(() => (this.#pumping = false))
171
+ }
172
+
173
+ /** @private */
174
+ async #pump () {
175
+ let page
176
+
177
+ do {
178
+ page = await this.#read(page?.[page.length - 1]?.id)
179
+
180
+ if (page.length === 0) break
181
+
182
+ /*
183
+ * A row is unpublished in the database until a cycle marks it, so a page includes what
184
+ * this replica is sending right now and what a failed marking left behind. Only this
185
+ * process knows either.
186
+ */
187
+ const rows = page.filter((row) =>
188
+ !this.#published.has(row.id) && !this.#publishing.has(row.id))
189
+
190
+ if (rows.length > 0) {
191
+ console.info('Outbox recovering unpublished events', { count: rows.length })
192
+
193
+ // every row is given its chance; what the broker refused stays unpublished and comes
194
+ // back on a later cycle
195
+ await Promise.allSettled(rows.map((row) => this.#publish(row)))
196
+ }
197
+
198
+ // a full page is a page that may have been cut short
199
+ } while (page.length === this.#batch)
200
+
201
+ await this.#mark()
202
+ }
203
+
204
+ /**
205
+ * One page of what is due. In a healthy system the first one is empty, every cycle — a row is
206
+ * due only if the process that wrote it failed to publish or died before marking it.
207
+ *
208
+ * Reading is suspended, not stopped, while this replica does not know which lanes are its
209
+ * own: the cycle keeps running and keeps marking, and reading resumes as soon as an
210
+ * assignment arrives. Reading without an assignment would be a different guarantee, where
211
+ * every replica publishes every stranded row.
212
+ *
213
+ * @private
214
+ * @param {string} [after] the last id of the page before, so a page is never read twice
215
+ */
216
+ async #read (after) {
217
+ const lanes = this.#atom.slots(LANES)
218
+
219
+ if (lanes === null || lanes.length === 0) return []
220
+
221
+ return this.#storage.outbox.pending(lanes, Date.now(), this.#batch, after)
222
+ .catch((error) => {
223
+ console.warn('Outbox read failed', { error })
224
+
225
+ return []
226
+ })
227
+ }
228
+
229
+ /**
230
+ * One batched write for many events, which is why the ids are held in memory rather than
231
+ * marked one by one. Ids that fail to be marked are kept and retried; a row that is never
232
+ * marked is simply published again, which is within the contract.
233
+ *
234
+ * @private
235
+ */
236
+ async #mark () {
237
+ if (this.#published.size === 0) return
238
+
239
+ const ids = [...this.#published]
240
+
241
+ try {
242
+ await this.#storage.outbox.settle(ids)
243
+
244
+ for (const id of ids) this.#published.delete(id)
245
+ } catch (error) {
246
+ console.warn('Outbox marking failed', { count: ids.length, error })
247
+ }
248
+ }
249
+
250
+ /**
251
+ * A lane this replica currently owns, so that in steady state it settles its own rows
252
+ * before it ever reads them. Any lane at all when it owns none: the row still has to be
253
+ * written, and whoever ends up owning that lane will pump it.
254
+ *
255
+ * @private
256
+ */
257
+ #lane () {
258
+ const owned = this.#atom.slots(LANES)
259
+
260
+ return owned === null || owned.length === 0
261
+ ? Math.floor(Math.random() * LANES)
262
+ : owned[Math.floor(Math.random() * owned.length)]
263
+ }
264
+ }
265
+
266
+ function number (variable, declared, fallback) {
267
+ if (declared !== undefined) return declared
268
+
269
+ const value = Number(process.env[variable])
270
+
271
+ return Number.isNaN(value) || value <= 0 ? fallback : value
272
+ }
273
+
274
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref())
275
+
276
+ /**
277
+ * Constant, never configuration: rows carry their lane, so lowering this would leave rows in
278
+ * lanes nobody reads any more. It is also the ceiling on replicas of one component, and a
279
+ * power of two so that the common replica counts divide evenly.
280
+ */
281
+ const LANES = 128
282
+
283
+ /** one cycle reads, publishes and marks; in steady state it finds nothing to read */
284
+ const INTERVAL = 5000
285
+
286
+ /**
287
+ * `gap = interval * K`. Not a steady-state necessity — a replica writes into a lane it owns
288
+ * and marks what it published — but a guard for when a lane changes hands between the write
289
+ * and the settle. Two cycles of separation, plus one of margin.
290
+ */
291
+ const K = 3
292
+
293
+ /** how many rows one read brings back; the pump reads on while a page comes back full */
294
+ const BATCH = 200
295
+
296
+ const DRAIN = 10_000
297
+ const INFLIGHT = 1000
298
+ const PUBLISHED = 10_000
299
+
300
+ exports.Outbox = Outbox
301
+ exports.LANES = LANES
package/src/state.js CHANGED
@@ -7,12 +7,12 @@ class State {
7
7
 
8
8
  #associated
9
9
  #entities
10
- #emission
10
+ #outbox
11
11
 
12
- constructor (storage, entity, emission, associated) {
12
+ constructor (storage, entity, outbox, associated) {
13
13
  this.storage = storage
14
14
  this.#entities = entity
15
- this.#emission = emission
15
+ this.#outbox = outbox
16
16
  this.#associated = associated === true
17
17
  }
18
18
 
@@ -66,14 +66,14 @@ class State {
66
66
 
67
67
  object.set(blank)
68
68
 
69
- const record = await this.storage.ensure(query, properties, object.get())
69
+ const row = this.#outbox?.row(object.event(input))
70
+ const record = await this.storage.ensure(query, properties, object.get(), row)
70
71
 
71
72
  if (record.id !== blank.id) // exists
72
73
  return this.#entities.object(record, NOT_MUTABLE)
73
74
 
74
- const event = object.event(input)
75
-
76
- await this.#emission.emit(event)
75
+ if (row !== undefined)
76
+ await this.#outbox.publish(row)
77
77
 
78
78
  return object
79
79
  }
@@ -83,36 +83,39 @@ class State {
83
83
  return this.massCommit(state, input)
84
84
 
85
85
  const data = state.get()
86
- const ok = await this.storage.store(data)
87
86
 
88
- // #20
89
- if (ok === true) {
90
- const event = state.event(input)
87
+ // the row is built before the write so that the storage can commit it in the same
88
+ // transaction, closing the window this used to have
89
+ const row = this.#outbox?.row(state.event(input))
90
+ const ok = await this.storage.store(data, row)
91
91
 
92
- await this.#emission.emit(event)
93
- }
92
+ if (ok === true && row !== undefined)
93
+ await this.#outbox.publish(row)
94
94
 
95
95
  return ok
96
96
  }
97
97
 
98
98
  async massCommit (state, input) {
99
99
  const data = state.get()
100
- const ok = await this.storage.massStore(data)
100
+ const rows = this.#outbox === undefined
101
+ ? undefined
102
+ : state.events(input).map((event) => this.#outbox.row(event))
101
103
 
102
- // #20
103
- if (ok === true) {
104
- const events = state.events(input)
104
+ const ok = await this.storage.massStore(data, rows)
105
105
 
106
- await Promise.all(events.map((event) => this.#emission.emit(event)))
107
- }
106
+ if (ok === true && rows !== undefined)
107
+ await Promise.all(rows.map((row) => this.#outbox.publish(row)))
108
108
 
109
109
  return ok
110
110
  }
111
111
 
112
- async apply (state) {
112
+ async apply (state, input) {
113
113
  const changeset = state.export()
114
114
 
115
- const result = await this.storage.upsert(state.query, changeset)
115
+ // an assignment's event is the write's own images, so the storage fills them in;
116
+ // see `apply` in the outbox design
117
+ const row = this.#outbox?.row({ input })
118
+ const result = await this.storage.upsert(state.query, changeset, row)
116
119
 
117
120
  if (result === null) {
118
121
  if (state.query.version !== undefined) {
@@ -120,12 +123,13 @@ class State {
120
123
  } else {
121
124
  throw new StateNotFoundException()
122
125
  }
123
- } else {
124
- // same as above
125
- await this.#emission.emit({
126
- changeset,
127
- state: result
128
- })
126
+ } else if (row !== undefined) {
127
+ // the storage fills `origin` and `state` from its own write; a storage that does not
128
+ // know how leaves the event with what it was given
129
+ row.event.state ??= result
130
+ row.event.origin ??= null
131
+
132
+ await this.#outbox.publish(row)
129
133
  }
130
134
 
131
135
  return result
@@ -1,7 +1,8 @@
1
1
  'use strict'
2
2
 
3
3
  const { generate } = require('randomstring')
4
- const { load } = require('@toa.io/yaml')
4
+ const { readFileSync } = require('node:fs')
5
+ const { load: parseYAML } = require('js-yaml')
5
6
  const { resolve } = require('path')
6
7
 
7
8
  // noinspection JSCheckFunctionSignatures
@@ -20,7 +21,7 @@ const schemas = {
20
21
  type: 'object',
21
22
  properties: {
22
23
  input: { type: 'null' },
23
- query: load.sync(resolve(__dirname, '../../src/contract/schemas/query.yaml')),
24
+ query: parseYAML(readFileSync(resolve(__dirname, '../../src/contract/schemas/query.yaml'), 'utf8')),
24
25
  authentic: { type: 'boolean' }
25
26
  },
26
27
  additionalProperties: true
@@ -9,8 +9,7 @@ const events = [0, 1, 2].map((index) => ({
9
9
 
10
10
  const event = {
11
11
  origin: { [generate()]: generate() },
12
- state: { [generate()]: generate() },
13
- changeset: { [generate()]: generate() }
12
+ state: { [generate()]: generate() }
14
13
  }
15
14
 
16
15
  exports.events = events
@@ -58,7 +58,7 @@ describe('tombstone', () => {
58
58
 
59
59
  expect(entity.get()._deleted).toBeNull()
60
60
  expect(entity.deleted).toBe(false)
61
- expect(entity.event().changeset._deleted).toBeNull()
61
+ expect(entity.event().state._deleted).toBeNull()
62
62
  })
63
63
 
64
64
  it('should keep tombstone written by transition', () => {
@@ -85,12 +85,8 @@ it('should provide event', () => {
85
85
 
86
86
  const event = entity.event()
87
87
 
88
- expect(event).toEqual(expect.objectContaining({
89
- state,
90
- origin,
91
- changeset: expect.objectContaining({
92
- foo: 'new value',
93
- _version: 1
94
- })
95
- }))
88
+ expect(event).toEqual(expect.objectContaining({ state, origin }))
89
+ expect(event.state.foo).toBe('new value')
90
+ expect(event.state._version).toBe(1)
91
+ expect(event.origin.foo).not.toBe('new value')
96
92
  })
@@ -0,0 +1,114 @@
1
+ 'use strict'
2
+
3
+ const { Outbox } = require('../src/outbox')
4
+
5
+ let emission, storage, atom, outbox
6
+
7
+ const BATCH = 4
8
+
9
+ /** rows as the storage hands them back, ids ascending like the uuid v7 they are */
10
+ const page = (from, count) =>
11
+ Array.from({ length: count }, (_, i) => ({
12
+ id: String(from + i).padStart(4, '0'),
13
+ event: { state: {} }
14
+ }))
15
+
16
+ beforeEach(() => {
17
+ jest.clearAllMocks()
18
+ jest.useFakeTimers()
19
+
20
+ emission = { emit: jest.fn(async () => undefined), link: jest.fn() }
21
+
22
+ storage = {
23
+ link: jest.fn(),
24
+ outbox: {
25
+ pending: jest.fn(async () => []),
26
+ settle: jest.fn(async () => undefined)
27
+ }
28
+ }
29
+
30
+ atom = { slots: jest.fn(() => [0]), link: jest.fn() }
31
+ outbox = new Outbox(emission, storage, atom, { interval: 1000, batch: BATCH })
32
+ })
33
+
34
+ afterEach(() => {
35
+ jest.useRealTimers()
36
+ })
37
+
38
+ /** one cycle, and everything it awaited */
39
+ const cycle = async () => {
40
+ jest.advanceTimersByTime(1000)
41
+
42
+ for (let i = 0; i < 20; i++) await Promise.resolve()
43
+ }
44
+
45
+ it('should read nothing more when the first page is short', async () => {
46
+ storage.outbox.pending.mockResolvedValueOnce(page(0, 3))
47
+
48
+ await outbox.open()
49
+ await cycle()
50
+
51
+ expect(storage.outbox.pending).toHaveBeenCalledTimes(1)
52
+ expect(emission.emit).toHaveBeenCalledTimes(3)
53
+ })
54
+
55
+ it('should keep reading while a page comes back full', async () => {
56
+ storage.outbox.pending
57
+ .mockResolvedValueOnce(page(0, BATCH))
58
+ .mockResolvedValueOnce(page(BATCH, BATCH))
59
+ .mockResolvedValueOnce(page(2 * BATCH, 5))
60
+
61
+ await outbox.open()
62
+ await cycle()
63
+
64
+ expect(storage.outbox.pending).toHaveBeenCalledTimes(3)
65
+ expect(emission.emit).toHaveBeenCalledTimes(2 * BATCH + 5)
66
+ })
67
+
68
+ it('should continue each page from the id the one before ended on', async () => {
69
+ storage.outbox.pending
70
+ .mockResolvedValueOnce(page(0, BATCH))
71
+ .mockResolvedValueOnce(page(BATCH, 1))
72
+
73
+ await outbox.open()
74
+ await cycle()
75
+
76
+ const [, second] = storage.outbox.pending.mock.calls
77
+
78
+ expect(storage.outbox.pending.mock.calls[0][3]).toBeUndefined()
79
+ expect(second[3]).toStrictEqual(String(BATCH - 1).padStart(4, '0'))
80
+ })
81
+
82
+ it('should mark what it published, once, after the last page', async () => {
83
+ storage.outbox.pending
84
+ .mockResolvedValueOnce(page(0, BATCH))
85
+ .mockResolvedValueOnce(page(BATCH, 2))
86
+
87
+ await outbox.open()
88
+ await cycle()
89
+
90
+ expect(storage.outbox.settle).toHaveBeenCalledTimes(1)
91
+ expect(storage.outbox.settle.mock.calls[0][0]).toHaveLength(BATCH + 2)
92
+ })
93
+
94
+ it('should not publish a row it has published and not yet marked', async () => {
95
+ storage.outbox.settle.mockRejectedValueOnce(new Error('mongo is out'))
96
+ storage.outbox.pending
97
+ .mockResolvedValueOnce(page(0, 2))
98
+ .mockResolvedValueOnce(page(0, 2))
99
+
100
+ await outbox.open()
101
+ await cycle()
102
+ await cycle()
103
+
104
+ expect(emission.emit).toHaveBeenCalledTimes(2)
105
+ })
106
+
107
+ it('should read nothing while it owns no slots', async () => {
108
+ atom.slots.mockReturnValue(null)
109
+
110
+ await outbox.open()
111
+ await cycle()
112
+
113
+ expect(storage.outbox.pending).not.toHaveBeenCalled()
114
+ })
@@ -8,7 +8,10 @@ const storage = {
8
8
  find: jest.fn(() => ([{ id: generate() }])),
9
9
  add: jest.fn(() => true),
10
10
  set: jest.fn(() => true),
11
- store: jest.fn(() => true)
11
+ store: jest.fn(() => true),
12
+ massStore: jest.fn(() => true),
13
+ upsert: jest.fn(() => ({ id: generate() })),
14
+ ensure: jest.fn((query, properties, state) => state)
12
15
  }
13
16
 
14
17
  const factory = {
@@ -20,9 +23,7 @@ const query = generate()
20
23
 
21
24
  const entity = {
22
25
  get: jest.fn(() => ({ [generate()]: generate() })),
23
- event: jest.fn(() => ({
24
- state: { [generate()]: generate() }, changeset: { [generate()]: generate() }
25
- }))
26
+ event: jest.fn(() => ({ state: { [generate()]: generate() } }))
26
27
  }
27
28
 
28
29
  const initial = {
@@ -31,16 +32,18 @@ const initial = {
31
32
 
32
33
  const unchanged = {
33
34
  ...entity,
34
- event: jest.fn(() => ({ state: { [generate()]: generate() }, changeset: {} }))
35
+ event: jest.fn(() => ({ state: { [generate()]: generate() } }))
35
36
  }
36
37
 
37
- const emitter = {
38
- emit: jest.fn()
38
+ // a legacy outbox: no storage capability, so `publish` emits inline
39
+ const outbox = {
40
+ row: jest.fn((event) => ({ id: generate(), lane: 0, published: false, pending: 0, event })),
41
+ publish: jest.fn()
39
42
  }
40
43
 
41
44
  exports.storage = storage
42
45
  exports.factory = factory
43
- exports.emitter = emitter
46
+ exports.outbox = outbox
44
47
  exports.query = query
45
48
  exports.entity = entity
46
49
  exports.initial = initial
@@ -8,7 +8,7 @@ let state
8
8
  beforeEach(() => {
9
9
  jest.clearAllMocks()
10
10
 
11
- state = new State(fixtures.storage, fixtures.factory, fixtures.emitter)
11
+ state = new State(fixtures.storage, fixtures.factory, fixtures.outbox)
12
12
  })
13
13
 
14
14
  it('should provide object', async () => {
@@ -37,11 +37,58 @@ it('should provide read-only objects', async () => {
37
37
  it('should store entity', async () => {
38
38
  await state.commit(fixtures.initial)
39
39
 
40
- expect(fixtures.storage.store).toHaveBeenCalledWith(fixtures.initial.get.mock.results[0].value)
40
+ expect(fixtures.storage.store).toHaveBeenCalledWith(
41
+ fixtures.initial.get.mock.results[0].value,
42
+ fixtures.outbox.row.mock.results[0].value)
41
43
  })
42
44
 
43
- it('should emit', async () => {
45
+ it('should publish the row', async () => {
44
46
  await state.commit(fixtures.entity)
45
47
 
46
- expect(fixtures.emitter.emit).toHaveBeenCalledWith(fixtures.entity.event.mock.results[0].value)
48
+ expect(fixtures.outbox.row).toHaveBeenCalledWith(fixtures.entity.event.mock.results[0].value)
49
+ expect(fixtures.outbox.publish).toHaveBeenCalledWith(fixtures.outbox.row.mock.results[0].value)
50
+ })
51
+
52
+ it('should not publish if the write did not happen', async () => {
53
+ fixtures.storage.store.mockImplementationOnce(() => false)
54
+
55
+ await state.commit(fixtures.entity)
56
+
57
+ expect(fixtures.outbox.publish).not.toHaveBeenCalled()
58
+ })
59
+
60
+ it('should build the row before the write', async () => {
61
+ // the storage commits the row in the same transaction, so it must already exist
62
+ fixtures.storage.store.mockImplementationOnce((_, row) => {
63
+ expect(row).toBeDefined()
64
+
65
+ return true
66
+ })
67
+
68
+ expect.assertions(1)
69
+
70
+ await state.commit(fixtures.entity)
71
+ })
72
+
73
+ describe('assignment', () => {
74
+ const changeset = { query: 'q', export: () => ({ foo: 1 }) }
75
+
76
+ it('should pass the row to upsert and publish it', async () => {
77
+ const result = await state.apply(changeset, { foo: 1 })
78
+
79
+ expect(fixtures.storage.upsert).toHaveBeenCalledWith(
80
+ changeset.query, { foo: 1 }, fixtures.outbox.row.mock.results[0].value)
81
+
82
+ expect(fixtures.outbox.publish).toHaveBeenCalledWith(fixtures.outbox.row.mock.results[0].value)
83
+ expect(result).toStrictEqual(fixtures.storage.upsert.mock.results[0].value)
84
+ })
85
+
86
+ it('should fill the state a storage without the outbox left alone', async () => {
87
+ await state.apply(changeset, { foo: 1 })
88
+
89
+ const row = fixtures.outbox.row.mock.results[0].value
90
+
91
+ expect(row.event.state).toStrictEqual(fixtures.storage.upsert.mock.results[0].value)
92
+ expect(row.event.input).toStrictEqual({ foo: 1 })
93
+ })
47
94
  })
@@ -0,0 +1,58 @@
1
+ // noinspection ES6UnusedImports
2
+
3
+ import { Connector } from './connector'
4
+
5
+ declare namespace toa.core {
6
+
7
+ namespace atomicity {
8
+
9
+ /**
10
+ * What one group of replicas decides together, in one place. The decisions here are the
11
+ * ones processes cannot arrange by talking to each other: they need a single arbiter and a
12
+ * step indivisible from its point of view.
13
+ */
14
+ interface Atom extends Connector {
15
+ /**
16
+ * An exclusive claim on slots of `0..total`: while this replica holds one, no other
17
+ * replica of the group does. Answered from memory, so it costs nothing to ask.
18
+ *
19
+ * `null` while this replica owns nothing — after a restart, during a rollout, or while
20
+ * coordination is unreachable. Whoever asks must be able to stand down: acting on a
21
+ * claim that cannot be supported is a different guarantee, not a degraded one.
22
+ */
23
+ slots (total: number): number[] | null
24
+
25
+ /**
26
+ * Debt the group has run up under each key, in milliseconds. Every call adds its own
27
+ * deltas and reads back where the group stands, so a replica reports what it alone has
28
+ * spent and still decides on what all of them have.
29
+ *
30
+ * Rejects where there is nothing to arbitrate through.
31
+ */
32
+ meter (keys: string[], deltas: number[]): Promise<number[]>
33
+
34
+ /**
35
+ * Runs `routine` holding `keys`, and while it holds them no other replica of the group
36
+ * does. Waits for as long as it takes to acquire them.
37
+ *
38
+ * The lease is extended for as long as the routine runs. An extension that fails aborts
39
+ * the signal the routine is given, which is the only way it learns it no longer holds
40
+ * what it is working under.
41
+ *
42
+ * Rejects where there is nothing to arbitrate through.
43
+ */
44
+ lock<T> (keys: string | string[],
45
+ routine: (signal: AbortSignal, context: unknown) => Promise<T>): Promise<T>
46
+ }
47
+
48
+ interface Factory {
49
+ /** @param group what the replicas deciding together have in common */
50
+ atom (group: string, options?: object): Atom
51
+ }
52
+
53
+ }
54
+
55
+ }
56
+
57
+ export type Atom = toa.core.atomicity.Atom
58
+ export type Factory = toa.core.atomicity.Factory
package/types/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * as bindings from './bindings'
2
2
  export * as extensions from './extensions'
3
3
  export * as storages from './storages'
4
+ export * as outbox from './outbox'
5
+ export * as atomicity from './atomicity'
4
6
  export * as bridges from './bridges'
5
7
  export * as operations from './operations'
6
8
 
@@ -0,0 +1,53 @@
1
+ import * as _state from './state'
2
+ import * as _atomicity from './atomicity'
3
+
4
+ declare namespace toa.core {
5
+
6
+ namespace outbox {
7
+
8
+ /**
9
+ * The intent to publish, committed with the state change it belongs to. Everything about
10
+ * it is core's: the storage writes it and, where the images are the write's own, fills
11
+ * `event.origin` and `event.state` in.
12
+ */
13
+ interface Row {
14
+ id: string
15
+
16
+ /** which replica pumps this row; carries no other meaning, and no ordering */
17
+ lane: number
18
+
19
+ published: boolean
20
+
21
+ /** not before this */
22
+ pending: number
23
+
24
+ event: _state.Event
25
+ }
26
+
27
+ /** What a storage offers when it can commit a row atomically with the entity. */
28
+ interface Storage {
29
+ insert (row: Row, session?: unknown): Promise<void>
30
+
31
+ insertMany (rows: Row[], session?: unknown): Promise<void>
32
+
33
+ /**
34
+ * One page of what is due, still unpublished, and in one of the given lanes, in the
35
+ * order the rows were written. `after` continues from the last id of the page before.
36
+ */
37
+ pending (lanes: number[], now: number, limit: number, after?: string): Promise<Row[]>
38
+
39
+ settle (ids: string[]): Promise<void>
40
+ }
41
+
42
+ }
43
+
44
+ }
45
+
46
+ export type Row = toa.core.outbox.Row
47
+ export type Storage = toa.core.outbox.Storage
48
+
49
+ /**
50
+ * A lane is a slot of `atomicity`: which replica pumps a row, and nothing else. The outbox is
51
+ * the first thing to claim slots this way, not the last.
52
+ */
53
+ export type Atom = _atomicity.Atom
package/types/state.d.ts CHANGED
@@ -6,9 +6,12 @@ declare namespace toa.core {
6
6
  namespace transition {
7
7
 
8
8
  type Event = {
9
- origin: Object
9
+ /** the pre-image; null when the entity did not exist before */
10
+ origin: Object | null
10
11
  state: Object
11
- changeset: Object
12
+ /** out-of-band values an algorithm wrote into `state._trailers`; must be serializable */
13
+ trailers?: Object
14
+ input?: Object
12
15
  }
13
16
 
14
17
  }
@@ -24,9 +27,9 @@ declare namespace toa.core {
24
27
 
25
28
  none(): null
26
29
 
27
- commit(entity: _entity.Entity): Promise<boolean>
30
+ commit(entity: _entity.Entity, input?: Object): Promise<boolean>
28
31
 
29
- apply(changeset: _entity.Changeset): Promise<void>
32
+ apply(changeset: _entity.Changeset, input?: Object): Promise<_storages.Record>
30
33
  }
31
34
 
32
35
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Locator } from './locator'
4
4
  import { Connector } from './connector'
5
+ import * as outbox from './outbox'
5
6
 
6
7
  declare namespace toa.core {
7
8
 
@@ -43,8 +44,13 @@ declare namespace toa.core {
43
44
  table (database: string, locator: Locator, schema: Object, reset?: boolean): Promise<string>
44
45
  }
45
46
 
47
+ interface Options {
48
+ /** whether this component publishes anything, and so needs an outbox */
49
+ outbox?: boolean
50
+ }
51
+
46
52
  interface Factory {
47
- storage (locator: Locator, properties?: object): Storage
53
+ storage (locator: Locator, properties?: object, options?: Options): Storage
48
54
 
49
55
  migration? (driver?: string): Migration
50
56
  }
@@ -58,10 +64,27 @@ declare namespace toa.core {
58
64
  find? (query: storages.Query): Promise<storages.Record[]>
59
65
 
60
66
  // commit
61
- store? (record: storages.Record): Promise<boolean>
67
+ store? (record: storages.Record, row?: outbox.Row): Promise<boolean>
68
+
69
+ // mass commit
70
+ massStore? (records: storages.Record[], rows?: outbox.Row[]): Promise<boolean>
62
71
 
63
72
  // assignment
64
- upsert? (query: storages.Query, changeset: Object, insert: storages.Record): Promise<storages.Record>
73
+ upsert? (query: storages.Query, changeset: Object, row?: outbox.Row): Promise<storages.Record>
74
+
75
+ // atomic get-or-create
76
+ ensure? (query: storages.Query, properties: Object, record: storages.Record, row?: outbox.Row): Promise<storages.Record>
77
+
78
+ /**
79
+ * Present only where a row can be committed atomically with the entity. Its absence is
80
+ * what makes the runtime fall back to publishing inline, so a storage that cannot do
81
+ * this must not offer it: a row written outside the transaction would be a second write
82
+ * with a crash window in front of it, which is the defect the outbox exists to close.
83
+ *
84
+ * The row schema is the connector's own — it also owns `pending` and `settle` — and
85
+ * never crosses this boundary.
86
+ */
87
+ outbox?: outbox.Storage
65
88
  }
66
89
 
67
90
  }