diff --git a/.changeset/big-steaks-train.md b/.changeset/big-steaks-train.md new file mode 100644 index 00000000000..dd3d3c2a0b9 --- /dev/null +++ b/.changeset/big-steaks-train.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add `Effect.acquireDisposable` diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index d9143c239b6..72b26918b35 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -5460,6 +5460,29 @@ export const acquireRelease: { ): Effect } = fiberRuntime.acquireRelease +/** + * Constructs a scoped resource from an {@linkcode AsyncDisposable} or {@linkcode Disposable} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/using} + * + * @example + * import sqlite from "node:sqlite"; + * import { Effect } from "effect"; + * + * // Define how the resource is acquired + * const acquire = Effect.sync(() => new sqlite.DatabaseSync(":memory:")) + * const resource = Effect.acquireDisposable(acquire) + * + * @see {@link acquireRelease} for more information about scopes. + * + * @since 3.0.0 + * @category Scoping, Resources & Finalization + */ +export const acquireDisposable: { + ( + acquire: Effect + ): Effect +} = fiberRuntime.acquireDisposable + /** * Creates a scoped resource with an interruptible acquire action. * diff --git a/packages/effect/src/internal/fiberRuntime.ts b/packages/effect/src/internal/fiberRuntime.ts index f93e75c92f9..47b399dd80e 100644 --- a/packages/effect/src/internal/fiberRuntime.ts +++ b/packages/effect/src/internal/fiberRuntime.ts @@ -1691,6 +1691,17 @@ export const acquireRelease: { core.tap(acquire, (a) => addFinalizer((exit) => release(a, exit))) )) +/* @internal */ +export const acquireDisposable: { + ( + acquire: Effect.Effect + ): Effect.Effect +} = (acquire) => + acquireRelease(acquire, (resource) => + Predicate.hasProperty(resource, Symbol.asyncDispose) + ? internalEffect.promise(() => resource[Symbol.asyncDispose]()) + : core.sync(() => resource[Symbol.dispose]())) + /* @internal */ export const acquireReleaseInterruptible: { ( diff --git a/packages/effect/test/Effect/acquire-release.test.ts b/packages/effect/test/Effect/acquire-release.test.ts index 575b8923ba9..b01ed90d7a7 100644 --- a/packages/effect/test/Effect/acquire-release.test.ts +++ b/packages/effect/test/Effect/acquire-release.test.ts @@ -1,13 +1,23 @@ import { describe, it } from "@effect/vitest" -import { assertTrue, strictEqual } from "@effect/vitest/utils" +import { assertEquals, assertFalse, assertTrue, strictEqual } from "@effect/vitest/utils" import * as Cause from "effect/Cause" import * as Chunk from "effect/Chunk" import * as Effect from "effect/Effect" -import { equals } from "effect/Equal" import * as Exit from "effect/Exit" import { pipe } from "effect/Function" import * as Ref from "effect/Ref" +const disposable = (hook: () => void) => ({ + [Symbol.dispose]() { + hook() + } +}) +const asyncDisposable = (hook: () => Promise) => ({ + async [Symbol.asyncDispose]() { + await hook() + } +}) + describe("Effect", () => { it.effect("acquireUseRelease - happy path", () => Effect.gen(function*() { @@ -53,8 +63,8 @@ describe("Effect", () => { exit, Exit.matchEffect({ onFailure: Effect.succeed, onSuccess: () => Effect.fail("effect should have failed") }) ) - assertTrue(equals(Cause.failures(result), Chunk.of("use failed"))) - assertTrue(equals(Cause.defects(result), Chunk.of(releaseDied))) + assertEquals(Cause.failures(result), Chunk.of("use failed")) + assertEquals(Cause.defects(result), Chunk.of(releaseDied)) })) it.effect("acquireUseRelease - error handling + disconnect", () => Effect.gen(function*() { @@ -75,8 +85,8 @@ describe("Effect", () => { onSuccess: () => Effect.fail("effect should have failed") }) ) - assertTrue(equals(Cause.failures(result), Chunk.of("use failed"))) - assertTrue(equals(Cause.defects(result), Chunk.of(releaseDied))) + assertEquals(Cause.failures(result), Chunk.of("use failed")) + assertEquals(Cause.defects(result), Chunk.of(releaseDied)) })) it.effect("acquireUseRelease - beast mode error handling + disconnect", () => Effect.gen(function*() { @@ -105,7 +115,62 @@ describe("Effect", () => { ) ) const released = yield* (Ref.get(release)) - assertTrue(equals(Cause.defects(result), Chunk.of(useDied))) + assertEquals(Cause.defects(result), Chunk.of(useDied)) assertTrue(released) })) + it.effect("acquireDisposable - happy path", () => + Effect.gen(function*() { + let disposed = false + yield* Effect.succeed( + disposable(() => { + disposed = true + }) + ) + .pipe( + Effect.acquireDisposable, + Effect.tap(() => assertFalse(disposed)), + Effect.scoped + ) + assertTrue(disposed) + })) + it.effect("acquireDisposable - happy path async", () => + Effect.gen(function*() { + let disposed = false + yield* Effect.succeed( + asyncDisposable(() => + new Promise((resolve) => { + disposed = true + resolve() + }) + ) + ) + .pipe( + Effect.acquireDisposable, + Effect.tap(() => assertFalse(disposed)), + Effect.scoped + ) + assertTrue(disposed) + })) + it.effect("acquireDisposable - error handling", () => + Effect.gen(function*() { + const err = new Error("oh no!") + const exit = yield* Effect.succeed( + disposable(() => { + throw err + }) + ) + .pipe( + Effect.acquireDisposable, + Effect.scoped, + Effect.exit + ) + const result = yield* Exit.matchEffect( + exit, + { + onFailure: Effect.succeed, + onSuccess: () => Effect.fail("effect should have failed") + } + ) + assertEquals(Cause.defects(result), Chunk.of(err)) + })) })