@effectionx/effect-ts 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/effect.test.ts ADDED
@@ -0,0 +1,479 @@
1
+ import { describe, it } from "@effectionx/bdd";
2
+ import { Context, Effect, Exit, Fiber, Layer } from "effect";
3
+ import { call, scoped, sleep, spawn, suspend } from "effection";
4
+ import { expect } from "expect";
5
+
6
+ import {
7
+ EffectionRuntime,
8
+ makeEffectRuntime,
9
+ makeEffectionRuntime,
10
+ } from "./mod.ts";
11
+
12
+ describe("@effectionx/effect-ts", () => {
13
+ describe("EffectRuntime - Effect inside Effection", () => {
14
+ describe("run()", () => {
15
+ it("runs a successful Effect and returns the value", function* () {
16
+ const runtime = yield* makeEffectRuntime();
17
+ const result = yield* runtime.run(Effect.succeed(42));
18
+ expect(result).toEqual(42);
19
+ });
20
+
21
+ it("runs Effect with transformations (map, flatMap)", function* () {
22
+ const runtime = yield* makeEffectRuntime();
23
+ const program = Effect.succeed(10).pipe(
24
+ Effect.map((n) => n * 2),
25
+ Effect.flatMap((n) => Effect.succeed(n + 1)),
26
+ );
27
+ const result = yield* runtime.run(program);
28
+ expect(result).toEqual(21);
29
+ });
30
+
31
+ it("throws Effect failures as JavaScript errors", function* () {
32
+ const runtime = yield* makeEffectRuntime();
33
+ let caught: unknown;
34
+ try {
35
+ yield* runtime.run(Effect.fail(new Error("boom")));
36
+ throw new Error("should have thrown");
37
+ } catch (error) {
38
+ caught = error;
39
+ }
40
+ expect((caught as Error).message).toEqual("boom");
41
+ });
42
+
43
+ it("handles Effect.die (defects)", function* () {
44
+ const runtime = yield* makeEffectRuntime();
45
+ let caught: unknown;
46
+ try {
47
+ yield* runtime.run(Effect.die("unexpected"));
48
+ throw new Error("should have thrown");
49
+ } catch (error) {
50
+ caught = error;
51
+ }
52
+ // Effect.die wraps in FiberFailure, check the message
53
+ expect(String(caught)).toContain("unexpected");
54
+ });
55
+
56
+ it("runs Effect.sleep correctly", function* () {
57
+ const runtime = yield* makeEffectRuntime();
58
+ const start = Date.now();
59
+ yield* runtime.run(Effect.sleep("50 millis"));
60
+ const elapsed = Date.now() - start;
61
+ expect(elapsed).toBeGreaterThanOrEqual(45);
62
+ });
63
+
64
+ it("runs Effect.gen programs", function* () {
65
+ const runtime = yield* makeEffectRuntime();
66
+ const program = Effect.gen(function* () {
67
+ const a = yield* Effect.succeed(1);
68
+ const b = yield* Effect.succeed(2);
69
+ return a + b;
70
+ });
71
+ const result = yield* runtime.run(program);
72
+ expect(result).toEqual(3);
73
+ });
74
+
75
+ it("works with Effect.async", function* () {
76
+ const runtime = yield* makeEffectRuntime();
77
+ const program = Effect.async<number>((resume) => {
78
+ const timer = setTimeout(() => resume(Effect.succeed(42)), 10);
79
+ return Effect.sync(() => clearTimeout(timer));
80
+ });
81
+ const result = yield* runtime.run(program);
82
+ expect(result).toEqual(42);
83
+ });
84
+ });
85
+
86
+ describe("runExit()", () => {
87
+ it("returns Exit.Success for successful Effect", function* () {
88
+ const runtime = yield* makeEffectRuntime();
89
+ const exit = yield* runtime.runExit(Effect.succeed(42));
90
+ expect(Exit.isSuccess(exit)).toEqual(true);
91
+ if (Exit.isSuccess(exit)) {
92
+ expect(exit.value).toEqual(42);
93
+ }
94
+ });
95
+
96
+ it("returns Exit.Failure for failed Effect", function* () {
97
+ const runtime = yield* makeEffectRuntime();
98
+ const exit = yield* runtime.runExit(Effect.fail(new Error("boom")));
99
+ expect(Exit.isFailure(exit)).toEqual(true);
100
+ });
101
+
102
+ it("returns Exit.Failure for Effect.die", function* () {
103
+ const runtime = yield* makeEffectRuntime();
104
+ const exit = yield* runtime.runExit(Effect.die("defect"));
105
+ expect(Exit.isFailure(exit)).toEqual(true);
106
+ });
107
+
108
+ it("preserves the full Cause in Exit.Failure", function* () {
109
+ const runtime = yield* makeEffectRuntime();
110
+ const error = new Error("typed error");
111
+ const exit = yield* runtime.runExit(Effect.fail(error));
112
+ expect(Exit.isFailure(exit)).toEqual(true);
113
+ // Can inspect Cause for error details
114
+ });
115
+ });
116
+
117
+ describe("with optional layer", () => {
118
+ it("provides services from the layer", function* () {
119
+ class Counter extends Context.Tag("Counter")<
120
+ Counter,
121
+ { value: number }
122
+ >() {}
123
+ const CounterLive = Layer.succeed(Counter, { value: 100 });
124
+
125
+ const runtime = yield* makeEffectRuntime(CounterLive);
126
+ // Types flow correctly - runtime is EffectRuntime<Counter>
127
+ const result = yield* runtime.run(
128
+ Effect.gen(function* () {
129
+ const counter = yield* Counter;
130
+ return counter.value;
131
+ }),
132
+ );
133
+ expect(result).toEqual(100);
134
+ });
135
+
136
+ it("supports composed layers", function* () {
137
+ class A extends Context.Tag("A")<A, { a: number }>() {}
138
+ class B extends Context.Tag("B")<B, { b: number }>() {}
139
+
140
+ const ALive = Layer.succeed(A, { a: 1 });
141
+ const BLive = Layer.succeed(B, { b: 2 });
142
+ const AppLayer = Layer.mergeAll(ALive, BLive);
143
+
144
+ const runtime = yield* makeEffectRuntime(AppLayer);
145
+ // Types flow correctly - runtime is EffectRuntime<A | B>
146
+ const result = yield* runtime.run(
147
+ Effect.gen(function* () {
148
+ const a = yield* A;
149
+ const b = yield* B;
150
+ return a.a + b.b;
151
+ }),
152
+ );
153
+ expect(result).toEqual(3);
154
+ });
155
+ });
156
+
157
+ describe("cancellation", () => {
158
+ it("interrupts Effect when Effection task is halted", function* () {
159
+ let finalizerRan = false;
160
+ let effectStarted = false;
161
+
162
+ // Run in a nested scope so we can control when it ends
163
+ yield* scoped(function* () {
164
+ const runtime = yield* makeEffectRuntime();
165
+
166
+ // Spawn so the effect runs concurrently and we can end the scope
167
+ yield* spawn(function* () {
168
+ yield* runtime.run(
169
+ Effect.gen(function* () {
170
+ effectStarted = true;
171
+ yield* Effect.addFinalizer(() =>
172
+ Effect.sync(() => {
173
+ finalizerRan = true;
174
+ }),
175
+ );
176
+ yield* Effect.sleep("10 seconds");
177
+ }).pipe(Effect.scoped),
178
+ );
179
+ });
180
+
181
+ // Give the effect time to start before scope ends
182
+ yield* sleep(50);
183
+ });
184
+
185
+ // After the scoped block completes, the finalizer should have run
186
+ expect(effectStarted).toEqual(true);
187
+ expect(finalizerRan).toEqual(true);
188
+ });
189
+ });
190
+
191
+ describe("lifecycle", () => {
192
+ it("disposes ManagedRuntime when Effection scope ends", function* () {
193
+ let runtimeActive = false;
194
+
195
+ yield* scoped(function* () {
196
+ const runtime = yield* makeEffectRuntime();
197
+ yield* runtime.run(
198
+ Effect.sync(() => {
199
+ runtimeActive = true;
200
+ }),
201
+ );
202
+ // After scoped block completes, runtime should be disposed
203
+ });
204
+
205
+ // Runtime was active during the scope
206
+ expect(runtimeActive).toEqual(true);
207
+ });
208
+ });
209
+ });
210
+
211
+ describe("EffectionRuntime - Effection inside Effect", () => {
212
+ // Helper to run Effect programs with EffectionRuntime
213
+ const runWithEffection = <A, E>(
214
+ effect: Effect.Effect<A, E, EffectionRuntime>,
215
+ ): Promise<A> =>
216
+ Effect.runPromise(
217
+ effect.pipe(Effect.provide(makeEffectionRuntime()), Effect.scoped),
218
+ );
219
+
220
+ const runWithEffectionExit = <A, E>(
221
+ effect: Effect.Effect<A, E, EffectionRuntime>,
222
+ ): Promise<Exit.Exit<A, E>> =>
223
+ Effect.runPromiseExit(
224
+ effect.pipe(Effect.provide(makeEffectionRuntime()), Effect.scoped),
225
+ );
226
+
227
+ describe("run()", () => {
228
+ it("runs a successful Operation and returns the value", function* () {
229
+ const result = yield* call(() =>
230
+ runWithEffection(
231
+ Effect.gen(function* () {
232
+ const runtime = yield* EffectionRuntime;
233
+ return yield* runtime.run(function* () {
234
+ return 42;
235
+ });
236
+ }),
237
+ ),
238
+ );
239
+ expect(result).toEqual(42);
240
+ });
241
+
242
+ it("runs Operation with sleep", function* () {
243
+ const start = Date.now();
244
+ yield* call(() =>
245
+ runWithEffection(
246
+ Effect.gen(function* () {
247
+ const runtime = yield* EffectionRuntime;
248
+ yield* runtime.run(function* () {
249
+ yield* sleep(50);
250
+ });
251
+ }),
252
+ ),
253
+ );
254
+ const elapsed = Date.now() - start;
255
+ expect(elapsed).toBeGreaterThanOrEqual(45);
256
+ });
257
+
258
+ it("wraps Operation errors as UnknownException", function* () {
259
+ const exit = yield* call(() =>
260
+ runWithEffectionExit(
261
+ Effect.gen(function* () {
262
+ const runtime = yield* EffectionRuntime;
263
+ return yield* runtime.run(function* () {
264
+ throw new Error("boom");
265
+ });
266
+ }),
267
+ ),
268
+ );
269
+
270
+ expect(Exit.isFailure(exit)).toEqual(true);
271
+ });
272
+
273
+ it("returns an Effect", function* () {
274
+ const result = yield* call(() =>
275
+ runWithEffection(
276
+ Effect.gen(function* () {
277
+ const runtime = yield* EffectionRuntime;
278
+ const effect = runtime.run(function* () {
279
+ return 42;
280
+ });
281
+ expect(Effect.isEffect(effect)).toEqual(true);
282
+ return yield* effect;
283
+ }),
284
+ ),
285
+ );
286
+ expect(result).toEqual(42);
287
+ });
288
+ });
289
+
290
+ describe("cancellation", () => {
291
+ it("runs Effection finally blocks when Effect scope ends", function* () {
292
+ let finalizerRan = false;
293
+
294
+ yield* call(() =>
295
+ runWithEffection(
296
+ Effect.gen(function* () {
297
+ const runtime = yield* EffectionRuntime;
298
+ yield* runtime
299
+ .run(function* () {
300
+ try {
301
+ yield* suspend();
302
+ } finally {
303
+ finalizerRan = true;
304
+ }
305
+ })
306
+ .pipe(Effect.fork);
307
+
308
+ yield* Effect.sleep("50 millis");
309
+ // Scope ends here, which should close Effection scope
310
+ }),
311
+ ),
312
+ );
313
+
314
+ expect(finalizerRan).toEqual(true);
315
+ });
316
+
317
+ it("runs Effection finally blocks when Effect fiber is interrupted", function* () {
318
+ let finalizerRan = false;
319
+
320
+ yield* call(() =>
321
+ runWithEffection(
322
+ Effect.gen(function* () {
323
+ const runtime = yield* EffectionRuntime;
324
+
325
+ const fiber = yield* runtime
326
+ .run(function* () {
327
+ try {
328
+ yield* suspend();
329
+ } finally {
330
+ finalizerRan = true;
331
+ }
332
+ })
333
+ .pipe(Effect.fork);
334
+
335
+ yield* Effect.sleep("50 millis");
336
+ yield* Fiber.interrupt(fiber);
337
+ }),
338
+ ),
339
+ );
340
+
341
+ expect(finalizerRan).toEqual(true);
342
+ });
343
+ });
344
+
345
+ describe("lifecycle", () => {
346
+ it("closes Effection scope when Effect scope ends", function* () {
347
+ let scopeEnded = false;
348
+
349
+ yield* call(async () => {
350
+ await Effect.runPromise(
351
+ Effect.gen(function* () {
352
+ const runtime = yield* EffectionRuntime;
353
+ yield* runtime
354
+ .run(function* () {
355
+ try {
356
+ yield* suspend();
357
+ } finally {
358
+ scopeEnded = true;
359
+ }
360
+ })
361
+ .pipe(Effect.fork);
362
+ yield* Effect.sleep("10 millis");
363
+ }).pipe(Effect.provide(makeEffectionRuntime()), Effect.scoped),
364
+ );
365
+ });
366
+
367
+ expect(scopeEnded).toEqual(true);
368
+ });
369
+ });
370
+ });
371
+
372
+ describe("bidirectional", () => {
373
+ it("Effect -> Effection: runs Effect pipeline in Effection", function* () {
374
+ const runtime = yield* makeEffectRuntime();
375
+
376
+ const result = yield* runtime.run(
377
+ Effect.succeed(42).pipe(Effect.map((n) => n * 2)),
378
+ );
379
+
380
+ expect(result).toEqual(84);
381
+ });
382
+
383
+ it("nested: Effect uses EffectionRuntime which runs Operation", function* () {
384
+ const effectRuntime = yield* makeEffectRuntime();
385
+
386
+ const result = yield* effectRuntime.run(
387
+ Effect.gen(function* () {
388
+ const effectionRuntime = yield* EffectionRuntime;
389
+ return yield* effectionRuntime.run(function* () {
390
+ yield* sleep(10);
391
+ return "nested";
392
+ });
393
+ }).pipe(Effect.provide(makeEffectionRuntime()), Effect.scoped),
394
+ );
395
+
396
+ expect(result).toEqual("nested");
397
+ });
398
+
399
+ it("deeply nested: Effection -> Effect -> Effection -> Effect", function* () {
400
+ const outerEffectRuntime = yield* makeEffectRuntime();
401
+
402
+ const result = yield* outerEffectRuntime.run(
403
+ Effect.gen(function* () {
404
+ const effectionRuntime = yield* EffectionRuntime;
405
+
406
+ return yield* effectionRuntime.run(function* () {
407
+ const innerEffectRuntime = yield* makeEffectRuntime();
408
+ return yield* innerEffectRuntime.run(Effect.succeed("deep"));
409
+ });
410
+ }).pipe(Effect.provide(makeEffectionRuntime()), Effect.scoped),
411
+ );
412
+
413
+ expect(result).toEqual("deep");
414
+ });
415
+ });
416
+
417
+ describe("resource cleanup", () => {
418
+ it("cleans up Effect resources when Effection scope halts", function* () {
419
+ const cleanupOrder: string[] = [];
420
+
421
+ yield* scoped(function* () {
422
+ const runtime = yield* makeEffectRuntime();
423
+
424
+ yield* spawn(function* () {
425
+ yield* runtime.run(
426
+ Effect.gen(function* () {
427
+ yield* Effect.acquireRelease(
428
+ Effect.sync(() => {
429
+ cleanupOrder.push("acquired");
430
+ }),
431
+ () =>
432
+ Effect.sync(() => {
433
+ cleanupOrder.push("released");
434
+ }),
435
+ );
436
+ // Keep the effect running so we can test cleanup
437
+ yield* Effect.never;
438
+ }).pipe(Effect.scoped),
439
+ );
440
+ });
441
+
442
+ yield* sleep(50);
443
+ // Scope ends here, halting the spawned task
444
+ });
445
+
446
+ // After scoped block completes, cleanup should have happened
447
+ expect(cleanupOrder).toContain("acquired");
448
+ expect(cleanupOrder).toContain("released");
449
+ });
450
+
451
+ it("cleans up Effection resources when Effect interrupts", function* () {
452
+ const cleanupOrder: string[] = [];
453
+
454
+ yield* call(() =>
455
+ Effect.runPromise(
456
+ Effect.gen(function* () {
457
+ const runtime = yield* EffectionRuntime;
458
+
459
+ const fiber = yield* runtime
460
+ .run(function* () {
461
+ try {
462
+ cleanupOrder.push("started");
463
+ yield* suspend();
464
+ } finally {
465
+ cleanupOrder.push("cleaned");
466
+ }
467
+ })
468
+ .pipe(Effect.fork);
469
+
470
+ yield* Effect.sleep("50 millis");
471
+ yield* Fiber.interrupt(fiber);
472
+ }).pipe(Effect.provide(makeEffectionRuntime()), Effect.scoped),
473
+ ),
474
+ );
475
+
476
+ expect(cleanupOrder).toEqual(["started", "cleaned"]);
477
+ });
478
+ });
479
+ });
@@ -0,0 +1,126 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+ import type { UnknownException } from "effect/Cause";
3
+ import { type Operation, type Scope, createScope } from "effection";
4
+
5
+ /**
6
+ * A runtime for executing Effection operations inside Effect programs.
7
+ */
8
+ export interface EffectionRuntime {
9
+ /**
10
+ * Run an Effection operation and return its result as an Effect.
11
+ *
12
+ * Errors thrown in the operation become `UnknownException` in Effect.
13
+ * The Effection scope is automatically cleaned up when the Effect completes
14
+ * or is interrupted.
15
+ *
16
+ * @param operation - The Effection operation (generator function) to run
17
+ * @returns An Effect that yields the operation's result
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const program = Effect.gen(function* () {
22
+ * const runtime = yield* EffectionRuntime;
23
+ * return yield* runtime.run(function* () {
24
+ * yield* sleep(100);
25
+ * return "hello";
26
+ * });
27
+ * });
28
+ * ```
29
+ */
30
+ run<T>(operation: () => Operation<T>): Effect.Effect<T, UnknownException>;
31
+ }
32
+
33
+ /**
34
+ * Effect Context Tag for accessing the EffectionRuntime.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * const program = Effect.gen(function* () {
39
+ * const runtime = yield* EffectionRuntime;
40
+ * // use runtime.run(...)
41
+ * });
42
+ * ```
43
+ */
44
+ export const EffectionRuntime =
45
+ Context.GenericTag<EffectionRuntime>("EffectionRuntime");
46
+
47
+ /**
48
+ * Create an Effect Layer that provides an EffectionRuntime.
49
+ *
50
+ * The Effection scope is automatically closed when the Effect scope ends,
51
+ * ensuring proper cleanup of Effection resources.
52
+ *
53
+ * @param parent - Optional parent Effection scope. If provided, the runtime's
54
+ * scope will inherit all contexts from the parent scope.
55
+ * @returns An Effect Layer providing EffectionRuntime
56
+ *
57
+ * @example Basic usage
58
+ * ```ts
59
+ * import { Effect } from "effect";
60
+ * import { sleep } from "effection";
61
+ * import { makeEffectionRuntime, EffectionRuntime } from "@effectionx/effect";
62
+ *
63
+ * const program = Effect.gen(function* () {
64
+ * const runtime = yield* EffectionRuntime;
65
+ * const result = yield* runtime.run(function* () {
66
+ * yield* sleep(100);
67
+ * return "hello from effection";
68
+ * });
69
+ * return result;
70
+ * });
71
+ *
72
+ * await Effect.runPromise(
73
+ * program.pipe(
74
+ * Effect.provide(makeEffectionRuntime()),
75
+ * Effect.scoped
76
+ * )
77
+ * );
78
+ * ```
79
+ *
80
+ * @example With parent scope (to inherit Effection contexts)
81
+ * ```ts
82
+ * import { Effect } from "effect";
83
+ * import { useScope } from "effection";
84
+ * import { makeEffectionRuntime, EffectionRuntime } from "@effectionx/effect";
85
+ *
86
+ * function* myOperation() {
87
+ * const scope = yield* useScope();
88
+ * const result = yield* call(() =>
89
+ * Effect.runPromise(
90
+ * Effect.gen(function* () {
91
+ * const runtime = yield* EffectionRuntime;
92
+ * return yield* runtime.run(function* () {
93
+ * // Can access Effection contexts from parent scope
94
+ * return "hello";
95
+ * });
96
+ * }).pipe(Effect.provide(makeEffectionRuntime(scope)), Effect.scoped)
97
+ * )
98
+ * );
99
+ * return result;
100
+ * }
101
+ * ```
102
+ */
103
+ export function makeEffectionRuntime(
104
+ parent?: Scope,
105
+ ): Layer.Layer<EffectionRuntime> {
106
+ return Layer.scoped(
107
+ EffectionRuntime,
108
+ Effect.gen(function* () {
109
+ const [scope, close] = createScope(parent);
110
+
111
+ const run: EffectionRuntime["run"] = <T>(
112
+ operation: () => Operation<T>,
113
+ ) => {
114
+ return Effect.tryPromise(() => scope.run(operation));
115
+ };
116
+
117
+ yield* Effect.addFinalizer(() =>
118
+ Effect.gen(function* () {
119
+ yield* Effect.tryPromise(() => close()).pipe(Effect.exit);
120
+ }),
121
+ );
122
+
123
+ return { run };
124
+ }),
125
+ );
126
+ }
package/mod.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Effect -> Effection
2
+ export { makeEffectRuntime } from "./effect-runtime.ts";
3
+ export type { EffectRuntime } from "./effect-runtime.ts";
4
+
5
+ // Effection -> Effect
6
+ export { EffectionRuntime, makeEffectionRuntime } from "./effection-runtime.ts";
7
+ export type { EffectionRuntime as EffectionRuntimeType } from "./effection-runtime.ts";
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@effectionx/effect-ts",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/mod.js",
6
+ "types": "./dist/mod.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "development": "./mod.ts",
10
+ "default": "./dist/mod.js"
11
+ }
12
+ },
13
+ "peerDependencies": {
14
+ "effect": "^3",
15
+ "effection": "^3 || ^4"
16
+ },
17
+ "devDependencies": {
18
+ "effect": "^3",
19
+ "@effectionx/bdd": "0.4.1"
20
+ },
21
+ "license": "MIT",
22
+ "author": "engineering@frontside.com",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/thefrontside/effectionx.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/thefrontside/effectionx/issues"
29
+ },
30
+ "engines": {
31
+ "node": ">= 22"
32
+ },
33
+ "sideEffects": false
34
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "."
6
+ },
7
+ "include": ["**/*.ts"],
8
+ "exclude": ["**/*.test.ts", "dist"],
9
+ "references": [{ "path": "../bdd" }]
10
+ }