@toa.io/core 1.0.0-alpha.283 → 1.0.0-alpha.284
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 +12 -0
- package/package.json +2 -2
- package/src/contract/request.js +1 -1
- package/src/outbox/outbox.js +10 -0
- package/src/query/criteria.js +8 -1
- package/test/outbox.test.js +49 -2
- package/test/query.test.js +7 -0
- package/types/atomicity.d.ts +21 -0
- package/types/remote.d.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
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.284](https://github.com/toa-io/toa/compare/v1.0.0-alpha.283...v1.0.0-alpha.284) (2026-09-05)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **core:** a criteria list coerces every value in it ([37f57ee](https://github.com/toa-io/toa/commit/37f57ee8b50907a13722b7a558b881cccf105442))
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* an operation states what it is ([b5e2f66](https://github.com/toa-io/toa/commit/b5e2f66c8bf67924e2eaaaa11f283dfb4d810981))
|
|
15
|
+
* **atomicity:** a replica can be told what it owns, not only ask ([f7b56b9](https://github.com/toa-io/toa/commit/f7b56b99cc43313f3f855df658a80fdc0659689e))
|
|
16
|
+
|
|
17
|
+
|
|
6
18
|
# [1.0.0-alpha.283](https://github.com/toa-io/toa/compare/v1.0.0-alpha.282...v1.0.0-alpha.283) (2026-09-04)
|
|
7
19
|
|
|
8
20
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/core",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.284",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Toa Core",
|
|
6
6
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
@@ -27,5 +27,5 @@
|
|
|
27
27
|
"openspan": "1.0.0-alpha.277",
|
|
28
28
|
"uuid": "14.0.2"
|
|
29
29
|
},
|
|
30
|
-
"gitHead": "
|
|
30
|
+
"gitHead": "006755556879e91e8d68fa86753aa3b475bdf4bf"
|
|
31
31
|
}
|
package/src/contract/request.js
CHANGED
|
@@ -9,7 +9,7 @@ export class Request extends Contract {
|
|
|
9
9
|
constructor (schema, definition) {
|
|
10
10
|
super(schema)
|
|
11
11
|
|
|
12
|
-
for (const key of ['input', 'output', 'errors'])
|
|
12
|
+
for (const key of ['description', 'input', 'output', 'errors'])
|
|
13
13
|
if (definition[key] !== undefined)
|
|
14
14
|
this.discovery[key] = definition[key]
|
|
15
15
|
}
|
package/src/outbox/outbox.js
CHANGED
|
@@ -33,6 +33,7 @@ export class Outbox extends Connector {
|
|
|
33
33
|
#publishing = new Set()
|
|
34
34
|
|
|
35
35
|
#timer
|
|
36
|
+
#off
|
|
36
37
|
#pumping = false
|
|
37
38
|
#closing = false
|
|
38
39
|
|
|
@@ -101,11 +102,20 @@ export class Outbox extends Connector {
|
|
|
101
102
|
|
|
102
103
|
this.#timer = setInterval(() => this.#tick(), this.#interval)
|
|
103
104
|
this.#timer.unref()
|
|
105
|
+
|
|
106
|
+
/*
|
|
107
|
+
* A lane changing hands is exactly when rows stranded in it become this replica's to
|
|
108
|
+
* publish, and the cycle would not notice for up to an interval. Being told costs a cycle
|
|
109
|
+
* that finds nothing in the usual case, where the claim arrives once and never changes.
|
|
110
|
+
*/
|
|
111
|
+
this.#off = this.#atom.onassigned(() => this.#tick())
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
async close () {
|
|
107
115
|
this.#closing = true
|
|
108
116
|
|
|
117
|
+
this.#off?.()
|
|
118
|
+
|
|
109
119
|
if (this.#timer !== undefined) clearInterval(this.#timer)
|
|
110
120
|
|
|
111
121
|
await this.#drain()
|
package/src/query/criteria.js
CHANGED
|
@@ -23,7 +23,14 @@ const coerce = (node, properties) => {
|
|
|
23
23
|
throw new QuerySyntaxException(`Criteria selector '${node.left.selector}' is not defined`)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
const coerce = COERCE[property.type]
|
|
27
|
+
|
|
28
|
+
// `=in=` and `=out=` carry a list, and coercing that as one value gives whatever
|
|
29
|
+
// `parseInt` makes of a comma-separated string
|
|
30
|
+
if (coerce !== undefined)
|
|
31
|
+
node.right.value = Array.isArray(node.right.value)
|
|
32
|
+
? node.right.value.map((value) => coerce(value))
|
|
33
|
+
: coerce(node.right.value)
|
|
27
34
|
} else {
|
|
28
35
|
if (node.left !== undefined) coerce(node.left, properties)
|
|
29
36
|
if (node.right !== undefined) coerce(node.right, properties)
|
package/test/outbox.test.js
CHANGED
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
|
|
4
4
|
import { Outbox } from '../src/outbox/index.js'
|
|
5
5
|
|
|
6
|
-
let emission, storage, atom, outbox
|
|
6
|
+
let emission, storage, atom, outbox, listeners
|
|
7
7
|
|
|
8
8
|
const BATCH = 4
|
|
9
9
|
|
|
@@ -28,7 +28,18 @@ beforeEach(() => {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
listeners = []
|
|
32
|
+
|
|
33
|
+
atom = {
|
|
34
|
+
slots: mock.fn(() => [0]),
|
|
35
|
+
onassigned: (listener) => {
|
|
36
|
+
listeners.push(listener)
|
|
37
|
+
listener({ i: 0, n: 1 })
|
|
38
|
+
|
|
39
|
+
return () => { listeners = listeners.filter((one) => one !== listener) }
|
|
40
|
+
},
|
|
41
|
+
link: mock.fn()
|
|
42
|
+
}
|
|
32
43
|
outbox = new Outbox(emission, storage, atom, { interval: 1000, batch: BATCH })
|
|
33
44
|
})
|
|
34
45
|
|
|
@@ -36,6 +47,11 @@ afterEach(() => {
|
|
|
36
47
|
mock.timers.reset()
|
|
37
48
|
})
|
|
38
49
|
|
|
50
|
+
/** everything the current turn awaited, without moving the clock */
|
|
51
|
+
const settled = async () => {
|
|
52
|
+
for (let i = 0; i < 20; i++) await Promise.resolve()
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
/** one cycle, and everything it awaited */
|
|
40
56
|
const cycle = async () => {
|
|
41
57
|
mock.timers.tick(1000)
|
|
@@ -119,3 +135,34 @@ function resetCalls (target = [assert, BATCH, page, cycle], seen = new Set()) {
|
|
|
119
135
|
if (typeof value === 'function' && value.mock !== undefined) value.mock.resetCalls()
|
|
120
136
|
else resetCalls(value, seen)
|
|
121
137
|
}
|
|
138
|
+
|
|
139
|
+
it('should read as soon as a claim arrives, rather than on its next cycle', async () => {
|
|
140
|
+
atom.slots.mock.mockImplementation(() => null)
|
|
141
|
+
|
|
142
|
+
await outbox.open()
|
|
143
|
+
await settled()
|
|
144
|
+
|
|
145
|
+
assert.strictEqual(storage.outbox.pending.mock.callCount(), 0, 'nothing is owned yet')
|
|
146
|
+
|
|
147
|
+
atom.slots.mock.mockImplementation(() => [0])
|
|
148
|
+
|
|
149
|
+
for (const listener of listeners) listener({ i: 0, n: 1 })
|
|
150
|
+
|
|
151
|
+
await settled()
|
|
152
|
+
|
|
153
|
+
assert.strictEqual(storage.outbox.pending.mock.callCount(), 1,
|
|
154
|
+
'the lane is its own now, and the cycle is up to five seconds away')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should read the lanes it inherits when the group resizes', async () => {
|
|
158
|
+
await outbox.open()
|
|
159
|
+
await settled()
|
|
160
|
+
|
|
161
|
+
const before = storage.outbox.pending.mock.callCount()
|
|
162
|
+
|
|
163
|
+
for (const listener of listeners) listener({ i: 0, n: 2 })
|
|
164
|
+
|
|
165
|
+
await settled()
|
|
166
|
+
|
|
167
|
+
assert.strictEqual(storage.outbox.pending.mock.callCount(), before + 1)
|
|
168
|
+
})
|
package/test/query.test.js
CHANGED
|
@@ -23,6 +23,13 @@ describe('criteria', () => {
|
|
|
23
23
|
assert.deepStrictEqual(query.criteria, fixtures.samples.simple.parsed.criteria)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
+
it('should coerce every value of a list', () => {
|
|
27
|
+
const instance = new Query({ n: { type: 'integer' } })
|
|
28
|
+
const query = instance.parse({ criteria: 'n=in=(1,2,3)' })
|
|
29
|
+
|
|
30
|
+
assert.deepStrictEqual(query.criteria.right.value, [1, 2, 3])
|
|
31
|
+
})
|
|
32
|
+
|
|
26
33
|
it('should keep a parsed criteria', () => {
|
|
27
34
|
const instance = new Query(fixtures.samples.simple.properties)
|
|
28
35
|
|
package/types/atomicity.d.ts
CHANGED
|
@@ -22,6 +22,20 @@ declare namespace toa.core {
|
|
|
22
22
|
*/
|
|
23
23
|
slots (total: number): number[] | null
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Calls `listener` with the assignment this replica holds, and again whenever it
|
|
27
|
+
* changes — one arrived, was lost, or the group resized. Answers with what removes the
|
|
28
|
+
* listener again.
|
|
29
|
+
*
|
|
30
|
+
* A change and not a heartbeat: a group that stays as it is never calls back. It is
|
|
31
|
+
* called once as it is added, with the claim as it stands.
|
|
32
|
+
*
|
|
33
|
+
* What `slots` cannot say. Reading serves a consumer that asks often, where a stale
|
|
34
|
+
* answer costs it one cycle; a consumer that asks rarely has to be told, or it samples
|
|
35
|
+
* the one moment a rollout was passing through and stands down for a whole cycle.
|
|
36
|
+
*/
|
|
37
|
+
onassigned (listener: (assignment: Assignment | null) => void): () => void
|
|
38
|
+
|
|
25
39
|
/**
|
|
26
40
|
* Debt the group has run up under each key, in milliseconds. Every call adds its own
|
|
27
41
|
* deltas and reads back where the group stands, so a replica reports what it alone has
|
|
@@ -45,6 +59,12 @@ declare namespace toa.core {
|
|
|
45
59
|
routine: (signal: AbortSignal, context: unknown) => Promise<T>): Promise<T>
|
|
46
60
|
}
|
|
47
61
|
|
|
62
|
+
/** Which of the group this replica is, and how many of them there are. */
|
|
63
|
+
interface Assignment {
|
|
64
|
+
i: number
|
|
65
|
+
n: number
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
interface Factory {
|
|
49
69
|
/** @param group what the replicas deciding together have in common */
|
|
50
70
|
atom (group: string, options?: object): Atom
|
|
@@ -55,4 +75,5 @@ declare namespace toa.core {
|
|
|
55
75
|
}
|
|
56
76
|
|
|
57
77
|
export type Atom = toa.core.atomicity.Atom
|
|
78
|
+
export type Assignment = toa.core.atomicity.Assignment
|
|
58
79
|
export type Factory = toa.core.atomicity.Factory
|