@toa.io/atomicity 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 CHANGED
@@ -3,6 +3,13 @@
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
+ ### Features
9
+
10
+ * **atomicity:** a replica can be told what it owns, not only ask ([f7b56b9](https://github.com/toa-io/toa/commit/f7b56b99cc43313f3f855df658a80fdc0659689e))
11
+
12
+
6
13
  # [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
14
 
8
15
  **Note:** Version bump only for package @toa.io/atomicity
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/atomicity",
3
- "version": "1.0.0-alpha.283",
3
+ "version": "1.0.0-alpha.284",
4
4
  "type": "module",
5
5
  "description": "Toa Atomicity Connector",
6
6
  "author": "temich <tema.gurtovoy@gmail.com>",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@sesamecare-oss/redlock": "1.4.0",
21
- "@toa.io/core": "1.0.0-alpha.283",
21
+ "@toa.io/core": "1.0.0-alpha.284",
22
22
  "ioredis": "6.0.0",
23
23
  "n-and-i": "0.3.2",
24
24
  "openspan": "1.0.0-alpha.277"
@@ -26,5 +26,5 @@
26
26
  "scripts": {
27
27
  "test": "echo \"Error: run tests from root\" && exit 1"
28
28
  },
29
- "gitHead": "2f52b023bed0ccb2ecdb2b82fe59fbe38bf282e1"
29
+ "gitHead": "006755556879e91e8d68fa86753aa3b475bdf4bf"
30
30
  }
package/readme.md CHANGED
@@ -18,6 +18,26 @@ Returns `null` while this replica owns nothing — at startup, during a rollout,
18
18
 
19
19
  Uses [n-and-i](https://github.com/temich/nandi).
20
20
 
21
+ ### Being told
22
+
23
+ `slots` answers whoever asks. A consumer that asks rarely — once an hour, say — samples one
24
+ moment, and a rollout passing through that moment costs it the whole hour. `onassigned` tells it
25
+ instead.
26
+
27
+ ```javascript
28
+ const off = atom.onassigned((assignment) => { … }) // { i, n } — or null, owning nothing
29
+ ```
30
+
31
+ Called with the assignment this replica holds, and again whenever it changes: one arrived, was
32
+ lost, or the group resized. A change and not a heartbeat — a group that stays as it is never
33
+ calls back. It is called once as it is added, with the assignment as it stands, so a listener
34
+ arriving after the group settled is not left waiting for something that has already happened.
35
+
36
+ The answer removes the listener. A listener that raises ends the loop it was told through, and
37
+ that loop is what keeps this replica registered — so this replica owns nothing from that moment,
38
+ `slots` answers `null`, and it stands down. What to do about the exception is the runtime's;
39
+ nothing here catches it.
40
+
21
41
  ## Metering
22
42
 
23
43
  Debt the group has run up under each key, in milliseconds. A call adds its own deltas and answers
package/src/atom.js CHANGED
@@ -15,6 +15,9 @@ export class Atom extends Connector {
15
15
  /** @type {{ i: number, n: number } | null} */
16
16
  #assignment = null
17
17
 
18
+ /** @type {Set<(assignment: { i: number, n: number } | null) => void>} */
19
+ #listeners = new Set()
20
+
18
21
  /** @type {AbortController} */
19
22
  #abort
20
23
 
@@ -60,6 +63,29 @@ export class Atom extends Connector {
60
63
  return owned
61
64
  }
62
65
 
66
+ /**
67
+ * Calls `listener` with the assignment this replica holds, and again whenever it changes —
68
+ * one arrived, was lost, or the group resized. Answers with what removes the listener again.
69
+ *
70
+ * A change and not a heartbeat: a group that stays as it is never calls back. It is called
71
+ * once as it is added, with the claim as it stands, so that a listener arriving after the
72
+ * group settled is not left waiting for something that has already happened.
73
+ *
74
+ * This is what `slots` cannot say. Reading answers a consumer that asks often, where a stale
75
+ * answer costs one cycle; a consumer that asks rarely needs to be told, or it samples the
76
+ * one moment a rollout was passing through and stands down for a whole cycle.
77
+ *
78
+ * @param listener {(assignment: { i: number, n: number } | null) => void}
79
+ * @returns {() => void}
80
+ */
81
+ onassigned (listener) {
82
+ this.#listeners.add(listener)
83
+
84
+ listener(this.#assignment)
85
+
86
+ return () => this.#listeners.delete(listener)
87
+ }
88
+
63
89
  /**
64
90
  * Debt the group has run up under each key, in milliseconds: every call adds its own deltas
65
91
  * and reads back where the group stands, so a replica reports only what it has spent and
@@ -146,9 +172,22 @@ export class Atom extends Connector {
146
172
  console: this.#console
147
173
  })
148
174
 
149
- // the loop yields when ownership changes, which is exactly when work has to be handed over
150
- for await (const { i, n } of loop)
151
- this.#assignment = i === null ? null : { i, n }
175
+ try {
176
+ // the loop yields when ownership changes, which is exactly when work has to be handed over
177
+ for await (const { i, n } of loop) {
178
+ this.#assignment = i === null ? null : { i, n }
179
+
180
+ for (const listener of this.#listeners) listener(this.#assignment)
181
+ }
182
+ } finally {
183
+ /*
184
+ * The loop is what keeps this replica registered, so once it has ended — because it was
185
+ * aborted, or because a listener raised through it — this replica owns nothing. Left as
186
+ * it was, the last assignment would answer `slots` for as long as the process ran, and
187
+ * a claim nothing supports is what the whole of this exists to prevent.
188
+ */
189
+ this.#assignment = null
190
+ }
152
191
  }
153
192
  }
154
193