@volter/supercode-teams 0.3.159 → 0.3.161

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/mail/carrier.mjs CHANGED
@@ -14,6 +14,7 @@
14
14
  // - a hand-over is waited for at most HANDOVER_BOUND_MS, and one mailbox's attempt never waits on another's;
15
15
  // - each attempt is a line of the carrier's record.
16
16
  import { render } from './envelope.mjs';
17
+ import { expireDelivery, paneWaitReason, terminalReceipt } from './delivery-status.mjs';
17
18
 
18
19
  const FIRST_RETRY_MS = 2_000;
19
20
  const LAST_RETRY_MS = 300_000;
@@ -25,6 +26,10 @@ const OFFLINE_CEILING_MS = 30 * 24 * 60 * 60_000;
25
26
  // and the hand-over itself answers 'later' until it settles (MailSend.handOver), so a frozen door holds only its own
26
27
  // mailbox's wakes, and only for this long
27
28
  const HANDOVER_BOUND_MS = 150_000;
29
+ // Each target proceeds independently, with bounded simultaneous door calls.
30
+ // In-flight targets are excluded from the one next-due query until they settle.
31
+ const MAX_ACTIVE_TARGETS = 16;
32
+ const MAX_TIMER_MS = 2_147_483_647;
28
33
 
29
34
  const offline = (text) => String(text).includes('machine_offline');
30
35
 
