agent-inbox 0.2.2 → 0.2.4

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.
Files changed (149) hide show
  1. package/AGENTS.md +18 -0
  2. package/CLAUDE.md +92 -1
  3. package/README.md +73 -6
  4. package/bench/inbox-growth.bench.ts +224 -0
  5. package/dist/federation/connection-manager.d.ts +8 -0
  6. package/dist/federation/connection-manager.d.ts.map +1 -1
  7. package/dist/federation/connection-manager.js +12 -0
  8. package/dist/federation/connection-manager.js.map +1 -1
  9. package/dist/federation/delivery-queue.d.ts +11 -3
  10. package/dist/federation/delivery-queue.d.ts.map +1 -1
  11. package/dist/federation/delivery-queue.js +38 -8
  12. package/dist/federation/delivery-queue.js.map +1 -1
  13. package/dist/federation/queue-store.d.ts +42 -0
  14. package/dist/federation/queue-store.d.ts.map +1 -0
  15. package/dist/federation/queue-store.js +87 -0
  16. package/dist/federation/queue-store.js.map +1 -0
  17. package/dist/index.d.mts +2 -0
  18. package/dist/index.d.ts +29 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +124 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +1 -0
  23. package/dist/index.mjs.map +1 -0
  24. package/dist/jsonrpc/mail-push-types.d.ts +9 -0
  25. package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
  26. package/dist/jsonrpc/mail-push-types.js +1 -0
  27. package/dist/jsonrpc/mail-push-types.js.map +1 -1
  28. package/dist/jsonrpc/mail-server.d.ts +8 -1
  29. package/dist/jsonrpc/mail-server.d.ts.map +1 -1
  30. package/dist/jsonrpc/mail-server.js +42 -1
  31. package/dist/jsonrpc/mail-server.js.map +1 -1
  32. package/dist/mail/address-book.d.ts +43 -0
  33. package/dist/mail/address-book.d.ts.map +1 -0
  34. package/dist/mail/address-book.js +95 -0
  35. package/dist/mail/address-book.js.map +1 -0
  36. package/dist/mail/attachment-store.d.ts +31 -0
  37. package/dist/mail/attachment-store.d.ts.map +1 -0
  38. package/dist/mail/attachment-store.js +74 -0
  39. package/dist/mail/attachment-store.js.map +1 -0
  40. package/dist/mail/email-mapper.d.ts +41 -0
  41. package/dist/mail/email-mapper.d.ts.map +1 -0
  42. package/dist/mail/email-mapper.js +216 -0
  43. package/dist/mail/email-mapper.js.map +1 -0
  44. package/dist/mail/fs-attachment-store.d.ts +38 -0
  45. package/dist/mail/fs-attachment-store.d.ts.map +1 -0
  46. package/dist/mail/fs-attachment-store.js +165 -0
  47. package/dist/mail/fs-attachment-store.js.map +1 -0
  48. package/dist/mail/mail-gateway.d.ts +114 -0
  49. package/dist/mail/mail-gateway.d.ts.map +1 -0
  50. package/dist/mail/mail-gateway.js +402 -0
  51. package/dist/mail/mail-gateway.js.map +1 -0
  52. package/dist/mail/provider-transport.d.ts +138 -0
  53. package/dist/mail/provider-transport.d.ts.map +1 -0
  54. package/dist/mail/provider-transport.js +434 -0
  55. package/dist/mail/provider-transport.js.map +1 -0
  56. package/dist/mail/rate-limiter.d.ts +20 -0
  57. package/dist/mail/rate-limiter.d.ts.map +1 -0
  58. package/dist/mail/rate-limiter.js +56 -0
  59. package/dist/mail/rate-limiter.js.map +1 -0
  60. package/dist/mail/smtp-transport.d.ts +141 -0
  61. package/dist/mail/smtp-transport.d.ts.map +1 -0
  62. package/dist/mail/smtp-transport.js +415 -0
  63. package/dist/mail/smtp-transport.js.map +1 -0
  64. package/dist/mail/types.d.ts +177 -0
  65. package/dist/mail/types.d.ts.map +1 -0
  66. package/dist/mail/types.js +11 -0
  67. package/dist/mail/types.js.map +1 -0
  68. package/dist/push/notifier.d.ts +21 -0
  69. package/dist/push/notifier.d.ts.map +1 -1
  70. package/dist/push/notifier.js +84 -2
  71. package/dist/push/notifier.js.map +1 -1
  72. package/dist/router/destination.d.ts +69 -0
  73. package/dist/router/destination.d.ts.map +1 -0
  74. package/dist/router/destination.js +106 -0
  75. package/dist/router/destination.js.map +1 -0
  76. package/dist/router/message-router.d.ts +15 -0
  77. package/dist/router/message-router.d.ts.map +1 -1
  78. package/dist/router/message-router.js +25 -3
  79. package/dist/router/message-router.js.map +1 -1
  80. package/dist/storage/interface.d.ts +21 -0
  81. package/dist/storage/interface.d.ts.map +1 -1
  82. package/dist/storage/memory.d.ts +12 -0
  83. package/dist/storage/memory.d.ts.map +1 -1
  84. package/dist/storage/memory.js +50 -0
  85. package/dist/storage/memory.js.map +1 -1
  86. package/dist/storage/sqlite.d.ts +14 -0
  87. package/dist/storage/sqlite.d.ts.map +1 -1
  88. package/dist/storage/sqlite.js +79 -1
  89. package/dist/storage/sqlite.js.map +1 -1
  90. package/dist/traceability/traceability.d.ts.map +1 -1
  91. package/dist/traceability/traceability.js +7 -17
  92. package/dist/traceability/traceability.js.map +1 -1
  93. package/dist/types.d.ts +80 -0
  94. package/dist/types.d.ts.map +1 -1
  95. package/docs/DESIGN.md +15 -0
  96. package/docs/MAIL-INTEROP-PLAN.md +660 -0
  97. package/package.json +29 -3
  98. package/renovate.json5 +6 -0
  99. package/rules/agent-inbox.md +1 -0
  100. package/src/federation/connection-manager.ts +12 -0
  101. package/src/federation/delivery-queue.ts +38 -8
  102. package/src/federation/queue-store.ts +124 -0
  103. package/src/index.ts +186 -1
  104. package/src/jsonrpc/mail-push-types.ts +10 -0
  105. package/src/jsonrpc/mail-server.ts +48 -1
  106. package/src/mail/address-book.ts +111 -0
  107. package/src/mail/attachment-store.ts +90 -0
  108. package/src/mail/email-mapper.ts +288 -0
  109. package/src/mail/fs-attachment-store.ts +163 -0
  110. package/src/mail/mail-gateway.ts +505 -0
  111. package/src/mail/provider-transport.ts +577 -0
  112. package/src/mail/rate-limiter.ts +51 -0
  113. package/src/mail/smtp-transport.ts +589 -0
  114. package/src/mail/types.ts +221 -0
  115. package/src/push/notifier.ts +98 -2
  116. package/src/router/destination.ts +140 -0
  117. package/src/router/message-router.ts +41 -4
  118. package/src/storage/interface.ts +22 -0
  119. package/src/storage/memory.ts +59 -0
  120. package/src/storage/sqlite.ts +114 -1
  121. package/src/traceability/traceability.ts +7 -16
  122. package/src/types.ts +74 -0
  123. package/test/federation/delivery-queue-sqlite.test.ts +158 -0
  124. package/test/load.test.ts +288 -0
  125. package/test/mail/address-book.test.ts +111 -0
  126. package/test/mail/attachment-store-contract.test.ts +92 -0
  127. package/test/mail/attachment-store.test.ts +69 -0
  128. package/test/mail/destination.test.ts +115 -0
  129. package/test/mail/dsn-parse.test.ts +239 -0
  130. package/test/mail/email-mapper.test.ts +341 -0
  131. package/test/mail/external-id.test.ts +43 -0
  132. package/test/mail/fs-attachment-store.test.ts +134 -0
  133. package/test/mail/full-flow-e2e.test.ts +200 -0
  134. package/test/mail/mail-gateway.test.ts +419 -0
  135. package/test/mail/mail-transport-contract.test.ts +134 -0
  136. package/test/mail/mock-mail.ts +161 -0
  137. package/test/mail/mock-postmark.ts +66 -0
  138. package/test/mail/provider-transport.test.ts +381 -0
  139. package/test/mail/rate-limiter.test.ts +48 -0
  140. package/test/mail/router-mail-integration.test.ts +138 -0
  141. package/test/mail/smtp-e2e.test.ts +98 -0
  142. package/test/mail/smtp-transport.test.ts +138 -0
  143. package/test/mail-presence.test.ts +149 -0
  144. package/test/mail-push.test.ts +44 -0
  145. package/test/mail-server.test.ts +25 -0
  146. package/test/push-notifier.test.ts +81 -0
  147. package/test/sqlite-storage.test.ts +106 -0
  148. package/test/storage.test.ts +92 -0
  149. package/vitest.bench.config.ts +8 -0
