opencode-swarm-plugin 0.18.0 → 0.19.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.
@@ -1,5 +1,30 @@
1
1
  /**
2
- * PGLite Event Store Setup
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
3
28
  *
4
29
  * Embedded PostgreSQL database for event sourcing.
5
30
  * No external server required - runs in-process.
@@ -41,6 +66,38 @@ export async function withTimeout<T>(
41
66
  return Promise.race([promise, timeout]);
42
67
  }
43
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
+
44
101
  // ============================================================================
45
102
  // Debug Logging
46
103
  // ============================================================================
@@ -1,9 +1,44 @@
1
1
  /**
2
- * Schema Migration System for PGLite Event Store
2
+ * Schema Migration System
3
3
  *
4
- * Version-based migrations with up/down support.
5
- * Tracks applied migrations in schema_version table.
6
- * Idempotent - safe to run multiple times.
4
+ * Handles database schema evolution for the PGLite event store.
5
+ *
6
+ * ## How It Works
7
+ *
8
+ * 1. Each migration has a unique version number (incrementing integer)
9
+ * 2. On startup, `runMigrations()` checks current schema version
10
+ * 3. Migrations are applied in order until schema is current
11
+ * 4. Version is stored in `schema_version` table
12
+ *
13
+ * ## Adding a New Migration
14
+ *
15
+ * ```typescript
16
+ * // In migrations.ts
17
+ * export const migrations: Migration[] = [
18
+ * // ... existing migrations
19
+ * {
20
+ * version: 3,
21
+ * description: "add_new_column",
22
+ * up: `ALTER TABLE events ADD COLUMN new_col TEXT`,
23
+ * down: `ALTER TABLE events DROP COLUMN new_col`,
24
+ * },
25
+ * ];
26
+ * ```
27
+ *
28
+ * ## Rollback
29
+ *
30
+ * Rollback is supported via `rollbackTo(db, targetVersion)`.
31
+ * Note: Some migrations may not be fully reversible (data loss).
32
+ *
33
+ * ## Best Practices
34
+ *
35
+ * - Always test migrations on a copy of production data
36
+ * - Keep migrations small and focused
37
+ * - Include both `up` and `down` SQL
38
+ * - Use transactions for multi-statement migrations
39
+ * - Document any data transformations
40
+ *
41
+ * @module migrations
7
42
  */
8
43
  import type { PGlite } from "@electric-sql/pglite";
9
44
 
@@ -11,10 +46,17 @@ import type { PGlite } from "@electric-sql/pglite";
11
46
  // Types
12
47
  // ============================================================================
13
48
 
49
+ /**
50
+ * A database migration definition.
51
+ */
14
52
  export interface Migration {
53
+ /** Unique version number (must be sequential) */
15
54
  version: number;
55
+ /** Human-readable migration description */
16
56
  description: string;
57
+ /** SQL to apply the migration */
17
58
  up: string;
59
+ /** SQL to rollback the migration (best effort) */
18
60
  down: string;
19
61
  }
20
62
 
@@ -17,6 +17,7 @@ import {
17
17
  readEvents,
18
18
  getLatestSequence,
19
19
  replayEvents,
20
+ replayEventsBatched,
20
21
  registerAgent,
21
22
  sendMessage,
22
23
  reserveFiles,
@@ -343,6 +344,115 @@ describe("Event Store", () => {
343
344
  });
344
345
  });
345
346
 
347
+ describe("replayEventsBatched", () => {
348
+ it("should replay events in batches with progress tracking", async () => {
349
+ // Create 50 events
350
+ for (let i = 0; i < 50; i++) {
351
+ await registerAgent(
352
+ "test-project",
353
+ `Agent${i}`,
354
+ { taskDescription: `Agent ${i}` },
355
+ TEST_PROJECT_PATH,
356
+ );
357
+ }
358
+
359
+ // Manually corrupt the views
360
+ const db = await getDatabase(TEST_PROJECT_PATH);
361
+ await db.query("DELETE FROM agents WHERE project_key = 'test-project'");
362
+
363
+ // Verify views are empty
364
+ const empty = await db.query<{ count: string }>(
365
+ "SELECT COUNT(*) as count FROM agents WHERE project_key = 'test-project'",
366
+ );
367
+ expect(parseInt(empty.rows[0]?.count ?? "0")).toBe(0);
368
+
369
+ // Track progress
370
+ const progressUpdates: Array<{
371
+ processed: number;
372
+ total: number;
373
+ percent: number;
374
+ }> = [];
375
+
376
+ // Replay in batches of 10
377
+ const result = await replayEventsBatched(
378
+ "test-project",
379
+ async (_events, progress) => {
380
+ progressUpdates.push(progress);
381
+ },
382
+ { batchSize: 10, clearViews: false },
383
+ TEST_PROJECT_PATH,
384
+ );
385
+
386
+ // Verify all events replayed
387
+ expect(result.eventsReplayed).toBe(50);
388
+
389
+ // Verify progress updates
390
+ expect(progressUpdates.length).toBe(5); // 50 events / 10 per batch = 5 batches
391
+ expect(progressUpdates[0]).toMatchObject({
392
+ processed: 10,
393
+ total: 50,
394
+ percent: 20,
395
+ });
396
+ expect(progressUpdates[4]).toMatchObject({
397
+ processed: 50,
398
+ total: 50,
399
+ percent: 100,
400
+ });
401
+
402
+ // Verify views are restored
403
+ const restored = await db.query<{ count: string }>(
404
+ "SELECT COUNT(*) as count FROM agents WHERE project_key = 'test-project'",
405
+ );
406
+ expect(parseInt(restored.rows[0]?.count ?? "0")).toBe(50);
407
+ });
408
+
409
+ it("should handle zero events gracefully", async () => {
410
+ const progressUpdates: Array<{
411
+ processed: number;
412
+ total: number;
413
+ percent: number;
414
+ }> = [];
415
+
416
+ const result = await replayEventsBatched(
417
+ "test-project",
418
+ async (_events, progress) => {
419
+ progressUpdates.push(progress);
420
+ },
421
+ { batchSize: 10 },
422
+ TEST_PROJECT_PATH,
423
+ );
424
+
425
+ expect(result.eventsReplayed).toBe(0);
426
+ expect(progressUpdates.length).toBe(0);
427
+ });
428
+
429
+ it("should use custom batch size", async () => {
430
+ // Create 25 events
431
+ for (let i = 0; i < 25; i++) {
432
+ await registerAgent("test-project", `Agent${i}`, {}, TEST_PROJECT_PATH);
433
+ }
434
+
435
+ const progressUpdates: Array<{
436
+ processed: number;
437
+ total: number;
438
+ percent: number;
439
+ }> = [];
440
+
441
+ // Replay with batch size of 5
442
+ await replayEventsBatched(
443
+ "test-project",
444
+ async (_events, progress) => {
445
+ progressUpdates.push(progress);
446
+ },
447
+ { batchSize: 5, clearViews: false },
448
+ TEST_PROJECT_PATH,
449
+ );
450
+
451
+ // Should have 5 batches (25 events / 5 per batch)
452
+ expect(progressUpdates.length).toBe(5);
453
+ });
454
+ });
455
+
346
456
  describe("getDatabaseStats", () => {
347
457
  it("should return correct counts", async () => {
348
458
  await registerAgent("test-project", "Agent1", {}, TEST_PROJECT_PATH);