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,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
|
+
});
|