@@ -36,6 +36,8 @@ export class SqliteStorage implements Storage {
36
36
  this.db = new Database(path);
37
37
  this.db.pragma("journal_mode = WAL");
38
38
  this.db.pragma("foreign_keys = ON");
39
+ // Bound the WAL file: checkpoint after ~1000 dirty pages (~4 MiB).
40
+ this.db.pragma("wal_autocheckpoint = 1000");
39
41
  this.externalDb = false;
40
42
  }
41
43
 
@@ -126,6 +128,13 @@ export class SqliteStorage implements Storage {
126
128
  FOREIGN KEY (conversation_id) REFERENCES ${t("conversations")}(id)
127
129
  );
128
130
 
131
+ -- External-id mapping (mail dedup + bounce correlation)
132
+ CREATE TABLE IF NOT EXISTS ${t("external_ids")} (
133
+ external_id TEXT PRIMARY KEY,
134
+ message_id TEXT NOT NULL,
135
+ recorded_at TEXT NOT NULL
136
+ );
137
+
129
138
  -- Indexes for common queries
130
139
  CREATE INDEX IF NOT EXISTS idx_${t("messages")}_scope ON ${t("messages")}(scope);
131
140
  CREATE INDEX IF NOT EXISTS idx_${t("messages")}_sender ON ${t("messages")}(sender_id);
