@toa.io/core 1.0.0-alpha.28 → 1.0.0-alpha.282

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/package.json +8 -10
  3. package/src/assignment.js +5 -8
  4. package/src/call.js +35 -9
  5. package/src/cascade.js +9 -11
  6. package/src/component.js +61 -21
  7. package/src/composition.js +3 -7
  8. package/src/connector.js +29 -22
  9. package/src/context.js +8 -6
  10. package/src/contract/contract.js +18 -0
  11. package/src/contract/index.js +2 -7
  12. package/src/contract/reply.js +36 -15
  13. package/src/contract/request.js +20 -10
  14. package/src/contract/schemas/index.js +9 -5
  15. package/src/contract/schemas/query.yaml +14 -1
  16. package/src/contract/schemas/source.yaml +23 -0
  17. package/src/discovery.js +15 -13
  18. package/src/effect.js +15 -0
  19. package/src/emission.js +2 -6
  20. package/src/entities/changeset.js +7 -14
  21. package/src/entities/entity.js +75 -29
  22. package/src/entities/factory.js +21 -14
  23. package/src/entities/index.js +1 -5
  24. package/src/entities/newid.js +9 -0
  25. package/src/entities/set.js +13 -4
  26. package/src/event.js +21 -7
  27. package/src/exceptions.js +53 -39
  28. package/src/exposition.js +5 -8
  29. package/src/guard.js +13 -0
  30. package/src/index.js +28 -47
  31. package/src/locator.js +2 -6
  32. package/src/observation.js +3 -15
  33. package/src/operation.js +40 -14
  34. package/src/outbox/index.js +1 -0
  35. package/src/outbox/outbox.js +296 -0
  36. package/src/query/criteria.js +3 -7
  37. package/src/query/options.js +5 -8
  38. package/src/query.js +36 -7
  39. package/src/receiver.js +76 -18
  40. package/src/reflection.js +2 -6
  41. package/src/remote.js +9 -12
  42. package/src/state.js +89 -50
  43. package/src/transition.js +13 -25
  44. package/src/transmission.js +15 -10
  45. package/src/unmanaged.js +7 -0
  46. package/test/call.fixtures.js +8 -12
  47. package/test/call.test.js +21 -9
  48. package/test/component.fixtures.js +5 -8
  49. package/test/component.test.js +22 -10
  50. package/test/connector.fixtures.js +10 -8
  51. package/test/connector.test.js +59 -38
  52. package/test/context.fixtures.js +8 -12
  53. package/test/context.test.js +18 -7
  54. package/test/contract/conditions.test.js +11 -9
  55. package/test/contract/contract.fixtures.js +12 -16
  56. package/test/contract/request.test.js +84 -34
  57. package/test/discovery.test.js +64 -0
  58. package/test/emission.fixtures.js +6 -10
  59. package/test/emission.test.js +29 -12
  60. package/test/entities/entity.fixtures.js +7 -11
  61. package/test/entities/entity.test.js +70 -49
  62. package/test/entities/factory.fixtures.js +22 -10
  63. package/test/entities/factory.test.js +29 -15
  64. package/test/entities/set.fixtures.js +6 -8
  65. package/test/entities/set.test.js +6 -5
  66. package/test/event.fixtures.js +9 -14
  67. package/test/event.test.js +57 -31
  68. package/test/locator.test.js +25 -24
  69. package/test/outbox.test.js +121 -0
  70. package/test/query.fixtures.js +1 -5
  71. package/test/query.test.js +42 -14
  72. package/test/receiver.fixtures.js +9 -12
  73. package/test/receiver.test.js +45 -22
  74. package/test/reflection.test.js +7 -6
  75. package/test/state.fixtures.js +25 -30
  76. package/test/state.test.js +72 -19
  77. package/test/transmission.fixtures.js +4 -6
  78. package/test/transmission.test.js +33 -15
  79. package/types/atomicity.d.ts +58 -0
  80. package/types/bindings.d.ts +8 -6
  81. package/types/bridges.ts +5 -2
  82. package/types/component.d.ts +7 -4
  83. package/types/context.d.ts +6 -3
  84. package/types/entity.d.ts +4 -4
  85. package/types/event.d.ts +1 -1
  86. package/types/extensions.d.ts +35 -10
  87. package/types/index.ts +16 -13
  88. package/types/message.d.ts +1 -0
  89. package/types/operations.d.ts +6 -0
  90. package/types/outbox.d.ts +53 -0
  91. package/types/query.d.ts +2 -0
  92. package/types/receiver.d.ts +2 -2
  93. package/types/reflection.d.ts +1 -1
  94. package/types/remote.d.ts +18 -0
  95. package/types/request.d.ts +16 -1
  96. package/types/state.d.ts +11 -8
  97. package/types/storages.d.ts +36 -11
  98. package/src/contract/conditions.js +0 -21