@@ -35,195 +40,221 @@ export class MailCarrier {
35
40
  * `typeTurn(pane, harness, text, ids)` a wake typed into a hooked session's pane; `askMachine(machine, request)` one
36
41
  * request to another machine's mail door.
37
42
  */
38
- constructor({ machine, store, live, handOver, typeTurn, askMachine, log = () => {} }) {
39
- Object.assign(this, { machine, store, live, handOver, typeTurn, askMachine, log });
43
+ constructor({ machine, store, live, handOver, typeTurn, typeUserTurns, paneOf, askMachine, log = () => {} }) {
44
+ Object.assign(this, { machine, store, live, handOver, typeTurn, typeUserTurns, paneOf, askMachine, log });
40
45
  this.stopped = false;
41
- this.timers = new Map(); // `${address}\0${id}` → timer of a wake here, due
42
- this.running = new Set(); // mailboxes (addresses) with an attempt in flight
43
- this.machines = new Map(); // machine → timer of its next carry
44
- this.carrying = new Set(); // machines with a carry in flight
45
- this.remote = new Map(); // machine → Map(`${address}\0${id}` → { address, id }): wakes for another machine
46
+ this.active = new Map(); // kind + target → the door call in progress
47
+ this.timer = null;
48
+ this.timerAt = null;
46
49
  }
47
50
 
48
- /** Follow every wake: those filed before this daemon (every wake the store holds), each as the store names it (the
49
- * write that made it), and every wake again once per LAST_RETRY_MS, so a wake no event named is carried within that
50
- * bound. */
51
+ /** Writes refresh their target's persisted due row before notifying this
52
+ * scheduler. Only the startup/five-minute reconciliation walks all targets. */
51
53
  start() {
52
- this.onWake = ({ address, id }) => this.#wake(address, id);
54
+ this.onWake = () => this.#schedule();
53
55
  this.store.on('wake', this.onWake);
56
+ this.onLive = () => {
57
+ if (this.stopped) return;
58
+ try { this.store.wakeIdleSessions(this.live.sessions().filter((row) => row.status === 'idle').map((row) => row.address)); }
59
+ catch (error) { this.log(`carrier: idle waits could not be resumed: ${error.message}`); }
60
+ };
61
+ this.live.on('changed', this.onLive);
54
62
  this.#sweep();
55
63
  this.sweeper = setInterval(() => this.#sweep(), LAST_RETRY_MS);
56
64
  this.sweeper.unref?.();
57
65
  return this;
58
66
  }
59
67
 
60
- /** Every wake followed (one already due or scheduled is not scheduled twice). */
61
- #sweep() { for (const { address, id } of this.store.allWakes()) this.#wake(address, id); }
68
+ #sweep() {
69
+ if (this.stopped) return;
70
+ try { this.store.reconcileDeliverySchedule(); this.#schedule(); }
71
+ catch (error) { this.log(`carrier: its delivery schedule could not be reconciled: ${error.message}`); }
72
+ }
62
73
 
63
- stop() { this.stopped = true; this.store.off('wake', this.onWake); clearInterval(this.sweeper); for (const timer of [...this.timers.values(), ...this.machines.values()]) clearTimeout(timer); this.timers.clear(); this.machines.clear(); }
74
+ stop() {
75
+ this.stopped = true;
76
+ this.store.off('wake', this.onWake);
77
+ this.live.off('changed', this.onLive);
78
+ clearInterval(this.sweeper); clearTimeout(this.timer);
79
+ this.timer = null; this.timerAt = null;
80
+ }
64
81
 
65
- /** A wake seen (written or changed): attempted when it is due. */
66
- #wake(address, id) {
67
- if (this.stopped || !/^[A-Za-z0-9_-]+$/.test(id) || !address) return;
68
- const machine = address.split(':')[1];
69
- if (machine !== this.machine) {
70
- if (!this.remote.has(machine)) this.remote.set(machine, new Map());
71
- this.remote.get(machine).set(`${address}\0${id}`, { address, id });
72
- this.#scheduleMachine(machine);
73
- return;
82
+ #next() { return this.store.nextDeliveryTarget([...this.active.values()]); }
83
+
84
+ /** There is one due timer. A changed earlier deadline replaces it immediately,
85
+ * and a removed last delivery disarms it. Very distant dates are clamped to
86
+ * Node's timer range and queried again when that bound ends. */
87
+ #schedule() {
88
+ if (this.stopped) return;
89
+ let next;
90
+ try { next = this.active.size < MAX_ACTIVE_TARGETS ? this.#next() : null; }
91
+ catch (error) { this.log(`carrier: its next deadline could not be read: ${error.message}`); return; }
92
+ const at = next?.due_at_ms ?? null;
93
+ if (this.timer && this.timerAt === at) return;
94
+ clearTimeout(this.timer); this.timer = null; this.timerAt = at;
95
+ if (at == null) return;
96
+ this.timer = setTimeout(() => {
97
+ this.timer = null; this.timerAt = null;
98
+ try { this.#dispatch(); }
99
+ catch (error) { this.log(`carrier: its due target could not be read: ${error.message}`); }
100
+ }, Math.min(MAX_TIMER_MS, Math.max(0, at - Date.now())));
101
+ this.timer.unref?.();
102
+ }
103
+
104
+ #dispatch() {
105
+ if (this.stopped) return;
106
+ while (this.active.size < MAX_ACTIVE_TARGETS) {
107
+ const next = this.#next();
108
+ if (!next || next.due_at_ms > Date.now()) break;
109
+ const key = `${next.kind}\0${next.target}`;
110
+ this.active.set(key, { kind: next.kind, target: next.target });
111
+ let failed = false;
112
+ void (next.kind === 'mailbox' ? this.#local(next.target) : this.#carry(next.target))
113
+ .catch((error) => {
114
+ failed = true;
115
+ this.log(`carrier: ${next.target}'s attempt could not finish: ${error.message}`);
116
+ // A failed store operation must not create an immediate retry loop.
117
+ // An ordinary write may schedule sooner; reconciliation is the backstop.
118
+ if (!this.stopped) this.store.delayDeliveryTarget(next.kind, next.target, Date.now() + LAST_RETRY_MS);
119
+ })
120
+ .finally(() => {
121
+ this.active.delete(key);
122
+ if (!this.stopped) {
123
+ if (!failed) this.store.refreshDeliveryTarget(next.kind, next.target);
124
+ this.#schedule();
125
+ }
126
+ }).catch((error) => this.log(`carrier: ${next.target}'s schedule could not be kept: ${error.message}`));
74
127
  }
75
- const key = `${address}\0${id}`;
76
- if (this.timers.has(key)) return;
77
- const state = this.store.wakeState(address, id);
78
- const timer = setTimeout(() => { this.timers.delete(key); void this.#local(address); }, Math.max(0, (state.next_at_ms ?? 0) - Date.now()));
79
- timer.unref?.();
80
- this.timers.set(key, timer);
128
+ this.#schedule();
81
129
  }
82
130
 
83
131
  /** The wakes of one mailbox here that are due, attempted (mail_watch.rs `tick`, its local half). */
84
132
  async #local(address) {
85
133
  if (this.stopped) return;
86
- if (this.running.has(address)) { setTimeout(() => this.#rewake(address), 1000).unref?.(); return; }
87
- this.running.add(address);
88
- try {
89
- const now = Date.now();
90
- const due = this.#pending(address).filter((id) => (this.store.wakeState(address, id).next_at_ms ?? 0) <= now);
91
- if (!due.length) return;
92
- // a session in a daemon pane whose mail is typed there: a hooked one (its hook shows it the rest), and one with no
93
- // hook and no relay (Grok), whose mail that found its composer busy waits here until it is free
94
- const session = this.live.sessions().find((row) => row.address === address);
95
- if ((session?.door === 'hook' || session?.door === 'pane') && session.pane) {
96
- for (const id of due) {
97
- const stored = this.store.findIn(address, id);
98
- const envelope = stored ? this.store.body(stored) : null;
99
- let carried;
100
- if (!envelope) carried = { kind: 'filed' };
101
- else {
102
- try {
103
- const typed = await this.typeTurn(session.pane, address.split(':')[2], render(envelope), [id]);
104
- // held: the daemon's queue for the session types it once the composer frees; nothing asks it again
105
- carried = typed?.delivered ? { kind: 'delivered' } : typed?.queued ? { kind: 'filed' } : { kind: 'later', reason: 'its pane was not at its prompt' };
106
- } catch (error) { carried = { kind: 'later', reason: error.message }; }
107
- }
108
- if (this.stopped) return;
109
- this.#record({ to: address, id, path: `${session.door}-pane`, carried });
110
- this.#settle(address, id, carried);
111
- }
112
- return;
134
+ const now = Date.now();
135
+ let due = this.store.dueWakes(address, now);
136
+ if (!due.length) return;
137
+ // a session in a daemon pane whose mail is typed there: a hooked one (its hook shows it the rest), and one with no
138
+ // hook and no relay (Grok), whose mail that found its composer busy waits here until it is free
139
+ const session = this.live.sessions().find((row) => row.address === address);
140
+ const pane = session ? this.paneOf(session) : null;
141
+ const users = due.filter((id) => this.store.findIn(address, id)?.envelope.kind === 'user');
142
+ if (users.length) {
143
+ if (pane) await this.typeUserTurns(address, pane);
144
+ if (this.stopped) return;
145
+ for (const id of users) {
146
+ const found = this.store.findIn(address, id);
147
+ if (!found || found.state === 'read') this.#settle(address, id, { kind: 'filed' });
148
+ else this.store.setWakeState(address, id, { attempts: 0, next_at_ms: Date.now() + (pane ? FIRST_RETRY_MS : LAST_RETRY_MS), last_error: 'its user turn waits for an empty composer' });
113
149
  }
150
+ due = due.filter((id) => !users.includes(id));
151
+ }
152
+ // A terminal queue owns its accepted paste. Follow its durable outcome
153
+ // instead of sending another input or treating a queue entry as delivery.
154
+ due = due.filter((id) => {
155
+ const found = this.store.findIn(address, id);
156
+ if (!found || (found.state === 'read' && this.store.claimOf(address, id)?.recipient === true)) {
157
+ this.#settle(address, id, { kind: 'filed' });
158
+ return false;
159
+ }
160
+ const receipt = terminalReceipt(this.store, address, id);
161
+ if (!receipt) return true;
162
+ this.#settle(address, id, receipt.delivered ? { kind: 'delivered' }
163
+ : receipt.refused ? { kind: 'refused', reason: receipt.reason }
164
+ : { kind: 'pending', reason: receipt.reason });
165
+ return false;
166
+ });
167
+ if ((session?.door === 'hook' || session?.door === 'pane') && pane) {
114
168
  for (const id of due) {
115
- const found = this.store.findIn(address, id);
169
+ const stored = this.store.findIn(address, id);
170
+ const envelope = stored ? this.store.body(stored) : null;
116
171
  let carried;
117
- // its recipient has it already (its own read, or a hand-over by an attempt that ended before it was settled):
118
- // never delivered twice; a read by anyone else does not count
119
- if (!found || (found.state === 'read' && this.store.claimOf(address, id)?.recipient === true)) carried = { kind: 'filed' };
172
+ if (!envelope) carried = { kind: 'filed' };
120
173
  else {
121
174
  try {
122
- let bound;
123
- const answer = await Promise.race([
124
- this.handOver(address, id),
125
- new Promise((settle) => { bound = setTimeout(() => settle({ outcome: 'later', detail: `its hand-over was still running after ${HANDOVER_BOUND_MS / 1000} s` }), HANDOVER_BOUND_MS); bound.unref?.(); }),
126
- ]).finally(() => clearTimeout(bound));
127
- carried = answer?.outcome === 'delivered' ? { kind: 'delivered' } : answer?.outcome === 'filed' ? { kind: 'filed' } : { kind: 'later', reason: answer?.detail ?? 'not handed over' };
128
- } catch (error) { carried = { kind: 'later', reason: `this machine's daemon did not hand it over: ${error.message}` }; }
175
+ const typed = await this.typeTurn(pane, address.split(':')[2], render(envelope), [id], address);
176
+ // held: the daemon's queue for the session types it once the composer frees; nothing asks it again
177
+ carried = typed?.delivered ? { kind: 'delivered' } : typed?.queued ? { kind: 'pending', reason: typed.reason }
178
+ : typed?.refused === true ? { kind: 'refused', reason: typed.reason } : { kind: 'later', reason: paneWaitReason(typed) };
179
+ } catch (error) { carried = { kind: 'later', reason: error.message }; }
129
180
  }
130
181
  if (this.stopped) return;
131
- this.#record({ to: address, id, path: 'door', carried });
182
+ this.#record({ to: address, id, path: `${session.door}-pane`, carried });
132
183
  this.#settle(address, id, carried);
133
184
  }
134
- } finally {
135
- this.running.delete(address);
136
- this.#rewake(address);
185
+ return;
137
186
  }
138
- }
139
-
140
- /** A mailbox's wakes followed again (each one scheduled at its own due time). */
141
- #rewake(address) { if (this.stopped) return; for (const id of this.store.wakes(address)) this.#wake(address, id); }
142
-
143
- /** A mailbox's wakes whose message is still filed; a wake whose message is gone is let go (mailbox.rs `pending_wakes`). */
144
- #pending(address) {
145
- return this.store.wakes(address).filter((id) => {
146
- if (this.store.findIn(address, id)) return true;
147
- this.store.removeWake(address, id);
148
- return false;
149
- });
150
- }
151
-
152
- // ---- another machine's mail ------------------------------------------------------------------------------------
153
-
154
- /** A machine's mail carried at its next due time: its wait's next probe when it is offline, else its earliest wake. */
155
- #scheduleMachine(machine) {
156
- if (this.stopped || this.machines.has(machine) || this.carrying.has(machine)) return;
157
- const wait = this.store.machineWait(machine);
158
- let at = wait?.next_probe_ms ?? null;
159
- if (at == null) {
160
- at = Infinity;
161
- for (const { address, id } of this.#remoteWakes(machine)) at = Math.min(at, this.store.wakeState(address, id).next_at_ms ?? 0);
162
- if (!Number.isFinite(at)) return;
187
+ for (const id of due) {
188
+ const found = this.store.findIn(address, id);
189
+ let carried;
190
+ // its recipient has it already (its own read, or a hand-over by an attempt that ended before it was settled):
191
+ // never delivered twice; a read by anyone else does not count
192
+ if (!found || (found.state === 'read' && this.store.claimOf(address, id)?.recipient === true)) carried = { kind: 'filed' };
193
+ else {
194
+ try {
195
+ let bound;
196
+ const answer = await Promise.race([
197
+ this.handOver(address, id),
198
+ new Promise((settle) => { bound = setTimeout(() => settle({ outcome: 'later', detail: `its hand-over was still running after ${HANDOVER_BOUND_MS / 1000} s` }), HANDOVER_BOUND_MS); bound.unref?.(); }),
199
+ ]).finally(() => clearTimeout(bound));
200
+ carried = answer?.outcome === 'delivered' ? { kind: 'delivered' } : answer?.outcome === 'filed' ? { kind: 'filed' }
201
+ : answer?.outcome === 'waiting' ? { kind: 'waiting', reason: answer.detail }
202
+ : answer?.outcome === 'pending' ? { kind: 'pending', reason: answer.detail } : { kind: 'later', reason: answer?.detail ?? 'not handed over' };
203
+ } catch (error) { carried = { kind: 'later', reason: `this machine's daemon did not hand it over: ${error.message}` }; }
204
+ }
205
+ if (this.stopped) return;
206
+ this.#record({ to: address, id, path: 'door', carried });
207
+ this.#settle(address, id, carried);
163
208
  }
164
- const timer = setTimeout(() => { this.machines.delete(machine); void this.#carry(machine); }, Math.max(0, at - Date.now()));
165
- timer.unref?.();
166
- this.machines.set(machine, timer);
167
209
  }
168
210
 
169
- /** Every wake filed here for `machine` (as their events named them), with when its message was filed, oldest first; a
170
- * wake whose message is gone, or that is gone itself, is let go. */
171
- #remoteWakes(machine) {
172
- const items = [];
173
- for (const [key, item] of this.remote.get(machine) ?? []) {
174
- const present = this.store.hasWake(item.address, item.id);
175
- const found = present ? this.store.findIn(item.address, item.id) : null;
176
- if (!found) { this.remote.get(machine).delete(key); if (present) this.store.removeWake(item.address, item.id); continue; }
177
- const envelope = this.store.body(found);
178
- items.push({ ...item, filed: Number(envelope?.created_at_ms) || 0 });
179
- }
180
- return items.sort((a, b) => a.filed - b.filed);
181
- }
211
+ // ---- another machine's mail ------------------------------------------------------------------------------------
182
212
 
183
- /** One machine's mail carried (mail_watch.rs `tick`, its remote half): its oldest due message probes the machine, and
184
- * when it answers, the rest follow in this pass. */
213
+ /** One offline probe establishes reachability. Once the machine answers,
214
+ * remaining batches obey each message's own retry deadline. */
185
215
  async #carry(machine) {
186
216
  if (this.stopped) return;
187
- this.carrying.add(machine);
188
- try {
189
- const now = Date.now();
190
- const items = this.#remoteWakes(machine);
191
- if (!items.length) return;
192
- const wait = this.store.machineWait(machine);
193
- if (wait) {
194
- // a machine away longer than the ceiling is not coming back for this mail: it expires, saying so
195
- if (now - (wait.offline_since_ms ?? now) >= OFFLINE_CEILING_MS) {
196
- const reason = `${machine} has been offline for ${OFFLINE_CEILING_MS / 86_400_000} days (since epoch ms ${wait.offline_since_ms}): ${wait.last_error ?? 'not linked'}`;
197
- for (const item of items) this.#expire(item.address, item.id, this.store.wakeState(item.address, item.id).attempts ?? 0, reason);
198
- this.#setMachineWait(machine, null);
199
- this.#line({ t: now, machine, outcome: 'expired', messages: items.length, reason });
200
- return;
201
- }
202
- if ((wait.next_probe_ms ?? 0) > now) return;
217
+ const now = Date.now();
218
+ let wait = this.store.machineWait(machine);
219
+ // Earlier candidates kept an online-draining wait. Its existence must not
220
+ // release newly assigned failure deadlines on this owner or after restart.
221
+ if (wait?.online_draining) { this.#setMachineWait(machine, null); wait = null; }
222
+ const offlineExpired = wait && now - (wait.offline_since_ms ?? now) >= OFFLINE_CEILING_MS;
223
+ const items = this.store.remoteWakes(machine, now, { probing: Boolean(wait), limit: wait && !offlineExpired ? 1 : 256 });
224
+ if (!items.length) return;
225
+ if (wait) {
226
+ // a machine away longer than the ceiling is not coming back for this mail: it expires, saying so
227
+ if (offlineExpired) {
228
+ const reason = `${machine} has been offline for ${OFFLINE_CEILING_MS / 86_400_000} days (since epoch ms ${wait.offline_since_ms}): ${wait.last_error ?? 'not linked'}`;
229
+ for (const item of items) this.#expire(item.address, item.id, this.store.wakeState(item.address, item.id).attempts ?? 0, reason);
230
+ if (!this.store.remoteWakes(machine, now, { probing: true, limit: 1 }).length) this.#setMachineWait(machine, null);
231
+ this.#line({ t: now, machine, outcome: 'expired', messages: items.length, reason });
232
+ return;
203
233
  }
204
- let carriedHere = 0, offlineAnswer = null, called = false;
205
- for (const item of items) {
206
- if (!wait && (this.store.wakeState(item.address, item.id).next_at_ms ?? 0) > now) continue;
207
- const found = this.store.findIn(item.address, item.id);
208
- let carried;
209
- // carried already (this carrier's own record): never carried twice
210
- if (!found || (found.state === 'read' && this.store.claimOf(item.address, item.id)?.recipient === true)) carried = { kind: 'filed' };
211
- else {
212
- called = true;
213
- carried = await this.#carryOne(machine, item.address, this.store.body(found));
214
- }
215
- if (this.stopped) return;
216
- if (carried.kind === 'offline') { offlineAnswer = carried.reason; break; }
217
- carriedHere++;
218
- this.#settle(item.address, item.id, carried);
234
+ if ((wait.next_probe_ms ?? 0) > now) return;
235
+ }
236
+ let carriedHere = 0, offlineAnswer = null, called = false;
237
+ for (const item of items) {
238
+ if (!wait && (this.store.wakeState(item.address, item.id).next_at_ms ?? 0) > now) continue;
239
+ const found = this.store.findIn(item.address, item.id);
240
+ let carried;
241
+ // carried already (this carrier's own record): never carried twice
242
+ if (!found || (found.state === 'read' && this.store.claimOf(item.address, item.id)?.recipient === true)) carried = { kind: 'filed' };
243
+ else {
244
+ called = true;
245
+ carried = await this.#carryOne(machine, item.address, this.store.body(found));
219
246
  }
220
- if (called) this.#line({ t: now, machine, outcome: offlineAnswer ? 'offline' : 'carried', waiting: items.length, carried: carriedHere, reason: offlineAnswer });
221
- if (offlineAnswer) this.#setMachineWait(machine, { machine, offline_since_ms: wait?.offline_since_ms ?? now, next_probe_ms: now + LAST_RETRY_MS, probes: (wait?.probes ?? 0) + 1, last_error: offlineAnswer });
222
- else if (called) this.#setMachineWait(machine, null);
223
- } finally {
224
- this.carrying.delete(machine);
225
- this.#scheduleMachine(machine);
247
+ if (this.stopped) return;
248
+ if (carried.kind === 'offline') { offlineAnswer = carried.reason; break; }
249
+ carriedHere++;
250
+ this.#settle(item.address, item.id, carried);
226
251
  }
252
+ if (called) this.#line({ t: now, machine, outcome: offlineAnswer ? 'offline' : 'carried', waiting: items.length, carried: carriedHere, reason: offlineAnswer });
253
+ if (offlineAnswer) this.#setMachineWait(machine, { machine, offline_since_ms: wait?.offline_since_ms ?? now, next_probe_ms: now + LAST_RETRY_MS, probes: (wait?.probes ?? 0) + 1, last_error: offlineAnswer });
254
+ // A refusal still proves that the machine answered. Clearing its wait
255
+ // restores due-row selection; this attempt's failure keeps its backoff,
256
+ // and unattempted due mail advances without probing that failure again.
257
+ else if (wait || called) this.#setMachineWait(machine, null);
227
258
  }
228
259
 
229
260
  /** One message carried to its machine's mail door, filed there with a wake. */
@@ -242,7 +273,7 @@ export class MailCarrier {
242
273
  }
243
274
 
244
275
  #setMachineWait(machine, wait) {
245
- try { this.store.setMachineWait(machine, wait); } catch (error) { this.log(`carrier: ${machine}'s wait was not kept: ${error.message}`); }
276
+ this.store.setMachineWait(machine, wait);
246
277
  }
247
278
 
248
279
  // ---- what waits, and what expired (`message waiting`, `message expired`) -----------------------------------------
@@ -255,7 +286,8 @@ export class MailCarrier {
255
286
  for (const { address, id } of this.store.allWakes()) {
256
287
  const machine = address.split(':')[1];
257
288
  if (machine !== this.machine && !waits.has(machine)) waits.set(machine, this.store.machineWait(machine));
258
- const wait = machine !== this.machine ? waits.get(machine) : null;
289
+ const heldWait = machine !== this.machine ? waits.get(machine) : null;
290
+ const wait = heldWait?.online_draining ? null : heldWait;
259
291
  {
260
292
  const state = this.store.wakeState(address, id);
261
293
  if (!wait && !(state.attempts > 0) && state.last_error == null) continue;
@@ -288,9 +320,25 @@ export class MailCarrier {
288
320
  return;
289
321
  }
290
322
  if (carried.kind === 'offline') return;
323
+ if (carried.kind === 'pending') {
324
+ const state = this.store.wakeState(address, id);
325
+ this.store.setWakeState(address, id, { ...state, next_at_ms: Date.now() + FIRST_RETRY_MS, last_error: carried.reason });
326
+ return;
327
+ }
328
+ if (carried.kind === 'waiting') {
329
+ const state = this.store.wakeState(address, id);
330
+ this.store.setWakeState(address, id, { ...state, waiting_for_idle: true, next_at_ms: Date.now() + LAST_RETRY_MS });
331
+ return;
332
+ }
291
333
  const now = Date.now();
292
334
  const state = this.store.wakeState(address, id);
335
+ const envelope = this.store.findIn(address, id)?.envelope;
336
+ if (envelope?.pool_reply_failure_notice || envelope?.delivery_failure_notice) {
337
+ this.store.setWakeState(address, id, { ...state, next_at_ms: now + LAST_RETRY_MS, last_error: carried.reason });
338
+ return;
339
+ }
293
340
  const attempts = (state.attempts ?? 0) + 1;
341
+ if (carried.kind === 'refused') { this.#expire(address, id, attempts, carried.reason); return; }
294
342
  const since = state.failing_since_ms ?? now;
295
343
  const lifetime = address.split(':')[1] === this.machine ? LOCAL_WAKE_LIFETIME_MS : REMOTE_WAKE_LIFETIME_MS;
296
344
  if (attempts >= WAKE_ATTEMPTS && now - since >= lifetime) { this.#expire(address, id, attempts, carried.reason); return; }
@@ -300,10 +348,7 @@ export class MailCarrier {
300
348
 
301
349
  /** End a wake undelivered: the message stays filed and unread, and the expiry is recorded. */
302
350
  #expire(address, id, attempts, reason) {
303
- const found = this.store.findIn(address, id);
304
- const envelope = found ? this.store.body(found) : null;
305
- const expiry = { id, to: address, from: envelope?.from ?? '', subject: envelope?.subject ?? null, filed_at_ms: Number(envelope?.created_at_ms) || 0, expired_at_ms: Date.now(), attempts, reason };
306
- try { this.store.expire(address, id, expiry); } catch (error) { this.log(`carrier: the expiry of ${id} was not recorded: ${error.message}`); }
351
+ expireDelivery(this.store, this.machine, address, id, attempts, reason);
307
352
  }
308
353
 
309
354
  /** One local delivery attempt in the carrier's record. */
@@ -0,0 +1,143 @@
1
+ // Durable owner-side row changes. SQL triggers record each mutation in its
2
+ // transaction; a commit event merely wakes readers of that persisted sequence.
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ export const MAX_CHANGE_ROWS = 100_000;
6
+ const PAGE = 256;
7
+
8
+ const ROWS = [
9
+ ['machine_record_heads', 'record-head', (row) => `json_object('role', ${row}.role, 'machine', ${row}.machine)`, (row) => `${row}.value`],
10
+ ['machine_record_rows', 'record-row', (row) => `json_object('role', ${row}.role, 'machine', ${row}.machine, 'section', ${row}.section, 'key', ${row}.key, 'position', ${row}.position)`, (row) => `${row}.value`],
11
+ ['messages', 'message', (row) => `json_object('address', ${row}.mailbox, 'id', ${row}.id)`,
12
+ (row) => `json_object('state', ${row}.state, 'body_expired_at_ms', ${row}.body_expired_at_ms)`],
13
+ ['deliveries', 'delivery', (row) => `json_object('address', ${row}.mailbox, 'id', ${row}.message)`,
14
+ (row) => `json_object('due_at_ms', ${row}.due_at_ms, 'attempts', ${row}.attempts)`],
15
+ ['claims', 'claim', (row) => `json_object('address', ${row}.mailbox, 'id', ${row}.message)`],
16
+ ['subscriptions', 'subscription', (row) => `json_object('address', ${row}.mailbox, 'id', ${row}.message)`],
17
+ ['mail_records', 'record', (row) => `json_object('collection', ${row}.collection, 'key', ${row}.key)`],
18
+ ['thread_markers', 'thread_marker', (row) => `json_object('thread', ${row}.thread, 'marker', ${row}.marker)`],
19
+ ['sent_messages', 'sent', (row) => `json_object('sender_key', ${row}.sender_key, 'id', ${row}.id, 'recipient', ${row}.recipient)`],
20
+ ];
21
+
22
+ export const CHANGE_SCHEMA = `
23
+ CREATE TABLE IF NOT EXISTS machine_changes (
24
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ at_ms INTEGER NOT NULL,
26
+ topic TEXT NOT NULL,
27
+ key TEXT NOT NULL CHECK (json_valid(key)),
28
+ operation TEXT NOT NULL CHECK (operation IN ('insert', 'update', 'delete')),
29
+ value TEXT CHECK (value IS NULL OR json_valid(value))
30
+ ) STRICT;
31
+ CREATE INDEX IF NOT EXISTS machine_changes_topic ON machine_changes(topic, sequence);
32
+ CREATE TRIGGER IF NOT EXISTS machine_changes_bound AFTER INSERT ON machine_changes BEGIN
33
+ DELETE FROM machine_changes WHERE sequence <= NEW.sequence - ${MAX_CHANGE_ROWS};
34
+ END;
35
+ ${ROWS.flatMap(([table, topic, key, value]) => ['insert', 'update', 'delete'].map((operation) => {
36
+ const row = operation === 'delete' ? 'OLD' : 'NEW';
37
+ return `CREATE TRIGGER IF NOT EXISTS ${table}_change_${operation} AFTER ${operation.toUpperCase()} ON ${table} BEGIN
38
+ INSERT INTO machine_changes(at_ms, topic, key, operation, value)
39
+ VALUES (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER), '${topic}', ${key(row)}, '${operation}', ${value && operation !== 'delete' ? value(row) : 'NULL'});
40
+ END;`;
41
+ })).join('\n')}
42
+ `;
43
+
44
+ export class ChangeStream {
45
+ constructor(database) {
46
+ this.database = database;
47
+ this.id = database.metadata('change_stream_id');
48
+ if (!this.id) { this.id = randomUUID(); database.setMetadata('change_stream_id', this.id); }
49
+ this.readers = new Set();
50
+ }
51
+
52
+ cursor() {
53
+ const row = this.database.statement("SELECT seq FROM sqlite_sequence WHERE name = 'machine_changes'").get();
54
+ return { id: this.id, sequence: row?.seq ?? 0 };
55
+ }
56
+
57
+ /** A cursor is for this database, not merely a number that another fresh
58
+ * database may reach. A pruned gap is explicit and never a partial replay. */
59
+ read(after, { topics = [], addresses = [], limit = PAGE } = {}) {
60
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > PAGE) throw new Error('a change page has 1–256 rows');
61
+ if (![topics, addresses].every((items) => Array.isArray(items) && items.every((item) => typeof item === 'string'))) throw new Error('change filters must be string arrays');
62
+ const head = this.cursor();
63
+ const oldest = this.database.statement('SELECT min(sequence) AS sequence FROM machine_changes').get().sequence ?? head.sequence + 1;
64
+ let reason = null;
65
+ if (!after) reason = 'no cursor';
66
+ else if (after.id !== this.id) reason = 'the cursor names a different database';
67
+ else if (!Number.isSafeInteger(after.sequence) || after.sequence < 0 || after.sequence > head.sequence) reason = 'the cursor sequence is invalid';
68
+ else if (after.sequence < oldest - 1) reason = 'the retained change window no longer contains this cursor';
69
+ if (reason) return { cursor: head, reset: true, reason, changes: [], more: false };
70
+ const rows = this.database.statement(`SELECT sequence, at_ms, topic, key, operation, value FROM machine_changes
71
+ WHERE sequence > ? AND (json_array_length(?) = 0 OR topic IN (SELECT value FROM json_each(?)))
72
+ AND (json_array_length(?) = 0 OR json_extract(key, '$.address') IN (SELECT value FROM json_each(?)))
73
+ ORDER BY sequence LIMIT ?`).all(after.sequence, JSON.stringify(topics), JSON.stringify(topics), JSON.stringify(addresses), JSON.stringify(addresses), limit + 1);
74
+ const more = rows.length > limit;
75
+ if (more) rows.pop();
76
+ return {
77
+ cursor: { id: this.id, sequence: more ? rows.at(-1).sequence : head.sequence }, reset: false, more,
78
+ changes: rows.map(({ key, value, ...row }) => ({ ...row, key: JSON.parse(key), value: value == null ? null : JSON.parse(value) })),
79
+ };
80
+ }
81
+
82
+ /** Owner-generated changes (for example record row deltas) share the sequence
83
+ * with mail, and can be committed with the state that they describe. */
84
+ append(topic, key, value, operation = 'update') {
85
+ const row = this.database.statement('INSERT INTO machine_changes(at_ms, topic, key, operation, value) VALUES (?, ?, ?, ?, ?)')
86
+ .run(Date.now(), topic, JSON.stringify(key), operation, value == null ? null : JSON.stringify(value));
87
+ return { id: this.id, sequence: Number(row.lastInsertRowid) };
88
+ }
89
+
90
+ /** Subscribe before the first read. A synchronous commit between page reads
91
+ * marks the drain dirty; reconnects resume from the last delivered cursor. */
92
+ follow(write, { after = null, topics = [], addresses = [], initial = false } = {}) {
93
+ let cursor = after, closed = false, scheduled = false, first = true;
94
+ const stop = () => {
95
+ if (closed) return;
96
+ closed = true; this.database.off('commit', wake); this.readers.delete(stop);
97
+ };
98
+ const drain = () => {
99
+ scheduled = false;
100
+ if (closed || this.database.closed) return stop();
101
+ try {
102
+ const page = this.read(cursor, { topics, addresses });
103
+ cursor = page.cursor;
104
+ if (page.reset || page.changes.length || (first && initial)) {
105
+ const resumed = write(page);
106
+ if (resumed?.id === this.id && Number.isSafeInteger(resumed.sequence)) cursor = resumed;
107
+ }
108
+ first = false;
109
+ if (page.more) wake();
110
+ } catch (error) { stop(); try { write({ error: error.message }); } catch { /* its reader is gone */ } }
111
+ };
112
+ const wake = () => {
113
+ if (closed || scheduled) return;
114
+ scheduled = true; setImmediate(drain);
115
+ };
116
+ this.database.on('commit', wake); this.readers.add(stop); wake();
117
+ return stop;
118
+ }
119
+
120
+ /** A long-poll predicate is evaluated after installing the durable stream,
121
+ * closing the old read/listen gap. A cursor reset asks the predicate for its
122
+ * current snapshot. Stop resolves false before the database is closed. */
123
+ wait(predicate, { topics = [], addresses = [], timeoutMs = 0 } = {}) {
124
+ return new Promise((resolve, reject) => {
125
+ let timer, settled = false;
126
+ const finish = (value, error = null) => {
127
+ if (settled) return;
128
+ settled = true; clearTimeout(timer); stop(); this.readers.delete(ended);
129
+ error ? reject(error) : resolve(value);
130
+ };
131
+ const check = () => {
132
+ try { if (predicate()) finish(true); } catch (error) { finish(false, error); }
133
+ };
134
+ const ended = () => finish(false);
135
+ const stop = this.follow((page) => page.error ? finish(false, new Error(page.error)) : check(), { after: this.cursor(), topics, addresses });
136
+ this.readers.add(ended);
137
+ timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
138
+ check();
139
+ });
140
+ }
141
+
142
+ stop() { for (const stop of [...this.readers]) stop(); }
143
+ }