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,578 @@
1
+ /**
2
+ * SwarmMail Event Store - PGLite-based event sourcing
3
+ *
4
+ * ## Thread Safety
5
+ *
6
+ * PGLite runs in-process as a single-threaded SQLite-compatible database.
7
+ * While Node.js is single-threaded, async operations can interleave.
8
+ *
9
+ * **Concurrency Model:**
10
+ * - Single PGLite instance per project (singleton pattern via LRU cache)
11
+ * - Transactions provide isolation for multi-statement operations
12
+ * - appendEvents uses BEGIN/COMMIT for atomic event batches
13
+ * - Concurrent reads are safe (no locks needed)
14
+ * - Concurrent writes are serialized by PGLite internally
15
+ *
16
+ * **Race Condition Mitigations:**
17
+ * - File reservations use INSERT with conflict detection
18
+ * - Sequence numbers are auto-incremented by database
19
+ * - Materialized views updated within same transaction as events
20
+ * - Pending instance promises prevent duplicate initialization
21
+ *
22
+ * **Known Limitations:**
23
+ * - No distributed locking (single-process only)
24
+ * - Large transactions may block other operations
25
+ * - No connection pooling (embedded database)
26
+ *
27
+ * ## Database Setup
28
+ *
29
+ * Embedded PostgreSQL database for event sourcing.
30
+ * No external server required - runs in-process.
31
+ *
32
+ * Database location: .opencode/streams.db (project-local)
33
+ * or ~/.opencode/streams.db (global fallback)
34
+ */
35
+ import { PGlite } from "@electric-sql/pglite";
36
+ import { existsSync, mkdirSync, appendFileSync } from "node:fs";
37
+ import { join } from "node:path";
38
+ import { homedir } from "node:os";
39
+
40
+ // ============================================================================
41
+ // Query Timeout Wrapper
42
+ // ============================================================================
43
+
44
+ const DEFAULT_QUERY_TIMEOUT_MS = 30000; // 30 seconds
45
+
46
+ /**
47
+ * Wrap a promise with a timeout
48
+ *
49
+ * @param promise - The promise to wrap
50
+ * @param ms - Timeout in milliseconds
51
+ * @param operation - Operation name for error message
52
+ * @returns The result of the promise
53
+ * @throws Error if timeout is reached
54
+ */
55
+ export async function withTimeout<T>(
56
+ promise: Promise<T>,
57
+ ms: number,
58
+ operation: string,
59
+ ): Promise<T> {
60
+ const timeout = new Promise<never>((_, reject) =>
61
+ setTimeout(
62
+ () => reject(new Error(`${operation} timed out after ${ms}ms`)),
63
+ ms,
64
+ ),
65
+ );
66
+ return Promise.race([promise, timeout]);
67
+ }
68
+
69
+ // ============================================================================
70
+ // Performance Monitoring
71
+ // ============================================================================
72
+
73
+ /** Threshold for slow query warnings in milliseconds */
74
+ const SLOW_QUERY_THRESHOLD_MS = 100;
75
+
76
+ /**
77
+ * Execute a database operation with timing instrumentation.
78
+ * Logs a warning if the operation exceeds SLOW_QUERY_THRESHOLD_MS.
79
+ *
80
+ * @param operation - Name of the operation for logging
81
+ * @param fn - Async function to execute
82
+ * @returns Result of the function
83
+ */
84
+ export async function withTiming<T>(
85
+ operation: string,
86
+ fn: () => Promise<T>,
87
+ ): Promise<T> {
88
+ const start = performance.now();
89
+ try {
90
+ return await fn();
91
+ } finally {
92
+ const duration = performance.now() - start;
93
+ if (duration > SLOW_QUERY_THRESHOLD_MS) {
94
+ console.warn(
95
+ `[SwarmMail] Slow operation: ${operation} took ${duration.toFixed(1)}ms`,
96
+ );
97
+ }
98
+ }
99
+ }
100
+
101
+ // ============================================================================
102
+ // Debug Logging
103
+ // ============================================================================
104
+
105
+ const DEBUG_LOG_PATH = join(homedir(), ".opencode", "streams-debug.log");
106
+
107
+ function debugLog(message: string, data?: unknown): void {
108
+ const timestamp = new Date().toISOString();
109
+ const logLine = data
110
+ ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n`
111
+ : `[${timestamp}] ${message}\n`;
112
+ try {
113
+ appendFileSync(DEBUG_LOG_PATH, logLine);
114
+ } catch {
115
+ // Ignore write errors
116
+ }
117
+ }
118
+
119
+ // ============================================================================
120
+ // Configuration
121
+ // ============================================================================
122
+
123
+ /**
124
+ * Get the database path for a project
125
+ *
126
+ * Prefers project-local .opencode/streams.db
127
+ * Falls back to global ~/.opencode/streams.db
128
+ */
129
+ export function getDatabasePath(projectPath?: string): string {
130
+ // Try project-local first
131
+ if (projectPath) {
132
+ const localDir = join(projectPath, ".opencode");
133
+ if (existsSync(localDir) || existsSync(projectPath)) {
134
+ if (!existsSync(localDir)) {
135
+ mkdirSync(localDir, { recursive: true });
136
+ }
137
+ return join(localDir, "streams");
138
+ }
139
+ }
140
+
141
+ // Fall back to global
142
+ const globalDir = join(homedir(), ".opencode");
143
+ if (!existsSync(globalDir)) {
144
+ mkdirSync(globalDir, { recursive: true });
145
+ }
146
+ return join(globalDir, "streams");
147
+ }
148
+
149
+ // ============================================================================
150
+ // Database Instance Management
151
+ // ============================================================================
152
+
153
+ /** Singleton database instances keyed by path */
154
+ const instances = new Map<string, PGlite>();
155
+
156
+ /** Pending database initialization promises to prevent race conditions */
157
+ const pendingInstances = new Map<string, Promise<PGlite>>();
158
+
159
+ /** Whether schema has been initialized for each instance */
160
+ const schemaInitialized = new Map<string, boolean>();
161
+
162
+ /** Track degraded instances (path -> error) */
163
+ const degradedInstances = new Map<string, Error>();
164
+
165
+ /** LRU tracking: path -> last access timestamp */
166
+ const lastAccess = new Map<string, number>();
167
+
168
+ /** Maximum number of cached database instances */
169
+ const MAX_CACHE_SIZE = 10;
170
+
171
+ /**
172
+ * Evict least recently used instance if cache is full
173
+ */
174
+ function evictLRU(): void {
175
+ if (instances.size < MAX_CACHE_SIZE) {
176
+ return;
177
+ }
178
+
179
+ let oldestPath: string | null = null;
180
+ let oldestTime = Number.POSITIVE_INFINITY;
181
+
182
+ for (const [path, time] of lastAccess) {
183
+ if (time < oldestTime) {
184
+ oldestTime = time;
185
+ oldestPath = path;
186
+ }
187
+ }
188
+
189
+ if (oldestPath) {
190
+ const db = instances.get(oldestPath);
191
+ if (db) {
192
+ db.close().catch((err) => {
193
+ console.error(
194
+ `[swarm-mail] Failed to close evicted database: ${err.message}`,
195
+ );
196
+ });
197
+ }
198
+ instances.delete(oldestPath);
199
+ pendingInstances.delete(oldestPath);
200
+ schemaInitialized.delete(oldestPath);
201
+ degradedInstances.delete(oldestPath);
202
+ lastAccess.delete(oldestPath);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Get or create a PGLite instance for the given path
208
+ *
209
+ * If initialization fails, falls back to in-memory database and marks instance as degraded.
210
+ *
211
+ * Uses Promise-based caching to prevent race conditions when multiple concurrent
212
+ * calls occur before the first one completes.
213
+ */
214
+ export async function getDatabase(projectPath?: string): Promise<PGlite> {
215
+ const dbPath = getDatabasePath(projectPath);
216
+
217
+ // Return existing instance if available
218
+ const existingDb = instances.get(dbPath);
219
+ if (existingDb) {
220
+ lastAccess.set(dbPath, Date.now());
221
+ return existingDb;
222
+ }
223
+
224
+ // Return pending promise if initialization is in progress (fixes race condition)
225
+ const pendingPromise = pendingInstances.get(dbPath);
226
+ if (pendingPromise) {
227
+ return pendingPromise;
228
+ }
229
+
230
+ // Create new initialization promise
231
+ const initPromise = createDatabaseInstance(dbPath);
232
+ pendingInstances.set(dbPath, initPromise);
233
+
234
+ try {
235
+ const db = await initPromise;
236
+ instances.set(dbPath, db);
237
+ lastAccess.set(dbPath, Date.now());
238
+ return db;
239
+ } finally {
240
+ // Clean up pending promise once resolved/rejected
241
+ pendingInstances.delete(dbPath);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Create and initialize a database instance
247
+ *
248
+ * Separated from getDatabase for cleaner Promise-based caching logic
249
+ */
250
+ async function createDatabaseInstance(dbPath: string): Promise<PGlite> {
251
+ // Evict LRU if cache is full
252
+ evictLRU();
253
+
254
+ debugLog("createDatabaseInstance called", { dbPath, cwd: process.cwd() });
255
+
256
+ let db: PGlite;
257
+
258
+ // Try to create new instance
259
+ try {
260
+ debugLog("Creating PGlite instance", { dbPath });
261
+ db = new PGlite(dbPath);
262
+ debugLog("PGlite instance created successfully");
263
+
264
+ // Initialize schema if needed
265
+ if (!schemaInitialized.get(dbPath)) {
266
+ debugLog("Initializing schema");
267
+ await initializeSchema(db);
268
+ schemaInitialized.set(dbPath, true);
269
+ debugLog("Schema initialized");
270
+ }
271
+
272
+ return db;
273
+ } catch (error) {
274
+ const err = error as Error;
275
+ debugLog("Failed to initialize database", {
276
+ dbPath,
277
+ error: err.message,
278
+ stack: err.stack,
279
+ });
280
+ console.error(
281
+ `[swarm-mail] Failed to initialize database at ${dbPath}:`,
282
+ err.message,
283
+ );
284
+ degradedInstances.set(dbPath, err);
285
+
286
+ // Fall back to in-memory database
287
+ console.warn(
288
+ `[swarm-mail] Falling back to in-memory database (data will not persist)`,
289
+ );
290
+
291
+ try {
292
+ db = new PGlite(); // in-memory mode
293
+
294
+ // Initialize schema for in-memory instance
295
+ await initializeSchema(db);
296
+ schemaInitialized.set(dbPath, true);
297
+
298
+ return db;
299
+ } catch (fallbackError) {
300
+ const fallbackErr = fallbackError as Error;
301
+ console.error(
302
+ `[swarm-mail] CRITICAL: In-memory fallback failed:`,
303
+ fallbackErr.message,
304
+ );
305
+ throw new Error(
306
+ `Database initialization failed: ${err.message}. Fallback also failed: ${fallbackErr.message}`,
307
+ );
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Close a database instance
314
+ */
315
+ export async function closeDatabase(projectPath?: string): Promise<void> {
316
+ const dbPath = getDatabasePath(projectPath);
317
+ const db = instances.get(dbPath);
318
+ if (db) {
319
+ await db.close();
320
+ instances.delete(dbPath);
321
+ pendingInstances.delete(dbPath);
322
+ schemaInitialized.delete(dbPath);
323
+ degradedInstances.delete(dbPath);
324
+ lastAccess.delete(dbPath);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Close all database instances
330
+ */
331
+ export async function closeAllDatabases(): Promise<void> {
332
+ for (const [path, db] of instances) {
333
+ await db.close();
334
+ instances.delete(path);
335
+ schemaInitialized.delete(path);
336
+ }
337
+ pendingInstances.clear();
338
+ degradedInstances.clear();
339
+ lastAccess.clear();
340
+ }
341
+
342
+ /**
343
+ * Reset database for testing - clears all data but keeps schema
344
+ */
345
+ export async function resetDatabase(projectPath?: string): Promise<void> {
346
+ const db = await getDatabase(projectPath);
347
+ await db.exec(`
348
+ DELETE FROM message_recipients;
349
+ DELETE FROM messages;
350
+ DELETE FROM reservations;
351
+ DELETE FROM agents;
352
+ DELETE FROM events;
353
+ DELETE FROM locks;
354
+ DELETE FROM cursors;
355
+ `);
356
+ }
357
+
358
+ // ============================================================================
359
+ // Schema Initialization
360
+ // ============================================================================
361
+
362
+ /**
363
+ * Initialize the database schema
364
+ *
365
+ * Creates tables for:
366
+ * - events: The append-only event log
367
+ * - agents: Materialized view of registered agents
368
+ * - messages: Materialized view of messages
369
+ * - reservations: Materialized view of file reservations
370
+ * - cursors, deferred: Effect-TS durable primitives (via migrations)
371
+ * - locks: Distributed mutual exclusion (DurableLock)
372
+ */
373
+ async function initializeSchema(db: PGlite): Promise<void> {
374
+ // Create core event store tables
375
+ await db.exec(`
376
+ -- Events table: The source of truth (append-only)
377
+ CREATE TABLE IF NOT EXISTS events (
378
+ id SERIAL PRIMARY KEY,
379
+ type TEXT NOT NULL,
380
+ project_key TEXT NOT NULL,
381
+ timestamp BIGINT NOT NULL,
382
+ sequence SERIAL,
383
+ data JSONB NOT NULL,
384
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
385
+ );
386
+
387
+ -- Index for efficient queries
388
+ CREATE INDEX IF NOT EXISTS idx_events_project_key ON events(project_key);
389
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
390
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
391
+ CREATE INDEX IF NOT EXISTS idx_events_project_type ON events(project_key, type);
392
+
393
+ -- Agents materialized view (rebuilt from events)
394
+ CREATE TABLE IF NOT EXISTS agents (
395
+ id SERIAL PRIMARY KEY,
396
+ project_key TEXT NOT NULL,
397
+ name TEXT NOT NULL,
398
+ program TEXT DEFAULT 'opencode',
399
+ model TEXT DEFAULT 'unknown',
400
+ task_description TEXT,
401
+ registered_at BIGINT NOT NULL,
402
+ last_active_at BIGINT NOT NULL,
403
+ UNIQUE(project_key, name)
404
+ );
405
+
406
+ CREATE INDEX IF NOT EXISTS idx_agents_project ON agents(project_key);
407
+
408
+ -- Messages materialized view
409
+ CREATE TABLE IF NOT EXISTS messages (
410
+ id SERIAL PRIMARY KEY,
411
+ project_key TEXT NOT NULL,
412
+ from_agent TEXT NOT NULL,
413
+ subject TEXT NOT NULL,
414
+ body TEXT NOT NULL,
415
+ thread_id TEXT,
416
+ importance TEXT DEFAULT 'normal',
417
+ ack_required BOOLEAN DEFAULT FALSE,
418
+ created_at BIGINT NOT NULL
419
+ );
420
+
421
+ CREATE INDEX IF NOT EXISTS idx_messages_project ON messages(project_key);
422
+ CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
423
+ CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at DESC);
424
+
425
+ -- Message recipients (many-to-many)
426
+ CREATE TABLE IF NOT EXISTS message_recipients (
427
+ message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
428
+ agent_name TEXT NOT NULL,
429
+ read_at BIGINT,
430
+ acked_at BIGINT,
431
+ PRIMARY KEY(message_id, agent_name)
432
+ );
433
+
434
+ CREATE INDEX IF NOT EXISTS idx_recipients_agent ON message_recipients(agent_name);
435
+
436
+ -- File reservations materialized view
437
+ CREATE TABLE IF NOT EXISTS reservations (
438
+ id SERIAL PRIMARY KEY,
439
+ project_key TEXT NOT NULL,
440
+ agent_name TEXT NOT NULL,
441
+ path_pattern TEXT NOT NULL,
442
+ exclusive BOOLEAN DEFAULT TRUE,
443
+ reason TEXT,
444
+ created_at BIGINT NOT NULL,
445
+ expires_at BIGINT NOT NULL,
446
+ released_at BIGINT
447
+ );
448
+
449
+ CREATE INDEX IF NOT EXISTS idx_reservations_project ON reservations(project_key);
450
+ CREATE INDEX IF NOT EXISTS idx_reservations_agent ON reservations(agent_name);
451
+ CREATE INDEX IF NOT EXISTS idx_reservations_expires ON reservations(expires_at);
452
+ CREATE INDEX IF NOT EXISTS idx_reservations_active ON reservations(project_key, released_at) WHERE released_at IS NULL;
453
+
454
+ -- Locks table for distributed mutual exclusion (DurableLock)
455
+ CREATE TABLE IF NOT EXISTS locks (
456
+ resource TEXT PRIMARY KEY,
457
+ holder TEXT NOT NULL,
458
+ seq INTEGER NOT NULL DEFAULT 0,
459
+ acquired_at BIGINT NOT NULL,
460
+ expires_at BIGINT NOT NULL
461
+ );
462
+
463
+ CREATE INDEX IF NOT EXISTS idx_locks_expires ON locks(expires_at);
464
+ CREATE INDEX IF NOT EXISTS idx_locks_holder ON locks(holder);
465
+ `);
466
+
467
+ // Run schema migrations for Effect-TS durable primitives (cursors, deferred)
468
+ const { runMigrations } = await import("./migrations");
469
+ await runMigrations(db);
470
+ }
471
+
472
+ // ============================================================================
473
+ // Health Check
474
+ // ============================================================================
475
+
476
+ /**
477
+ * Check if the database is healthy
478
+ *
479
+ * Returns false if database is in degraded mode (using in-memory fallback)
480
+ */
481
+ export async function isDatabaseHealthy(
482
+ projectPath?: string,
483
+ ): Promise<boolean> {
484
+ const dbPath = getDatabasePath(projectPath);
485
+
486
+ // Check if instance is degraded
487
+ if (degradedInstances.has(dbPath)) {
488
+ const err = degradedInstances.get(dbPath);
489
+ console.error(
490
+ `[swarm-mail] Database is in degraded mode (using in-memory fallback). Original error: ${err?.message}`,
491
+ );
492
+ return false;
493
+ }
494
+
495
+ try {
496
+ const db = await getDatabase(projectPath);
497
+ const result = await db.query("SELECT 1 as ok");
498
+ return result.rows.length > 0;
499
+ } catch (error) {
500
+ const err = error as Error;
501
+ console.error(`[swarm-mail] Health check failed: ${err.message}`);
502
+ return false;
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Get database statistics
508
+ */
509
+ export async function getDatabaseStats(projectPath?: string): Promise<{
510
+ events: number;
511
+ agents: number;
512
+ messages: number;
513
+ reservations: number;
514
+ }> {
515
+ const db = await getDatabase(projectPath);
516
+
517
+ const [events, agents, messages, reservations] = await Promise.all([
518
+ db.query<{ count: string }>("SELECT COUNT(*) as count FROM events"),
519
+ db.query<{ count: string }>("SELECT COUNT(*) as count FROM agents"),
520
+ db.query<{ count: string }>("SELECT COUNT(*) as count FROM messages"),
521
+ db.query<{ count: string }>(
522
+ "SELECT COUNT(*) as count FROM reservations WHERE released_at IS NULL",
523
+ ),
524
+ ]);
525
+
526
+ return {
527
+ events: parseInt(events.rows[0]?.count || "0"),
528
+ agents: parseInt(agents.rows[0]?.count || "0"),
529
+ messages: parseInt(messages.rows[0]?.count || "0"),
530
+ reservations: parseInt(reservations.rows[0]?.count || "0"),
531
+ };
532
+ }
533
+
534
+ // ============================================================================
535
+ // Process Exit Handlers
536
+ // ============================================================================
537
+
538
+ /**
539
+ * Close all databases on process exit
540
+ */
541
+ function handleExit() {
542
+ // Use sync version if available, otherwise fire-and-forget
543
+ const dbsToClose = Array.from(instances.values());
544
+ for (const db of dbsToClose) {
545
+ try {
546
+ // PGlite doesn't have a sync close, so we just attempt async
547
+ db.close().catch(() => {
548
+ // Ignore errors during shutdown
549
+ });
550
+ } catch {
551
+ // Ignore errors
552
+ }
553
+ }
554
+ }
555
+
556
+ // Register exit handlers
557
+ process.on("exit", handleExit);
558
+ process.on("SIGINT", () => {
559
+ handleExit();
560
+ process.exit(0);
561
+ });
562
+ process.on("SIGTERM", () => {
563
+ handleExit();
564
+ process.exit(0);
565
+ });
566
+
567
+ // ============================================================================
568
+ // Exports
569
+ // ============================================================================
570
+
571
+ export { PGlite };
572
+ export * from "./agent-mail";
573
+ export * from "./debug";
574
+ export * from "./events";
575
+ export * from "./migrations";
576
+ export * from "./projections";
577
+ export * from "./store";
578
+ export * from "./swarm-mail";