| … | … | @@ -21,23 +21,36 @@ |
| 21 | 21 | export class SignerPresence { |
| 22 | 22 | // self: this device's member key (hex). graceMs: how long a departed co-owner stays eligible (blip |
| 23 | 23 | // tolerance / failover delay). clock: injectable now() for deterministic tests. |
| 24 | | constructor ({ self, graceMs = 15000, clock = () => Date.now() } = {}) { |
| 24 | // onChange: fired on an ACTUAL presence change (a co-owner connected or departed) — the EVENT that |
| 25 | // drives zero-timer failover: the daemon re-evaluates holdsSigner + signs on this, plus on every |
| 26 | // StateBase advance (sb.watch), so no polling timer is needed. Grace expiry itself has no event (a |
| 27 | // write/replication/presence event after grace picks it up — acceptable failover latency, no loss). |
| 28 | constructor ({ self, graceMs = 15000, clock = () => Date.now(), onChange = null } = {}) { |
| 25 | 29 | if (!self) throw new Error('SignerPresence: self (this device member key) required') |
| 26 | 30 | this.self = String(self) |
| 27 | 31 | this.graceMs = graceMs |
| 28 | 32 | this.clock = clock |
| 33 | this.onChange = onChange |
| 29 | 34 | this._present = new Map() // memberKey -> lastSeen: Infinity = connected now; a number = ms it departed |
| 30 | 35 | this._present.set(this.self, Infinity) // we are always present to ourselves |
| 31 | 36 | } |
| 32 | 37 | |
| 33 | | // A co-owner (holds the signing key + is attested) is connected. Idempotent. |
| 34 | | markPresent (memberKey) { this._present.set(String(memberKey), Infinity) } |
| 38 | _fire () { if (this.onChange) { try { this.onChange() } catch {} } } |
| 39 | |
| 40 | // A co-owner (holds the signing key + is attested) is connected. Idempotent. Fires onChange only on |
| 41 | // an actual transition (not-present/departed -> present) so the daemon re-evaluates the signer. |
| 42 | markPresent (memberKey) { |
| 43 | const k = String(memberKey) |
| 44 | if (this._present.get(k) === Infinity) return // already connected — no change |
| 45 | this._present.set(k, Infinity) |
| 46 | this._fire() |
| 47 | } |
| 35 | 48 | |
| 36 | 49 | // A co-owner disconnected — start its grace countdown (do NOT drop it immediately; a blip must not fail |
| 37 | | // the signer over). No-op for a key we never saw. |
| 50 | // the signer over). No-op for a key we never saw. Fires onChange so a departure is noticed event-driven. |
| 38 | 51 | markAbsent (memberKey) { |
| 39 | 52 | const k = String(memberKey) |
| 40 | | if (this._present.has(k) && this._present.get(k) === Infinity) this._present.set(k, this.clock()) |
| 53 | if (this._present.has(k) && this._present.get(k) === Infinity) { this._present.set(k, this.clock()); this._fire() } |
| 41 | 54 | } |
| 42 | 55 | |
| 43 | 56 | // The co-owners eligible to sign right now: connected, or departed within graceMs. Sorted (stable pick). |