swarm-mail 0.1.0

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 (36) hide show
  1. package/README.md +201 -0
  2. package/package.json +28 -0
  3. package/src/adapter.ts +306 -0
  4. package/src/index.ts +57 -0
  5. package/src/pglite.ts +189 -0
  6. package/src/streams/agent-mail.test.ts +777 -0
  7. package/src/streams/agent-mail.ts +535 -0
  8. package/src/streams/debug.test.ts +500 -0
  9. package/src/streams/debug.ts +727 -0
  10. package/src/streams/effect/ask.integration.test.ts +314 -0
  11. package/src/streams/effect/ask.ts +202 -0
  12. package/src/streams/effect/cursor.integration.test.ts +418 -0
  13. package/src/streams/effect/cursor.ts +288 -0
  14. package/src/streams/effect/deferred.test.ts +357 -0
  15. package/src/streams/effect/deferred.ts +445 -0
  16. package/src/streams/effect/index.ts +17 -0
  17. package/src/streams/effect/layers.ts +73 -0
  18. package/src/streams/effect/lock.test.ts +385 -0
  19. package/src/streams/effect/lock.ts +399 -0
  20. package/src/streams/effect/mailbox.test.ts +260 -0
  21. package/src/streams/effect/mailbox.ts +318 -0
  22. package/src/streams/events.test.ts +924 -0
  23. package/src/streams/events.ts +329 -0
  24. package/src/streams/index.test.ts +229 -0
  25. package/src/streams/index.ts +578 -0
  26. package/src/streams/migrations.test.ts +359 -0
  27. package/src/streams/migrations.ts +362 -0
  28. package/src/streams/projections.test.ts +611 -0
  29. package/src/streams/projections.ts +564 -0
  30. package/src/streams/store.integration.test.ts +658 -0
  31. package/src/streams/store.ts +1129 -0
  32. package/src/streams/swarm-mail.ts +552 -0
  33. package/src/types/adapter.ts +392 -0
  34. package/src/types/database.ts +127 -0
  35. package/src/types/index.ts +26 -0
  36. package/tsconfig.json +22 -0
