Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-owls-bracket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fluent-effect": minor
---

Add `fx.acquireUseRelease` and `fx.bracket` for resource-safe acquire/use/release workflows.
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 46 additions & 0 deletions docs/resources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 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.sync(() => {
try {
connection.close({ failed: exit._tag === "Failure" });
} catch (cause) {
console.error("connection cleanup failed", cause);
}
}),
Comment thread
samlaycock marked this conversation as resolved.
);
```

`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.
6 changes: 6 additions & 0 deletions src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,9 @@ export const _try = <A, E>(options: TryOptions<A, E>): Task<A, E> =>
/** Wrap a synchronous function that might throw into a Task. */
export const trySync = <A, E>(options: { try: () => A; catch: (e: unknown) => E }): Task<A, E> =>
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;
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
85 changes: 85 additions & 0 deletions test/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();

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[] = [];
Expand Down
33 changes: 33 additions & 0 deletions test/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,39 @@ const syncWrapped = fx.trySync({
type _try_sync_result = Expect<Equal<TaskResult<typeof syncWrapped>, User>>;
type _try_sync_error = Expect<Equal<TaskError<typeof syncWrapped>, NetworkError>>;

interface ReleaseAudit {
readonly record: (message: string) => Task<void>;
}

const ReleaseAudit = fx.dependency<ReleaseAudit>("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<Equal<TaskResult<typeof resourceManaged>, User>>;
type _acquire_use_release_error = Expect<
Equal<TaskError<typeof resourceManaged>, NetworkError | NotFound>
>;
type _acquire_use_release_deps = Expect<Equal<TaskDeps<typeof resourceManaged>, ReleaseAudit>>;
type _bracket_result = Expect<Equal<TaskResult<typeof bracketManaged>, string>>;
type _bracket_error = Expect<Equal<TaskError<typeof bracketManaged>, never>>;

const succeeded = fx.succeed(user);
const fromSync = fx.fromSync(() => user);

Expand Down
Loading