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,385 @@
1
+ /**
2
+ * DurableLock Tests - Distributed Mutual Exclusion
3
+ *
4
+ * Tests:
5
+ * - Basic acquire/release
6
+ * - Lock expiry (TTL)
7
+ * - Contention handling (retry with backoff)
8
+ * - Concurrent acquisition attempts
9
+ * - withLock helper
10
+ * - Deadlock detection (lock not held)
11
+ */
12
+
13
+ import { randomUUID } from "node:crypto";
14
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
15
+ import { Effect } from "effect";
16
+ import {
17
+ DurableLock,
18
+ DurableLockLive,
19
+ acquireLock,
20
+ releaseLock,
21
+ withLock,
22
+ type LockHandle,
23
+ } from "./lock";
24
+ import { closeDatabase, resetDatabase } from "../index";
25
+
26
+ // Isolated test path for each test run
27
+ let testDbPath: string;
28
+
29
+ describe("DurableLock", () => {
30
+ beforeEach(async () => {
31
+ testDbPath = `/tmp/lock-test-${randomUUID()}`;
32
+ await resetDatabase(testDbPath);
33
+ });
34
+
35
+ afterEach(async () => {
36
+ await closeDatabase(testDbPath);
37
+ });
38
+
39
+ describe("Basic acquire/release", () => {
40
+ it("should acquire and release a lock", async () => {
41
+ const program = Effect.gen(function* (_) {
42
+ const service = yield* _(DurableLock);
43
+ const lock = yield* _(service.acquire("test-resource"));
44
+
45
+ expect(lock.resource).toBe("test-resource");
46
+ expect(lock.holder).toBeDefined();
47
+ expect(lock.seq).toBe(0);
48
+ expect(lock.acquiredAt).toBeGreaterThan(0);
49
+ expect(lock.expiresAt).toBeGreaterThan(lock.acquiredAt);
50
+
51
+ yield* _(lock.release());
52
+ });
53
+
54
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
55
+ });
56
+
57
+ it("should use convenience function acquireLock", async () => {
58
+ const program = Effect.gen(function* (_) {
59
+ const lock = yield* _(acquireLock("test-resource"));
60
+
61
+ expect(lock.resource).toBe("test-resource");
62
+ expect(lock.seq).toBe(0);
63
+
64
+ yield* _(lock.release());
65
+ });
66
+
67
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
68
+ });
69
+
70
+ it("should use convenience function releaseLock", async () => {
71
+ const program = Effect.gen(function* (_) {
72
+ const lock = yield* _(acquireLock("test-resource"));
73
+ yield* _(releaseLock(lock.resource, lock.holder));
74
+ });
75
+
76
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
77
+ });
78
+ });
79
+
80
+ describe("Lock contention", () => {
81
+ it("should fail when lock is held by another holder", async () => {
82
+ const program = Effect.gen(function* (_) {
83
+ // First lock succeeds
84
+ const lock1 = yield* _(
85
+ acquireLock("test-resource", { ttlSeconds: 10 }),
86
+ );
87
+
88
+ // Second lock should timeout after retries
89
+ const result2 = yield* _(
90
+ acquireLock("test-resource", { maxRetries: 2, baseDelayMs: 10 }).pipe(
91
+ Effect.either,
92
+ ),
93
+ );
94
+
95
+ expect(result2._tag).toBe("Left");
96
+ if (result2._tag === "Left") {
97
+ const error = result2.left;
98
+ expect(error._tag).toBe("LockTimeout");
99
+ if (error._tag === "LockTimeout") {
100
+ expect(error.resource).toBe("test-resource");
101
+ }
102
+ }
103
+
104
+ // Cleanup
105
+ yield* _(lock1.release());
106
+ });
107
+
108
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
109
+ });
110
+
111
+ it("should allow same holder to re-acquire lock", async () => {
112
+ const program = Effect.gen(function* (_) {
113
+ const holder = "custom-holder-123";
114
+ const lock1 = yield* _(
115
+ acquireLock("test-resource", { holderId: holder, ttlSeconds: 10 }),
116
+ );
117
+
118
+ expect(lock1.seq).toBe(0);
119
+
120
+ // Same holder can re-acquire (increments seq)
121
+ const lock2 = yield* _(
122
+ acquireLock("test-resource", { holderId: holder, ttlSeconds: 10 }),
123
+ );
124
+
125
+ expect(lock2.seq).toBe(1);
126
+ expect(lock2.holder).toBe(holder);
127
+
128
+ // Cleanup
129
+ yield* _(lock2.release());
130
+ });
131
+
132
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
133
+ });
134
+ });
135
+
136
+ describe("Lock expiry (TTL)", () => {
137
+ it("should allow acquisition after lock expires", async () => {
138
+ const program = Effect.gen(function* (_) {
139
+ // Acquire with 1 second TTL
140
+ const lock1 = yield* _(acquireLock("test-resource", { ttlSeconds: 1 }));
141
+
142
+ expect(lock1.seq).toBe(0);
143
+
144
+ // Wait for expiry
145
+ yield* _(Effect.sleep("1100 millis"));
146
+
147
+ // Different holder can now acquire
148
+ const lock2 = yield* _(
149
+ acquireLock("test-resource", { holderId: "other" }),
150
+ );
151
+
152
+ expect(lock2.seq).toBeGreaterThan(0); // Sequence incremented
153
+ expect(lock2.holder).toBe("other");
154
+
155
+ // Cleanup
156
+ yield* _(lock2.release());
157
+ });
158
+
159
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
160
+ }, 5000);
161
+ });
162
+
163
+ describe("withLock helper", () => {
164
+ it("should execute effect with automatic lock/release", async () => {
165
+ const program = Effect.gen(function* (_) {
166
+ let executed = false;
167
+
168
+ yield* _(
169
+ withLock(
170
+ "test-resource",
171
+ Effect.sync(() => {
172
+ executed = true;
173
+ return 42;
174
+ }),
175
+ ),
176
+ );
177
+
178
+ expect(executed).toBe(true);
179
+ });
180
+
181
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
182
+ });
183
+
184
+ it("should release lock even if effect fails", async () => {
185
+ const program = Effect.gen(function* (_) {
186
+ const service = yield* _(DurableLock);
187
+
188
+ // withLock with failing effect
189
+ const result = yield* _(
190
+ service
191
+ .withLock(
192
+ "test-resource",
193
+ Effect.fail(new Error("Intentional failure")),
194
+ )
195
+ .pipe(Effect.either),
196
+ );
197
+
198
+ expect(result._tag).toBe("Left");
199
+
200
+ // Lock should be released - we can acquire it again
201
+ const lock = yield* _(service.acquire("test-resource"));
202
+ expect(lock.seq).toBe(0); // Lock was deleted, seq resets
203
+
204
+ yield* _(lock.release());
205
+ });
206
+
207
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
208
+ });
209
+
210
+ it("should pass through effect result", async () => {
211
+ const program = Effect.gen(function* (_) {
212
+ const result = yield* _(withLock("test-resource", Effect.succeed(42)));
213
+
214
+ expect(result).toBe(42);
215
+ });
216
+
217
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
218
+ });
219
+ });
220
+
221
+ describe("Concurrent acquisition", () => {
222
+ it("should handle concurrent acquisition attempts", async () => {
223
+ const program = Effect.gen(function* (_) {
224
+ const attempts = Array.from({ length: 5 }, (_, i) =>
225
+ acquireLock("test-resource", {
226
+ holderId: `holder-${i}`,
227
+ maxRetries: 3,
228
+ baseDelayMs: 10,
229
+ }).pipe(Effect.either),
230
+ );
231
+
232
+ // Run all attempts in parallel
233
+ const results = yield* _(
234
+ Effect.all(attempts, { concurrency: "unbounded" }),
235
+ );
236
+
237
+ // Exactly one should succeed
238
+ const successes = results.filter((r) => r._tag === "Right");
239
+ const failures = results.filter((r) => r._tag === "Left");
240
+
241
+ expect(successes.length).toBe(1);
242
+ expect(failures.length).toBe(4);
243
+
244
+ // All failures should be timeout
245
+ for (const failure of failures) {
246
+ if (failure._tag === "Left") {
247
+ expect(failure.left._tag).toBe("LockTimeout");
248
+ }
249
+ }
250
+
251
+ // Cleanup
252
+ if (successes[0] && successes[0]._tag === "Right") {
253
+ yield* _(successes[0].right.release());
254
+ }
255
+ });
256
+
257
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
258
+ }, 10000);
259
+
260
+ it("should handle sequential acquisition after release", async () => {
261
+ const program = Effect.gen(function* (_) {
262
+ const results: LockHandle[] = [];
263
+
264
+ // Sequential acquisitions
265
+ for (let i = 0; i < 3; i++) {
266
+ const lock = yield* _(
267
+ acquireLock("test-resource", { holderId: `holder-${i}` }),
268
+ );
269
+ results.push(lock);
270
+ yield* _(lock.release());
271
+ }
272
+
273
+ // All get seq=0 because lock is deleted after each release
274
+ expect(results[0]!.seq).toBe(0);
275
+ expect(results[1]!.seq).toBe(0);
276
+ expect(results[2]!.seq).toBe(0);
277
+ });
278
+
279
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
280
+ });
281
+ });
282
+
283
+ describe("Error handling", () => {
284
+ it("should fail with LockNotHeld when releasing unowned lock", async () => {
285
+ const program = Effect.gen(function* (_) {
286
+ const lock = yield* _(acquireLock("test-resource"));
287
+
288
+ // Try to release with wrong holder
289
+ const result = yield* _(
290
+ releaseLock("test-resource", "wrong-holder").pipe(Effect.either),
291
+ );
292
+
293
+ expect(result._tag).toBe("Left");
294
+ if (result._tag === "Left") {
295
+ const error = result.left;
296
+ expect(error._tag).toBe("LockNotHeld");
297
+ if (error._tag === "LockNotHeld") {
298
+ expect(error.resource).toBe("test-resource");
299
+ expect(error.holder).toBe("wrong-holder");
300
+ }
301
+ }
302
+
303
+ // Cleanup
304
+ yield* _(lock.release());
305
+ });
306
+
307
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
308
+ });
309
+
310
+ it("should fail with LockNotHeld when releasing already-released lock", async () => {
311
+ const program = Effect.gen(function* (_) {
312
+ const lock = yield* _(acquireLock("test-resource"));
313
+
314
+ // First release succeeds
315
+ yield* _(lock.release());
316
+
317
+ // Second release fails
318
+ const result = yield* _(lock.release().pipe(Effect.either));
319
+
320
+ expect(result._tag).toBe("Left");
321
+ if (result._tag === "Left") {
322
+ expect(result.left._tag).toBe("LockNotHeld");
323
+ }
324
+ });
325
+
326
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
327
+ });
328
+ });
329
+
330
+ describe("Configuration", () => {
331
+ it("should respect custom TTL", async () => {
332
+ const program = Effect.gen(function* (_) {
333
+ const ttlSeconds = 5;
334
+ const lock = yield* _(acquireLock("test-resource", { ttlSeconds }));
335
+
336
+ const expectedExpiry = lock.acquiredAt + ttlSeconds * 1000;
337
+ expect(lock.expiresAt).toBeGreaterThanOrEqual(expectedExpiry - 100);
338
+ expect(lock.expiresAt).toBeLessThanOrEqual(expectedExpiry + 100);
339
+
340
+ yield* _(lock.release());
341
+ });
342
+
343
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
344
+ });
345
+
346
+ it("should respect custom holder ID", async () => {
347
+ const program = Effect.gen(function* (_) {
348
+ const holderId = "my-custom-holder-id";
349
+ const lock = yield* _(acquireLock("test-resource", { holderId }));
350
+
351
+ expect(lock.holder).toBe(holderId);
352
+
353
+ yield* _(lock.release());
354
+ });
355
+
356
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
357
+ });
358
+
359
+ it("should respect retry configuration", async () => {
360
+ const program = Effect.gen(function* (_) {
361
+ // Hold lock
362
+ const lock1 = yield* _(acquireLock("test-resource"));
363
+
364
+ const startTime = Date.now();
365
+
366
+ // Try to acquire with quick timeout (2 retries, 10ms each)
367
+ const result = yield* _(
368
+ acquireLock("test-resource", { maxRetries: 2, baseDelayMs: 10 }).pipe(
369
+ Effect.either,
370
+ ),
371
+ );
372
+
373
+ const elapsed = Date.now() - startTime;
374
+
375
+ expect(result._tag).toBe("Left");
376
+ // Should timeout quickly (< 500ms with exponential backoff)
377
+ expect(elapsed).toBeLessThan(500);
378
+
379
+ yield* _(lock1.release());
380
+ });
381
+
382
+ await Effect.runPromise(program.pipe(Effect.provide(DurableLockLive)));
383
+ });
384
+ });
385
+ });