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,399 @@
1
+ /**
2
+ * DurableLock - Distributed Mutual Exclusion via CAS
3
+ *
4
+ * Uses seq=0 CAS (Compare-And-Swap) pattern for distributed locking.
5
+ * Provides acquire/release/withLock methods with TTL expiry and contention handling.
6
+ *
7
+ * Based on Kyle Matthews' pattern from Agent Mail.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * // Using Effect API
12
+ * const program = Effect.gen(function* (_) {
13
+ * const lock = yield* _(acquireLock("my-resource", { ttlSeconds: 30 }))
14
+ * try {
15
+ * // Critical section
16
+ * } finally {
17
+ * yield* _(lock.release())
18
+ * }
19
+ * }).pipe(Effect.provide(DurableLockLive))
20
+ *
21
+ * // Or use withLock helper
22
+ * const program = Effect.gen(function* (_) {
23
+ * const lock = yield* _(DurableLock)
24
+ * yield* _(lock.withLock("my-resource", Effect.succeed(42)))
25
+ * }).pipe(Effect.provide(DurableLockLive))
26
+ * ```
27
+ */
28
+
29
+ import { Context, Effect, Layer, Schedule } from "effect";
30
+ import { getDatabase } from "../index";
31
+ import { randomUUID } from "node:crypto";
32
+
33
+ // ============================================================================
34
+ // Types & Errors
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Configuration for lock acquisition
39
+ */
40
+ export interface LockConfig {
41
+ /**
42
+ * Time-to-live in seconds before lock auto-expires
43
+ * @default 30
44
+ */
45
+ ttlSeconds?: number;
46
+
47
+ /**
48
+ * Maximum retry attempts when lock is contended
49
+ * @default 10
50
+ */
51
+ maxRetries?: number;
52
+
53
+ /**
54
+ * Base delay in milliseconds for exponential backoff
55
+ * @default 50
56
+ */
57
+ baseDelayMs?: number;
58
+
59
+ /**
60
+ * Project path for database instance
61
+ */
62
+ projectPath?: string;
63
+
64
+ /**
65
+ * Custom holder ID (defaults to generated UUID)
66
+ */
67
+ holderId?: string;
68
+ }
69
+
70
+ /**
71
+ * Handle representing an acquired lock
72
+ */
73
+ export interface LockHandle {
74
+ /** Resource being locked */
75
+ readonly resource: string;
76
+ /** Holder ID who owns the lock */
77
+ readonly holder: string;
78
+ /** Sequence number when acquired */
79
+ readonly seq: number;
80
+ /** Timestamp when lock was acquired */
81
+ readonly acquiredAt: number;
82
+ /** Timestamp when lock expires */
83
+ readonly expiresAt: number;
84
+ /** Release the lock */
85
+ readonly release: () => Effect.Effect<void, LockError>;
86
+ }
87
+
88
+ /**
89
+ * Lock errors
90
+ */
91
+ export type LockError =
92
+ | { readonly _tag: "LockTimeout"; readonly resource: string }
93
+ | { readonly _tag: "LockContention"; readonly resource: string }
94
+ | {
95
+ readonly _tag: "LockNotHeld";
96
+ readonly resource: string;
97
+ readonly holder: string;
98
+ }
99
+ | { readonly _tag: "DatabaseError"; readonly error: Error };
100
+
101
+ // ============================================================================
102
+ // Service Definition
103
+ // ============================================================================
104
+
105
+ /**
106
+ * DurableLock service for distributed mutual exclusion
107
+ */
108
+ export class DurableLock extends Context.Tag("DurableLock")<
109
+ DurableLock,
110
+ {
111
+ /**
112
+ * Acquire a lock on a resource
113
+ *
114
+ * Uses CAS (seq=0) pattern:
115
+ * - INSERT if no lock exists
116
+ * - UPDATE if expired or we already hold it
117
+ *
118
+ * Retries with exponential backoff on contention.
119
+ */
120
+ readonly acquire: (
121
+ resource: string,
122
+ config?: LockConfig,
123
+ ) => Effect.Effect<LockHandle, LockError>;
124
+
125
+ /**
126
+ * Release a lock
127
+ *
128
+ * Only succeeds if the holder matches.
129
+ */
130
+ readonly release: (
131
+ resource: string,
132
+ holder: string,
133
+ projectPath?: string,
134
+ ) => Effect.Effect<void, LockError>;
135
+
136
+ /**
137
+ * Execute an effect with automatic lock acquisition and release
138
+ *
139
+ * Guarantees lock release even on error (Effect.ensuring).
140
+ */
141
+ readonly withLock: <A, E, R>(
142
+ resource: string,
143
+ effect: Effect.Effect<A, E, R>,
144
+ config?: LockConfig,
145
+ ) => Effect.Effect<A, E | LockError, R | DurableLock>;
146
+ }
147
+ >() {}
148
+
149
+ // ============================================================================
150
+ // Implementation
151
+ // ============================================================================
152
+
153
+ /**
154
+ * Try to acquire lock once via CAS pattern
155
+ *
156
+ * Returns sequence number on success, null on contention
157
+ */
158
+ async function tryAcquire(
159
+ resource: string,
160
+ holder: string,
161
+ expiresAt: number,
162
+ projectPath?: string,
163
+ ): Promise<{ seq: number; acquiredAt: number } | null> {
164
+ const db = await getDatabase(projectPath);
165
+ const now = Date.now();
166
+
167
+ try {
168
+ // Try INSERT first (no existing lock)
169
+ const insertResult = await db.query<{ seq: number }>(
170
+ `INSERT INTO locks (resource, holder, seq, acquired_at, expires_at)
171
+ VALUES ($1, $2, 0, $3, $4)
172
+ RETURNING seq`,
173
+ [resource, holder, now, expiresAt],
174
+ );
175
+
176
+ if (insertResult.rows.length > 0) {
177
+ return { seq: insertResult.rows[0]!.seq, acquiredAt: now };
178
+ }
179
+ } catch {
180
+ // INSERT failed - lock exists, try UPDATE
181
+ const updateResult = await db.query<{ seq: number }>(
182
+ `UPDATE locks
183
+ SET holder = $2, seq = seq + 1, acquired_at = $3, expires_at = $4
184
+ WHERE resource = $1
185
+ AND (expires_at < $3 OR holder = $2)
186
+ RETURNING seq`,
187
+ [resource, holder, now, expiresAt],
188
+ );
189
+
190
+ if (updateResult.rows.length > 0) {
191
+ return { seq: updateResult.rows[0]!.seq, acquiredAt: now };
192
+ }
193
+ }
194
+
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * Release a lock by holder
200
+ */
201
+ async function tryRelease(
202
+ resource: string,
203
+ holder: string,
204
+ projectPath?: string,
205
+ ): Promise<boolean> {
206
+ const db = await getDatabase(projectPath);
207
+
208
+ const result = await db.query<{ holder: string }>(
209
+ `DELETE FROM locks
210
+ WHERE resource = $1 AND holder = $2
211
+ RETURNING holder`,
212
+ [resource, holder],
213
+ );
214
+
215
+ return result.rows.length > 0;
216
+ }
217
+
218
+ /**
219
+ * Acquire implementation
220
+ */
221
+ function acquireImpl(
222
+ resource: string,
223
+ config?: LockConfig,
224
+ ): Effect.Effect<LockHandle, LockError> {
225
+ return Effect.gen(function* (_) {
226
+ const {
227
+ ttlSeconds = 30,
228
+ maxRetries = 10,
229
+ baseDelayMs = 50,
230
+ projectPath,
231
+ holderId,
232
+ } = config || {};
233
+
234
+ const holder = holderId || randomUUID();
235
+ const expiresAt = Date.now() + ttlSeconds * 1000;
236
+
237
+ // Retry schedule: exponential backoff with max retries
238
+ const retrySchedule = Schedule.exponential(baseDelayMs).pipe(
239
+ Schedule.compose(Schedule.recurs(maxRetries)),
240
+ );
241
+
242
+ // Attempt acquisition with retries
243
+ const result = yield* _(
244
+ Effect.tryPromise({
245
+ try: () => tryAcquire(resource, holder, expiresAt, projectPath),
246
+ catch: (error) => ({
247
+ _tag: "DatabaseError" as const,
248
+ error: error as Error,
249
+ }),
250
+ }).pipe(
251
+ Effect.flatMap((result) =>
252
+ result
253
+ ? Effect.succeed(result)
254
+ : Effect.fail({
255
+ _tag: "LockContention" as const,
256
+ resource,
257
+ }),
258
+ ),
259
+ Effect.retry(retrySchedule),
260
+ Effect.catchTag("LockContention", () =>
261
+ Effect.fail({
262
+ _tag: "LockTimeout" as const,
263
+ resource,
264
+ }),
265
+ ),
266
+ ),
267
+ );
268
+
269
+ const { seq, acquiredAt } = result;
270
+
271
+ // Create lock handle with release method
272
+ const lockHandle: LockHandle = {
273
+ resource,
274
+ holder,
275
+ seq,
276
+ acquiredAt,
277
+ expiresAt,
278
+ release: () => releaseImpl(resource, holder, projectPath),
279
+ };
280
+
281
+ return lockHandle;
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Release implementation
287
+ */
288
+ function releaseImpl(
289
+ resource: string,
290
+ holder: string,
291
+ projectPath?: string,
292
+ ): Effect.Effect<void, LockError> {
293
+ return Effect.gen(function* (_) {
294
+ const released = yield* _(
295
+ Effect.tryPromise({
296
+ try: () => tryRelease(resource, holder, projectPath),
297
+ catch: (error) => ({
298
+ _tag: "DatabaseError" as const,
299
+ error: error as Error,
300
+ }),
301
+ }),
302
+ );
303
+
304
+ if (!released) {
305
+ yield* _(
306
+ Effect.fail({
307
+ _tag: "LockNotHeld" as const,
308
+ resource,
309
+ holder,
310
+ }),
311
+ );
312
+ }
313
+ });
314
+ }
315
+
316
+ /**
317
+ * WithLock implementation
318
+ */
319
+ function withLockImpl<A, E, R>(
320
+ resource: string,
321
+ effect: Effect.Effect<A, E, R>,
322
+ config?: LockConfig,
323
+ ): Effect.Effect<A, E | LockError, R | DurableLock> {
324
+ return Effect.gen(function* (_) {
325
+ const lock = yield* _(DurableLock);
326
+ const lockHandle = yield* _(lock.acquire(resource, config));
327
+
328
+ // Execute effect with guaranteed release
329
+ const result = yield* _(
330
+ effect.pipe(
331
+ Effect.ensuring(
332
+ lockHandle.release().pipe(
333
+ Effect.catchAll(() => Effect.void), // Swallow release errors in cleanup
334
+ ),
335
+ ),
336
+ ),
337
+ );
338
+
339
+ return result;
340
+ });
341
+ }
342
+
343
+ // ============================================================================
344
+ // Layer
345
+ // ============================================================================
346
+
347
+ /**
348
+ * Live implementation of DurableLock service
349
+ */
350
+ export const DurableLockLive = Layer.succeed(DurableLock, {
351
+ acquire: acquireImpl,
352
+ release: releaseImpl,
353
+ withLock: withLockImpl,
354
+ });
355
+
356
+ // ============================================================================
357
+ // Convenience Functions
358
+ // ============================================================================
359
+
360
+ /**
361
+ * Acquire a lock (convenience Effect wrapper)
362
+ */
363
+ export function acquireLock(
364
+ resource: string,
365
+ config?: LockConfig,
366
+ ): Effect.Effect<LockHandle, LockError, DurableLock> {
367
+ return Effect.gen(function* (_) {
368
+ const service = yield* _(DurableLock);
369
+ return yield* _(service.acquire(resource, config));
370
+ });
371
+ }
372
+
373
+ /**
374
+ * Release a lock (convenience Effect wrapper)
375
+ */
376
+ export function releaseLock(
377
+ resource: string,
378
+ holder: string,
379
+ projectPath?: string,
380
+ ): Effect.Effect<void, LockError, DurableLock> {
381
+ return Effect.gen(function* (_) {
382
+ const service = yield* _(DurableLock);
383
+ return yield* _(service.release(resource, holder, projectPath));
384
+ });
385
+ }
386
+
387
+ /**
388
+ * Execute with lock (convenience Effect wrapper)
389
+ */
390
+ export function withLock<A, E, R>(
391
+ resource: string,
392
+ effect: Effect.Effect<A, E, R>,
393
+ config?: LockConfig,
394
+ ): Effect.Effect<A, E | LockError, R | DurableLock> {
395
+ return Effect.gen(function* (_) {
396
+ const service = yield* _(DurableLock);
397
+ return yield* _(service.withLock(resource, effect, config));
398
+ });
399
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Tests for DurableMailbox service
3
+ */
4
+ import { describe, it, expect, beforeEach, afterAll } from "vitest";
5
+ import { Effect, Layer } from "effect";
6
+ import { DurableMailbox, DurableMailboxLive, type Envelope } from "./mailbox";
7
+ import { DurableCursor, DurableCursorLive } from "./cursor";
8
+ import { resetDatabase, closeDatabase } from "../index";
9
+
10
+ describe("DurableMailbox", () => {
11
+ const projectPath = "/tmp/mailbox-test";
12
+ const projectKey = "/test/project";
13
+
14
+ beforeEach(async () => {
15
+ await resetDatabase(projectPath);
16
+ });
17
+
18
+ afterAll(async () => {
19
+ await closeDatabase(projectPath);
20
+ });
21
+
22
+ describe("send/receive cycle", () => {
23
+ it("should send and receive a message", async () => {
24
+ const program = Effect.gen(function* () {
25
+ const mailboxService = yield* DurableMailbox;
26
+
27
+ // Create mailboxes for two agents
28
+ const senderMailbox = yield* mailboxService.create({
29
+ agent: "sender",
30
+ projectKey,
31
+ projectPath,
32
+ });
33
+
34
+ const receiverMailbox = yield* mailboxService.create({
35
+ agent: "receiver",
36
+ projectKey,
37
+ projectPath,
38
+ });
39
+
40
+ // Send message
41
+ yield* senderMailbox.send("receiver", {
42
+ payload: { task: "process-data", value: 42 },
43
+ });
44
+
45
+ // Receive message using Effect.promise for async iteration
46
+ const messages = yield* Effect.promise(async () => {
47
+ const results: Envelope<{ task: string; value: number }>[] = [];
48
+ for await (const envelope of receiverMailbox.receive<{
49
+ task: string;
50
+ value: number;
51
+ }>()) {
52
+ results.push(envelope);
53
+ await Effect.runPromise(envelope.commit());
54
+ break;
55
+ }
56
+ return results;
57
+ });
58
+
59
+ return messages;
60
+ });
61
+
62
+ const layer = Layer.provideMerge(
63
+ DurableMailboxLive,
64
+ Layer.succeed(DurableCursor, DurableCursorLive),
65
+ );
66
+ const messages = await Effect.runPromise(
67
+ program.pipe(Effect.provide(layer)),
68
+ );
69
+
70
+ expect(messages).toHaveLength(1);
71
+ expect(messages[0]?.payload).toEqual({
72
+ task: "process-data",
73
+ value: 42,
74
+ });
75
+ expect(messages[0]?.sender).toBe("sender");
76
+ });
77
+
78
+ it("should support replyTo pattern", async () => {
79
+ const program = Effect.gen(function* () {
80
+ const mailboxService = yield* DurableMailbox;
81
+
82
+ const senderMailbox = yield* mailboxService.create({
83
+ agent: "sender",
84
+ projectKey,
85
+ projectPath,
86
+ });
87
+
88
+ const receiverMailbox = yield* mailboxService.create({
89
+ agent: "receiver",
90
+ projectKey,
91
+ projectPath,
92
+ });
93
+
94
+ // Send message with replyTo
95
+ yield* senderMailbox.send("receiver", {
96
+ payload: { request: "ping" },
97
+ replyTo: "deferred:test-123",
98
+ });
99
+
100
+ // Receive and check replyTo
101
+ yield* Effect.promise(async () => {
102
+ for await (const envelope of receiverMailbox.receive()) {
103
+ expect(envelope.replyTo).toBe("deferred:test-123");
104
+ await Effect.runPromise(envelope.commit());
105
+ break;
106
+ }
107
+ });
108
+ });
109
+
110
+ const layer = Layer.provideMerge(
111
+ DurableMailboxLive,
112
+ Layer.succeed(DurableCursor, DurableCursorLive),
113
+ );
114
+ await Effect.runPromise(program.pipe(Effect.provide(layer)));
115
+ });
116
+
117
+ it("should filter messages by recipient", async () => {
118
+ const program = Effect.gen(function* () {
119
+ const mailboxService = yield* DurableMailbox;
120
+
121
+ const senderMailbox = yield* mailboxService.create({
122
+ agent: "sender",
123
+ projectKey,
124
+ projectPath,
125
+ });
126
+
127
+ const agent1Mailbox = yield* mailboxService.create({
128
+ agent: "agent1",
129
+ projectKey,
130
+ projectPath,
131
+ });
132
+
133
+ const agent2Mailbox = yield* mailboxService.create({
134
+ agent: "agent2",
135
+ projectKey,
136
+ projectPath,
137
+ });
138
+
139
+ // Send to agent1 only
140
+ yield* senderMailbox.send("agent1", {
141
+ payload: { for: "agent1" },
142
+ });
143
+
144
+ // Send to agent2 only
145
+ yield* senderMailbox.send("agent2", {
146
+ payload: { for: "agent2" },
147
+ });
148
+
149
+ // Agent1 should only see their message
150
+ const agent1Messages = yield* Effect.promise(async () => {
151
+ const results: Envelope<{ for: string }>[] = [];
152
+ for await (const envelope of agent1Mailbox.receive<{
153
+ for: string;
154
+ }>()) {
155
+ results.push(envelope);
156
+ await Effect.runPromise(envelope.commit());
157
+ break;
158
+ }
159
+ return results;
160
+ });
161
+
162
+ // Agent2 should only see their message
163
+ const agent2Messages = yield* Effect.promise(async () => {
164
+ const results: Envelope<{ for: string }>[] = [];
165
+ for await (const envelope of agent2Mailbox.receive<{
166
+ for: string;
167
+ }>()) {
168
+ results.push(envelope);
169
+ await Effect.runPromise(envelope.commit());
170
+ break;
171
+ }
172
+ return results;
173
+ });
174
+
175
+ return { agent1Messages, agent2Messages };
176
+ });
177
+
178
+ const layer = Layer.provideMerge(
179
+ DurableMailboxLive,
180
+ Layer.succeed(DurableCursor, DurableCursorLive),
181
+ );
182
+ const { agent1Messages, agent2Messages } = await Effect.runPromise(
183
+ program.pipe(Effect.provide(layer)),
184
+ );
185
+
186
+ expect(agent1Messages).toHaveLength(1);
187
+ expect(agent1Messages[0]?.payload.for).toBe("agent1");
188
+
189
+ expect(agent2Messages).toHaveLength(1);
190
+ expect(agent2Messages[0]?.payload.for).toBe("agent2");
191
+ });
192
+ });
193
+
194
+ describe("peek", () => {
195
+ it("should return next message without consuming", async () => {
196
+ const program = Effect.gen(function* () {
197
+ const mailboxService = yield* DurableMailbox;
198
+
199
+ const senderMailbox = yield* mailboxService.create({
200
+ agent: "sender",
201
+ projectKey,
202
+ projectPath,
203
+ });
204
+
205
+ const receiverMailbox = yield* mailboxService.create({
206
+ agent: "receiver",
207
+ projectKey,
208
+ projectPath,
209
+ });
210
+
211
+ // Send message
212
+ yield* senderMailbox.send("receiver", {
213
+ payload: { value: 123 },
214
+ });
215
+
216
+ // Peek (doesn't consume)
217
+ const peeked = yield* receiverMailbox.peek<{ value: number }>();
218
+ expect(peeked?.payload.value).toBe(123);
219
+
220
+ // Receive (should still be there)
221
+ yield* Effect.promise(async () => {
222
+ for await (const envelope of receiverMailbox.receive<{
223
+ value: number;
224
+ }>()) {
225
+ expect(envelope.payload.value).toBe(123);
226
+ await Effect.runPromise(envelope.commit());
227
+ break;
228
+ }
229
+ });
230
+ });
231
+
232
+ const layer = Layer.provideMerge(
233
+ DurableMailboxLive,
234
+ Layer.succeed(DurableCursor, DurableCursorLive),
235
+ );
236
+ await Effect.runPromise(program.pipe(Effect.provide(layer)));
237
+ });
238
+
239
+ it("should return null when no messages", async () => {
240
+ const program = Effect.gen(function* () {
241
+ const mailboxService = yield* DurableMailbox;
242
+
243
+ const mailbox = yield* mailboxService.create({
244
+ agent: "receiver",
245
+ projectKey,
246
+ projectPath,
247
+ });
248
+
249
+ const peeked = yield* mailbox.peek();
250
+ expect(peeked).toBeNull();
251
+ });
252
+
253
+ const layer = Layer.provideMerge(
254
+ DurableMailboxLive,
255
+ Layer.succeed(DurableCursor, DurableCursorLive),
256
+ );
257
+ await Effect.runPromise(program.pipe(Effect.provide(layer)));
258
+ });
259
+ });
260
+ });