diff --git a/.changeset/scoped-resource-helpers.md b/.changeset/scoped-resource-helpers.md new file mode 100644 index 0000000..6b52842 --- /dev/null +++ b/.changeset/scoped-resource-helpers.md @@ -0,0 +1,5 @@ +--- +"fluent-effect": minor +--- + +Add `fx.acquireRelease`, `fx.scoped`, and `fx.layerScoped` for ergonomic scoped resource workflows. diff --git a/docs/api-reference.md b/docs/api-reference.md index 12d7292..41cfa69 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -18,6 +18,8 @@ import { fx } from "fluent-effect"; | `fx.trySync` | Convert synchronous throwing code into a synchronously runnable task. | | `fx.acquireUseRelease` | Acquire, use, and release a resource safely. | | `fx.bracket` | Alias for `fx.acquireUseRelease`. | +| `fx.acquireRelease` | Acquire a scoped resource and register its finalizer. | +| `fx.scoped` | Run a scoped task and close the scope when it completes. | ## Errors @@ -78,6 +80,7 @@ import { fx } from "fluent-effect"; | `fx.provideDependencyTask`, `fx.dependencyTask` | Build and provide a dependency from a task. | | `fx.layer` | Native `Layer.effect`. | | `fx.layerSync` | Native `Layer.succeed`. | +| `fx.layerScoped` | Native `Layer.scoped` for scoped dependencies. | | `fx.dependencies`, `fx.mergeAllLayers` | Merge many layers. | | `fx.mergeLayers` | Merge two layers. | | `fx.withDependency`, `fx.withDependencies` | Provide one value or one layer to a task. | diff --git a/docs/resources.md b/docs/resources.md index 94f9a93..638b59c 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -29,18 +29,82 @@ const program = fx.acquireUseRelease( `fx.bracket` is the same helper under a shorter traditional name. +## Scoped Resources + +Use `fx.acquireRelease` when acquiring a resource should register a finalizer +with the current Effect scope. Wrap the scoped workflow in `fx.scoped` when the +scope should be opened and closed around one task. + +```ts +import { fx } from "fluent-effect"; + +const program = fx.scoped( + fx + .acquireRelease( + fx.try({ + try: () => openConnection(), + catch: (cause) => ({ _tag: "OpenFailed" as const, cause }), + }), + (connection, exit) => + fx.sync(() => { + connection.close({ failed: exit._tag === "Failure" }); + }), + ) + .pipe( + fx.chain((connection) => + fx.try({ + try: () => connection.query("select 1"), + catch: (cause) => ({ _tag: "QueryFailed" as const, cause }), + }), + ), + ), +); +``` + +Use `fx.layerScoped` when a dependency implementation itself owns a scoped +resource. The resource is released when the runtime boundary is disposed. + +```ts +import { fx } from "fluent-effect"; + +interface Database { + readonly query: (sql: string) => Promise; +} + +const Database = fx.dependency("Database"); + +const databaseLayer = fx.layerScoped( + Database, + fx.acquireRelease( + fx.try({ + try: () => connectDatabase(), + catch: (cause) => ({ _tag: "DatabaseOpenFailed" as const, cause }), + }), + (database) => + fx.sync(() => { + database.close(); + }), + ), +); +``` + ## Typing The returned task includes the acquire and use success/failure/dependency types, plus any dependencies needed by the release finalizer. The release success value is discarded. +`fx.acquireRelease` returns a scoped task, so its dependency type includes the +native `Scope.Scope` requirement until it is passed to `fx.scoped` or used in +`fx.layerScoped`. + Effect finalizers are expected not to introduce typed failures. If cleanup can fail, handle that failure inside the release task by logging, reporting, or defecting intentionally with native Effect APIs. ## Native Escape Hatch -Prefer `fx.acquireUseRelease` for direct acquire/use/release workflows. Import -from `fluent-effect/effect` when you need lower-level scoped resources, layers, -custom finalizer composition, or other native resource primitives. +Prefer `fx.acquireUseRelease` for direct acquire/use/release workflows. Prefer +`fx.acquireRelease`, `fx.scoped`, and `fx.layerScoped` when a resource should +live for a scope or runtime boundary. Import from `fluent-effect/effect` when +you need custom finalizer composition or other native resource primitives. diff --git a/examples/dependencies.ts b/examples/dependencies.ts index fab3efa..ee083de 100644 --- a/examples/dependencies.ts +++ b/examples/dependencies.ts @@ -53,4 +53,4 @@ const dependencies = fx.dependencies(testUsers, consoleAudit); const app = fx.app(dependencies); -export const main = app.run(loadUser("1")); +export const main = app.run(loadUser("1")).finally(() => app.dispose()); diff --git a/src/builders.ts b/src/builders.ts index 9b127fd..1bffe19 100644 --- a/src/builders.ts +++ b/src/builders.ts @@ -110,3 +110,9 @@ export const acquireUseRelease = Effect.acquireUseRelease; /** Alias for acquire-use-release resource safety. */ export const bracket = acquireUseRelease; + +/** Acquire a scoped resource and register its finalizer with the current scope. */ +export const acquireRelease = Effect.acquireRelease; + +/** Run a scoped task and release its finalizers when the scope closes. */ +export const scoped = Effect.scoped; diff --git a/src/dependencies.ts b/src/dependencies.ts index f0c4adc..9315f77 100644 --- a/src/dependencies.ts +++ b/src/dependencies.ts @@ -63,6 +63,9 @@ export const layer = ( export const layerSync = (tag: Context.Tag, impl: S): Layer.Layer => Layer.succeed(tag, impl); +/** Build a Layer whose service implementation is acquired as a scoped resource. */ +export const layerScoped = Layer.scoped; + /** Provide a dependency implementation from a plain value. */ export function provideDependency(tag: Context.Tag, impl: S): Layer.Layer; export function provideDependency(tag: Context.Tag, impl: S): Layer.Layer { diff --git a/src/index.ts b/src/index.ts index a0a6206..62ddba6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,8 @@ export const fx = { trySync: builders.trySync, acquireUseRelease: builders.acquireUseRelease, bracket: builders.bracket, + acquireRelease: builders.acquireRelease, + scoped: builders.scoped, // Combinators map: concurrency.map, @@ -97,6 +99,7 @@ export const fx = { getDependencies: dependencies.getDependencies, layer: dependencies.layer, layerSync: dependencies.layerSync, + layerScoped: dependencies.layerScoped, provideDependency: dependencies.provideDependency, provideDependencyTask: dependencies.provideDependencyTask, dependencyValue: dependencies.dependencyValue, diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 91c1109..afb72cb 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -746,6 +746,141 @@ describe("fx runtime behavior", () => { expect(result).toBe("released"); }); + test("fx.acquireRelease with fx.scoped releases resources after success", async () => { + const events: string[] = []; + + const result = await fx.run( + fx.scoped( + fx + .acquireRelease( + fx.sync(() => { + events.push("acquire"); + return "resource"; + }), + (resource, exit) => + fx.sync(() => { + events.push(`release:${resource}:${exit._tag}`); + }), + ) + .pipe( + fx.chain((resource) => + fx.sync(() => { + events.push(`use:${resource}`); + return "done"; + }), + ), + ), + ), + ); + + expect(result).toBe("done"); + expect(events).toEqual(["acquire", "use:resource", "release:resource:Success"]); + }); + + test("fx.acquireRelease with fx.scoped releases resources after failure", async () => { + const AppError = fx.errors<{ Boom: { message: string } }>(); + const events: string[] = []; + + const result = await fx.runExit( + fx.scoped( + fx + .acquireRelease( + fx.sync(() => { + events.push("acquire"); + return "resource"; + }), + (resource, exit) => + fx.sync(() => { + events.push(`release:${resource}:${exit._tag}`); + }), + ) + .pipe( + fx.chain((resource) => + fx.task(function* () { + events.push(`use:${resource}`); + return yield* fx.fail(AppError.Boom({ message: "bad" })); + }), + ), + ), + ), + ); + + expect(result._tag).toBe("Failure"); + expect(events).toEqual(["acquire", "use:resource", "release:resource:Failure"]); + }); + + test("fx.acquireRelease with fx.scoped releases resources after interruption", async () => { + const result = await fx.run( + Effect.gen(function* () { + const released = yield* Deferred.make(); + + const fiber = yield* Effect.fork( + fx.scoped( + fx + .acquireRelease(fx.succeed("resource"), (resource, exit) => + fx + .sync(() => { + expect(resource).toBe("resource"); + expect(exit._tag).toBe("Failure"); + }) + .pipe(Effect.zipRight(Deferred.succeed(released, undefined))), + ) + .pipe(fx.chain(() => Effect.never)), + ), + ); + + yield* Effect.timeoutFail( + Fiber.interrupt(fiber).pipe(Effect.zipRight(Deferred.await(released))), + { + duration: "1 second", + onTimeout: () => + new Error("Timed out waiting for interrupted scoped resource to release"), + }, + ); + + return "released"; + }), + ); + + expect(result).toBe("released"); + }); + + test("fx.layerScoped releases resources when an app is disposed", async () => { + interface Resource { + readonly value: string; + } + + const Resource = fx.dependency("Resource"); + const events: string[] = []; + const layer = fx.layerScoped( + Resource, + fx.acquireRelease( + fx.sync(() => { + events.push("acquire"); + return { value: "ready" }; + }), + (resource, exit) => + fx.sync(() => { + events.push(`release:${resource.value}:${exit._tag}`); + }), + ), + ); + + const app = fx.app(layer); + const value = await app.run( + fx.task(function* () { + const resource = yield* fx.getDependency(Resource); + events.push(`use:${resource.value}`); + return resource.value; + }), + ); + + await app.dispose(); + + expect(value).toBe("ready"); + expect(events).toEqual(["acquire", "use:ready", "release:ready:Success"]); + }); + test("fx.onSuccess and fx.onFailure run hooks without changing the original result", async () => { const AppError = fx.errors<{ Boom: { message: string } }>(); const events: string[] = []; diff --git a/test/types.test.ts b/test/types.test.ts index 6a72b0c..b09203f 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -1,4 +1,4 @@ -import { Effect, Either, Layer } from "../src/effect"; +import { Effect, Either, Layer, Scope } from "../src/effect"; import { fx, type ErrorOf, @@ -90,7 +90,12 @@ interface ReleaseAudit { readonly record: (message: string) => Task; } +interface ResourceScope { + readonly id: string; +} + const ReleaseAudit = fx.dependency("ReleaseAudit"); +const ResourceScope = fx.dependency("ResourceScope"); const resourceManaged = fx.acquireUseRelease( fx.trySync({ @@ -111,6 +116,43 @@ const bracketManaged = fx.bracket( () => fx.ok(undefined), ); +const scopedResource = fx.acquireRelease( + fx.task(function* () { + yield* fx.getDependency(ResourceScope); + return { id: "resource" }; + }), + (resource) => + fx.task(function* () { + const audit = yield* fx.getDependency(ReleaseAudit); + yield* audit.record(resource.id); + }), +); + +const scopedResourceProgram = fx.scoped( + scopedResource.pipe( + fx.chain((resource) => + resource.id === user.id ? fx.ok(resource.id) : fx.fail(new NotFound(resource.id)), + ), + ), +); + +const scopedResourceLayer = fx.layerScoped( + Users, + fx.acquireRelease( + fx.task(function* () { + yield* fx.getDependency(ResourceScope); + return { + findById: (id: string) => fx.ok({ id, name: "Ada" }), + }; + }), + () => + fx.task(function* () { + const audit = yield* fx.getDependency(ReleaseAudit); + yield* audit.record("released"); + }), + ), +); + type _acquire_use_release_result = Expect, User>>; type _acquire_use_release_error = Expect< Equal, NetworkError | NotFound> @@ -118,6 +160,19 @@ type _acquire_use_release_error = Expect< type _acquire_use_release_deps = Expect, ReleaseAudit>>; type _bracket_result = Expect, string>>; type _bracket_error = Expect, never>>; +type _acquire_release_result = Expect, { id: string }>>; +type _acquire_release_error = Expect, never>>; +type _acquire_release_deps = Expect< + Equal, ResourceScope | ReleaseAudit | Scope.Scope> +>; +type _scoped_result = Expect, string>>; +type _scoped_error = Expect, NotFound>>; +type _scoped_deps = Expect< + Equal, ResourceScope | ReleaseAudit> +>; +type _layer_scoped_deps = Expect< + Equal> +>; const succeeded = fx.succeed(user); const fromSync = fx.fromSync(() => user);