@@ -0,0 +1,611 @@
1
+ /**
2
+ * Unit tests for Projections Layer (TDD - RED phase)
3
+ *
4
+ * Projections query materialized views to compute current state.
5
+ * These are the read-side of CQRS - fast queries over denormalized data.
6
+ */
7
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
8
+ import { mkdir, rm } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import { closeDatabase } from "./index";
12
+ import { registerAgent, sendMessage, reserveFiles, appendEvent } from "./store";
13
+ import { createEvent } from "./events";
14
+ import {
15
+ getAgents,
16
+ getAgent,
17
+ getInbox,
18
+ getMessage,
19
+ getActiveReservations,
20
+ checkConflicts,
21
+ getThreadMessages,
22
+ } from "./projections";
23
+
24
+ let TEST_PROJECT_PATH: string;
25
+ const PROJECT_KEY = "test-project";
26
+
27
+ describe("Projections", () => {
28
+ beforeEach(async () => {
29
+ TEST_PROJECT_PATH = join(
30
+ tmpdir(),
31
+ `projections-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
32
+ );
33
+ await mkdir(TEST_PROJECT_PATH, { recursive: true });
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await closeDatabase(TEST_PROJECT_PATH);
38
+ try {
39
+ await rm(join(TEST_PROJECT_PATH, ".opencode"), { recursive: true });
40
+ } catch {
41
+ // Ignore
42
+ }
43
+ });
44
+
45
+ // ==========================================================================
46
+ // Agent Projections
47
+ // ==========================================================================
48
+
49
+ describe("getAgents", () => {
50
+ it("returns empty array when no agents registered", async () => {
51
+ const agents = await getAgents(PROJECT_KEY, TEST_PROJECT_PATH);
52
+ expect(agents).toEqual([]);
53
+ });
54
+
55
+ it("returns all agents for a project", async () => {
56
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
57
+ await registerAgent(PROJECT_KEY, "RedStone", {}, TEST_PROJECT_PATH);
58
+
59
+ const agents = await getAgents(PROJECT_KEY, TEST_PROJECT_PATH);
60
+
61
+ expect(agents.length).toBe(2);
62
+ expect(agents.map((a) => a.name).sort()).toEqual([
63
+ "BlueLake",
64
+ "RedStone",
65
+ ]);
66
+ });
67
+
68
+ it("only returns agents for specified project", async () => {
69
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
70
+ await registerAgent("other-project", "RedStone", {}, TEST_PROJECT_PATH);
71
+
72
+ const agents = await getAgents(PROJECT_KEY, TEST_PROJECT_PATH);
73
+
74
+ expect(agents.length).toBe(1);
75
+ expect(agents[0]?.name).toBe("BlueLake");
76
+ });
77
+ });
78
+
79
+ describe("getAgent", () => {
80
+ it("returns null for non-existent agent", async () => {
81
+ const agent = await getAgent(
82
+ PROJECT_KEY,
83
+ "NonExistent",
84
+ TEST_PROJECT_PATH,
85
+ );
86
+ expect(agent).toBeNull();
87
+ });
88
+
89
+ it("returns agent details", async () => {
90
+ await registerAgent(
91
+ PROJECT_KEY,
92
+ "BlueLake",
93
+ {
94
+ program: "opencode",
95
+ model: "claude-sonnet-4",
96
+ taskDescription: "Testing",
97
+ },
98
+ TEST_PROJECT_PATH,
99
+ );
100
+
101
+ const agent = await getAgent(PROJECT_KEY, "BlueLake", TEST_PROJECT_PATH);
102
+
103
+ expect(agent).not.toBeNull();
104
+ expect(agent?.name).toBe("BlueLake");
105
+ expect(agent?.program).toBe("opencode");
106
+ expect(agent?.model).toBe("claude-sonnet-4");
107
+ expect(agent?.task_description).toBe("Testing");
108
+ });
109
+
110
+ it("returns updated last_active_at after activity", async () => {
111
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
112
+
113
+ const before = await getAgent(PROJECT_KEY, "BlueLake", TEST_PROJECT_PATH);
114
+ const beforeActive = before?.last_active_at;
115
+
116
+ // Wait a bit and send activity
117
+ await new Promise((r) => setTimeout(r, 10));
118
+ await appendEvent(
119
+ createEvent("agent_active", {
120
+ project_key: PROJECT_KEY,
121
+ agent_name: "BlueLake",
122
+ }),
123
+ TEST_PROJECT_PATH,
124
+ );
125
+
126
+ const after = await getAgent(PROJECT_KEY, "BlueLake", TEST_PROJECT_PATH);
127
+
128
+ expect(after?.last_active_at).toBeGreaterThan(beforeActive ?? 0);
129
+ });
130
+ });
131
+
132
+ // ==========================================================================
133
+ // Message Projections
134
+ // ==========================================================================
135
+
136
+ describe("getInbox", () => {
137
+ it("returns empty array when no messages", async () => {
138
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
139
+
140
+ const inbox = await getInbox(
141
+ PROJECT_KEY,
142
+ "BlueLake",
143
+ {},
144
+ TEST_PROJECT_PATH,
145
+ );
146
+
147
+ expect(inbox).toEqual([]);
148
+ });
149
+
150
+ it("returns messages sent to agent", async () => {
151
+ await registerAgent(PROJECT_KEY, "Sender", {}, TEST_PROJECT_PATH);
152
+ await registerAgent(PROJECT_KEY, "Receiver", {}, TEST_PROJECT_PATH);
153
+
154
+ await sendMessage(
155
+ PROJECT_KEY,
156
+ "Sender",
157
+ ["Receiver"],
158
+ "Hello",
159
+ "World",
160
+ {},
161
+ TEST_PROJECT_PATH,
162
+ );
163
+
164
+ const inbox = await getInbox(
165
+ PROJECT_KEY,
166
+ "Receiver",
167
+ {},
168
+ TEST_PROJECT_PATH,
169
+ );
170
+
171
+ expect(inbox.length).toBe(1);
172
+ expect(inbox[0]?.subject).toBe("Hello");
173
+ expect(inbox[0]?.from_agent).toBe("Sender");
174
+ });
175
+
176
+ it("respects limit parameter", async () => {
177
+ await registerAgent(PROJECT_KEY, "Sender", {}, TEST_PROJECT_PATH);
178
+ await registerAgent(PROJECT_KEY, "Receiver", {}, TEST_PROJECT_PATH);
179
+
180
+ for (let i = 0; i < 5; i++) {
181
+ await sendMessage(
182
+ PROJECT_KEY,
183
+ "Sender",
184
+ ["Receiver"],
185
+ `Message ${i}`,
186
+ "Body",
187
+ {},
188
+ TEST_PROJECT_PATH,
189
+ );
190
+ }
191
+
192
+ const inbox = await getInbox(
193
+ PROJECT_KEY,
194
+ "Receiver",
195
+ { limit: 2 },
196
+ TEST_PROJECT_PATH,
197
+ );
198
+
199
+ expect(inbox.length).toBe(2);
200
+ });
201
+
202
+ it("filters by urgentOnly", async () => {
203
+ await registerAgent(PROJECT_KEY, "Sender", {}, TEST_PROJECT_PATH);
204
+ await registerAgent(PROJECT_KEY, "Receiver", {}, TEST_PROJECT_PATH);
205
+
206
+ await sendMessage(
207
+ PROJECT_KEY,
208
+ "Sender",
209
+ ["Receiver"],
210
+ "Normal",
211
+ "Body",
212
+ { importance: "normal" },
213
+ TEST_PROJECT_PATH,
214
+ );
215
+ await sendMessage(
216
+ PROJECT_KEY,
217
+ "Sender",
218
+ ["Receiver"],
219
+ "Urgent",
220
+ "Body",
221
+ { importance: "urgent" },
222
+ TEST_PROJECT_PATH,
223
+ );
224
+
225
+ const inbox = await getInbox(
226
+ PROJECT_KEY,
227
+ "Receiver",
228
+ { urgentOnly: true },
229
+ TEST_PROJECT_PATH,
230
+ );
231
+
232
+ expect(inbox.length).toBe(1);
233
+ expect(inbox[0]?.subject).toBe("Urgent");
234
+ });
235
+
236
+ it("filters by unreadOnly", async () => {
237
+ await registerAgent(PROJECT_KEY, "Sender", {}, TEST_PROJECT_PATH);
238
+ await registerAgent(PROJECT_KEY, "Receiver", {}, TEST_PROJECT_PATH);
239
+
240
+ await sendMessage(
241
+ PROJECT_KEY,
242
+ "Sender",
243
+ ["Receiver"],
244
+ "Message 1",
245
+ "Body",
246
+ {},
247
+ TEST_PROJECT_PATH,
248
+ );
249
+ await sendMessage(
250
+ PROJECT_KEY,
251
+ "Sender",
252
+ ["Receiver"],
253
+ "Message 2",
254
+ "Body",
255
+ {},
256
+ TEST_PROJECT_PATH,
257
+ );
258
+
259
+ // Mark second message as read
260
+ await appendEvent(
261
+ createEvent("message_read", {
262
+ project_key: PROJECT_KEY,
263
+ message_id: 2, // Second message
264
+ agent_name: "Receiver",
265
+ }),
266
+ TEST_PROJECT_PATH,
267
+ );
268
+
269
+ const inbox = await getInbox(
270
+ PROJECT_KEY,
271
+ "Receiver",
272
+ { unreadOnly: true },
273
+ TEST_PROJECT_PATH,
274
+ );
275
+
276
+ expect(inbox.length).toBe(1);
277
+ expect(inbox[0]?.subject).toBe("Message 1");
278
+ });
279
+
280
+ it("excludes body when includeBodies is false", async () => {
281
+ await registerAgent(PROJECT_KEY, "Sender", {}, TEST_PROJECT_PATH);
282
+ await registerAgent(PROJECT_KEY, "Receiver", {}, TEST_PROJECT_PATH);
283
+
284
+ await sendMessage(
285
+ PROJECT_KEY,
286
+ "Sender",
287
+ ["Receiver"],
288
+ "Hello",
289
+ "This is the body",
290
+ {},
291
+ TEST_PROJECT_PATH,
292
+ );
293
+
294
+ const inbox = await getInbox(
295
+ PROJECT_KEY,
296
+ "Receiver",
297
+ { includeBodies: false },
298
+ TEST_PROJECT_PATH,
299
+ );
300
+
301
+ expect(inbox[0]?.body).toBeUndefined();
302
+ });
303
+ });
304
+
305
+ describe("getMessage", () => {
306
+ it("returns null for non-existent message", async () => {
307
+ const msg = await getMessage(PROJECT_KEY, 999, TEST_PROJECT_PATH);
308
+ expect(msg).toBeNull();
309
+ });
310
+
311
+ it("returns full message with body", async () => {
312
+ await registerAgent(PROJECT_KEY, "Sender", {}, TEST_PROJECT_PATH);
313
+ await registerAgent(PROJECT_KEY, "Receiver", {}, TEST_PROJECT_PATH);
314
+
315
+ await sendMessage(
316
+ PROJECT_KEY,
317
+ "Sender",
318
+ ["Receiver"],
319
+ "Hello",
320
+ "Full body content",
321
+ { threadId: "bd-123" },
322
+ TEST_PROJECT_PATH,
323
+ );
324
+
325
+ const msg = await getMessage(PROJECT_KEY, 1, TEST_PROJECT_PATH);
326
+
327
+ expect(msg).not.toBeNull();
328
+ expect(msg?.subject).toBe("Hello");
329
+ expect(msg?.body).toBe("Full body content");
330
+ expect(msg?.thread_id).toBe("bd-123");
331
+ });
332
+ });
333
+
334
+ describe("getThreadMessages", () => {
335
+ it("returns empty array for non-existent thread", async () => {
336
+ const messages = await getThreadMessages(
337
+ PROJECT_KEY,
338
+ "non-existent",
339
+ TEST_PROJECT_PATH,
340
+ );
341
+ expect(messages).toEqual([]);
342
+ });
343
+
344
+ it("returns all messages in a thread", async () => {
345
+ await registerAgent(PROJECT_KEY, "Agent1", {}, TEST_PROJECT_PATH);
346
+ await registerAgent(PROJECT_KEY, "Agent2", {}, TEST_PROJECT_PATH);
347
+
348
+ const threadId = "bd-epic-123";
349
+
350
+ await sendMessage(
351
+ PROJECT_KEY,
352
+ "Agent1",
353
+ ["Agent2"],
354
+ "First",
355
+ "Body 1",
356
+ { threadId },
357
+ TEST_PROJECT_PATH,
358
+ );
359
+ await sendMessage(
360
+ PROJECT_KEY,
361
+ "Agent2",
362
+ ["Agent1"],
363
+ "Reply",
364
+ "Body 2",
365
+ { threadId },
366
+ TEST_PROJECT_PATH,
367
+ );
368
+ await sendMessage(
369
+ PROJECT_KEY,
370
+ "Agent1",
371
+ ["Agent2"],
372
+ "Unrelated",
373
+ "Body 3",
374
+ {}, // No thread
375
+ TEST_PROJECT_PATH,
376
+ );
377
+
378
+ const messages = await getThreadMessages(
379
+ PROJECT_KEY,
380
+ threadId,
381
+ TEST_PROJECT_PATH,
382
+ );
383
+
384
+ expect(messages.length).toBe(2);
385
+ expect(messages[0]?.subject).toBe("First");
386
+ expect(messages[1]?.subject).toBe("Reply");
387
+ });
388
+ });
389
+
390
+ // ==========================================================================
391
+ // Reservation Projections
392
+ // ==========================================================================
393
+
394
+ describe("getActiveReservations", () => {
395
+ it("returns empty array when no reservations", async () => {
396
+ const reservations = await getActiveReservations(
397
+ PROJECT_KEY,
398
+ TEST_PROJECT_PATH,
399
+ );
400
+ expect(reservations).toEqual([]);
401
+ });
402
+
403
+ it("returns active reservations", async () => {
404
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
405
+
406
+ await reserveFiles(
407
+ PROJECT_KEY,
408
+ "BlueLake",
409
+ ["src/auth/**"],
410
+ { reason: "Working on auth", ttlSeconds: 3600 },
411
+ TEST_PROJECT_PATH,
412
+ );
413
+
414
+ const reservations = await getActiveReservations(
415
+ PROJECT_KEY,
416
+ TEST_PROJECT_PATH,
417
+ );
418
+
419
+ expect(reservations.length).toBe(1);
420
+ expect(reservations[0]?.agent_name).toBe("BlueLake");
421
+ expect(reservations[0]?.path_pattern).toBe("src/auth/**");
422
+ });
423
+
424
+ it("excludes released reservations", async () => {
425
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
426
+
427
+ await reserveFiles(
428
+ PROJECT_KEY,
429
+ "BlueLake",
430
+ ["src/auth/**"],
431
+ { ttlSeconds: 3600 },
432
+ TEST_PROJECT_PATH,
433
+ );
434
+
435
+ // Release the reservation
436
+ await appendEvent(
437
+ createEvent("file_released", {
438
+ project_key: PROJECT_KEY,
439
+ agent_name: "BlueLake",
440
+ paths: ["src/auth/**"],
441
+ }),
442
+ TEST_PROJECT_PATH,
443
+ );
444
+
445
+ const reservations = await getActiveReservations(
446
+ PROJECT_KEY,
447
+ TEST_PROJECT_PATH,
448
+ );
449
+
450
+ expect(reservations.length).toBe(0);
451
+ });
452
+
453
+ it("excludes expired reservations", async () => {
454
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
455
+
456
+ // Create reservation that expires immediately
457
+ await appendEvent(
458
+ createEvent("file_reserved", {
459
+ project_key: PROJECT_KEY,
460
+ agent_name: "BlueLake",
461
+ paths: ["src/expired/**"],
462
+ exclusive: true,
463
+ ttl_seconds: 0,
464
+ expires_at: Date.now() - 1000, // Already expired
465
+ }),
466
+ TEST_PROJECT_PATH,
467
+ );
468
+
469
+ const reservations = await getActiveReservations(
470
+ PROJECT_KEY,
471
+ TEST_PROJECT_PATH,
472
+ );
473
+
474
+ expect(reservations.length).toBe(0);
475
+ });
476
+
477
+ it("filters by agent when specified", async () => {
478
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
479
+ await registerAgent(PROJECT_KEY, "RedStone", {}, TEST_PROJECT_PATH);
480
+
481
+ await reserveFiles(
482
+ PROJECT_KEY,
483
+ "BlueLake",
484
+ ["src/a/**"],
485
+ {},
486
+ TEST_PROJECT_PATH,
487
+ );
488
+ await reserveFiles(
489
+ PROJECT_KEY,
490
+ "RedStone",
491
+ ["src/b/**"],
492
+ {},
493
+ TEST_PROJECT_PATH,
494
+ );
495
+
496
+ const reservations = await getActiveReservations(
497
+ PROJECT_KEY,
498
+ TEST_PROJECT_PATH,
499
+ "BlueLake",
500
+ );
501
+
502
+ expect(reservations.length).toBe(1);
503
+ expect(reservations[0]?.agent_name).toBe("BlueLake");
504
+ });
505
+ });
506
+
507
+ describe("checkConflicts", () => {
508
+ it("returns empty array when no conflicts", async () => {
509
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
510
+ await reserveFiles(
511
+ PROJECT_KEY,
512
+ "BlueLake",
513
+ ["src/a/**"],
514
+ {},
515
+ TEST_PROJECT_PATH,
516
+ );
517
+
518
+ const conflicts = await checkConflicts(
519
+ PROJECT_KEY,
520
+ "RedStone",
521
+ ["src/b/**"], // Different path
522
+ TEST_PROJECT_PATH,
523
+ );
524
+
525
+ expect(conflicts).toEqual([]);
526
+ });
527
+
528
+ it("detects exact path conflicts", async () => {
529
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
530
+ await reserveFiles(
531
+ PROJECT_KEY,
532
+ "BlueLake",
533
+ ["src/auth.ts"],
534
+ { exclusive: true },
535
+ TEST_PROJECT_PATH,
536
+ );
537
+
538
+ const conflicts = await checkConflicts(
539
+ PROJECT_KEY,
540
+ "RedStone",
541
+ ["src/auth.ts"],
542
+ TEST_PROJECT_PATH,
543
+ );
544
+
545
+ expect(conflicts.length).toBe(1);
546
+ expect(conflicts[0]?.path).toBe("src/auth.ts");
547
+ expect(conflicts[0]?.holder).toBe("BlueLake");
548
+ });
549
+
550
+ it("detects glob pattern conflicts", async () => {
551
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
552
+ await reserveFiles(
553
+ PROJECT_KEY,
554
+ "BlueLake",
555
+ ["src/auth/**"],
556
+ { exclusive: true },
557
+ TEST_PROJECT_PATH,
558
+ );
559
+
560
+ const conflicts = await checkConflicts(
561
+ PROJECT_KEY,
562
+ "RedStone",
563
+ ["src/auth/oauth.ts"], // Matches glob
564
+ TEST_PROJECT_PATH,
565
+ );
566
+
567
+ expect(conflicts.length).toBe(1);
568
+ expect(conflicts[0]?.holder).toBe("BlueLake");
569
+ });
570
+
571
+ it("ignores own reservations", async () => {
572
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
573
+ await reserveFiles(
574
+ PROJECT_KEY,
575
+ "BlueLake",
576
+ ["src/auth/**"],
577
+ { exclusive: true },
578
+ TEST_PROJECT_PATH,
579
+ );
580
+
581
+ const conflicts = await checkConflicts(
582
+ PROJECT_KEY,
583
+ "BlueLake", // Same agent
584
+ ["src/auth/oauth.ts"],
585
+ TEST_PROJECT_PATH,
586
+ );
587
+
588
+ expect(conflicts).toEqual([]);
589
+ });
590
+
591
+ it("ignores non-exclusive reservations", async () => {
592
+ await registerAgent(PROJECT_KEY, "BlueLake", {}, TEST_PROJECT_PATH);
593
+ await reserveFiles(
594
+ PROJECT_KEY,
595
+ "BlueLake",
596
+ ["src/shared/**"],
597
+ { exclusive: false }, // Non-exclusive
598
+ TEST_PROJECT_PATH,
599
+ );
600
+
601
+ const conflicts = await checkConflicts(
602
+ PROJECT_KEY,
603
+ "RedStone",
604
+ ["src/shared/utils.ts"],
605
+ TEST_PROJECT_PATH,
606
+ );
607
+
608
+ expect(conflicts).toEqual([]);
609
+ });
610
+ });
611
+ });