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