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.
- package/README.md +201 -0
- package/package.json +28 -0
- package/src/adapter.ts +306 -0
- package/src/index.ts +57 -0
- package/src/pglite.ts +189 -0
- package/src/streams/agent-mail.test.ts +777 -0
- package/src/streams/agent-mail.ts +535 -0
- package/src/streams/debug.test.ts +500 -0
- package/src/streams/debug.ts +727 -0
- package/src/streams/effect/ask.integration.test.ts +314 -0
- package/src/streams/effect/ask.ts +202 -0
- package/src/streams/effect/cursor.integration.test.ts +418 -0
- package/src/streams/effect/cursor.ts +288 -0
- package/src/streams/effect/deferred.test.ts +357 -0
- package/src/streams/effect/deferred.ts +445 -0
- package/src/streams/effect/index.ts +17 -0
- package/src/streams/effect/layers.ts +73 -0
- package/src/streams/effect/lock.test.ts +385 -0
- package/src/streams/effect/lock.ts +399 -0
- package/src/streams/effect/mailbox.test.ts +260 -0
- package/src/streams/effect/mailbox.ts +318 -0
- package/src/streams/events.test.ts +924 -0
- package/src/streams/events.ts +329 -0
- package/src/streams/index.test.ts +229 -0
- package/src/streams/index.ts +578 -0
- package/src/streams/migrations.test.ts +359 -0
- package/src/streams/migrations.ts +362 -0
- package/src/streams/projections.test.ts +611 -0
- package/src/streams/projections.ts +564 -0
- package/src/streams/store.integration.test.ts +658 -0
- package/src/streams/store.ts +1129 -0
- package/src/streams/swarm-mail.ts +552 -0
- package/src/types/adapter.ts +392 -0
- package/src/types/database.ts +127 -0
- package/src/types/index.ts +26 -0
- 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";
|