From 2753831cd445e55bc34b723591612497da46cb59 Mon Sep 17 00:00:00 2001 From: Samuel Laycock Date: Tue, 12 May 2026 20:45:51 +0100 Subject: [PATCH 1/2] feat: add acquire-use-release helper Expose fx.acquireUseRelease with a fx.bracket alias for resource-safe workflows. Add runtime coverage for success, failure, and interruption finalization plus type coverage for acquire/use/release inference. Document resource safety guidance and add a changeset for the new public API. --- .changeset/brave-owls-bracket.md | 5 ++ docs/README.md | 2 + docs/api-reference.md | 2 + docs/resources.md | 45 +++++++++++++++++ src/builders.ts | 6 +++ src/index.ts | 2 + test/runtime.test.ts | 85 ++++++++++++++++++++++++++++++++ test/types.test.ts | 33 +++++++++++++ 8 files changed, 180 insertions(+) create mode 100644 .changeset/brave-owls-bracket.md create mode 100644 docs/resources.md diff --git a/.changeset/brave-owls-bracket.md b/.changeset/brave-owls-bracket.md new file mode 100644 index 0000000..5e6e4ca --- /dev/null +++ b/.changeset/brave-owls-bracket.md @@ -0,0 +1,5 @@ +--- +"fluent-effect": minor +--- + +Add `fx.acquireUseRelease` and `fx.bracket` for resource-safe acquire/use/release workflows. diff --git a/docs/README.md b/docs/README.md index bcded9f..372a76a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,8 @@ that differ from raw Effect defaults. provider helpers, layer composition, and runtime application wiring. - [Concurrency](./concurrency.md) covers sequential defaults, unbounded concurrency, bounded concurrency, and discard traversal helpers. +- [Resource Safety](./resources.md) covers acquire/use/release workflows and + when to use the native Effect escape hatch. - [Retry and Timeout](./retry-timeout.md) covers retry attempt counting, backoff options, native schedules, and timeout failure behavior. - [Package Exports](./package-exports.md) covers the package entrypoints and diff --git a/docs/api-reference.md b/docs/api-reference.md index 54104df..516d9e2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -16,6 +16,8 @@ import { fx } from "fluent-effect"; | `fx.fail` | Create a failed task. | | `fx.try` | Convert throwing or rejecting code into a typed task. | | `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`. | ## Errors diff --git a/docs/resources.md b/docs/resources.md new file mode 100644 index 0000000..1eb0bb7 --- /dev/null +++ b/docs/resources.md @@ -0,0 +1,45 @@ +# Resource Safety + +Use `fx.acquireUseRelease` when a workflow needs a resource to be released +after it is used, even when use fails or the task is interrupted. + +```ts +import { fx } from "fluent-effect"; + +const program = fx.acquireUseRelease( + fx.try({ + try: () => openConnection(), + catch: (cause) => ({ _tag: "OpenFailed" as const, cause }), + }), + (connection) => + fx.try({ + try: () => connection.query("select 1"), + catch: (cause) => ({ _tag: "QueryFailed" as const, cause }), + }), + (connection, exit) => + fx.try({ + try: () => connection.close({ failed: exit._tag === "Failure" }), + catch: (cause) => { + console.error("connection cleanup failed", cause); + }, + }), +); +``` + +`fx.bracket` is the same helper under a shorter traditional name. + +## 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. + +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. diff --git a/src/builders.ts b/src/builders.ts index 550d7a8..13f2bc8 100644 --- a/src/builders.ts +++ b/src/builders.ts @@ -99,3 +99,9 @@ export const _try = (options: TryOptions): Task => /** Wrap a synchronous function that might throw into a Task. */ export const trySync = (options: { try: () => A; catch: (e: unknown) => E }): Task => Effect.try(options); + +/** Acquire a resource, use it, and release it on success, failure, or interruption. */ +export const acquireUseRelease = Effect.acquireUseRelease; + +/** Alias for acquire-use-release resource safety. */ +export const bracket = acquireUseRelease; diff --git a/src/index.ts b/src/index.ts index dd4ffa9..1ecfd28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,8 @@ export const fx = { errors: builders.errors, try: builders._try, trySync: builders.trySync, + acquireUseRelease: builders.acquireUseRelease, + bracket: builders.bracket, // Combinators map: concurrency.map, diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 58d036e..414a419 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -592,6 +592,91 @@ describe("fx runtime behavior", () => { expect(signalWasAborted).toBe(true); }); + test("fx.acquireUseRelease releases resources after success", async () => { + const events: string[] = []; + + const result = await fx.run( + fx.acquireUseRelease( + fx.sync(() => { + events.push("acquire"); + return "resource"; + }), + (resource) => + fx.sync(() => { + events.push(`use:${resource}`); + return "done"; + }), + (resource, exit) => + fx.sync(() => { + events.push(`release:${resource}:${exit._tag}`); + }), + ), + ); + + expect(result).toBe("done"); + expect(events).toEqual(["acquire", "use:resource", "release:resource:Success"]); + }); + + test("fx.acquireUseRelease releases resources after failure", async () => { + const AppError = fx.errors<{ Boom: { message: string } }>(); + const events: string[] = []; + + const result = await fx.runExit( + fx.acquireUseRelease( + fx.sync(() => { + events.push("acquire"); + return "resource"; + }), + (resource) => + fx.task(function* () { + events.push(`use:${resource}`); + return yield* fx.fail(AppError.Boom({ message: "bad" })); + }), + (resource, exit) => + fx.sync(() => { + events.push(`release:${resource}:${exit._tag}`); + }), + ), + ); + + expect(result._tag).toBe("Failure"); + expect(events).toEqual(["acquire", "use:resource", "release:resource:Failure"]); + }); + + test("fx.bracket releases resources after interruption", async () => { + const result = await fx.run( + Effect.gen(function* () { + const released = yield* Deferred.make(); + + const fiber = yield* Effect.fork( + fx.bracket( + fx.succeed("resource"), + () => Effect.never, + (resource, exit) => + fx + .sync(() => { + expect(resource).toBe("resource"); + expect(exit._tag).toBe("Failure"); + }) + .pipe(Effect.zipRight(Deferred.succeed(released, undefined))), + ), + ); + + yield* Effect.timeoutFail( + Fiber.interrupt(fiber).pipe(Effect.zipRight(Deferred.await(released))), + { + duration: "1 second", + onTimeout: () => new Error("Timed out waiting for interrupted bracket to release"), + }, + ); + + return "released"; + }), + ); + + expect(result).toBe("released"); + }); + 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 01adcfe..855a005 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -85,6 +85,39 @@ const syncWrapped = fx.trySync({ type _try_sync_result = Expect, User>>; type _try_sync_error = Expect, NetworkError>>; +interface ReleaseAudit { + readonly record: (message: string) => Task; +} + +const ReleaseAudit = fx.dependency("ReleaseAudit"); + +const resourceManaged = fx.acquireUseRelease( + fx.trySync({ + try: () => ({ id: "resource" }), + catch: (cause) => new NetworkError(cause), + }), + (resource) => (resource.id === user.id ? fx.ok(user) : fx.fail(new NotFound(resource.id))), + (resource, exit) => + fx.task(function* () { + const audit = yield* fx.getDependency(ReleaseAudit); + yield* audit.record(`${resource.id}:${exit._tag}`); + }), +); + +const bracketManaged = fx.bracket( + fx.ok({ id: "resource" }), + (resource) => fx.ok(resource.id), + () => fx.ok(undefined), +); + +type _acquire_use_release_result = Expect, User>>; +type _acquire_use_release_error = Expect< + Equal, NetworkError | NotFound> +>; +type _acquire_use_release_deps = Expect, ReleaseAudit>>; +type _bracket_result = Expect, string>>; +type _bracket_error = Expect, never>>; + const succeeded = fx.succeed(user); const fromSync = fx.fromSync(() => user); From 997da2d9f96efb93d6ba2786f60dfad8a7b3b3f3 Mon Sep 17 00:00:00 2001 From: Samuel Laycock Date: Tue, 12 May 2026 21:21:12 +0100 Subject: [PATCH 2/2] docs: keep release example error-free Update the resource safety guide so the release callback handles cleanup failures inside fx.sync instead of returning a typed failure from fx.try. This keeps the example aligned with the guidance that acquire-use-release finalizers should not widen the typed error channel. --- docs/resources.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/resources.md b/docs/resources.md index 1eb0bb7..94f9a93 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -17,11 +17,12 @@ const program = fx.acquireUseRelease( catch: (cause) => ({ _tag: "QueryFailed" as const, cause }), }), (connection, exit) => - fx.try({ - try: () => connection.close({ failed: exit._tag === "Failure" }), - catch: (cause) => { + fx.sync(() => { + try { + connection.close({ failed: exit._tag === "Failure" }); + } catch (cause) { console.error("connection cleanup failed", cause); - }, + } }), ); ```