@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/connector/index.mjs +2 -2
- package/live-sessions.mjs +12 -3
- package/machine-records.mjs +113 -45
- package/machine.mjs +75 -63
- package/mail/carrier.mjs +202 -157
- package/mail/change-stream.mjs +143 -0
- package/mail/delivery-status.mjs +42 -0
- package/mail/hooks.mjs +90 -15
- package/mail/reads.mjs +2 -1
- package/mail/relay-routing.mjs +41 -0
- package/mail/relay.mjs +120 -204
- package/mail/send.mjs +56 -32
- package/mail/sqlite-store.mjs +166 -16
- package/mail/sqlite.mjs +42 -4
- package/package.json +1 -1
- package/record-state.mjs +143 -0
package/mail/send.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { AGENT_HARNESS, RESUME_WAIT_MS } from './agents.mjs';
|
|
|
9
9
|
import { MAX_RELAYED_BYTES, RELAY_NAME_PREFIX } from './relay.mjs';
|
|
10
10
|
import { FULL_ID_LEN, cameThroughLink, envelope as makeEnvelope, makeAddress, messageIdFor, newMessageId, normalMachineName, parseAddress, senderIdentity, subjectLine, threadOf } from './compose.mjs';
|
|
11
11
|
import { nativeMailContinuation } from './continuation.mjs';
|
|
12
|
+
import { paneWaitReason, terminalReceipt } from './delivery-status.mjs';
|
|
12
13
|
|
|
13
14
|
export const EXIT_REFUSED = 2;
|
|
14
15
|
export const EXIT_UNKNOWN = 3;
|
|
@@ -29,7 +30,7 @@ const CODEX_KNOWN_WAIT_MS = 20_000;
|
|
|
29
30
|
const RELAY_GRACE_MS = 30_000;
|
|
30
31
|
/** The kinds of delivery that hand nothing over (#deliverWoken's `how`): the copy waits filed. An operator's mailbox,
|
|
31
32
|
* which its operator reads, and a session's hook, which shows it, are deliveries. */
|
|
32
|
-
const LEFT_FILED = new Set(['queued', 'stored', 'pane_held', 'pane_waits', 'not_running']);
|
|
33
|
+
const LEFT_FILED = new Set(['queued', 'stored', 'pane_held', 'pane_waits', 'native_waits', 'not_running']);
|
|
33
34
|
|
|
34
35
|
/** Replies in one unbroken chain between two sessions beyond which another is refused. */
|
|
35
36
|
const REPLY_CHAIN_LIMIT = 32;
|
|
@@ -41,7 +42,7 @@ const NAME_SEEN_EVERY_MS = 60 * 60 * 1000;
|
|
|
41
42
|
const outcome = (code, text, receipt = null) => ({ code, text, receipt });
|
|
42
43
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
43
44
|
/** The daemon pane a live session runs in, or null (mail_route.rs `daemon_pane`). */
|
|
44
|
-
const paneOf = (session) => { const
|
|
45
|
+
const paneOf = (session) => { const selected = session.door === 'native' ? session.delivery_pane : session.pane; const pane = typeof selected === 'string' ? selected.split(':')[0] : null; return pane && /^p_[A-Za-z0-9_-]+$/.test(pane) ? pane : null; };
|
|
45
46
|
|
|
46
47
|
/** A delivery a door refused before anything was sent. */
|
|
47
48
|
class Refused extends Error { constructor(kind, bytes = 0) { super(kind); this.kind = kind; this.bytes = bytes; } }
|
|
@@ -274,7 +275,10 @@ export class MailSend {
|
|
|
274
275
|
const session = sessions.find((row) => row.address === address);
|
|
275
276
|
if (!session) return null;
|
|
276
277
|
if (session.door === 'runtime') return { kind: 'runtime', session };
|
|
277
|
-
if (session.door === 'native')
|
|
278
|
+
if (session.door === 'native') {
|
|
279
|
+
const pane = paneOf(session);
|
|
280
|
+
return pane ? { kind: 'pane', session, pane } : { kind: 'native', session };
|
|
281
|
+
}
|
|
278
282
|
if (session.door === 'hook') return { kind: 'hook', session, pane: session.pane ?? null, idle: session.status === 'idle' };
|
|
279
283
|
// a session with no hook and no relay (Grok) in a daemon pane: its mail is typed there, into an empty composer
|
|
280
284
|
if (session.door === 'pane' && session.pane) return { kind: 'pane', session, pane: session.pane };
|
|
@@ -288,11 +292,16 @@ export class MailSend {
|
|
|
288
292
|
if (address === caller.address) return outcome(EXIT_REFUSED, 'Not sent: that address is your own session.');
|
|
289
293
|
const door = parseAddress(address).harness === AGENT_HARNESS ? null : this.#door(address, sessions);
|
|
290
294
|
const messageId = id == null && chosen ? chosen : messageIdFor(caller.address, id);
|
|
291
|
-
const terminal = door?.kind
|
|
295
|
+
const terminal = ['hook', 'pane'].includes(door?.kind) && door.pane != null;
|
|
292
296
|
const previous = this.store.sentMarker(caller.address, messageId);
|
|
293
297
|
if (previous !== null) {
|
|
294
|
-
|
|
295
|
-
|
|
298
|
+
const stored = this.store.findIn(address, messageId);
|
|
299
|
+
const waiting = !['operator', 'board'].includes(parseAddress(address).harness) && stored?.state === 'unread'
|
|
300
|
+
&& this.store.claimOf(address, messageId)?.recipient !== true;
|
|
301
|
+
const receipt = this.#deliveryReceipt(address, messageId, terminal || waiting);
|
|
302
|
+
if ((terminal || waiting) && !queue && !receipt.refused && !receipt.delivered) this.store.requestWake(address, messageId, { keep: true });
|
|
303
|
+
return outcome(!receipt.delivered ? EXIT_STORED : 0,
|
|
304
|
+
`Already filed with --id ${id ?? ''}: ${previous} Nothing was sent again.${receipt.refused ? ` Delivery stopped: ${receipt.reason}.` : ''}`, receipt);
|
|
296
305
|
}
|
|
297
306
|
const answered = this.#answeredId(caller.address, in_reply_to);
|
|
298
307
|
if (answered.refused) return answered.refused;
|
|
@@ -334,7 +343,7 @@ export class MailSend {
|
|
|
334
343
|
const [code, what] = {
|
|
335
344
|
steered: [0, 'steered into its running turn'],
|
|
336
345
|
started: [0, 'it was idle, so the message started a turn'],
|
|
337
|
-
|
|
346
|
+
native_waits: [EXIT_STORED, 'Stored: its native session is not known to be idle, so the message waits unread until it is idle. Do not resend'],
|
|
338
347
|
native_idle: [0, 'it was idle, so the message starts its next turn'],
|
|
339
348
|
hooked: [0, 'queued in its mailbox; terminal delivery waits for native transcript confirmation'],
|
|
340
349
|
hook_woken: [0, 'it was idle, so its pane was told to read its mailbox; its hook shows it the message in that turn'],
|
|
@@ -342,8 +351,8 @@ export class MailSend {
|
|
|
342
351
|
operator: [0, 'filed in its mailbox'],
|
|
343
352
|
already: [0, 'it already had this message; nothing was sent again'],
|
|
344
353
|
typed: [0, 'typed into its pane as its next turn'],
|
|
345
|
-
pane_held: [
|
|
346
|
-
pane_waits: [EXIT_STORED,
|
|
354
|
+
pane_held: [EXIT_STORED, 'Stored: held by its terminal queue; native transcript acceptance is not confirmed. Do not resend'],
|
|
355
|
+
pane_waits: [EXIT_STORED, `Stored: its pane did not accept input (${this.store.wakeState(address, messageId).last_error ?? 'not at an empty composer'}); the message waits unread in its mailbox. Do not resend`],
|
|
347
356
|
stored: [EXIT_STORED, `Stored (not failed): it has no delivery door, so it sees this only if it runs supercode message inbox${parseAddress(address).harness === 'codex' ? ' (a Codex session gets one with: supercode message setup codex)' : ''}. Don't resend, and don't wait for a reply`],
|
|
348
357
|
}[delivered];
|
|
349
358
|
this.#recordSend(caller.address, address, messageId, sentIn, receiver.continuation);
|
|
@@ -351,8 +360,8 @@ export class MailSend {
|
|
|
351
360
|
const text = code === 0
|
|
352
361
|
? `sent to ${name} (${harness}, ${door.kind}): ${what}. Message id ${shortId(messageId)}.${receiver.alias ?? ''}${idleNote} Don't poll; carry on.`
|
|
353
362
|
: `${name} (${harness}): ${what}. Message id ${shortId(messageId)}.${idleNote}`;
|
|
354
|
-
if (code === 0 && id != null) { try { this.store.setSentMarker(caller.address, messageId, text); } catch { /* the marker is best kept */ } }
|
|
355
|
-
return outcome(code, text, code === 0 ? this.#deliveryReceipt(address, messageId, terminal) : null);
|
|
363
|
+
if ((code === 0 || (terminal && code === EXIT_STORED)) && id != null) { try { this.store.setSentMarker(caller.address, messageId, text); } catch { /* the marker is best kept */ } }
|
|
364
|
+
return outcome(code, text, code === 0 || terminal ? this.#deliveryReceipt(address, messageId, terminal) : null);
|
|
356
365
|
}
|
|
357
366
|
|
|
358
367
|
/** An agent's plan for `sent` (its thread store decides), or an outcome when the plan refused it. */
|
|
@@ -386,8 +395,16 @@ export class MailSend {
|
|
|
386
395
|
if (how === 'not_woken') { this.store.deliver(to, sent); delivered = 'queued'; }
|
|
387
396
|
else { this.store.deliverRead(to, handed); finalReply = answers; delivered = how === 'steered' ? 'steered' : 'started'; }
|
|
388
397
|
} else if (door.kind === 'native') {
|
|
389
|
-
const busy = door.session.status !== 'idle';
|
|
398
|
+
const busy = door.session.status !== 'idle' || door.session.panes_current === false;
|
|
390
399
|
if (!wake && !busy) throw new Refused('queue');
|
|
400
|
+
if (busy) {
|
|
401
|
+
this.store.deliver(to, sent);
|
|
402
|
+
this.store.requestWake(to, sent.id, { keep: true });
|
|
403
|
+
const state = this.store.wakeState(to, sent.id);
|
|
404
|
+
this.store.setWakeState(to, sent.id, { ...state, waiting_for_idle: true, next_at_ms: Date.now() + 300_000 });
|
|
405
|
+
if (notifyWhenIdle && !cameThroughLink(sent)) this.store.subscribeIdle(to, { message_id: sent.id, subscriber: sent.from, notice: true, final_reply: false });
|
|
406
|
+
return 'native_waits';
|
|
407
|
+
}
|
|
391
408
|
// its reply line names the door this session really has: its tools when it runs supercode's MCP server
|
|
392
409
|
const handed = sent.reply_via?.mode === 'command' && door.session.tools ? { ...sent, reply_via: { mode: 'tool' } } : sent;
|
|
393
410
|
const text = render(handed);
|
|
@@ -421,7 +438,7 @@ export class MailSend {
|
|
|
421
438
|
}
|
|
422
439
|
this.relaying.add(key);
|
|
423
440
|
let relayed;
|
|
424
|
-
try { relayed = await this.relays.send(handed.from, handed.from_name, { sessionId: door.session.address.split(':').slice(3).join(':'), name: door.session.name.split('@')[0], socket: door.session.socket }, text, handed.id); }
|
|
441
|
+
try { relayed = await this.relays.send(handed.from, handed.from_name, { sessionId: door.session.address.split(':').slice(3).join(':'), name: door.session.name.split('@')[0], socket: door.session.socket }, text, handed.id, { expectsReply: handed.reply_via?.mode !== 'none' }); }
|
|
425
442
|
finally { this.relaying.delete(key); }
|
|
426
443
|
// a send the relay may have made keeps its copy and is unanswered, never taken back and said not sent; only one the
|
|
427
444
|
// relay is known not to have made (refused, never handed in) takes its copy back. One its receiver read from its
|
|
@@ -441,7 +458,7 @@ export class MailSend {
|
|
|
441
458
|
const now = this.store.findIn(to, handed.id);
|
|
442
459
|
if (now?.state === 'unread') { try { this.store.markRead(to, now.path); } catch { /* moved meanwhile */ } }
|
|
443
460
|
}
|
|
444
|
-
delivered =
|
|
461
|
+
delivered = 'native_idle';
|
|
445
462
|
} else if (door.kind === 'hook') {
|
|
446
463
|
this.store.deliver(to, sent);
|
|
447
464
|
if (wake) this.store.requestWake(to, sent.id);
|
|
@@ -449,13 +466,21 @@ export class MailSend {
|
|
|
449
466
|
} else if (door.kind === 'pane') {
|
|
450
467
|
// typed as its next turn only into an empty composer (machine.mjs #paneTurn); otherwise it waits in its mailbox
|
|
451
468
|
let typed = null;
|
|
452
|
-
if (wake) { try { typed = await this.typeTurn(door.pane, parseAddress(to).harness, render(sent), [sent.id]); } catch {
|
|
469
|
+
if (wake) { try { typed = await this.typeTurn(door.pane, parseAddress(to).harness, render(sent), [sent.id], to); } catch (error) { typed = { reason: error.message }; } }
|
|
453
470
|
if (typed?.delivered) { this.store.deliverRead(to, sent); delivered = 'typed'; }
|
|
454
471
|
// held by the daemon's queue for the pane, typed once its composer frees: not typed yet, so kept unread
|
|
455
|
-
else if (typed?.queued) { this.store.deliver(to, sent); delivered = 'pane_held'; }
|
|
472
|
+
else if (typed?.queued) { this.store.deliver(to, sent); if (wake) this.store.requestWake(to, sent.id, { keep: true }); delivered = 'pane_held'; }
|
|
456
473
|
// not typed (its composer held a draft or a turn ran): it waits unread with a wake, so the carrier types it once the
|
|
457
474
|
// composer is empty (mail/carrier.mjs), never left for a read a session with no hook never makes
|
|
458
|
-
else {
|
|
475
|
+
else {
|
|
476
|
+
this.store.deliver(to, sent);
|
|
477
|
+
if (wake) {
|
|
478
|
+
this.store.requestWake(to, sent.id, { keep: true });
|
|
479
|
+
const state = this.store.wakeState(to, sent.id);
|
|
480
|
+
this.store.setWakeState(to, sent.id, { ...state, next_at_ms: Date.now() + 2_000, last_error: paneWaitReason(typed) });
|
|
481
|
+
}
|
|
482
|
+
delivered = wake ? 'pane_waits' : 'queued';
|
|
483
|
+
}
|
|
459
484
|
} else if (door.kind === 'stored') { this.store.deliver(to, sent); delivered = 'stored'; }
|
|
460
485
|
else { this.store.deliver(to, sent); delivered = 'operator'; }
|
|
461
486
|
// a hand-over to the recipient's own door is its delivery, recorded as such
|
|
@@ -592,7 +617,7 @@ export class MailSend {
|
|
|
592
617
|
if (error instanceof Refused) throw new Error(`${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}`);
|
|
593
618
|
throw error;
|
|
594
619
|
}
|
|
595
|
-
return { how, text: { steered: 'steered into its running turn', started: 'started a turn',
|
|
620
|
+
return { how, text: { steered: 'steered into its running turn', started: 'started a turn', native_waits: 'waits unread until its native turn is idle', native_idle: 'starts its next turn', hooked: 'in its mailbox, its hook shows it', queued: 'waits in its mailbox', operator: 'filed', already: 'it already had it', stored: 'stored; it has no delivery door', typed: 'typed into its pane', pane_held: 'held for its pane until its composer is free', pane_waits: 'waits in its mailbox (its composer was not empty)' }[how] };
|
|
596
621
|
}
|
|
597
622
|
|
|
598
623
|
// ---- a filing --------------------------------------------------------------------------------------------------
|
|
@@ -665,9 +690,11 @@ export class MailSend {
|
|
|
665
690
|
id = `m-${blake3Hex(JSON.stringify([sender, receiver, key]))}`.slice(0, 26);
|
|
666
691
|
const previous = this.store.findIn(receiver, id);
|
|
667
692
|
const saved = previous ? this.store.body(previous) : null;
|
|
668
|
-
|
|
693
|
+
const bodyMatches = previous ? this.store.bodyMatches(receiver, id, text) : null;
|
|
694
|
+
if (saved && (bodyMatches === false || (saved.subject ?? null) !== subject || saved.kind !== (params.channel ? 'channel' : 'peer') || saved.from_name !== (params.sender_name ?? `${fromName}@${this.machine}`) || (saved.in_reply_to ?? null) !== (params.in_reply_to ?? null) || (saved.voice_for ?? null) !== (params.voice_for ?? null))) {
|
|
669
695
|
return refused('idempotency_conflict', 'this key already names different mail');
|
|
670
696
|
}
|
|
697
|
+
if (saved && bodyMatches == null) return { ...refused('idempotency_payload_expired', 'this key already names retained mail, but an older release expired its payload before keeping a digest; the original payload cannot be compared. Nothing was filed again.'), message_id: id };
|
|
671
698
|
}
|
|
672
699
|
const inReplyTo = typeof params.in_reply_to === 'string' ? params.in_reply_to : null;
|
|
673
700
|
const sent = makeEnvelope({
|
|
@@ -691,9 +718,9 @@ export class MailSend {
|
|
|
691
718
|
return refused('delivery_failed', error.message);
|
|
692
719
|
}
|
|
693
720
|
return {
|
|
694
|
-
delivered_to_bus: door.kind !== 'stored' &&
|
|
721
|
+
delivered_to_bus: door.kind !== 'stored' && !['pane_waits', 'pane_held', 'native_waits'].includes(how), message_id: sent.id, reply_to: sender,
|
|
695
722
|
target: { harness, session_id: sessionId, name: door.kind === 'native' ? door.session.name.split('@')[0] : null },
|
|
696
|
-
delivery: { door: door.kind, how: { steered: 'steered', started: 'started',
|
|
723
|
+
delivery: { door: door.kind, how: { steered: 'steered', started: 'started', native_waits: 'waiting_for_idle', native_idle: 'started', hooked: 'hook', queued: 'queued', stored: 'stored', operator: 'filed', already: 'already', typed: 'typed', pane_held: 'held', pane_waits: 'stored' }[how] },
|
|
697
724
|
};
|
|
698
725
|
}
|
|
699
726
|
|
|
@@ -807,7 +834,7 @@ export class MailSend {
|
|
|
807
834
|
const claimed = this.store.claimUserTurn(address, waiting);
|
|
808
835
|
if (!claimed) break;
|
|
809
836
|
let result;
|
|
810
|
-
try { result = await this.typeTurn(pane, harness, claimed.envelope.body, [claimed.envelope.id]); }
|
|
837
|
+
try { result = await this.typeTurn(pane, harness, claimed.envelope.body, [claimed.envelope.id], address); }
|
|
811
838
|
catch (error) {
|
|
812
839
|
try { this.store.release(address, claimed); } catch { /* recovered when this process ends */ }
|
|
813
840
|
this.log(`mail: the user's turn ${claimed.envelope.id} for ${address} waits: ${error.message}`);
|
|
@@ -859,7 +886,7 @@ export class MailSend {
|
|
|
859
886
|
const door = this.#door(address, this.live.sessions());
|
|
860
887
|
if (!door) return { outcome: 'later', detail: 'its session is not running' };
|
|
861
888
|
if (door.kind === 'other_machine') return { outcome: 'later', detail: `it is on ${door.machine}` };
|
|
862
|
-
if (door.kind !== 'native' && door.kind !== 'runtime') {
|
|
889
|
+
if (door.kind !== 'native' && door.kind !== 'runtime' && door.kind !== 'pane') {
|
|
863
890
|
// a hook's session is pointed at its mailbox by a wake (a native notice filed for it asks one)
|
|
864
891
|
if (door.kind === 'hook' && wake) this.store.requestWake(address, id);
|
|
865
892
|
return { outcome: 'filed', detail: `its door is ${door.kind}` };
|
|
@@ -874,6 +901,7 @@ export class MailSend {
|
|
|
874
901
|
try {
|
|
875
902
|
const how = await this.route(sent, address, door, true, false);
|
|
876
903
|
// handed to the session (or already its): moved to cur/ (one only queued for a runtime not woken would stay unread)
|
|
904
|
+
if (LEFT_FILED.has(how)) return { outcome: how === 'native_waits' ? 'waiting' : how === 'pane_held' ? 'pending' : 'later', detail: how === 'pane_waits' ? this.store.wakeState(address, id).last_error : how };
|
|
877
905
|
const now = this.store.findIn(address, id);
|
|
878
906
|
if (how !== 'queued' && now?.state === 'unread') this.store.markRead(address, now.path);
|
|
879
907
|
return { outcome: 'delivered', detail: null };
|
|
@@ -979,15 +1007,11 @@ export class MailSend {
|
|
|
979
1007
|
* `delivery_receipt`). */
|
|
980
1008
|
#deliveryReceipt(address, id, terminal) {
|
|
981
1009
|
if (!terminal) return { delivered: true, message_ids: [id], receipt: 'native-mail' };
|
|
982
|
-
const
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
if (
|
|
986
|
-
|
|
987
|
-
if (receipt) return receipt;
|
|
988
|
-
return { delivered: false, queued: true, message_ids: [id], reason: state.active?.error ?? null };
|
|
989
|
-
}
|
|
990
|
-
return { delivered: false, queued: true, message_ids: [id], reason: 'awaiting terminal mailbox' };
|
|
1010
|
+
const expired = this.store.record('expired', `${address}/${id}`);
|
|
1011
|
+
const receipt = terminalReceipt(this.store, address, id);
|
|
1012
|
+
if (receipt?.delivered) return receipt;
|
|
1013
|
+
if (expired) return { delivered: false, queued: false, refused: true, message_ids: [id], reason: expired.reason };
|
|
1014
|
+
return receipt ?? { delivered: false, queued: true, message_ids: [id], reason: this.store.wakeState(address, id).last_error ?? 'awaiting terminal mailbox' };
|
|
991
1015
|
}
|
|
992
1016
|
|
|
993
1017
|
// ---- the names ledger --------------------------------------------------------------------------------------------
|
package/mail/sqlite-store.mjs
CHANGED
|
@@ -27,6 +27,7 @@ const stored = (row) => ({
|
|
|
27
27
|
});
|
|
28
28
|
const COLUMNS = 'mailbox, id, header, state, claim_token';
|
|
29
29
|
const validId = (id) => typeof id === 'string' && /^[A-Za-z0-9_-]+$/.test(id);
|
|
30
|
+
const OFFLINE_CEILING_MS = 30 * 24 * 60 * 60_000;
|
|
30
31
|
|
|
31
32
|
export class SqliteMailStore extends EventEmitter {
|
|
32
33
|
constructor({ mailRoot, machine = null, log = () => {}, database = null, retention } = {}) {
|
|
@@ -51,6 +52,7 @@ export class SqliteMailStore extends EventEmitter {
|
|
|
51
52
|
this.database ??= new MailDatabase(join(dirname(this.root), 'mail.sqlite'), { retention: this.retention });
|
|
52
53
|
this.imported = await migrateMaildir(this.database, this.root, { log: this.log });
|
|
53
54
|
this.database.retain();
|
|
55
|
+
this.reconcileDeliverySchedule();
|
|
54
56
|
this.database.on('message', (address) => this.emit('changed', address));
|
|
55
57
|
this.database.on('wake', ({ address, id }) => this.emit('wake', { address, id }));
|
|
56
58
|
this.database.on('subscriptions', (address) => this.emit('subscriptions', address));
|
|
@@ -69,6 +71,7 @@ export class SqliteMailStore extends EventEmitter {
|
|
|
69
71
|
this.owner?.close(); this.owner = null;
|
|
70
72
|
}
|
|
71
73
|
statement(sql) { return this.database.statement(sql); }
|
|
74
|
+
get changes() { return this.database.changes; }
|
|
72
75
|
transaction(operation) { return this.database.transaction(operation); }
|
|
73
76
|
record(collection, key) { return this.database.record(collection, key); }
|
|
74
77
|
records(collection) { return this.database.records(collection); }
|
|
@@ -88,6 +91,29 @@ export class SqliteMailStore extends EventEmitter {
|
|
|
88
91
|
messages: Object.fromEntries(states.map(({ state, count }) => [state, count])),
|
|
89
92
|
mailbox_count: this.statement('SELECT count(*) AS count FROM mailboxes').get().count,
|
|
90
93
|
pending_deliveries: this.statement('SELECT count(*) AS count FROM deliveries').get().count,
|
|
94
|
+
delivery_targets: this.statement('SELECT count(*) AS count FROM delivery_targets').get().count,
|
|
95
|
+
next_delivery: this.nextDeliveryTarget(),
|
|
96
|
+
change_cursor: this.changes.cursor(),
|
|
97
|
+
relay_pool: {
|
|
98
|
+
handles: this.statement("SELECT count(*) AS count FROM mail_records WHERE collection = 'relays' AND key LIKE '%/relay.json' AND json_extract(record, '$.pool') = 1").get().count,
|
|
99
|
+
unanswered_starts: this.statement("SELECT count(*) AS count FROM mail_records WHERE collection = 'relays' AND key LIKE '%/start.json'").get().count,
|
|
100
|
+
// These are committed owner handles, not liveness or PID authority.
|
|
101
|
+
// Let the local operator inspect and close a known runtime through its
|
|
102
|
+
// owner without reading SQLite or exposing a runtime bearer token.
|
|
103
|
+
recorded_owners: this.statement(`SELECT key, json_extract(record, '$.runtime_id') AS runtime_id,
|
|
104
|
+
json_extract(record, '$.connection') AS connection, json_extract(record, '$.pid') AS pid
|
|
105
|
+
FROM mail_records WHERE collection = 'relays' AND key LIKE '%/relay.json'
|
|
106
|
+
AND json_extract(record, '$.pool') = 1 ORDER BY key LIMIT 64`).all(),
|
|
107
|
+
recorded_starts: this.statement(`SELECT key, json_extract(record, '$.name') AS name,
|
|
108
|
+
json_extract(record, '$.at_ms') AS at_ms, json_extract(record, '$.directory') AS directory
|
|
109
|
+
FROM mail_records WHERE collection = 'relays' AND key LIKE '%/start.json' ORDER BY key LIMIT 64`).all(),
|
|
110
|
+
recorded_limit: 64,
|
|
111
|
+
reply_routes: this.statement("SELECT count(*) AS count FROM mail_records WHERE collection = 'relay_routes'").get().count,
|
|
112
|
+
ambiguous_routes: this.statement("SELECT count(*) AS count FROM mail_records WHERE collection = 'relay_routes' AND json_extract(record, '$.ambiguous') = 1").get().count,
|
|
113
|
+
deferred_replies: this.statement("SELECT count(*) AS count FROM mail_records WHERE collection = 'relay_pending'").get().count,
|
|
114
|
+
undelivered_replies: this.statement("SELECT count(*) AS count FROM messages WHERE json_type(header, '$.pool_reply_undelivered') IS NOT NULL").get().count,
|
|
115
|
+
pending_writer_notices: this.statement("SELECT count(*) AS count FROM messages WHERE state IN ('new', 'claimed') AND json_type(header, '$.pool_reply_failure_notice') IS NOT NULL").get().count,
|
|
116
|
+
},
|
|
91
117
|
subscriptions: this.statement('SELECT count(*) AS count FROM subscriptions').get().count,
|
|
92
118
|
carrier_rows: this.statement('SELECT count(*) AS count FROM carrier_log').get().count,
|
|
93
119
|
terminal_log_rows: this.statement('SELECT count(*) AS count FROM terminal_log').get().count,
|
|
@@ -130,6 +156,17 @@ export class SqliteMailStore extends EventEmitter {
|
|
|
130
156
|
return this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND state IN ('new', 'claimed') ORDER BY created_at_ms, id`).all(address).map(stored);
|
|
131
157
|
}
|
|
132
158
|
|
|
159
|
+
hookSummary(address) {
|
|
160
|
+
const source = `FROM messages m WHERE mailbox = ? AND state IN ('new', 'claimed')
|
|
161
|
+
AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.mailbox = m.mailbox AND d.message = m.id)`;
|
|
162
|
+
const count = this.statement(`SELECT count(*) AS count ${source}`).get(address).count;
|
|
163
|
+
const senders = this.statement(`SELECT kind, json_extract(header, '$.from_name') AS name, min(created_at_ms) AS first_at
|
|
164
|
+
${source} GROUP BY kind, name ORDER BY first_at, kind, name LIMIT 65`).all(address);
|
|
165
|
+
const moreSenders = senders.length > 64;
|
|
166
|
+
if (moreSenders) senders.pop();
|
|
167
|
+
return { count, senders, moreSenders };
|
|
168
|
+
}
|
|
169
|
+
|
|
133
170
|
typedSince(address, since, prefix) {
|
|
134
171
|
return this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND kind = 'user'
|
|
135
172
|
AND created_at_ms > ? AND id >= ? AND id < ? ORDER BY created_at_ms, id`)
|
|
@@ -249,6 +286,15 @@ export class SqliteMailStore extends EventEmitter {
|
|
|
249
286
|
return { matches, messages: [...members.values()] };
|
|
250
287
|
}
|
|
251
288
|
|
|
289
|
+
/** null means an older release expired the body before keeping a digest.
|
|
290
|
+
* Never compare its retention placeholder as though it were original text. */
|
|
291
|
+
bodyMatches(address, id, text) {
|
|
292
|
+
const row = this.statement('SELECT body, body_digest FROM messages WHERE mailbox = ? AND id = ?').get(address, id);
|
|
293
|
+
if (!row) return null;
|
|
294
|
+
if (row.body != null) return row.body === text;
|
|
295
|
+
return row.body_digest ? row.body_digest === createHash('sha256').update(text).digest('hex') : null;
|
|
296
|
+
}
|
|
297
|
+
|
|
252
298
|
findIn(address, id) {
|
|
253
299
|
const row = this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND id = ?`).get(address, id);
|
|
254
300
|
return row ? stored(row) : null;
|
|
@@ -298,8 +344,9 @@ export class SqliteMailStore extends EventEmitter {
|
|
|
298
344
|
}
|
|
299
345
|
|
|
300
346
|
userTurns(address) {
|
|
347
|
+
this.#recoverAbandoned(address);
|
|
301
348
|
return this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND state = 'new' AND kind = 'user'
|
|
302
|
-
AND COALESCE(json_extract(header, '$.in_reply_to'), '') NOT LIKE 'q-%' ORDER BY created_at_ms, id`)
|
|
349
|
+
AND COALESCE(json_extract(header, '$.in_reply_to'), '') NOT LIKE 'q-%' ORDER BY created_at_ms, id LIMIT 256`)
|
|
303
350
|
.all(address).map((row) => { const item = stored(row); return { ...item, envelope: this.body(item) }; });
|
|
304
351
|
}
|
|
305
352
|
|
|
@@ -343,11 +390,14 @@ export class SqliteMailStore extends EventEmitter {
|
|
|
343
390
|
this.open(address);
|
|
344
391
|
const { body, ...header } = envelope;
|
|
345
392
|
const firstLine = Array.from(body.split('\n', 1)[0]).slice(0, 400).join('');
|
|
346
|
-
const changed = this.statement(`INSERT INTO messages(mailbox, id, thread, sender, kind, subject, created_at_ms, state, header, body, first_line, read_at_ms)
|
|
347
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(mailbox, id) DO NOTHING`)
|
|
393
|
+
const changed = this.statement(`INSERT INTO messages(mailbox, id, thread, sender, kind, subject, created_at_ms, state, header, body, body_digest, first_line, read_at_ms)
|
|
394
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(mailbox, id) DO NOTHING`)
|
|
348
395
|
.run(address, envelope.id, envelope.thread ?? envelope.id, envelope.from ?? '', envelope.kind ?? 'peer', envelope.subject ?? null,
|
|
349
|
-
envelope.created_at_ms, state, JSON.stringify(header), body, firstLine, state === 'cur' ? Date.now() : null).changes;
|
|
350
|
-
if (changed)
|
|
396
|
+
envelope.created_at_ms, state, JSON.stringify(header), body, createHash('sha256').update(body).digest('hex'), firstLine, state === 'cur' ? Date.now() : null).changes;
|
|
397
|
+
if (changed) {
|
|
398
|
+
if (state === 'new' && envelope.kind === 'user' && !String(envelope.in_reply_to ?? '').startsWith('q-')) this.requestWake(address, envelope.id, { keep: true });
|
|
399
|
+
this.database.changed('message', address);
|
|
400
|
+
}
|
|
351
401
|
return rowReference(address, envelope.id);
|
|
352
402
|
});
|
|
353
403
|
}
|
|
@@ -367,31 +417,127 @@ export class SqliteMailStore extends EventEmitter {
|
|
|
367
417
|
remove(address, path) {
|
|
368
418
|
const key = reference(path);
|
|
369
419
|
if (!key || key[0] !== address) throw new Error('mail row reference belongs to a different mailbox');
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
420
|
+
return this.transaction(() => {
|
|
421
|
+
const changed = this.statement('DELETE FROM messages WHERE mailbox = ? AND id = ?').run(address, key[1]).changes > 0;
|
|
422
|
+
if (changed) {
|
|
423
|
+
this.refreshDeliveryTarget(...this.#deliveryTarget(address));
|
|
424
|
+
this.database.changed('message', address);
|
|
425
|
+
this.database.changed('wake', { address, id: key[1] });
|
|
426
|
+
}
|
|
427
|
+
return changed;
|
|
428
|
+
});
|
|
373
429
|
}
|
|
374
430
|
|
|
375
431
|
requestWake(address, id, { keep = false } = {}) {
|
|
376
432
|
if (!validId(id)) throw new Error('invalid wake message id');
|
|
377
433
|
this.open(address);
|
|
378
|
-
const sql = `INSERT INTO deliveries(mailbox, message, due_at_ms, attempts, record) VALUES (?, ?, 0, 0, '{}')
|
|
434
|
+
const sql = `INSERT INTO deliveries(mailbox, message, machine, due_at_ms, attempts, record) VALUES (?, ?, ?, 0, 0, '{}')
|
|
379
435
|
ON CONFLICT(mailbox, message) ${keep ? 'DO NOTHING' : "DO UPDATE SET due_at_ms = 0, attempts = 0, record = '{}'"}`;
|
|
380
|
-
|
|
436
|
+
this.transaction(() => {
|
|
437
|
+
if (this.statement(sql).run(address, id, address.split(':')[1]).changes) {
|
|
438
|
+
this.refreshDeliveryTarget(...this.#deliveryTarget(address));
|
|
439
|
+
this.database.changed('wake', { address, id });
|
|
440
|
+
}
|
|
441
|
+
});
|
|
381
442
|
}
|
|
382
443
|
wakes(address) { return this.statement('SELECT message FROM deliveries WHERE mailbox = ? ORDER BY due_at_ms, message').all(address).map((row) => row.message); }
|
|
383
444
|
allWakes() { return this.statement('SELECT mailbox AS address, message AS id FROM deliveries ORDER BY due_at_ms, mailbox, message').all(); }
|
|
384
445
|
hasWake(address, id) { return validId(id) && Boolean(this.statement('SELECT 1 FROM deliveries WHERE mailbox = ? AND message = ?').get(address, id)); }
|
|
385
|
-
removeWake(address, id) {
|
|
446
|
+
removeWake(address, id) {
|
|
447
|
+
return this.transaction(() => {
|
|
448
|
+
const changed = this.statement('DELETE FROM deliveries WHERE mailbox = ? AND message = ?').run(address, id).changes > 0;
|
|
449
|
+
if (changed) {
|
|
450
|
+
this.refreshDeliveryTarget(...this.#deliveryTarget(address));
|
|
451
|
+
this.database.changed('wake', { address, id });
|
|
452
|
+
}
|
|
453
|
+
return changed;
|
|
454
|
+
});
|
|
455
|
+
}
|
|
386
456
|
wakeState(address, id) {
|
|
387
457
|
const row = this.statement('SELECT record FROM deliveries WHERE mailbox = ? AND message = ?').get(address, id);
|
|
388
458
|
return { attempts: 0, next_at_ms: 0, last_error: null, ...(row ? JSON.parse(row.record) : {}) };
|
|
389
459
|
}
|
|
390
460
|
setWakeState(address, id, state) {
|
|
391
461
|
const record = { attempts: state.attempts ?? 0, next_at_ms: state.next_at_ms ?? 0, last_error: state.last_error ?? null,
|
|
392
|
-
handover_at_ms: state.handover_at_ms ?? null, failing_since_ms: state.failing_since_ms ?? null };
|
|
393
|
-
|
|
394
|
-
|
|
462
|
+
handover_at_ms: state.handover_at_ms ?? null, failing_since_ms: state.failing_since_ms ?? null, waiting_for_idle: state.waiting_for_idle === true };
|
|
463
|
+
this.transaction(() => {
|
|
464
|
+
if (this.statement('UPDATE deliveries SET record = ?, due_at_ms = ?, attempts = ? WHERE mailbox = ? AND message = ?')
|
|
465
|
+
.run(JSON.stringify(record), record.next_at_ms, record.attempts, address, id).changes) {
|
|
466
|
+
this.refreshDeliveryTarget(...this.#deliveryTarget(address));
|
|
467
|
+
this.database.changed('wake', { address, id });
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
#deliveryTarget(address) {
|
|
473
|
+
const machine = address.split(':')[1];
|
|
474
|
+
return machine === this.machine ? ['mailbox', address] : ['machine', machine];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** One indexed row per local mailbox or remote machine. Offline probing is
|
|
478
|
+
* a machine deadline, independent of each message's retry deadline. */
|
|
479
|
+
refreshDeliveryTarget(kind, target) {
|
|
480
|
+
const row = kind === 'mailbox'
|
|
481
|
+
? this.statement('SELECT min(due_at_ms) AS due FROM deliveries WHERE mailbox = ?').get(target)
|
|
482
|
+
: this.statement('SELECT min(due_at_ms) AS due FROM deliveries WHERE machine = ?').get(target);
|
|
483
|
+
if (row.due == null) {
|
|
484
|
+
this.statement('DELETE FROM delivery_targets WHERE kind = ? AND target = ?').run(kind, target);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const wait = kind === 'machine' ? this.machineWait(target) : null;
|
|
488
|
+
const due = wait ? Math.min(wait.next_probe_ms ?? 0, (wait.offline_since_ms ?? Date.now()) + OFFLINE_CEILING_MS) : row.due;
|
|
489
|
+
this.statement(`INSERT INTO delivery_targets(kind, target, due_at_ms) VALUES (?, ?, ?)
|
|
490
|
+
ON CONFLICT(kind, target) DO UPDATE SET due_at_ms = excluded.due_at_ms`).run(kind, target, due);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Startup and the five-minute backstop rebuild derived schedule rows. Normal
|
|
494
|
+
* writes refresh only the target they changed, in the same transaction. */
|
|
495
|
+
reconcileDeliverySchedule() {
|
|
496
|
+
this.transaction(() => {
|
|
497
|
+
this.statement("UPDATE deliveries SET machine = substr(mailbox, 4, instr(substr(mailbox, 4), ':') - 1) WHERE machine = ''").run();
|
|
498
|
+
this.statement(`INSERT INTO deliveries(mailbox, message, machine, due_at_ms, attempts, record)
|
|
499
|
+
SELECT mailbox, id, ?, 0, 0, '{}' FROM messages WHERE kind = 'user' AND state IN ('new', 'claimed')
|
|
500
|
+
AND substr(mailbox, 4, instr(substr(mailbox, 4), ':') - 1) = ? AND COALESCE(json_extract(header, '$.in_reply_to'), '') NOT LIKE 'q-%'
|
|
501
|
+
ON CONFLICT(mailbox, message) DO NOTHING`).run(this.machine, this.machine);
|
|
502
|
+
this.statement('DELETE FROM delivery_targets').run();
|
|
503
|
+
const targets = this.statement(`SELECT DISTINCT 'mailbox' AS kind, mailbox AS target FROM deliveries WHERE machine = ?
|
|
504
|
+
UNION SELECT DISTINCT 'machine', machine FROM deliveries WHERE machine <> ?`).all(this.machine, this.machine);
|
|
505
|
+
for (const { kind, target } of targets) this.refreshDeliveryTarget(kind, target);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** A live idle transition wakes only the native waits for those addresses. */
|
|
510
|
+
wakeIdleSessions(addresses) {
|
|
511
|
+
this.transaction(() => {
|
|
512
|
+
const changed = this.statement(`UPDATE deliveries SET due_at_ms = 0, record = json_set(record, '$.next_at_ms', 0)
|
|
513
|
+
WHERE mailbox IN (SELECT value FROM json_each(?)) AND json_extract(record, '$.waiting_for_idle') = 1
|
|
514
|
+
RETURNING mailbox`).all(JSON.stringify(addresses));
|
|
515
|
+
for (const address of new Set(changed.map((row) => row.mailbox))) this.refreshDeliveryTarget('mailbox', address);
|
|
516
|
+
if (changed.length) this.database.changed('wake', {});
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
nextDeliveryTarget(excluded = []) {
|
|
521
|
+
return this.statement(`SELECT t.kind, t.target, t.due_at_ms FROM delivery_targets t
|
|
522
|
+
WHERE NOT EXISTS (SELECT 1 FROM json_each(?) e WHERE json_extract(e.value, '$.kind') = t.kind AND json_extract(e.value, '$.target') = t.target)
|
|
523
|
+
ORDER BY t.due_at_ms, t.kind, t.target LIMIT 1`).get(JSON.stringify(excluded)) ?? null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
delayDeliveryTarget(kind, target, until) {
|
|
527
|
+
this.statement('UPDATE delivery_targets SET due_at_ms = max(due_at_ms, ?) WHERE kind = ? AND target = ?').run(until, kind, target);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
dueWakes(address, now, limit = 256) {
|
|
531
|
+
return this.statement(`SELECT message FROM deliveries WHERE mailbox = ? AND due_at_ms <= ?
|
|
532
|
+
ORDER BY due_at_ms, message LIMIT ?`).all(address, now, limit).map(({ message }) => message);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
remoteWakes(machine, now, { probing = false, limit = 256 } = {}) {
|
|
536
|
+
const sql = `SELECT d.mailbox AS address, d.message AS id, m.created_at_ms AS filed
|
|
537
|
+
FROM deliveries d JOIN messages m ON m.mailbox = d.mailbox AND m.id = d.message
|
|
538
|
+
WHERE d.machine = ? ${probing ? '' : 'AND d.due_at_ms <= ?'}
|
|
539
|
+
ORDER BY ${probing ? '' : 'd.due_at_ms, '}m.created_at_ms, d.mailbox, d.message LIMIT ?`;
|
|
540
|
+
return this.statement(sql).all(machine, ...(probing ? [] : [now]), limit);
|
|
395
541
|
}
|
|
396
542
|
|
|
397
543
|
expire(address, id, expiry) {
|
|
@@ -404,8 +550,12 @@ export class SqliteMailStore extends EventEmitter {
|
|
|
404
550
|
#machineKey(machine) { return `${String(machine).replace(/[^A-Za-z0-9._-]/g, '_')}.json`; }
|
|
405
551
|
machineWait(machine) { return this.record('machine_waits', this.#machineKey(machine)); }
|
|
406
552
|
setMachineWait(machine, wait) {
|
|
407
|
-
|
|
408
|
-
|
|
553
|
+
this.transaction(() => {
|
|
554
|
+
if (wait) this.putRecord('machine_waits', this.#machineKey(machine), wait);
|
|
555
|
+
else this.removeRecord('machine_waits', this.#machineKey(machine));
|
|
556
|
+
this.refreshDeliveryTarget('machine', machine);
|
|
557
|
+
this.database.changed('wake', { machine });
|
|
558
|
+
});
|
|
409
559
|
}
|
|
410
560
|
carrierLine(line) { this.database.appendCarrier(line); }
|
|
411
561
|
carrierLines(limit = 1000) {
|
package/mail/sqlite.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// The machine daemon's mail database. Only its owner opens a writable handle;
|
|
2
2
|
// clients use the machine socket. This module contains no harness or delivery IO.
|
|
3
3
|
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
import { createRequire } from 'node:module';
|
|
5
6
|
import { chmodSync, closeSync, mkdirSync, openSync } from 'node:fs';
|
|
6
7
|
import { dirname } from 'node:path';
|
|
8
|
+
import { CHANGE_SCHEMA, ChangeStream } from './change-stream.mjs';
|
|
9
|
+
import { RECORD_SCHEMA } from '../record-state.mjs';
|
|
7
10
|
|
|
8
|
-
export const MAIL_SCHEMA_VERSION =
|
|
11
|
+
export const MAIL_SCHEMA_VERSION = 3;
|
|
9
12
|
export const MAIL_RETENTION = Object.freeze({
|
|
10
13
|
bodyDays: 30,
|
|
11
14
|
maxReadHeaders: 1_000_000,
|
|
@@ -34,6 +37,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|
|
34
37
|
state TEXT NOT NULL CHECK (state IN ('new', 'claimed', 'cur')),
|
|
35
38
|
header TEXT NOT NULL CHECK (json_valid(header)),
|
|
36
39
|
body TEXT,
|
|
40
|
+
body_digest TEXT,
|
|
37
41
|
first_line TEXT NOT NULL,
|
|
38
42
|
body_expired_at_ms INTEGER,
|
|
39
43
|
read_at_ms INTEGER,
|
|
@@ -62,6 +66,7 @@ CREATE TABLE IF NOT EXISTS claims (
|
|
|
62
66
|
CREATE TABLE IF NOT EXISTS deliveries (
|
|
63
67
|
mailbox TEXT NOT NULL,
|
|
64
68
|
message TEXT NOT NULL,
|
|
69
|
+
machine TEXT NOT NULL DEFAULT '',
|
|
65
70
|
due_at_ms INTEGER NOT NULL,
|
|
66
71
|
attempts INTEGER NOT NULL,
|
|
67
72
|
record TEXT NOT NULL CHECK (json_valid(record)),
|
|
@@ -69,6 +74,15 @@ CREATE TABLE IF NOT EXISTS deliveries (
|
|
|
69
74
|
FOREIGN KEY (mailbox, message) REFERENCES messages(mailbox, id) ON DELETE CASCADE
|
|
70
75
|
) STRICT;
|
|
71
76
|
CREATE INDEX IF NOT EXISTS deliveries_due ON deliveries(due_at_ms, mailbox, message);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS deliveries_machine_due ON deliveries(machine, due_at_ms, mailbox, message);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS deliveries_mailbox_due ON deliveries(mailbox, due_at_ms, message);
|
|
79
|
+
CREATE TABLE IF NOT EXISTS delivery_targets (
|
|
80
|
+
kind TEXT NOT NULL CHECK (kind IN ('mailbox', 'machine')),
|
|
81
|
+
target TEXT NOT NULL,
|
|
82
|
+
due_at_ms INTEGER NOT NULL,
|
|
83
|
+
PRIMARY KEY (kind, target)
|
|
84
|
+
) STRICT;
|
|
85
|
+
CREATE INDEX IF NOT EXISTS delivery_targets_due ON delivery_targets(due_at_ms, kind, target);
|
|
72
86
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
73
87
|
mailbox TEXT NOT NULL,
|
|
74
88
|
message TEXT NOT NULL,
|
|
@@ -183,20 +197,30 @@ export class MailDatabase extends EventEmitter {
|
|
|
183
197
|
if (tables.some(({ name }) => name === 'mail_metadata')) {
|
|
184
198
|
const stored = this.connection.prepare('SELECT value FROM mail_metadata WHERE key = ?').get('schema_version');
|
|
185
199
|
storedVersion = stored ? JSON.parse(stored.value) : null;
|
|
186
|
-
if (
|
|
200
|
+
if (![1, 2, MAIL_SCHEMA_VERSION].includes(storedVersion)) throw new Error(`mail schema ${storedVersion} is not supported by this release`);
|
|
187
201
|
} else if (tables.length) throw new Error(`the database at ${path} has no mail schema receipt; its schema has not been changed`);
|
|
188
202
|
const mode = this.connection.prepare('PRAGMA journal_mode = WAL').get();
|
|
189
203
|
if (String(mode.journal_mode).toLowerCase() !== 'wal') throw new Error(`mail database cannot use WAL at ${path}`);
|
|
190
204
|
this.connection.exec('PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 2000;');
|
|
205
|
+
this.connection.function('supercode_sha256', { deterministic: true }, (body) => typeof body === 'string' ? createHash('sha256').update(body).digest('hex') : null);
|
|
191
206
|
this.transaction(() => {
|
|
207
|
+
if (storedVersion != null && !this.connection.prepare('PRAGMA table_info(messages)').all().some(({ name }) => name === 'body_digest')) {
|
|
208
|
+
this.connection.exec('ALTER TABLE messages ADD COLUMN body_digest TEXT;');
|
|
209
|
+
}
|
|
210
|
+
if ([1, 2].includes(storedVersion) && !this.connection.prepare('PRAGMA table_info(deliveries)').all().some(({ name }) => name === 'machine')) {
|
|
211
|
+
this.connection.exec("ALTER TABLE deliveries ADD COLUMN machine TEXT NOT NULL DEFAULT '';");
|
|
212
|
+
}
|
|
192
213
|
this.connection.exec(SCHEMA);
|
|
193
214
|
// The private first candidate stored only the four legacy send fields.
|
|
194
215
|
// Add the complete record before publishing the upgraded schema receipt.
|
|
195
216
|
if (storedVersion === 1 && !this.connection.prepare('PRAGMA table_info(sent_messages)').all().some(({ name }) => name === 'record')) {
|
|
196
217
|
this.connection.exec("ALTER TABLE sent_messages ADD COLUMN record TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(record) AND json_type(record) = 'object');");
|
|
197
218
|
}
|
|
219
|
+
this.connection.exec(RECORD_SCHEMA);
|
|
220
|
+
this.connection.exec(CHANGE_SCHEMA);
|
|
198
221
|
this.setMetadata('schema_version', MAIL_SCHEMA_VERSION);
|
|
199
222
|
});
|
|
223
|
+
this.changes = new ChangeStream(this);
|
|
200
224
|
} catch (error) {
|
|
201
225
|
this.connection.close();
|
|
202
226
|
throw error;
|
|
@@ -205,7 +229,18 @@ export class MailDatabase extends EventEmitter {
|
|
|
205
229
|
|
|
206
230
|
statement(sql) {
|
|
207
231
|
if (this.closed) throw new Error('the mail database is closed; no write or native action is resumed');
|
|
208
|
-
if (!this.statements.has(sql))
|
|
232
|
+
if (!this.statements.has(sql)) {
|
|
233
|
+
const statement = this.connection.prepare(sql);
|
|
234
|
+
this.statements.set(sql, new Proxy(statement, { get: (target, key) => {
|
|
235
|
+
if (key === 'run') return (...args) => {
|
|
236
|
+
const value = target.run(...args);
|
|
237
|
+
if (!this.depth && value.changes) this.#notify('commit', null);
|
|
238
|
+
return value;
|
|
239
|
+
};
|
|
240
|
+
const value = target[key];
|
|
241
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
242
|
+
} }));
|
|
243
|
+
}
|
|
209
244
|
return this.statements.get(sql);
|
|
210
245
|
}
|
|
211
246
|
|
|
@@ -230,6 +265,7 @@ export class MailDatabase extends EventEmitter {
|
|
|
230
265
|
if (!depth) {
|
|
231
266
|
const events = this.pending.splice(0);
|
|
232
267
|
for (const event of events) this.#notify(event.topic, event.key);
|
|
268
|
+
this.#notify('commit', null);
|
|
233
269
|
}
|
|
234
270
|
return value;
|
|
235
271
|
}
|
|
@@ -241,6 +277,7 @@ export class MailDatabase extends EventEmitter {
|
|
|
241
277
|
}
|
|
242
278
|
|
|
243
279
|
#notify(topic, key) {
|
|
280
|
+
if (!this.listenerCount(topic)) return;
|
|
244
281
|
// A caller may set the delivery's grace deadline immediately after creating
|
|
245
282
|
// its wake. Notify after that write, as the former filesystem event did.
|
|
246
283
|
setImmediate(() => { if (!this.closed) this.emit(topic, key); });
|
|
@@ -299,7 +336,7 @@ export class MailDatabase extends EventEmitter {
|
|
|
299
336
|
retain(now = Date.now()) {
|
|
300
337
|
const cutoff = now - this.retention.bodyDays * 86_400_000;
|
|
301
338
|
return this.transaction(() => {
|
|
302
|
-
const expired = this.statement(`UPDATE messages SET body = NULL, first_line = '', body_expired_at_ms = ?
|
|
339
|
+
const expired = this.statement(`UPDATE messages SET body_digest = coalesce(body_digest, supercode_sha256(body)), body = NULL, first_line = '', body_expired_at_ms = ?
|
|
303
340
|
WHERE state = 'cur' AND created_at_ms < ? AND body IS NOT NULL
|
|
304
341
|
AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.mailbox = messages.mailbox AND d.message = messages.id)
|
|
305
342
|
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.mailbox = messages.mailbox AND s.message = messages.id)`)
|
|
@@ -345,6 +382,7 @@ export class MailDatabase extends EventEmitter {
|
|
|
345
382
|
|
|
346
383
|
close() {
|
|
347
384
|
if (this.closed) return;
|
|
385
|
+
this.changes?.stop();
|
|
348
386
|
this.closed = true;
|
|
349
387
|
this.removeAllListeners();
|
|
350
388
|
this.statements.clear();
|