package/src/exceptions.js CHANGED
@@ -1,10 +1,7 @@
1
- 'use strict'
1
+ import { swap } from '@toa.io/generic'
2
2
 
3
- const { swap } = require('@toa.io/generic')
4
-
5
- const codes = {
3
+ export const codes = {
6
4
  System: 0,
7
- NotImplemented: 10,
8
5
 
9
6
  Contract: 200,
10
7
  RequestSyntax: 201,
@@ -12,6 +9,7 @@ const codes = {
12
9
  RequestConflict: 203,
13
10
  ResponseContract: 211,
14
11
  EntityContract: 212,
12
+ EntityGuard: 213,
15
13
  QuerySyntax: 221,
16
14
 
17
15
  State: 300,
@@ -28,17 +26,20 @@ const codes = {
28
26
  /**
29
27
  * @implements {toa.core.Exception}
30
28
  */
31
- class Exception {
29
+ export class Exception {
32
30
  code
33
31
  message
34
32
 
35
- constructor (code, message) {
33
+ constructor (code, message, cause) {
36
34
  this.code = code
37
35
  this.message = message
36
+
37
+ if (cause !== undefined)
38
+ this.cause = cause
38
39
  }
39
40
  }
40
41
 
41
- class SystemException extends Exception {
42
+ export class SystemException extends Exception {
42
43
  stack
43
44
 
44
45
  constructor (error) {
@@ -48,53 +49,66 @@ class SystemException extends Exception {
48
49
  }
49
50
  }
50
51
 
51
- class ContractException extends Exception {
52
- keyword
53
- property
54
- schema
55
- path
56
-
57
- constructor (code, error) {
58
- super(code || codes.Contract, error.message)
52
+ export class ContractException extends Exception {
53
+ constructor (code, error, cause) {
54
+ super(code || codes.Contract, typeof error === 'string' ? error : error?.message, cause)
59
55
 
60
- this.keyword = error.keyword
61
- this.property = error.property
62
- this.schema = error.schema
63
- this.path = error.path
56
+ if (typeof error === 'object' && error !== null)
57
+ for (const k of ['keyword', 'property', 'schema', 'path', 'params'])
58
+ if (k in error)
59
+ this[k] = error[k]
64
60
  }
65
61
  }
66
62
 
67
- class RequestContractException extends ContractException {
68
- constructor (error) { super(codes.RequestContract, error) }
63
+ export class RequestContractException extends ContractException {
64
+ constructor (error, cause) { super(codes.RequestContract, error, cause) }
69
65
  }
70
66
 
71
- class ResponseContractException extends ContractException {
72
- constructor (error) { super(codes.ResponseContract, error) }
67
+ export class ResponseContractException extends ContractException {
68
+ constructor (error, cause) { super(codes.ResponseContract, error, cause) }
73
69
  }
74
70
 
75
- class EntityContractException extends ContractException {
76
- constructor (error) { super(codes.EntityContract, error) }
71
+ export class EntityContractException extends ContractException {
72
+ constructor (error, cause) { super(codes.EntityContract, error, cause) }
73
+ }
74
+
75
+ export class EntityGuardException extends ContractException {
76
+ constructor (name, cause) { super(codes.EntityGuard, name, cause) }
77
77
  }
78
78
 
79
79
  // #region exports
80
- exports.Exception = Exception
81
- exports.SystemException = SystemException
82
- exports.RequestContractException = RequestContractException
83
- exports.ResponseContractException = ResponseContractException
84
- exports.EntityContractException = EntityContractException
85
80
 
86
- for (const [name, code] of Object.entries(codes)) {
81
+
82
+
83
+
84
+
85
+
86
+
87
+ // a module's exports are static, so the ones that follow a code are named rather
88
+ // than generated onto the namespace
89
+ function derive (name) {
87
90
  const classname = name + 'Exception'
88
91
 
89
- if (exports[classname] === undefined) {
90
- exports[classname] = class extends Exception {
91
- constructor (message) {
92
- super(code, message ?? classname)
93
- }
92
+ return class extends Exception {
93
+ constructor (message, cause) {
94
+ super(codes[name], message ? `${classname}: ${message}` : classname, cause)
94
95
  }
96
+
97
+ static name = classname
95
98
  }
96
99
  }
97
100
 
98
- exports.codes = codes
99
- exports.names = swap(codes)
101
+ export const RequestSyntaxException = derive('RequestSyntax')
102
+ export const RequestConflictException = derive('RequestConflict')
103
+ export const QuerySyntaxException = derive('QuerySyntax')
104
+ export const StateException = derive('State')
105
+ export const StateNotFoundException = derive('StateNotFound')
106
+ export const StatePreconditionException = derive('StatePrecondition')
107
+ export const StateConcurrencyException = derive('StateConcurrency')
108
+ export const StateInitializationException = derive('StateInitialization')
109
+ export const DuplicateException = derive('Duplicate')
110
+ export const CommunicationException = derive('Communication')
111
+ export const TransmissionException = derive('Transmission')
112
+
113
+ export const names = swap(codes)
100
114
  // #endregion
package/src/exposition.js CHANGED
@@ -1,8 +1,6 @@
1
- 'use strict'
1
+ import { Connector } from './connector.js'
2
2
 
3
- const { Connector } = require('./connector')
4
-
5
- class Exposition extends Connector {
3
+ export class Exposition extends Connector {
6
4
  locator
7
5
 
8
6
  #exposition
@@ -20,8 +18,7 @@ class Exposition extends Connector {
20
18
  }
21
19
 
22
20
  const expose = (manifest) => {
23
- const { namespace, name, operations, events, entity } = manifest
24
- return { namespace, name, operations, events, entity }
25
- }
21
+ const { namespace, name, entity, operations, events } = manifest
26
22
 
27
- exports.Exposition = Exposition
23
+ return { namespace, name, entity, operations, events }
24
+ }
package/src/guard.js ADDED
@@ -0,0 +1,13 @@
1
+ export class Guard {
2
+ name
3
+ #bridge
4
+
5
+ constructor (name, bridge) {
6
+ this.name = name
7
+ this.#bridge = bridge
8
+ }
9
+
10
+ fit (state, origin) {
11
+ return this.#bridge.fit(state, origin)
12
+ }
13
+ }
package/src/index.js CHANGED
@@ -1,47 +1,28 @@
1
- const { Assignment } = require('./assignment')
2
- const { Call } = require('./call')
3
- const { Cascade } = require('./cascade')
4
- const { Composition } = require('./composition')
5
- const { Connector } = require('./connector')
6
- const { Context } = require('./context')
7
- const { Discovery } = require('./discovery')
8
- const { Emission } = require('./emission')
9
- const { Event } = require('./event')
10
- const { Exposition } = require('./exposition')
11
- const { Locator } = require('./locator')
12
- const { Observation } = require('./observation')
13
- const { Operation } = require('./operation')
14
- const { Query } = require('./query')
15
- const { Receiver } = require('./receiver')
16
- const { Reflection } = require('./reflection')
17
- const { Remote } = require('./remote')
18
- const { Component } = require('./component')
19
- const { State } = require('./state')
20
- const { Transition } = require('./transition')
21
- const { Transmission } = require('./transmission')
22
-
23
- exports.entities = require('./entities')
24
- exports.exceptions = require('./exceptions')
25
- exports.contract = require('./contract')
26
-
27
- exports.Assignment = Assignment
28
- exports.Call = Call
29
- exports.Cascade = Cascade
30
- exports.Component = Component
31
- exports.Composition = Composition
32
- exports.Connector = Connector
33
- exports.Context = Context
34
- exports.Discovery = Discovery
35
- exports.Emission = Emission
36
- exports.Event = Event
37
- exports.Exposition = Exposition
38
- exports.Locator = Locator
39
- exports.Observation = Observation
40
- exports.Operation = Operation
41
- exports.Query = Query
42
- exports.Receiver = Receiver
43
- exports.Reflection = Reflection
44
- exports.Remote = Remote
45
- exports.State = State
46
- exports.Transition = Transition
47
- exports.Transmission = Transmission
1
+ export { Assignment } from './assignment.js'
2
+ export { Call } from './call.js'
3
+ export { Cascade } from './cascade.js'
4
+ export { Component } from './component.js'
5
+ export { Composition } from './composition.js'
6
+ export { Connector } from './connector.js'
7
+ export { Context } from './context.js'
8
+ export { Discovery } from './discovery.js'
9
+ export { Effect } from './effect.js'
10
+ export { Emission } from './emission.js'
11
+ export { Event } from './event.js'
12
+ export { Exposition } from './exposition.js'
13
+ export { Locator } from './locator.js'
14
+ export { Observation } from './observation.js'
15
+ export { Outbox } from './outbox/index.js'
16
+ export { Operation } from './operation.js'
17
+ export { Query } from './query.js'
18
+ export { Receiver } from './receiver.js'
19
+ export { Reflection } from './reflection.js'
20
+ export { Remote } from './remote.js'
21
+ export { State } from './state.js'
22
+ export { Transition } from './transition.js'
23
+ export { Transmission } from './transmission.js'
24
+ export { Unmanaged } from './unmanaged.js'
25
+ export { Guard } from './guard.js'
26
+ export * as entities from './entities/index.js'
27
+ export * as exceptions from './exceptions.js'
28
+ export * as contract from './contract/index.js'
package/src/locator.js CHANGED
@@ -1,11 +1,9 @@
1
- 'use strict'
2
-
3
- const { concat } = require('@toa.io/generic')
1
+ import { concat } from '@toa.io/generic'
4
2
 
5
3
  /**
6
4
  * @implements {toa.core.Locator}
7
5
  */
8
- class Locator {
6
+ export class Locator {
9
7
  name
10
8
  namespace
11
9
 
@@ -50,5 +48,3 @@ class Locator {
50
48
  }
51
49
 
52
50
  const DOT = '.'
53
-
54
- exports.Locator = Locator
@@ -1,20 +1,8 @@
1
- 'use strict'
2
-
3
- const { Operation } = require('./operation')
4
-
5
- class Observation extends Operation {
6
- async acquire (store) {
7
- const scope = await this.query(store.request.query)
8
- const state = scope === null ? null : scope.get()
9
-
10
- store.scope = scope
11
- store.state = state
12
- }
1
+ import { Operation } from './operation.js'
13
2
 
3
+ export class Observation extends Operation {
14
4
  async run (store) {
15
- if (store.scope === null) store.reply = null
5
+ if (store.scope === null || (store.scope?.deleted === true && store.request.query?.options?.deleted !== true)) store.reply = null
16
6
  else await super.run(store)
17
7
  }
18
8
  }
19
-
20
- exports.Observation = Observation
package/src/operation.js CHANGED
@@ -1,11 +1,19 @@
1
- 'use strict'
1
+ import { Connector } from './connector.js'
2
+ import { SystemException, RequestContractException } from './exceptions.js'
3
+ import { Readable } from 'node:stream'
2
4
 
3
- const { Connector } = require('./connector')
4
- const { SystemException } = require('./exceptions')
5
-
6
- class Operation extends Connector {
5
+ export class Operation extends Connector {
7
6
  scope
8
7
 
8
+ /**
9
+ * Whether what this operation acquires may be modified and committed. Only a
10
+ * transition commits, and only a commit needs the pre-image an entity keeps
11
+ * to diff the new state against.
12
+ *
13
+ * @protected
14
+ */
15
+ mutable = false
16
+
9
17
  #cascade
10
18
  #contracts
11
19
  #query
@@ -26,8 +34,15 @@ class Operation extends Connector {
26
34
 
27
35
  async invoke (request) {
28
36
  try {
29
- if (request.authentic !== true) this.#contracts.request.fit(request)
30
- if ('query' in request) request.query = this.#query.parse(request.query)
37
+ if (request.authentic !== true)
38
+ this.#contracts.request.fit(request)
39
+
40
+ if ('query' in request)
41
+ request.query = this.#query.parse(request.query)
42
+
43
+ // validate entity
44
+ if ('entity' in request)
45
+ this.scope.fit(request.entity)
31
46
 
32
47
  const store = { request }
33
48
 
@@ -47,14 +62,24 @@ class Operation extends Connector {
47
62
  return store.reply
48
63
  }
49
64
 
50
- async acquire () {}
65
+ async acquire (store) {
66
+ if (this.#scope === 'none')
67
+ return
68
+
69
+ const scope = await this.query(store.request.query)
70
+ const raw = scope === null || scope instanceof Readable
71
+
72
+ store.scope = scope
73
+ store.state = raw ? scope : scope.get()
74
+ }
51
75
 
52
76
  async run (store) {
53
77
  const { request, state } = store
54
- // noinspection UnnecessaryLocalVariableJS
55
- const reply = await this.#cascade.run(request.input, state) || {}
78
+ const reply = await this.#cascade.run(request.input, state)
56
79
 
57
- // this.#contracts.reply.fit(reply)
80
+ // validate reply only on local environments
81
+ if (process.env.TOA_ENV === 'local' && !(reply instanceof Readable))
82
+ this.#contracts.reply.fit(reply)
58
83
 
59
84
  store.reply = reply
60
85
  }
@@ -62,8 +87,9 @@ class Operation extends Connector {
62
87
  async commit () {}
63
88
 
64
89
  async query (query) {
65
- return this.scope[this.#scope](query)
90
+ if (query === undefined)
91
+ throw new RequestContractException('Request query is required')
92
+
93
+ return this.scope[this.#scope](query, this.mutable)
66
94
  }
67
95
  }
68
-
69
- exports.Operation = Operation
@@ -0,0 +1 @@
1
+ export { Outbox, LANES } from './outbox.js'
@@ -0,0 +1,296 @@
1
+ import { console } from 'openspan'
2
+ import { Connector } from '../connector.js'
3
+ import { newid } from '../entities/newid.js'
4
+
5
+ /**
6
+ * Owns the intent to publish. A row is built before the write so that the storage can commit
7
+ * it in the same transaction as the entity; publication then happens off the operation's path,
8
+ * and anything that fails to publish is recovered from the row.
9
+ *
10
+ * The mechanism is a safety net: in a healthy system the row is written, published within
11
+ * milliseconds by the same process, and marked published on that process's next tick.
12
+ *
13
+ * A storage that cannot commit a row atomically has no outbox, and this degrades to the
14
+ * inline emission it replaces.
15
+ */
16
+ export class Outbox extends Connector {
17
+ #emission
18
+ #storage
19
+ #atom
20
+
21
+ #gap
22
+ #interval
23
+ #batch
24
+ #defer
25
+
26
+ /** ids this process has published, held until a cycle marks them */
27
+ #published = new Set()
28
+
29
+ /** in-flight publications, awaited (with a bound) on close */
30
+ #inflight = new Set()
31
+
32
+ /** rows this replica is publishing right now, so a cycle does not pick them up again */
33
+ #publishing = new Set()
34
+
35
+ #timer
36
+ #pumping = false
37
+ #closing = false
38
+
39
+ constructor (emission, storage, atom, options = {}) {
40
+ super()
41
+
42
+ this.#emission = emission
43
+ this.#storage = storage
44
+ this.#atom = atom
45
+
46
+ this.#interval = number('TOA_OUTBOX_INTERVAL', options.interval, INTERVAL)
47
+ this.#batch = number('TOA_OUTBOX_BATCH', options.batch, BATCH)
48
+ this.#gap = options.gap ?? this.#interval * K
49
+ this.#defer = process.env.TOA_OUTBOX_DEFER === '1'
50
+
51
+ this.depends(emission)
52
+ this.depends(atom)
53
+
54
+ if (storage !== undefined) this.depends(storage)
55
+ }
56
+
57
+ /** whether the storage can commit a row atomically with the entity */
58
+ get durable () {
59
+ return this.#storage?.outbox !== undefined
60
+ }
61
+
62
+ /**
63
+ * @param event {toa.core.transition.Event}
64
+ * @returns {object}
65
+ */
66
+ row (event) {
67
+ return {
68
+ id: newid(),
69
+ lane: this.#lane(),
70
+ published: false,
71
+ pending: Date.now() + this.#gap,
72
+ event
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Hands a committed row over. Awaited by the caller only on the legacy path — with an
78
+ * outbox this returns at once and the broker leaves the operation's path.
79
+ */
80
+ publish (row) {
81
+ if (!this.durable)
82
+ return this.#emission.emit(row.event)
83
+
84
+ /*
85
+ * A publication started while the pump is closing would outlive the emitters it needs,
86
+ * and `comq` waits on a connection that is going rather than failing. The row is already
87
+ * durable, so leaving it is exactly what it is for.
88
+ */
89
+ if (this.#closing || this.#defer ||
90
+ this.#inflight.size >= INFLIGHT || this.#published.size >= PUBLISHED)
91
+ return
92
+
93
+ void this.#publish(row)
94
+ }
95
+
96
+ async open () {
97
+ if (!this.durable) return
98
+
99
+ if (this.#defer)
100
+ console.warn('Outbox immediate publication is deferred; events are published by the pump only')
101
+
102
+ this.#timer = setInterval(() => this.#tick(), this.#interval)
103
+ this.#timer.unref()
104
+ }
105
+
106
+ async close () {
107
+ this.#closing = true
108
+
109
+ if (this.#timer !== undefined) clearInterval(this.#timer)
110
+
111
+ await this.#drain()
112
+ await this.#mark()
113
+ }
114
+
115
+ /**
116
+ * Publishes one row and swallows the failure: the row stays unpublished and comes back on a
117
+ * later cycle, which is the whole point of having written it.
118
+ *
119
+ * There is no timeout here on purpose. A publication is a confirmed write to a durable
120
+ * exchange, and `comq` waits for the broker to come back rather than failing — abandoning
121
+ * it would not stop it, it would only mean the row is published twice once it lands. What
122
+ * bounds this instead is the in-flight cap and the drain on close.
123
+ *
124
+ * @private
125
+ */
126
+ async #publish (row) {
127
+ this.#publishing.add(row.id)
128
+
129
+ const publishing = this.#emission.emit(row.event)
130
+
131
+ this.#inflight.add(publishing)
132
+
133
+ try {
134
+ await publishing
135
+
136
+ this.#published.add(row.id)
137
+ } catch (error) {
138
+ console.warn('Event publication failed', { row: row.id, error })
139
+ } finally {
140
+ this.#inflight.delete(publishing)
141
+ this.#publishing.delete(row.id)
142
+ }
143
+ }
144
+
145
+ /**
146
+ * `comq` retries a publish for as long as the broker is down rather than rejecting, so an
147
+ * unbounded drain outlives any grace period.
148
+ *
149
+ * @private
150
+ */
151
+ async #drain () {
152
+ if (this.#inflight.size === 0) return
153
+
154
+ await Promise.race([Promise.allSettled([...this.#inflight]), delay(DRAIN)])
155
+ }
156
+
157
+ /**
158
+ * Reads what is due, publishes it, and marks everything this process has sent — what it just
159
+ * published and what the immediate path published since the last cycle. One cycle at a time.
160
+ *
161
+ * @private
162
+ */
163
+ #tick () {
164
+ if (this.#pumping) return
165
+
166
+ this.#pumping = true
167
+
168
+ void this.#pump().finally(() => (this.#pumping = false))
169
+ }
170
+
171
+ /** @private */
172
+ async #pump () {
173
+ let page
174
+
175
+ do {
176
+ page = await this.#read(page?.[page.length - 1]?.id)
177
+
178
+ if (page.length === 0) break
179
+
180
+ /*
181
+ * A row is unpublished in the database until a cycle marks it, so a page includes what
182
+ * this replica is sending right now and what a failed marking left behind. Only this
183
+ * process knows either.
184
+ */
185
+ const rows = page.filter((row) =>
186
+ !this.#published.has(row.id) && !this.#publishing.has(row.id))
187
+
188
+ if (rows.length > 0) {
189
+ console.info('Outbox recovering unpublished events', { count: rows.length })
190
+
191
+ // every row is given its chance; what the broker refused stays unpublished and comes
192
+ // back on a later cycle
193
+ await Promise.allSettled(rows.map((row) => this.#publish(row)))
194
+ }
195
+
196
+ // a full page is a page that may have been cut short
197
+ } while (page.length === this.#batch)
198
+
199
+ await this.#mark()
200
+ }
201
+
202
+ /**
203
+ * One page of what is due. In a healthy system the first one is empty, every cycle — a row is
204
+ * due only if the process that wrote it failed to publish or died before marking it.
205
+ *
206
+ * Reading is suspended, not stopped, while this replica does not know which lanes are its
207
+ * own: the cycle keeps running and keeps marking, and reading resumes as soon as an
208
+ * assignment arrives. Reading without an assignment would be a different guarantee, where
209
+ * every replica publishes every stranded row.
210
+ *
211
+ * @private
212
+ * @param {string} [after] the last id of the page before, so a page is never read twice
213
+ */
214
+ async #read (after) {
215
+ const lanes = this.#atom.slots(LANES)
216
+
217
+ if (lanes === null || lanes.length === 0) return []
218
+
219
+ return this.#storage.outbox.pending(lanes, Date.now(), this.#batch, after)
220
+ .catch((error) => {
221
+ console.warn('Outbox read failed', { error })
222
+
223
+ return []
224
+ })
225
+ }
226
+
227
+ /**
228
+ * One batched write for many events, which is why the ids are held in memory rather than
229
+ * marked one by one. Ids that fail to be marked are kept and retried; a row that is never
230
+ * marked is simply published again, which is within the contract.
231
+ *
232
+ * @private
233
+ */
234
+ async #mark () {
235
+ if (this.#published.size === 0) return
236
+
237
+ const ids = [...this.#published]
238
+
239
+ try {
240
+ await this.#storage.outbox.settle(ids)
241
+
242
+ for (const id of ids) this.#published.delete(id)
243
+ } catch (error) {
244
+ console.warn('Outbox marking failed', { count: ids.length, error })
245
+ }
246
+ }
247
+
248
+ /**
249
+ * A lane this replica currently owns, so that in steady state it settles its own rows
250
+ * before it ever reads them. Any lane at all when it owns none: the row still has to be
251
+ * written, and whoever ends up owning that lane will pump it.
252
+ *
253
+ * @private
254
+ */
255
+ #lane () {
256
+ const owned = this.#atom.slots(LANES)
257
+
258
+ return owned === null || owned.length === 0
259
+ ? Math.floor(Math.random() * LANES)
260
+ : owned[Math.floor(Math.random() * owned.length)]
261
+ }
262
+ }
263
+
264
+ function number (variable, declared, fallback) {
265
+ if (declared !== undefined) return declared
266
+
267
+ const value = Number(process.env[variable])
268
+
269
+ return Number.isNaN(value) || value <= 0 ? fallback : value
270
+ }
271
+
272
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref())
273
+
274
+ /**
275
+ * Constant, never configuration: rows carry their lane, so lowering this would leave rows in
276
+ * lanes nobody reads any more. It is also the ceiling on replicas of one component, and a
277
+ * power of two so that the common replica counts divide evenly.
278
+ */
279
+ export const LANES = 128
280
+
281
+ /** one cycle reads, publishes and marks; in steady state it finds nothing to read */
282
+ const INTERVAL = 5000
283
+
284
+ /**
285
+ * `gap = interval * K`. Not a steady-state necessity — a replica writes into a lane it owns
286
+ * and marks what it published — but a guard for when a lane changes hands between the write
287
+ * and the settle. Two cycles of separation, plus one of margin.
288
+ */
289
+ const K = 3
290
+
291
+ /** how many rows one read brings back; the pump reads on while a page comes back full */
292
+ const BATCH = 200
293
+
294
+ const DRAIN = 10_000
295
+ const INFLIGHT = 1000
296
+ const PUBLISHED = 10_000