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,357 @@
1
+ /**
2
+ * Tests for DurableDeferred service
3
+ *
4
+ * Verifies:
5
+ * - Create deferred with unique URL
6
+ * - Resolve deferred from another context
7
+ * - Reject deferred with error
8
+ * - Timeout when not resolved in time
9
+ * - Concurrent access patterns
10
+ * - Cleanup of expired entries
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
14
+ import { mkdir, rm } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { Effect } from "effect";
18
+ import { closeDatabase } from "../index";
19
+ import {
20
+ TimeoutError,
21
+ NotFoundError,
22
+ createDeferred,
23
+ resolveDeferred,
24
+ rejectDeferred,
25
+ cleanupDeferreds,
26
+ DurableDeferredLive,
27
+ } from "./deferred";
28
+
29
+ let TEST_PROJECT_PATH: string;
30
+
31
+ describe("DurableDeferred", () => {
32
+ beforeEach(async () => {
33
+ TEST_PROJECT_PATH = join(
34
+ tmpdir(),
35
+ `deferred-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
36
+ );
37
+ await mkdir(TEST_PROJECT_PATH, { recursive: true });
38
+ });
39
+
40
+ afterEach(async () => {
41
+ try {
42
+ await closeDatabase(TEST_PROJECT_PATH);
43
+ // Small delay to let PGLite fully release file handles
44
+ await new Promise((r) => setTimeout(r, 50));
45
+ await rm(join(TEST_PROJECT_PATH, ".opencode"), {
46
+ recursive: true,
47
+ force: true,
48
+ });
49
+ } catch {
50
+ // Ignore cleanup errors
51
+ }
52
+ });
53
+
54
+ describe("create", () => {
55
+ it("creates a deferred with unique URL", async () => {
56
+ const program = Effect.gen(function* (_) {
57
+ const handle = yield* _(
58
+ createDeferred<string>({
59
+ ttlSeconds: 60,
60
+ projectPath: TEST_PROJECT_PATH,
61
+ }),
62
+ );
63
+
64
+ expect(handle.url).toMatch(/^deferred:/);
65
+ expect(handle.value).toBeDefined();
66
+ });
67
+
68
+ await Effect.runPromise(
69
+ program.pipe(Effect.provide(DurableDeferredLive)),
70
+ );
71
+ });
72
+
73
+ it("creates multiple deferreds with different URLs", async () => {
74
+ const program = Effect.gen(function* (_) {
75
+ const handle1 = yield* _(
76
+ createDeferred<string>({
77
+ ttlSeconds: 60,
78
+ projectPath: TEST_PROJECT_PATH,
79
+ }),
80
+ );
81
+ const handle2 = yield* _(
82
+ createDeferred<string>({
83
+ ttlSeconds: 60,
84
+ projectPath: TEST_PROJECT_PATH,
85
+ }),
86
+ );
87
+
88
+ expect(handle1.url).not.toBe(handle2.url);
89
+ });
90
+
91
+ await Effect.runPromise(
92
+ program.pipe(Effect.provide(DurableDeferredLive)),
93
+ );
94
+ });
95
+ });
96
+
97
+ describe("resolve", () => {
98
+ it("resolves a deferred and returns value", async () => {
99
+ const program = Effect.gen(function* (_) {
100
+ const handle = yield* _(
101
+ createDeferred<{ message: string }>({
102
+ ttlSeconds: 60,
103
+ projectPath: TEST_PROJECT_PATH,
104
+ }),
105
+ );
106
+
107
+ // Resolve in background
108
+ Effect.runFork(
109
+ Effect.gen(function* (_) {
110
+ yield* _(Effect.sleep("100 millis"));
111
+ yield* _(
112
+ resolveDeferred(
113
+ handle.url,
114
+ { message: "resolved!" },
115
+ TEST_PROJECT_PATH,
116
+ ),
117
+ );
118
+ }).pipe(Effect.provide(DurableDeferredLive)),
119
+ );
120
+
121
+ // Await resolution
122
+ const result = yield* _(handle.value);
123
+ expect(result).toEqual({ message: "resolved!" });
124
+ });
125
+
126
+ await Effect.runPromise(
127
+ program.pipe(Effect.provide(DurableDeferredLive)),
128
+ );
129
+ });
130
+
131
+ it("fails with NotFoundError for non-existent URL", async () => {
132
+ const program = Effect.gen(function* (_) {
133
+ yield* _(
134
+ resolveDeferred(
135
+ "deferred:nonexistent",
136
+ { value: 42 },
137
+ TEST_PROJECT_PATH,
138
+ ),
139
+ );
140
+ });
141
+
142
+ const result = await Effect.runPromise(
143
+ program.pipe(
144
+ Effect.provide(DurableDeferredLive),
145
+ Effect.flip, // Flip to get the error
146
+ ),
147
+ );
148
+
149
+ expect(result).toBeInstanceOf(NotFoundError);
150
+ expect((result as NotFoundError).url).toBe("deferred:nonexistent");
151
+ });
152
+ });
153
+
154
+ describe("reject", () => {
155
+ it("rejects a deferred with error", async () => {
156
+ const program = Effect.gen(function* (_) {
157
+ const handle = yield* _(
158
+ createDeferred<string>({
159
+ ttlSeconds: 60,
160
+ projectPath: TEST_PROJECT_PATH,
161
+ }),
162
+ );
163
+
164
+ // Reject in background
165
+ Effect.runFork(
166
+ Effect.gen(function* (_) {
167
+ yield* _(Effect.sleep("100 millis"));
168
+ yield* _(
169
+ rejectDeferred(
170
+ handle.url,
171
+ new Error("Something went wrong"),
172
+ TEST_PROJECT_PATH,
173
+ ),
174
+ );
175
+ }).pipe(Effect.provide(DurableDeferredLive)),
176
+ );
177
+
178
+ // Await should fail
179
+ yield* _(handle.value);
180
+ });
181
+
182
+ const result = await Effect.runPromise(
183
+ program.pipe(
184
+ Effect.provide(DurableDeferredLive),
185
+ Effect.flip, // Flip to get the error
186
+ ),
187
+ );
188
+
189
+ // Will be a NotFoundError since we map all errors to NotFoundError in awaitImpl
190
+ expect(result).toBeInstanceOf(NotFoundError);
191
+ });
192
+
193
+ it("fails with NotFoundError for non-existent URL", async () => {
194
+ const program = Effect.gen(function* (_) {
195
+ yield* _(
196
+ rejectDeferred(
197
+ "deferred:nonexistent",
198
+ new Error("test"),
199
+ TEST_PROJECT_PATH,
200
+ ),
201
+ );
202
+ });
203
+
204
+ const result = await Effect.runPromise(
205
+ program.pipe(Effect.provide(DurableDeferredLive), Effect.flip),
206
+ );
207
+
208
+ expect(result).toBeInstanceOf(NotFoundError);
209
+ });
210
+ });
211
+
212
+ describe("timeout", () => {
213
+ it("times out when not resolved within TTL", async () => {
214
+ const program = Effect.gen(function* (_) {
215
+ const handle = yield* _(
216
+ createDeferred<string>({
217
+ ttlSeconds: 1, // 1 second timeout
218
+ projectPath: TEST_PROJECT_PATH,
219
+ }),
220
+ );
221
+
222
+ // Don't resolve, just wait for timeout
223
+ yield* _(handle.value);
224
+ });
225
+
226
+ const result = await Effect.runPromise(
227
+ program.pipe(Effect.provide(DurableDeferredLive), Effect.flip),
228
+ );
229
+
230
+ expect(result).toBeInstanceOf(TimeoutError);
231
+ expect((result as TimeoutError).ttlSeconds).toBe(1);
232
+ }, 10000); // 10s test timeout
233
+ });
234
+
235
+ describe("concurrent access", () => {
236
+ it("handles multiple resolvers racing", async () => {
237
+ const program = Effect.gen(function* (_) {
238
+ const handle = yield* _(
239
+ createDeferred<number>({
240
+ ttlSeconds: 60,
241
+ projectPath: TEST_PROJECT_PATH,
242
+ }),
243
+ );
244
+
245
+ // Spawn multiple resolvers (first one wins)
246
+ Effect.runFork(
247
+ Effect.gen(function* (_) {
248
+ yield* _(Effect.sleep("50 millis"));
249
+ yield* _(resolveDeferred(handle.url, 1, TEST_PROJECT_PATH));
250
+ }).pipe(Effect.provide(DurableDeferredLive)),
251
+ );
252
+
253
+ Effect.runFork(
254
+ Effect.gen(function* (_) {
255
+ yield* _(Effect.sleep("100 millis"));
256
+ yield* _(resolveDeferred(handle.url, 2, TEST_PROJECT_PATH));
257
+ }).pipe(Effect.provide(DurableDeferredLive)),
258
+ );
259
+
260
+ const result = yield* _(handle.value);
261
+ expect(result).toBe(1); // First resolver wins
262
+ });
263
+
264
+ await Effect.runPromise(
265
+ program.pipe(Effect.provide(DurableDeferredLive)),
266
+ );
267
+ });
268
+
269
+ it("handles sequential waiters on same deferred", async () => {
270
+ const program = Effect.gen(function* (_) {
271
+ const handle = yield* _(
272
+ createDeferred<string>({
273
+ ttlSeconds: 60,
274
+ projectPath: TEST_PROJECT_PATH,
275
+ }),
276
+ );
277
+
278
+ // Resolve immediately
279
+ yield* _(resolveDeferred(handle.url, "resolved", TEST_PROJECT_PATH));
280
+
281
+ // Wait for value
282
+ const result = yield* _(handle.value);
283
+ expect(result).toBe("resolved");
284
+ });
285
+
286
+ await Effect.runPromise(
287
+ program.pipe(Effect.provide(DurableDeferredLive)),
288
+ );
289
+ });
290
+ });
291
+
292
+ describe("cleanup", () => {
293
+ it("cleans up expired entries", async () => {
294
+ const program = Effect.gen(function* (_) {
295
+ // Create deferred with 1s TTL
296
+ const handle = yield* _(
297
+ createDeferred<string>({
298
+ ttlSeconds: 1,
299
+ projectPath: TEST_PROJECT_PATH,
300
+ }),
301
+ );
302
+
303
+ // Wait for expiry
304
+ yield* _(Effect.sleep("1500 millis"));
305
+
306
+ // Cleanup
307
+ const count = yield* _(cleanupDeferreds(TEST_PROJECT_PATH));
308
+ expect(count).toBeGreaterThanOrEqual(0);
309
+ });
310
+
311
+ await Effect.runPromise(
312
+ program.pipe(Effect.provide(DurableDeferredLive)),
313
+ );
314
+ });
315
+ });
316
+
317
+ describe("type safety", () => {
318
+ it("preserves types through resolution", async () => {
319
+ interface TestData {
320
+ id: number;
321
+ name: string;
322
+ tags: string[];
323
+ }
324
+
325
+ const program = Effect.gen(function* (_) {
326
+ const handle = yield* _(
327
+ createDeferred<TestData>({
328
+ ttlSeconds: 60,
329
+ projectPath: TEST_PROJECT_PATH,
330
+ }),
331
+ );
332
+
333
+ Effect.runFork(
334
+ Effect.gen(function* (_) {
335
+ yield* _(Effect.sleep("100 millis"));
336
+ yield* _(
337
+ resolveDeferred(
338
+ handle.url,
339
+ { id: 1, name: "test", tags: ["a", "b"] },
340
+ TEST_PROJECT_PATH,
341
+ ),
342
+ );
343
+ }).pipe(Effect.provide(DurableDeferredLive)),
344
+ );
345
+
346
+ const result = yield* _(handle.value);
347
+ expect(result.id).toBe(1);
348
+ expect(result.name).toBe("test");
349
+ expect(result.tags).toEqual(["a", "b"]);
350
+ });
351
+
352
+ await Effect.runPromise(
353
+ program.pipe(Effect.provide(DurableDeferredLive)),
354
+ );
355
+ });
356
+ });
357
+ });