@@ -162,7 +171,11 @@ export class SqliteStorage implements Storage {
162
171
  ELSE '' END);
163
172
  END;
164
173
 
165
- CREATE TRIGGER IF NOT EXISTS ${t("messages_au")} AFTER UPDATE ON ${t("messages")} BEGIN
174
+ -- Drop legacy unscoped trigger if present, so we re-create it with OF clause
175
+ DROP TRIGGER IF EXISTS ${t("messages_au")};
176
+
177
+ CREATE TRIGGER IF NOT EXISTS ${t("messages_au")}
178
+ AFTER UPDATE OF subject, content ON ${t("messages")} BEGIN
166
179
  INSERT INTO ${t("messages_fts")}(${t("messages_fts")}, rowid, id, subject, text_content)
167
180
  VALUES ('delete', OLD.rowid, OLD.id, OLD.subject,
168
181
  CASE WHEN json_extract(OLD.content, '$.type') = 'text'
@@ -177,6 +190,12 @@ export class SqliteStorage implements Storage {
177
190
  `);
178
191
  }
179
192
 
193
+ /** Expose the underlying handle so sibling features (mail queue/attachments)
194
+ * can co-locate their tables. Caller must not close it. */
195
+ getDatabase(): Database.Database {
196
+ return this.db;
197
+ }
198
+
180
199
  // --- Agents ---
181
200
 
182
201
  getAgent(agentId: string): Agent | undefined {
@@ -277,6 +296,75 @@ export class SqliteStorage implements Storage {
277
296
  return message;
278
297
  }
279
298
 
299
+ setMessageConversationId(messageId: string, conversationId: string): void {
300
+ this.db
301
+ .prepare(
302
+ `UPDATE ${this.p("messages")} SET conversation_id = ? WHERE id = ?`
303
+ )
304
+ .run(conversationId, messageId);
305
+ }
306
+
307
+ recordExternalId(externalId: string, messageId: string): void {
308
+ this.db
309
+ .prepare(
310
+ `INSERT INTO ${this.p("external_ids")} (external_id, message_id, recorded_at)
311
+ VALUES (?, ?, ?)
312
+ ON CONFLICT(external_id) DO NOTHING`
313
+ )
314
+ .run(externalId, messageId, new Date().toISOString());
315
+ }
316
+
317
+ getMessageIdByExternalId(externalId: string): string | undefined {
318
+ const row = this.db
319
+ .prepare(
320
+ `SELECT message_id FROM ${this.p("external_ids")} WHERE external_id = ?`
321
+ )
322
+ .get(externalId) as { message_id: string } | undefined;
323
+ return row?.message_id;
324
+ }
325
+
326
+ hasSeenExternalId(externalId: string): boolean {
327
+ return this.getMessageIdByExternalId(externalId) !== undefined;
328
+ }
329
+
330
+ pruneMessagesOlderThan(cutoff: string): number {
331
+ const m = this.p("messages");
332
+ const r = this.p("recipients");
333
+ const turns = this.p("turns");
334
+ const threads = this.p("threads");
335
+
336
+ return this.db.transaction(() => {
337
+ const ids = (
338
+ this.db
339
+ .prepare(`SELECT id FROM ${m} WHERE created_at < ?`)
340
+ .all(cutoff) as { id: string }[]
341
+ ).map((row) => row.id);
342
+
343
+ if (ids.length === 0) return 0;
344
+
345
+ const placeholders = ids.map(() => "?").join(",");
346
+ this.db
347
+ .prepare(`DELETE FROM ${r} WHERE message_id IN (${placeholders})`)
348
+ .run(...ids);
349
+ this.db
350
+ .prepare(
351
+ `DELETE FROM ${turns} WHERE source_message_id IN (${placeholders})`
352
+ )
353
+ .run(...ids);
354
+ // Threads pinned to a pruned root turn are now orphans — drop them.
355
+ this.db
356
+ .prepare(
357
+ `DELETE FROM ${threads} WHERE root_turn_id NOT IN (SELECT id FROM ${turns})`
358
+ )
359
+ .run();
360
+ this.db
361
+ .prepare(`DELETE FROM ${m} WHERE id IN (${placeholders})`)
362
+ .run(...ids);
363
+
364
+ return ids.length;
365
+ })();
366
+ }
367
+
280
368
  getInbox(agentId: string, opts?: InboxQuery): Message[] {
281
369
  const m = this.p("messages");
282
370
  const r = this.p("recipients");
@@ -414,6 +502,31 @@ export class SqliteStorage implements Storage {
414
502
  return conversation;
415
503
  }
416
504
 
505
+ touchConversation(conversationId: string, updatedAt: string): void {
506
+ this.db
507
+ .prepare(
508
+ `UPDATE ${this.p("conversations")} SET updated_at = ? WHERE id = ?`
509
+ )
510
+ .run(updatedAt, conversationId);
511
+ }
512
+
513
+ addParticipant(
514
+ conversationId: string,
515
+ participant: { agent_id: string; role?: string; joined_at: string }
516
+ ): void {
517
+ this.db
518
+ .prepare(
519
+ `INSERT OR IGNORE INTO ${this.p("participants")}
520
+ (conversation_id, agent_id, role, joined_at) VALUES (?, ?, ?, ?)`
521
+ )
522
+ .run(
523
+ conversationId,
524
+ participant.agent_id,
525
+ participant.role ?? null,
526
+ participant.joined_at
527
+ );
528
+ }
529
+
417
530
  listConversations(scope?: string): Conversation[] {
418
531
  let rows: ConversationRow[];
419
532
  if (scope) {
@@ -30,16 +30,15 @@ export class TraceabilityLayer {
30
30
  private onMessage(message: Message): void {
31
31
  const conversationId = this.resolveConversation(message);
32
32
 
33
- // Update message's conversation_id if not already set
33
+ // Update message's conversation_id if not already set. Use a targeted
34
+ // UPDATE rather than re-upserting the whole row + recipients.
34
35
  if (!message.conversation_id) {
35
36
  message.conversation_id = conversationId;
36
- this.storage.putMessage(message);
37
+ this.storage.setMessageConversationId(message.id, conversationId);
37
38
  }
38
39
 
39
- // Ensure sender is a participant
40
+ // Ensure sender + recipients are participants (no-op if already present)
40
41
  this.ensureParticipant(conversationId, message.sender_id);
41
-
42
- // Ensure recipients are participants
43
42
  for (const r of message.recipients) {
44
43
  this.ensureParticipant(conversationId, r.agent_id);
45
44
  }
@@ -64,12 +63,8 @@ export class TraceabilityLayer {
64
63
 
65
64
  this.storage.addTurn(turn);
66
65
 
67
- // Update conversation updated_at
68
- const conv = this.storage.getConversation(conversationId);
69
- if (conv) {
70
- conv.updated_at = message.created_at;
71
- this.storage.putConversation(conv);
72
- }
66
+ // Bump conversation updated_at — single column UPDATE, no participant resync.
67
+ this.storage.touchConversation(conversationId, message.created_at);
73
68
  }
74
69
 
75
70
  private resolveConversation(message: Message): string {
@@ -129,14 +124,10 @@ export class TraceabilityLayer {
129
124
  }
130
125
 
131
126
  private ensureParticipant(conversationId: string, agentId: string): void {
132
- const conv = this.storage.getConversation(conversationId);
133
- if (!conv) return;
134
- if (conv.participants.some((p) => p.agent_id === agentId)) return;
135
- conv.participants.push({
127
+ this.storage.addParticipant(conversationId, {
136
128
  agent_id: agentId,
137
129
  joined_at: new Date().toISOString(),
138
130
  });
139
- this.storage.putConversation(conv);
140
131
  }
141
132
 
142
133
  private resolveThread(
package/src/types.ts CHANGED
@@ -83,6 +83,7 @@ export interface Turn {
83
83
  content: MessageContent;
84
84
  thread_id?: string;
85
85
  in_reply_to?: string;
86
+ importance?: Importance;
86
87
  created_at: string;
87
88
  }
88
89
 
@@ -210,6 +211,11 @@ export interface FederationPeerConfig {
210
211
  url?: string;
211
212
  /** Mesh peer ID for agentic-mesh transport connections. */
212
213
  meshPeerId?: string;
214
+ /**
215
+ * Dotted addresses (e.g. "team.corp.internal") routed to this peer rather
216
+ * than treated as external mail. See destination classification (§1).
217
+ */
218
+ domains?: string[];
213
219
  auth?: FederationAuth;
214
220
  exposure?: ExposurePolicy;
215
221
  }
@@ -310,6 +316,73 @@ export interface QueuedMessage {
310
316
  nextRetry?: string;
311
317
  }
312
318
 
319
+ // --- Mail interop ---
320
+
321
+ /** Maps between agent ids and email addresses; declares owned domains. */
322
+ export interface MailIdentityConfig {
323
+ /** Domains we receive for; inbound RCPT TO outside these is rejected. */
324
+ localDomains: string[];
325
+ /** agent_id ↔ primary email. Plus-addressing (agent+scope@domain) aware. */
326
+ mappings: Array<{ agentId: string; address: string }>;
327
+ /** Default mailbox for accepted-but-unmatched inbound (e.g. a triage agent). */
328
+ catchAllAgentId?: string;
329
+ }
330
+
331
+ export interface MailConfig {
332
+ enabled: boolean;
333
+ backend: "smtp" | "provider";
334
+ identity: MailIdentityConfig;
335
+ /**
336
+ * Domains we will SEND to without erroring (destination classification §1).
337
+ * Receiving uses identity.localDomains. Anything outside both → error.
338
+ */
339
+ routableDomains?: string[];
340
+ smtp?: {
341
+ listenPort?: number;
342
+ relay?: {
343
+ host: string;
344
+ port: number;
345
+ auth?: { user: string; pass: string };
346
+ };
347
+ dkim?: { domain: string; selector: string; privateKeyRef: string };
348
+ };
349
+ provider?: {
350
+ name: "ses" | "postmark" | "mailgun";
351
+ apiKeyRef: string;
352
+ webhookPath?: string;
353
+ };
354
+ /** Delivery queue overrides; defaults to sqlite persistence when mail enabled. */
355
+ queue?: Partial<DeliveryQueueConfig>;
356
+ /**
357
+ * Attachment byte storage. Defaults to "sqlite" (content-addressed BLOBs in
358
+ * the DB). Use "fs" to keep bytes on disk for large/high-volume deployments.
359
+ */
360
+ attachments?: {
361
+ backend?: "sqlite" | "fs";
362
+ /** Directory for the "fs" backend. Defaults to <home>/.claude/agent-inbox/attachments. */
363
+ dir?: string;
364
+ };
365
+ /** Inbound sender allow-list (domains). Empty/undefined = allow all. */
366
+ allowedSenderDomains?: string[];
367
+ /** Drop inbound whose DMARC verdict is "fail". */
368
+ rejectDmarcFail?: boolean;
369
+ /** Max attachments on an inbound message before rejection. */
370
+ maxAttachments?: number;
371
+ /** Inbound rate limiting (abuse control). Disabled if unset. */
372
+ rateLimit?: {
373
+ windowMs?: number;
374
+ perSenderDomain?: number;
375
+ global?: number;
376
+ };
377
+ /** Bounce handling (both default on). See §8. */
378
+ bounce?: {
379
+ /** Emit the mail.bounced event. */
380
+ emitEvent?: boolean;
381
+ /** Inject a synthetic bounce Message into the original sender's inbox. */
382
+ synthesizeInboxMessage?: boolean;
383
+ };
384
+ }
385
+
313
386
  // --- Config ---
314
387
 
315
388
  export interface InboxConfig {
@@ -326,4 +399,5 @@ export interface InboxConfig {
326
399
  };
327
400
  };
328
401
  federation?: FederationConfig;
402
+ mail?: MailConfig;
329
403
  }
@@ -0,0 +1,158 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import Database from "better-sqlite3";
4
+ import { DeliveryQueue } from "../../src/federation/delivery-queue.js";
5
+ import { SqliteQueueStore } from "../../src/federation/queue-store.js";
6
+ import type { DeliveryQueueConfig, Message } from "../../src/types.js";
7
+
8
+ function makeMessage(id: string): Message {
9
+ return {
10
+ id,
11
+ scope: "default",
12
+ sender_id: "sender",
13
+ recipients: [{ agent_id: "remote@peer", kind: "to" }],
14
+ content: { type: "text", text: `msg-${id}` },
15
+ importance: "normal",
16
+ metadata: {},
17
+ created_at: new Date().toISOString(),
18
+ };
19
+ }
20
+
21
+ const baseConfig: DeliveryQueueConfig = {
22
+ persistence: "sqlite",
23
+ maxTTL: 10_000,
24
+ maxQueueSize: 3,
25
+ retryStrategy: "exponential",
26
+ retryBaseInterval: 100,
27
+ retryMaxAttempts: 0,
28
+ flushOnReconnect: true,
29
+ overflow: "drop-oldest",
30
+ };
31
+
32
+ describe("DeliveryQueue (sqlite persistence)", () => {
33
+ let db: Database.Database;
34
+ let events: EventEmitter;
35
+
36
+ beforeEach(() => {
37
+ db = new Database(":memory:");
38
+ events = new EventEmitter();
39
+ });
40
+
41
+ afterEach(() => {
42
+ db.close();
43
+ });
44
+
45
+ function makeQueue(config: Partial<DeliveryQueueConfig> = {}) {
46
+ return new DeliveryQueue(
47
+ events,
48
+ { ...baseConfig, ...config },
49
+ { store: new SqliteQueueStore(db) }
50
+ );
51
+ }
52
+
53
+ it("persists enqueued messages to the store", () => {
54
+ const q = makeQueue();
55
+ q.enqueue("peer-1", makeMessage("m1"));
56
+ q.enqueue("peer-1", makeMessage("m2"));
57
+
58
+ const rows = db.prepare("SELECT * FROM delivery_queue").all();
59
+ expect(rows).toHaveLength(2);
60
+ });
61
+
62
+ it("survives a restart by hydrating from the store", () => {
63
+ const q1 = makeQueue();
64
+ q1.enqueue("peer-1", makeMessage("m1"));
65
+ q1.enqueue("peer-2", makeMessage("m2"));
66
+ expect(q1.totalSize()).toBe(2);
67
+
68
+ // Simulate restart: new queue + new store over the same db handle.
69
+ const q2 = new DeliveryQueue(events, baseConfig, {
70
+ store: new SqliteQueueStore(db),
71
+ });
72
+ expect(q2.totalSize()).toBe(2);
73
+ expect(q2.size("peer-1")).toBe(1);
74
+ expect(q2.size("peer-2")).toBe(1);
75
+ expect(q2.peers().sort()).toEqual(["peer-1", "peer-2"]);
76
+ });
77
+
78
+ it("removes entries from the store on removeEntry", () => {
79
+ const q = makeQueue();
80
+ const id = q.enqueue("peer-1", makeMessage("m1"))!;
81
+ q.removeEntry("peer-1", id);
82
+
83
+ expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
84
+ });
85
+
86
+ it("clears the store for a peer on flush", () => {
87
+ const q = makeQueue();
88
+ q.enqueue("peer-1", makeMessage("m1"));
89
+ q.enqueue("peer-1", makeMessage("m2"));
90
+ q.flush("peer-1");
91
+
92
+ expect(
93
+ db.prepare("SELECT * FROM delivery_queue WHERE peer_id = ?").all("peer-1")
94
+ ).toHaveLength(0);
95
+ });
96
+
97
+ it("drops the oldest entry from the store on overflow", () => {
98
+ const q = makeQueue({ maxQueueSize: 2, overflow: "drop-oldest" });
99
+ q.enqueue("peer-1", makeMessage("m1"));
100
+ q.enqueue("peer-1", makeMessage("m2"));
101
+ q.enqueue("peer-1", makeMessage("m3")); // evicts m1
102
+
103
+ const messages = (
104
+ db.prepare("SELECT message FROM delivery_queue").all() as Array<{
105
+ message: string;
106
+ }>
107
+ ).map((r) => JSON.parse(r.message).id);
108
+ expect(messages.sort()).toEqual(["m2", "m3"]);
109
+ });
110
+
111
+ it("updates attempt/next-retry state in the store on recordAttempt", () => {
112
+ const q = makeQueue({ retryMaxAttempts: 5 });
113
+ const id = q.enqueue("peer-1", makeMessage("m1"))!;
114
+ q.recordAttempt("peer-1", id);
115
+
116
+ const row = db
117
+ .prepare("SELECT attempts, next_retry FROM delivery_queue WHERE id = ?")
118
+ .get(id) as { attempts: number; next_retry: string | null };
119
+ expect(row.attempts).toBe(1);
120
+ expect(row.next_retry).toBeTruthy();
121
+ });
122
+
123
+ it("removes the entry from the store when max attempts are exceeded", () => {
124
+ const q = makeQueue({ retryMaxAttempts: 1 });
125
+ const id = q.enqueue("peer-1", makeMessage("m1"))!;
126
+ const kept = q.recordAttempt("peer-1", id);
127
+ expect(kept).toBe(false);
128
+ expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
129
+ });
130
+
131
+ it("removes expired entries from the store on tick", () => {
132
+ const q = makeQueue({ maxTTL: -1 }); // everything is immediately expired
133
+ q.enqueue("peer-1", makeMessage("m1"));
134
+ const expired = q.tick();
135
+ expect(expired).toBe(1);
136
+ expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
137
+ });
138
+
139
+ it("round-trips the full message payload through the store", () => {
140
+ const q1 = makeQueue();
141
+ const msg = makeMessage("m1");
142
+ msg.subject = "hello";
143
+ msg.thread_tag = "thread-xyz";
144
+ q1.enqueue("peer-1", msg);
145
+
146
+ const q2 = new DeliveryQueue(events, baseConfig, {
147
+ store: new SqliteQueueStore(db),
148
+ });
149
+ const flushed = q2.flush("peer-1");
150
+ expect(flushed).toHaveLength(1);
151
+ expect(flushed[0].message.subject).toBe("hello");
152
+ expect(flushed[0].message.thread_tag).toBe("thread-xyz");
153
+ expect(flushed[0].message.content).toEqual({
154
+ type: "text",
155
+ text: "msg-m1",
156
+ });
157
+ });
158
+ });