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/scoped-resource-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fluent-effect": minor
---

Add `fx.acquireRelease`, `fx.scoped`, and `fx.layerScoped` for ergonomic scoped resource workflows.
3 changes: 3 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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. |
Expand Down
70 changes: 67 additions & 3 deletions docs/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
}

const Database = fx.dependency<Database>("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.
2 changes: 1 addition & 1 deletion examples/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
6 changes: 6 additions & 0 deletions src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
3 changes: 3 additions & 0 deletions src/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ export const layer = <I, S, E, R>(
export const layerSync = <I, S>(tag: Context.Tag<I, S>, impl: S): Layer.Layer<I> =>
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<I, S>(tag: Context.Tag<I, S>, impl: S): Layer.Layer<I>;
export function provideDependency<I, S>(tag: Context.Tag<I, S>, impl: S): Layer.Layer<I> {
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
135 changes: 135 additions & 0 deletions test/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();

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>("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[] = [];
Expand Down
57 changes: 56 additions & 1 deletion test/types.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Either, Layer } from "../src/effect";
import { Effect, Either, Layer, Scope } from "../src/effect";
import {
fx,
type ErrorOf,
Expand Down Expand Up @@ -90,7 +90,12 @@ interface ReleaseAudit {
readonly record: (message: string) => Task<void>;
}

interface ResourceScope {
readonly id: string;
}

const ReleaseAudit = fx.dependency<ReleaseAudit>("ReleaseAudit");
const ResourceScope = fx.dependency<ResourceScope>("ResourceScope");

const resourceManaged = fx.acquireUseRelease(
fx.trySync({
Expand All @@ -111,13 +116,63 @@ 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<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>>;
type _acquire_release_result = Expect<Equal<TaskResult<typeof scopedResource>, { id: string }>>;
type _acquire_release_error = Expect<Equal<TaskError<typeof scopedResource>, never>>;
type _acquire_release_deps = Expect<
Equal<TaskDeps<typeof scopedResource>, ResourceScope | ReleaseAudit | Scope.Scope>
>;
type _scoped_result = Expect<Equal<TaskResult<typeof scopedResourceProgram>, string>>;
type _scoped_error = Expect<Equal<TaskError<typeof scopedResourceProgram>, NotFound>>;
type _scoped_deps = Expect<
Equal<TaskDeps<typeof scopedResourceProgram>, ResourceScope | ReleaseAudit>
>;
type _layer_scoped_deps = Expect<
Equal<typeof scopedResourceLayer, Layer.Layer<UserRepo, never, ResourceScope | ReleaseAudit>>
>;

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