From 47511de874ed8538c75aeaa5be7967fad556ec8b Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 17 Jan 2025 15:32:11 +1300 Subject: [PATCH 01/19] add RcMap.invalidate api, for removing a resource from an RcMap (#4278) --- .changeset/new-numbers-visit.md | 5 + packages/effect/src/Effect.ts | 16 +-- packages/effect/src/RcMap.ts | 9 ++ packages/effect/src/internal/core.ts | 18 +++ packages/effect/src/internal/rcMap.ts | 196 ++++++++++++++------------ packages/effect/test/RcMap.test.ts | 6 + 6 files changed, 147 insertions(+), 103 deletions(-) create mode 100644 .changeset/new-numbers-visit.md diff --git a/.changeset/new-numbers-visit.md b/.changeset/new-numbers-visit.md new file mode 100644 index 00000000000..dc0a423ab99 --- /dev/null +++ b/.changeset/new-numbers-visit.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add RcMap.invalidate api, for removing a resource from an RcMap diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index 59ad3980c8d..f5de552fa19 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -13852,18 +13852,4 @@ function fnApply(options: { * @since 3.12.0 * @category Tracing */ -export const fnUntraced: fn.Gen = (body: Function, ...pipeables: Array) => - defineLength( - body.length, - pipeables.length === 0 - ? function(this: any, ...args: Array) { - return core.fromIterator(() => body.apply(this, args)) - } - : function(this: any, ...args: Array) { - let effect = core.fromIterator(() => body.apply(this, args)) - for (const x of pipeables) { - effect = x(effect) - } - return effect - } - ) +export const fnUntraced: fn.Gen = core.fnUntraced diff --git a/packages/effect/src/RcMap.ts b/packages/effect/src/RcMap.ts index 1c33b4fcb66..df99d901517 100644 --- a/packages/effect/src/RcMap.ts +++ b/packages/effect/src/RcMap.ts @@ -109,3 +109,12 @@ export const get: { * @category combinators */ export const keys: (self: RcMap) => Effect.Effect, E> = internal.keys + +/** + * @since 3.13.0 + * @category combinators + */ +export const invalidate: { + (key: K): (self: RcMap) => Effect.Effect + (self: RcMap, key: K): Effect.Effect +} = internal.invalidate diff --git a/packages/effect/src/internal/core.ts b/packages/effect/src/internal/core.ts index d934997ca9f..d7c5ff16278 100644 --- a/packages/effect/src/internal/core.ts +++ b/packages/effect/src/internal/core.ts @@ -1421,6 +1421,24 @@ export const gen: typeof Effect.gen = function() { return fromIterator(() => f(pipe)) } +/** @internal */ +export const fnUntraced: Effect.fn.Gen = (body: Function, ...pipeables: Array) => + Object.defineProperty( + pipeables.length === 0 + ? function(this: any, ...args: Array) { + return fromIterator(() => body.apply(this, args)) + } + : function(this: any, ...args: Array) { + let effect = fromIterator(() => body.apply(this, args)) + for (const x of pipeables) { + effect = x(effect) + } + return effect + }, + "length", + { value: body.length, configurable: true } + ) + /* @internal */ export const withConcurrency = dual< (concurrency: number | "unbounded") => (self: Effect.Effect) => Effect.Effect, diff --git a/packages/effect/src/internal/rcMap.ts b/packages/effect/src/internal/rcMap.ts index d1fece6c776..34de2fbe14b 100644 --- a/packages/effect/src/internal/rcMap.ts +++ b/packages/effect/src/internal/rcMap.ts @@ -32,6 +32,7 @@ declare namespace State { interface Entry { readonly deferred: Deferred.Deferred readonly scope: Scope.CloseableScope + readonly finalizer: Effect fiber: RuntimeFiber | undefined refCount: number } @@ -121,96 +122,96 @@ export const make: { export const get: { (key: K): (self: RcMap.RcMap) => Effect (self: RcMap.RcMap, key: K): Effect -} = dual( - 2, - (self_: RcMap.RcMap, key: K): Effect => { - const self = self_ as RcMapImpl - return core.uninterruptibleMask((restore) => - core.suspend(() => { - if (self.state._tag === "Closed") { - return core.interrupt - } - const state = self.state - const o = MutableHashMap.get(state.map, key) - if (o._tag === "Some") { - const entry = o.value - entry.refCount++ - return entry.fiber - ? core.as(core.interruptFiber(entry.fiber), entry) - : core.succeed(entry) - } else if (Number.isFinite(self.capacity) && MutableHashMap.size(self.state.map) >= self.capacity) { - return core.fail( - new core.ExceededCapacityException(`RcMap attempted to exceed capacity of ${self.capacity}`) - ) as Effect +} = dual(2, (self_: RcMap.RcMap, key: K): Effect => { + const self = self_ as RcMapImpl + return core.uninterruptibleMask((restore) => getImpl(self, key, restore as any)) +}) + +const getImpl = core.fnUntraced(function*(self: RcMapImpl, key: K, restore: (a: A) => A) { + if (self.state._tag === "Closed") { + return yield* core.interrupt + } + const state = self.state + const o = MutableHashMap.get(state.map, key) + let entry: State.Entry + if (o._tag === "Some") { + entry = o.value + entry.refCount++ + if (entry.fiber) yield* core.interruptFiber(entry.fiber) + } else if (Number.isFinite(self.capacity) && MutableHashMap.size(self.state.map) >= self.capacity) { + return yield* core.fail( + new core.ExceededCapacityException(`RcMap attempted to exceed capacity of ${self.capacity}`) + ) as Effect + } else { + entry = yield* self.semaphore.withPermits(1)(acquire(self, key, restore)) + } + const scope = yield* fiberRuntime.scopeTag + yield* scope.addFinalizer(() => entry.finalizer) + return yield* restore(core.deferredAwait(entry.deferred)) +}) + +const acquire = core.fnUntraced(function*(self: RcMapImpl, key: K, restore: (a: A) => A) { + const scope = yield* fiberRuntime.scopeMake() + const deferred = yield* core.deferredMake() + const acquire = self.lookup(key) + yield* restore(core.fiberRefLocally( + acquire as Effect, + core.currentContext, + Context.add(self.context, fiberRuntime.scopeTag, scope) + )).pipe( + core.exit, + core.flatMap((exit) => core.deferredDone(deferred, exit)), + circular.forkIn(scope) + ) + const entry: State.Entry = { + deferred, + scope, + finalizer: undefined as any, + fiber: undefined, + refCount: 1 + } + ;(entry as any).finalizer = release(self, key, entry) + if (self.state._tag === "Open") { + MutableHashMap.set(self.state.map, key, entry) + } + return entry +}) + +const release = (self: RcMapImpl, key: K, entry: State.Entry) => + core.suspend(() => { + entry.refCount-- + if (entry.refCount > 0) { + return core.void + } else if ( + self.state._tag === "Closed" + || !MutableHashMap.has(self.state.map, key) + || self.idleTimeToLive === undefined + ) { + if (self.state._tag === "Open") { + MutableHashMap.remove(self.state.map, key) + } + return core.scopeClose(entry.scope, core.exitVoid) + } + + return coreEffect.sleep(self.idleTimeToLive).pipe( + core.interruptible, + core.zipRight(core.suspend(() => { + if (self.state._tag === "Open" && entry.refCount === 0) { + MutableHashMap.remove(self.state.map, key) + return core.scopeClose(entry.scope, core.exitVoid) } - const acquire = self.lookup(key) - return fiberRuntime.scopeMake().pipe( - coreEffect.bindTo("scope"), - coreEffect.bind("deferred", () => core.deferredMake()), - core.tap(({ deferred, scope }) => - restore(core.fiberRefLocally( - acquire as Effect, - core.currentContext, - Context.add(self.context, fiberRuntime.scopeTag, scope) - )).pipe( - core.exit, - core.flatMap((exit) => core.deferredDone(deferred, exit)), - circular.forkIn(scope) - ) - ), - core.map(({ deferred, scope }) => { - const entry: State.Entry = { - deferred, - scope, - fiber: undefined, - refCount: 1 - } - MutableHashMap.set(state.map, key, entry) - return entry - }) - ) - }).pipe( - self.semaphore.withPermits(1), - coreEffect.bindTo("entry"), - coreEffect.bind("scope", () => fiberRuntime.scopeTag), - core.tap(({ entry, scope }) => - scope.addFinalizer(() => - core.suspend(() => { - entry.refCount-- - if (entry.refCount > 0) { - return core.void - } else if (self.idleTimeToLive === undefined) { - if (self.state._tag === "Open") { - MutableHashMap.remove(self.state.map, key) - } - return core.scopeClose(entry.scope, core.exitVoid) - } - return coreEffect.sleep(self.idleTimeToLive).pipe( - core.interruptible, - core.zipRight(core.suspend(() => { - if (self.state._tag === "Open" && entry.refCount === 0) { - MutableHashMap.remove(self.state.map, key) - return core.scopeClose(entry.scope, core.exitVoid) - } - return core.void - })), - fiberRuntime.ensuring(core.sync(() => { - entry.fiber = undefined - })), - circular.forkIn(self.scope), - core.tap((fiber) => { - entry.fiber = fiber - }), - self.semaphore.withPermits(1) - ) - }) - ) - ), - core.flatMap(({ entry }) => restore(core.deferredAwait(entry.deferred))) - ) + return core.void + })), + fiberRuntime.ensuring(core.sync(() => { + entry.fiber = undefined + })), + circular.forkIn(self.scope), + core.tap((fiber) => { + entry.fiber = fiber + }), + self.semaphore.withPermits(1) ) - } -) + }) /** @internal */ export const keys = (self: RcMap.RcMap): Effect> => { @@ -219,3 +220,22 @@ export const keys = (self: RcMap.RcMap): Effect> => { impl.state._tag === "Closed" ? core.interrupt : core.succeed(MutableHashMap.keys(impl.state.map)) ) } + +/** @internal */ +export const invalidate: { + (key: K): (self: RcMap.RcMap) => Effect + (self: RcMap.RcMap, key: K): Effect +} = dual( + 2, + core.fnUntraced(function*(self_: RcMap.RcMap, key: K) { + const self = self_ as RcMapImpl + if (self.state._tag === "Closed") return + const o = MutableHashMap.get(self.state.map, key) + if (o._tag === "None") return + const entry = o.value + MutableHashMap.remove(self.state.map, key) + if (entry.refCount > 0) return + yield* core.scopeClose(entry.scope, core.exitVoid) + if (entry.fiber) yield* core.interruptFiber(entry.fiber) + }) +) diff --git a/packages/effect/test/RcMap.test.ts b/packages/effect/test/RcMap.test.ts index 2c52b1c2627..b45074a155c 100644 --- a/packages/effect/test/RcMap.test.ts +++ b/packages/effect/test/RcMap.test.ts @@ -89,6 +89,12 @@ describe("RcMap", () => { yield* TestClock.adjust(1000) deepStrictEqual(released, ["foo", "bar"]) + + yield* Effect.scoped(RcMap.get(map, "baz")) + deepStrictEqual(acquired, ["foo", "bar", "baz"]) + yield* RcMap.invalidate(map, "baz") + deepStrictEqual(acquired, ["foo", "bar", "baz"]) + deepStrictEqual(released, ["foo", "bar", "baz"]) })) it.scoped("capacity", () => From e3532eca3a6f83d5a378e235d8bc38d8696580e9 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 17 Jan 2025 16:08:26 +1300 Subject: [PATCH 02/19] add RcMap.touch, for reseting the idle timeout for an item (#4281) --- .changeset/great-buses-perform.md | 5 ++++ packages/effect/src/RcMap.ts | 9 +++++++ packages/effect/src/internal/rcMap.ts | 37 +++++++++++++++++++++++++-- packages/effect/test/RcMap.test.ts | 31 ++++++++++++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 .changeset/great-buses-perform.md diff --git a/.changeset/great-buses-perform.md b/.changeset/great-buses-perform.md new file mode 100644 index 00000000000..875a76566f4 --- /dev/null +++ b/.changeset/great-buses-perform.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add RcMap.touch, for reseting the idle timeout for an item diff --git a/packages/effect/src/RcMap.ts b/packages/effect/src/RcMap.ts index df99d901517..71061963918 100644 --- a/packages/effect/src/RcMap.ts +++ b/packages/effect/src/RcMap.ts @@ -118,3 +118,12 @@ export const invalidate: { (key: K): (self: RcMap) => Effect.Effect (self: RcMap, key: K): Effect.Effect } = internal.invalidate + +/** + * @since 3.13.0 + * @category combinators + */ +export const touch: { + (key: K): (self: RcMap) => Effect.Effect + (self: RcMap, key: K): Effect.Effect +} = internal.touch diff --git a/packages/effect/src/internal/rcMap.ts b/packages/effect/src/internal/rcMap.ts index 34de2fbe14b..aa93d18712f 100644 --- a/packages/effect/src/internal/rcMap.ts +++ b/packages/effect/src/internal/rcMap.ts @@ -1,4 +1,5 @@ import type * as Cause from "../Cause.js" +import type { Clock } from "../Clock.js" import * as Context from "../Context.js" import type * as Deferred from "../Deferred.js" import * as Duration from "../Duration.js" @@ -34,6 +35,7 @@ declare namespace State { readonly scope: Scope.CloseableScope readonly finalizer: Effect fiber: RuntimeFiber | undefined + expiresAt: number refCount: number } } @@ -168,6 +170,7 @@ const acquire = core.fnUntraced(function*(self: RcMapImpl, key scope, finalizer: undefined as any, fiber: undefined, + expiresAt: 0, refCount: 1 } ;(entry as any).finalizer = release(self, key, entry) @@ -178,7 +181,7 @@ const acquire = core.fnUntraced(function*(self: RcMapImpl, key }) const release = (self: RcMapImpl, key: K, entry: State.Entry) => - core.suspend(() => { + coreEffect.clockWith((clock) => { entry.refCount-- if (entry.refCount > 0) { return core.void @@ -193,7 +196,10 @@ const release = (self: RcMapImpl, key: K, entry: State.Entry { if (self.state._tag === "Open" && entry.refCount === 0) { @@ -213,6 +219,16 @@ const release = (self: RcMapImpl, key: K, entry: State.Entry(entry: State.Entry, clock: Clock) => + core.suspend(function loop(): Effect { + const now = clock.unsafeCurrentTimeMillis() + const remaining = entry.expiresAt - now + if (remaining <= 0) { + return core.void + } + return core.flatMap(clock.sleep(Duration.millis(remaining)), loop) + }) + /** @internal */ export const keys = (self: RcMap.RcMap): Effect> => { const impl = self as RcMapImpl @@ -239,3 +255,20 @@ export const invalidate: { if (entry.fiber) yield* core.interruptFiber(entry.fiber) }) ) + +/** @internal */ +export const touch: { + (key: K): (self: RcMap.RcMap) => Effect + (self: RcMap.RcMap, key: K): Effect +} = dual( + 2, + (self_: RcMap.RcMap, key: K) => + coreEffect.clockWith((clock) => { + const self = self_ as RcMapImpl + if (!self.idleTimeToLive || self.state._tag === "Closed") return core.void + const o = MutableHashMap.get(self.state.map, key) + if (o._tag === "None") return core.void + o.value.expiresAt = clock.unsafeCurrentTimeMillis() + Duration.toMillis(self.idleTimeToLive) + return core.void + }) +) diff --git a/packages/effect/test/RcMap.test.ts b/packages/effect/test/RcMap.test.ts index b45074a155c..d5b24917bb5 100644 --- a/packages/effect/test/RcMap.test.ts +++ b/packages/effect/test/RcMap.test.ts @@ -97,6 +97,37 @@ describe("RcMap", () => { deepStrictEqual(released, ["foo", "bar", "baz"]) })) + it.scoped(".touch", () => + Effect.gen(function*() { + const acquired: Array = [] + const released: Array = [] + const map = yield* RcMap.make({ + lookup: (key: string) => + Effect.acquireRelease( + Effect.sync(() => { + acquired.push(key) + return key + }), + () => Effect.sync(() => released.push(key)) + ), + idleTimeToLive: 1000 + }) + + assert.deepStrictEqual(acquired, []) + assert.strictEqual(yield* Effect.scoped(RcMap.get(map, "foo")), "foo") + assert.deepStrictEqual(acquired, ["foo"]) + assert.deepStrictEqual(released, []) + + yield* TestClock.adjust(500) + assert.deepStrictEqual(released, []) + + yield* RcMap.touch(map, "foo") + yield* TestClock.adjust(500) + assert.deepStrictEqual(released, []) + yield* TestClock.adjust(500) + assert.deepStrictEqual(released, ["foo"]) + })) + it.scoped("capacity", () => Effect.gen(function*() { const map = yield* RcMap.make({ From 6141b23a405eadedbd64c9ad8305c9c81fca1217 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 17 Jan 2025 16:32:38 +1300 Subject: [PATCH 03/19] reduce churn of RcMap idle timeout fiber (#4282) --- packages/effect/src/internal/rcMap.ts | 33 +++++++++------------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/effect/src/internal/rcMap.ts b/packages/effect/src/internal/rcMap.ts index aa93d18712f..131dd771f1a 100644 --- a/packages/effect/src/internal/rcMap.ts +++ b/packages/effect/src/internal/rcMap.ts @@ -1,5 +1,4 @@ import type * as Cause from "../Cause.js" -import type { Clock } from "../Clock.js" import * as Context from "../Context.js" import type * as Deferred from "../Deferred.js" import * as Duration from "../Duration.js" @@ -139,7 +138,6 @@ const getImpl = core.fnUntraced(function*(self: RcMapImpl, key if (o._tag === "Some") { entry = o.value entry.refCount++ - if (entry.fiber) yield* core.interruptFiber(entry.fiber) } else if (Number.isFinite(self.capacity) && MutableHashMap.size(self.state.map) >= self.capacity) { return yield* core.fail( new core.ExceededCapacityException(`RcMap attempted to exceed capacity of ${self.capacity}`) @@ -197,17 +195,18 @@ const release = (self: RcMapImpl, key: K, entry: State.Entry { - if (self.state._tag === "Open" && entry.refCount === 0) { - MutableHashMap.remove(self.state.map, key) - return core.scopeClose(entry.scope, core.exitVoid) - } - return core.void - })), + return core.interruptibleMask(function loop(restore): Effect { + const now = clock.unsafeCurrentTimeMillis() + const remaining = entry.expiresAt - now + if (remaining <= 0) { + if (self.state._tag === "Closed" || entry.refCount > 0) return core.void + MutableHashMap.remove(self.state.map, key) + return restore(core.scopeClose(entry.scope, core.exitVoid)) + } + return core.flatMap(clock.sleep(Duration.millis(remaining)), () => loop(restore)) + }).pipe( fiberRuntime.ensuring(core.sync(() => { entry.fiber = undefined })), @@ -219,16 +218,6 @@ const release = (self: RcMapImpl, key: K, entry: State.Entry(entry: State.Entry, clock: Clock) => - core.suspend(function loop(): Effect { - const now = clock.unsafeCurrentTimeMillis() - const remaining = entry.expiresAt - now - if (remaining <= 0) { - return core.void - } - return core.flatMap(clock.sleep(Duration.millis(remaining)), loop) - }) - /** @internal */ export const keys = (self: RcMap.RcMap): Effect> => { const impl = self as RcMapImpl From 397a86069f15831ac876aeaddb906232230aaadb Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Tue, 21 Jan 2025 07:22:29 +0100 Subject: [PATCH 04/19] Add `Effect.transposeOption`, closes #3142 (#4284) --- .changeset/cyan-radios-relate.md | 31 ++++++++++++++ packages/effect/dtslint/Effect.ts | 10 +++++ packages/effect/src/Effect.ts | 40 ++++++++++++++++++- .../optional-wrapping-unwrapping.test.ts | 20 ++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 .changeset/cyan-radios-relate.md create mode 100644 packages/effect/test/Effect/optional-wrapping-unwrapping.test.ts diff --git a/.changeset/cyan-radios-relate.md b/.changeset/cyan-radios-relate.md new file mode 100644 index 00000000000..f30a7d949b0 --- /dev/null +++ b/.changeset/cyan-radios-relate.md @@ -0,0 +1,31 @@ +--- +"effect": minor +--- + +Add `Effect.transposeOption`, closes #3142. + +Converts an `Option` of an `Effect` into an `Effect` of an `Option`. + +**Details** + +This function transforms an `Option>` into an +`Effect, E, R>`. If the `Option` is `None`, the resulting `Effect` +will immediately succeed with a `None` value. If the `Option` is `Some`, the +inner `Effect` will be executed, and its result wrapped in a `Some`. + +**Example** + +```ts +import { Effect, Option } from "effect" + +// ┌─── Option> +// ▼ +const maybe = Option.some(Effect.succeed(42)) + +// ┌─── Effect, never, never> +// ▼ +const result = Effect.transposeOption(maybe) + +console.log(Effect.runSync(result)) +// Output: { _id: 'Option', _tag: 'Some', value: 42 } +``` diff --git a/packages/effect/dtslint/Effect.ts b/packages/effect/dtslint/Effect.ts index 2f4b02e7063..21179cdcc7e 100644 --- a/packages/effect/dtslint/Effect.ts +++ b/packages/effect/dtslint/Effect.ts @@ -1350,3 +1350,13 @@ hole< }> > >() + +// ------------------------------------------------------------------------------------- +// transposeOption +// ------------------------------------------------------------------------------------- + +// $ExpectType Effect, never, never> +Effect.transposeOption(Option.none()) + +// $ExpectType Effect, "err-1", "dep-1"> +Effect.transposeOption(Option.some(string)) diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index f5de552fa19..f53c46ffdb7 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -34,6 +34,7 @@ import * as defaultServices from "./internal/defaultServices.js" import * as circular from "./internal/effect/circular.js" import * as fiberRuntime from "./internal/fiberRuntime.js" import * as layer from "./internal/layer.js" +import * as option_ from "./internal/option.js" import * as query from "./internal/query.js" import * as runtime_ from "./internal/runtime.js" import * as schedule_ from "./internal/schedule.js" @@ -12810,7 +12811,7 @@ export const withParentSpan: { * ``` * * @since 2.0.0 - * @category Optional Wrapping + * @category Optional Wrapping & Unwrapping */ export const fromNullable: (value: A) => Effect, Cause.NoSuchElementException> = effect.fromNullable @@ -12865,12 +12866,47 @@ export const fromNullable: (value: A) => Effect, Cause.NoSuchE * ``` * * @since 2.0.0 - * @category Optional Wrapping + * @category Optional Wrapping & Unwrapping */ export const optionFromOptional: ( self: Effect ) => Effect, Exclude, R> = effect.optionFromOptional +/** + * Converts an `Option` of an `Effect` into an `Effect` of an `Option`. + * + * **Details** + * + * This function transforms an `Option>` into an + * `Effect, E, R>`. If the `Option` is `None`, the resulting `Effect` + * will immediately succeed with a `None` value. If the `Option` is `Some`, the + * inner `Effect` will be executed, and its result wrapped in a `Some`. + * + * @example + * ```ts + * import { Effect, Option } from "effect" + * + * // ┌─── Option> + * // ▼ + * const maybe = Option.some(Effect.succeed(42)) + * + * // ┌─── Effect, never, never> + * // ▼ + * const result = Effect.transposeOption(maybe) + * + * console.log(Effect.runSync(result)) + * // Output: { _id: 'Option', _tag: 'Some', value: 42 } + * ``` + * + * @since 3.13.0 + * @category Optional Wrapping & Unwrapping + */ +export const transposeOption = ( + self: Option.Option> +): Effect, E, R> => { + return option_.isNone(self) ? succeedNone : map(self.value, option_.some) +} + /** * @since 2.0.0 * @category Models diff --git a/packages/effect/test/Effect/optional-wrapping-unwrapping.test.ts b/packages/effect/test/Effect/optional-wrapping-unwrapping.test.ts new file mode 100644 index 00000000000..3eb88438a45 --- /dev/null +++ b/packages/effect/test/Effect/optional-wrapping-unwrapping.test.ts @@ -0,0 +1,20 @@ +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as it from "effect/test/utils/extend" +import { assert, describe } from "vitest" + +describe("Effect", () => { + describe("transposeOption", () => { + it.effect("None", () => + Effect.gen(function*() { + const result = yield* Effect.transposeOption(Option.none()) + assert.ok(Option.isNone(result)) + })) + + it.effect("Some", () => + Effect.gen(function*() { + const result = yield* Effect.transposeOption(Option.some(Effect.succeed(42))) + assert.deepStrictEqual(result, Option.some(42)) + })) + }) +}) From e6519c7f2aad090ea6b05e9b3089dc6476a69c1b Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 24 Jan 2025 10:15:55 +1300 Subject: [PATCH 05/19] add Effect.filterEffect* apis (#4335) --- .changeset/warm-clouds-grab.md | 56 +++++++++ packages/effect/src/Effect.ts | 107 ++++++++++++++++++ packages/effect/src/internal/core.ts | 62 ++++++++++ packages/effect/test/Effect/filtering.test.ts | 28 +++++ 4 files changed, 253 insertions(+) create mode 100644 .changeset/warm-clouds-grab.md diff --git a/.changeset/warm-clouds-grab.md b/.changeset/warm-clouds-grab.md new file mode 100644 index 00000000000..01ea55a5402 --- /dev/null +++ b/.changeset/warm-clouds-grab.md @@ -0,0 +1,56 @@ +--- +"effect": minor +--- + +add Effect.filterEffect\* apis + +#### Effect.filterEffectOrElse + +Filters an effect with an effectful predicate, falling back to an alternative +effect if the predicate fails. + +```ts +import { Effect, pipe } from "effect" + +// Define a user interface +interface User { + readonly name: string +} + +// Simulate an asynchronous authentication function +declare const auth: () => Promise + +const program = pipe( + Effect.promise(() => auth()), + // Use filterEffectOrElse with an effectful predicate + Effect.filterEffectOrElse({ + predicate: (user) => Effect.succeed(user !== null), + orElse: (user) => Effect.fail(new Error(`Unauthorized user: ${user}`)) + }) +) +``` + +#### Effect.filterEffectOrFail + +Filters an effect with an effectful predicate, failing with a custom error if the predicate fails. + +```ts +import { Effect, pipe } from "effect" + +// Define a user interface +interface User { + readonly name: string +} + +// Simulate an asynchronous authentication function +declare const auth: () => Promise + +const program = pipe( + Effect.promise(() => auth()), + // Use filterEffectOrFail with an effectful predicate + Effect.filterEffectOrFail({ + predicate: (user) => Effect.succeed(user !== null), + orFailWith: (user) => Effect.fail(new Error(`Unauthorized user: ${user}`)) + }) +) +``` diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index f53c46ffdb7..d21a7848d02 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -8308,6 +8308,113 @@ export const filterOrFail: { (self: Effect, predicate: Predicate): Effect } = effect.filterOrFail +/** + * Filters an effect with an effectful predicate, falling back to an alternative + * effect if the predicate fails. + * + * **Details** + * + * This function applies a predicate to the result of an effect. If the + * predicate evaluates to `false`, the effect falls back to the `orElse` + * effect. The `orElse` effect can produce an alternative value or perform + * additional computations. + * + * @example + * ```ts + * import { Effect, pipe } from "effect" + * + * // Define a user interface + * interface User { + * readonly name: string + * } + * + * // Simulate an asynchronous authentication function + * declare const auth: () => Promise + * + * const program = pipe( + * Effect.promise(() => auth()), + * // Use filterEffectOrElse with an effectful predicate + * Effect.filterEffectOrElse({ + * predicate: (user) => Effect.succeed(user !== null), + * orElse: (user) => Effect.fail(new Error(`Unauthorized user: ${user}`)) + * }), + * ) + * ``` + * + * @since 3.13.0 + * @category Filtering + */ +export const filterEffectOrElse: { + ( + options: { + readonly predicate: (a: NoInfer) => Effect + readonly orElse: (a: NoInfer) => Effect + } + ): (self: Effect) => Effect + ( + self: Effect, + options: { + readonly predicate: (a: A) => Effect + readonly orElse: (a: A) => Effect + } + ): Effect +} = core.filterEffectOrElse + +/** + * Filters an effect with an effectful predicate, failing with a custom error if the predicate fails. + * + * **Details** + * + * This function applies a predicate to the result of an effect. If the + * predicate evaluates to `false`, the effect fails with a custom error + * generated by the `orFailWith` function. + * + * **When to Use** + * + * This is useful for enforcing constraints and treating violations as + * recoverable errors. + * + * @example + * ```ts + * import { Effect, pipe } from "effect" + * + * // Define a user interface + * interface User { + * readonly name: string + * } + * + * // Simulate an asynchronous authentication function + * declare const auth: () => Promise + * + * const program = pipe( + * Effect.promise(() => auth()), + * // Use filterEffectOrFail with an effectful predicate + * Effect.filterEffectOrFail({ + * predicate: (user) => Effect.succeed(user !== null), + * orFailWith: () => new Error("Unauthorized") + * }), + * ) + * ``` + * + * @since 3.13.0 + * @category Filtering + */ +export const filterEffectOrFail: { + ( + options: { + readonly predicate: (a: NoInfer) => Effect + readonly orFailWith: (a: NoInfer) => E3 + } + ): (self: Effect) => Effect + ( + self: Effect, + options: { + readonly predicate: (a: A) => Effect + readonly orFailWith: (a: A) => E3 + } + ): Effect +} = core.filterEffectOrFail + /** * Executes an effect only if the condition is `false`. * diff --git a/packages/effect/src/internal/core.ts b/packages/effect/src/internal/core.ts index d7c5ff16278..4519f3ce652 100644 --- a/packages/effect/src/internal/core.ts +++ b/packages/effect/src/internal/core.ts @@ -3058,6 +3058,68 @@ export const mapInputContext = dual< f: (context: Context.Context) => Context.Context ) => contextWithEffect((context: Context.Context) => provideContext(self, f(context)))) +// ----------------------------------------------------------------------------- +// Filtering +// ----------------------------------------------------------------------------- + +/** @internal */ +export const filterEffectOrElse: { + ( + options: { + readonly predicate: (a: NoInfer) => Effect.Effect + readonly orElse: (a: NoInfer) => Effect.Effect + } + ): (self: Effect.Effect) => Effect.Effect + ( + self: Effect.Effect, + options: { + readonly predicate: (a: A) => Effect.Effect + readonly orElse: (a: A) => Effect.Effect + } + ): Effect.Effect +} = dual(2, ( + self: Effect.Effect, + options: { + readonly predicate: (a: A) => Effect.Effect + readonly orElse: (a: A) => Effect.Effect + } +): Effect.Effect => + flatMap( + self, + (a) => + flatMap( + options.predicate(a), + (pass): Effect.Effect => pass ? succeed(a) : options.orElse(a) + ) + )) + +/** @internal */ +export const filterEffectOrFail: { + ( + options: { + readonly predicate: (a: NoInfer) => Effect.Effect + readonly orFailWith: (a: NoInfer) => E3 + } + ): (self: Effect.Effect) => Effect.Effect + ( + self: Effect.Effect, + options: { + readonly predicate: (a: A) => Effect.Effect + readonly orFailWith: (a: A) => E3 + } + ): Effect.Effect +} = dual(2, ( + self: Effect.Effect, + options: { + readonly predicate: (a: A) => Effect.Effect + readonly orFailWith: (a: A) => E3 + } +): Effect.Effect => + filterEffectOrElse(self, { + predicate: options.predicate, + orElse: (a) => fail(options.orFailWith(a)) + })) + // ----------------------------------------------------------------------------- // Tracing // ----------------------------------------------------------------------------- diff --git a/packages/effect/test/Effect/filtering.test.ts b/packages/effect/test/Effect/filtering.test.ts index 39f52cf9d33..0671edc57ab 100644 --- a/packages/effect/test/Effect/filtering.test.ts +++ b/packages/effect/test/Effect/filtering.test.ts @@ -189,6 +189,7 @@ describe("Effect", () => { assertRight(goodCase, 0) assertLeft(badCase, Either.left("predicate failed, got 1!")) })) + it.effect("filterOrFail - without orFailWith", () => Effect.gen(function*() { const goodCase = yield* pipe( @@ -207,4 +208,31 @@ describe("Effect", () => { deepStrictEqual(goodCaseDataFirst, 0) deepStrictEqual(badCase, new Cause.NoSuchElementException()) })) + + describe("filterEffectOrElse", () => { + it.effect("executes fallback", () => + Effect.gen(function*() { + const result = yield* Effect.succeed(1).pipe( + Effect.filterEffectOrElse({ + predicate: (n) => Effect.succeed(n === 0), + orElse: () => Effect.succeed(0) + }) + ) + assert.strictEqual(result, 0) + })) + }) + + describe("filterEffectOrFails", () => { + it.effect("executes orFailWith", () => + Effect.gen(function*() { + const result = yield* Effect.succeed(1).pipe( + Effect.filterEffectOrElse({ + predicate: (n) => Effect.succeed(n === 0), + orElse: () => Effect.fail("boom") + }), + Effect.flip + ) + assert.strictEqual(result, "boom") + })) + }) }) From 3e17ec78c003510ad399417d9823be232ce61e34 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 24 Jan 2025 22:54:41 +1300 Subject: [PATCH 06/19] add {FiberHandle,FiberSet,FiberMap}.awaitEmpty apis (#4337) --- .changeset/tough-cars-invite.md | 5 ++ packages/effect/src/FiberHandle.ts | 71 +++++++++++----------- packages/effect/src/FiberMap.ts | 77 ++++++++++++------------ packages/effect/src/FiberSet.ts | 59 ++++++++++-------- packages/effect/test/FiberHandle.test.ts | 16 ++++- packages/effect/test/FiberMap.test.ts | 19 +++++- packages/effect/test/FiberSet.test.ts | 19 +++++- 7 files changed, 162 insertions(+), 104 deletions(-) create mode 100644 .changeset/tough-cars-invite.md diff --git a/.changeset/tough-cars-invite.md b/.changeset/tough-cars-invite.md new file mode 100644 index 00000000000..304c8a69e64 --- /dev/null +++ b/.changeset/tough-cars-invite.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add {FiberHandle,FiberSet,FiberMap}.awaitEmpty apis diff --git a/packages/effect/src/FiberHandle.ts b/packages/effect/src/FiberHandle.ts index fc9c94d9dff..3a506f5c5f9 100644 --- a/packages/effect/src/FiberHandle.ts +++ b/packages/effect/src/FiberHandle.ts @@ -339,44 +339,31 @@ export const run: { } = function() { const self = arguments[0] as FiberHandle if (Effect.isEffect(arguments[1])) { - const effect = arguments[1] - const options = arguments[2] as { - readonly onlyIfMissing?: boolean - readonly propagateInterruption?: boolean | undefined - } | undefined - return Effect.suspend(() => { - if (self.state._tag === "Closed") { - return Effect.interrupt - } else if (self.state.fiber !== undefined && options?.onlyIfMissing === true) { - return Effect.sync(constInterruptedFiber) - } - return Effect.uninterruptibleMask((restore) => - Effect.tap( - restore(Effect.forkDaemon(effect)), - (fiber) => set(self, fiber, options) - ) - ) - }) as any + return runImpl(self, arguments[1], arguments[2]) as any } - const options = arguments[1] as { + const options = arguments[1] + return (effect: Effect.Effect) => runImpl(self, effect, options) +} + +const runImpl = ( + self: FiberHandle, + effect: Effect.Effect, + options?: { readonly onlyIfMissing?: boolean readonly propagateInterruption?: boolean | undefined - } | undefined - return (effect: Effect.Effect) => - Effect.suspend(() => { - if (self.state._tag === "Closed") { - return Effect.interrupt - } else if (self.state.fiber !== undefined && options?.onlyIfMissing === true) { - return Effect.sync(constInterruptedFiber) - } - return Effect.uninterruptibleMask((restore) => - Effect.tap( - restore(Effect.forkDaemon(effect)), - (fiber) => set(self, fiber, options) - ) - ) - }) -} + } +): Effect.Effect, never, R> => + Effect.fiberIdWith((fiberId) => { + if (self.state._tag === "Closed") { + return Effect.interrupt + } else if (self.state.fiber !== undefined && options?.onlyIfMissing === true) { + return Effect.sync(constInterruptedFiber) + } + return Effect.tap( + Effect.forkDaemon(effect), + (fiber) => unsafeSet(self, fiber, { ...options, interruptAs: fiberId }) + ) + }) /** * Capture a Runtime and use it to fork Effect's, adding the forked fibers to the FiberHandle. @@ -470,3 +457,17 @@ export const runtime: ( */ export const join = (self: FiberHandle): Effect.Effect => Deferred.await(self.deferred as Deferred.Deferred) + +/** + * Wait for the fiber in the FiberHandle to complete. + * + * @since 3.13.0 + * @categories combinators + */ +export const awaitEmpty = (self: FiberHandle): Effect.Effect => + Effect.suspend(() => { + if (self.state._tag === "Closed" || self.state.fiber === undefined) { + return Effect.void + } + return Fiber.await(self.state.fiber) + }) diff --git a/packages/effect/src/FiberMap.ts b/packages/effect/src/FiberMap.ts index b80d0b13c13..e4ba703c693 100644 --- a/packages/effect/src/FiberMap.ts +++ b/packages/effect/src/FiberMap.ts @@ -8,7 +8,7 @@ import * as Effect from "./Effect.js" import * as Exit from "./Exit.js" import * as Fiber from "./Fiber.js" import * as FiberId from "./FiberId.js" -import { constFalse, dual } from "./Function.js" +import { constFalse, constVoid, dual } from "./Function.js" import * as HashSet from "./HashSet.js" import * as Inspectable from "./Inspectable.js" import * as Iterable from "./Iterable.js" @@ -438,49 +438,35 @@ export const run: { } | undefined ): Effect.Effect, never, R> } = function() { + const self = arguments[0] if (Effect.isEffect(arguments[2])) { - const self = arguments[0] as FiberMap - const key = arguments[1] - const effect = arguments[2] as Effect.Effect - const options = arguments[3] as { - readonly onlyIfMissing?: boolean - readonly propagateInterruption?: boolean | undefined - } | undefined - return Effect.suspend(() => { - if (self.state._tag === "Closed") { - return Effect.interrupt - } else if (options?.onlyIfMissing === true && unsafeHas(self, key)) { - return Effect.sync(constInterruptedFiber) - } - return Effect.uninterruptibleMask((restore) => - Effect.tap( - restore(Effect.forkDaemon(effect)), - (fiber) => set(self, key, fiber, options) - ) - ) - }) as any + return runImpl(self, arguments[1], arguments[2], arguments[3]) as any } - const self = arguments[0] as FiberMap const key = arguments[1] - const options = arguments[2] as { + const options = arguments[2] + return (effect: Effect.Effect) => runImpl(self, key, effect, options) +} + +const runImpl = ( + self: FiberMap, + key: K, + effect: Effect.Effect, + options?: { readonly onlyIfMissing?: boolean readonly propagateInterruption?: boolean | undefined - } | undefined - return (effect: Effect.Effect) => - Effect.suspend(() => { - if (self.state._tag === "Closed") { - return Effect.interrupt - } else if (options?.onlyIfMissing === true && unsafeHas(self, key)) { - return Effect.sync(constInterruptedFiber) - } - return Effect.uninterruptibleMask((restore) => - Effect.tap( - restore(Effect.forkDaemon(effect)), - (fiber) => set(self, key, fiber, options) - ) - ) - }) -} + } +) => + Effect.fiberIdWith((fiberId) => { + if (self.state._tag === "Closed") { + return Effect.interrupt + } else if (options?.onlyIfMissing === true && unsafeHas(self, key)) { + return Effect.sync(constInterruptedFiber) + } + return Effect.tap( + Effect.forkDaemon(effect), + (fiber) => unsafeSet(self, key, fiber, { ...options, interruptAs: fiberId }) + ) + }) /** * Capture a Runtime and use it to fork Effect's, adding the forked fibers to the FiberMap. @@ -581,3 +567,16 @@ export const size = (self: FiberMap): Effect.Effect => */ export const join = (self: FiberMap): Effect.Effect => Deferred.await(self.deferred as Deferred.Deferred) + +/** + * Wait for the FiberMap to be empty. + * + * @since 3.13.0 + * @categories combinators + */ +export const awaitEmpty = (self: FiberMap): Effect.Effect => + Effect.whileLoop({ + while: () => self.state._tag === "Open" && MutableHashMap.size(self.state.backing) > 0, + body: () => Fiber.await(Iterable.unsafeHead(self)[1]), + step: constVoid + }) diff --git a/packages/effect/src/FiberSet.ts b/packages/effect/src/FiberSet.ts index 722db68018c..754d976544c 100644 --- a/packages/effect/src/FiberSet.ts +++ b/packages/effect/src/FiberSet.ts @@ -7,7 +7,7 @@ import * as Effect from "./Effect.js" import * as Exit from "./Exit.js" import * as Fiber from "./Fiber.js" import * as FiberId from "./FiberId.js" -import { constFalse, dual } from "./Function.js" +import { constFalse, constVoid, dual } from "./Function.js" import * as HashSet from "./HashSet.js" import * as Inspectable from "./Inspectable.js" import * as Iterable from "./Iterable.js" @@ -291,34 +291,32 @@ export const run: { } = function() { const self = arguments[0] as FiberSet if (!Effect.isEffect(arguments[1])) { - const options = arguments[1] as { readonly propagateInterruption?: boolean | undefined } | undefined - return (effect: Effect.Effect) => - Effect.suspend(() => { - if (self.state._tag === "Closed") { - return Effect.interrupt - } - return Effect.uninterruptibleMask((restore) => - Effect.tap( - restore(Effect.forkDaemon(effect)), - (fiber) => add(self, fiber, options) - ) - ) - }) + const options = arguments[1] + return (effect: Effect.Effect) => runImpl(self, effect, options) } - const effect = arguments[1] - const options = arguments[2] as { readonly propagateInterruption?: boolean | undefined } | undefined - return Effect.suspend(() => { + return runImpl(self, arguments[1], arguments[2]) as any +} + +const runImpl = ( + self: FiberSet, + effect: Effect.Effect, + options?: { + readonly propagateInterruption?: boolean | undefined + } +): Effect.Effect, never, R> => + Effect.fiberIdWith((fiberId) => { if (self.state._tag === "Closed") { return Effect.interrupt } - return Effect.uninterruptibleMask((restore) => - Effect.tap( - restore(Effect.forkDaemon(effect)), - (fiber) => add(self, fiber, options) - ) + return Effect.tap( + Effect.forkDaemon(effect), + (fiber) => + unsafeAdd(self, fiber, { + ...options, + interruptAs: fiberId + }) ) - }) as any -} + }) /** * Capture a Runtime and use it to fork Effect's, adding the forked fibers to the FiberSet. @@ -405,3 +403,16 @@ export const size = (self: FiberSet): Effect.Effect => */ export const join = (self: FiberSet): Effect.Effect => Deferred.await(self.deferred as Deferred.Deferred) + +/** + * Wait until the fiber set is empty. + * + * @since 3.13.0 + * @categories combinators + */ +export const awaitEmpty = (self: FiberSet): Effect.Effect => + Effect.whileLoop({ + while: () => self.state._tag === "Open" && self.state.backing.size > 0, + body: () => Fiber.await(Iterable.unsafeHead(self)), + step: constVoid + }) diff --git a/packages/effect/test/FiberHandle.test.ts b/packages/effect/test/FiberHandle.test.ts index 4420368f1bb..449b9bf5046 100644 --- a/packages/effect/test/FiberHandle.test.ts +++ b/packages/effect/test/FiberHandle.test.ts @@ -1,5 +1,5 @@ -import { describe, it } from "@effect/vitest" -import { Deferred, Effect, Exit, Fiber, FiberHandle, pipe, Ref } from "effect" +import { assert, describe, it } from "@effect/vitest" +import { Deferred, Effect, Exit, Fiber, FiberHandle, pipe, Ref, TestClock } from "effect" import { assertFalse, assertTrue, strictEqual } from "effect/test/util" describe("FiberHandle", () => { @@ -99,4 +99,16 @@ describe("FiberHandle", () => { ) )) })) + + it.scoped("awaitEmpty", () => + Effect.gen(function*() { + const handle = yield* FiberHandle.make() + yield* FiberHandle.run(handle, Effect.sleep(1000)) + + const fiber = yield* Effect.fork(FiberHandle.awaitEmpty(handle)) + yield* TestClock.adjust(500) + assert.isNull(fiber.unsafePoll()) + yield* TestClock.adjust(500) + assert.isDefined(fiber.unsafePoll()) + })) }) diff --git a/packages/effect/test/FiberMap.test.ts b/packages/effect/test/FiberMap.test.ts index 52168868399..2dc3800c57f 100644 --- a/packages/effect/test/FiberMap.test.ts +++ b/packages/effect/test/FiberMap.test.ts @@ -1,5 +1,5 @@ -import { describe, it } from "@effect/vitest" -import { Array, Deferred, Effect, Exit, Fiber, FiberMap, pipe, Ref, Scope } from "effect" +import { assert, describe, it } from "@effect/vitest" +import { Array, Deferred, Effect, Exit, Fiber, FiberMap, pipe, Ref, Scope, TestClock } from "effect" import { assertFalse, assertTrue, strictEqual } from "effect/test/util" describe("FiberMap", () => { @@ -121,4 +121,19 @@ describe("FiberMap", () => { ) )) })) + + it.scoped("awaitEmpty", () => + Effect.gen(function*() { + const map = yield* FiberMap.make() + yield* FiberMap.run(map, "a", Effect.sleep(1000)) + yield* FiberMap.run(map, "b", Effect.sleep(1000)) + yield* FiberMap.run(map, "c", Effect.sleep(1000)) + yield* FiberMap.run(map, "d", Effect.sleep(1000)) + + const fiber = yield* Effect.fork(FiberMap.awaitEmpty(map)) + yield* TestClock.adjust(500) + assert.isNull(fiber.unsafePoll()) + yield* TestClock.adjust(500) + assert.isDefined(fiber.unsafePoll()) + })) }) diff --git a/packages/effect/test/FiberSet.test.ts b/packages/effect/test/FiberSet.test.ts index 2c224c46aff..3d0e646027b 100644 --- a/packages/effect/test/FiberSet.test.ts +++ b/packages/effect/test/FiberSet.test.ts @@ -1,5 +1,5 @@ -import { describe, it } from "@effect/vitest" -import { Array, Deferred, Effect, Exit, Fiber, FiberSet, pipe, Ref, Scope } from "effect" +import { assert, describe, it } from "@effect/vitest" +import { Array, Deferred, Effect, Exit, Fiber, FiberSet, pipe, Ref, Scope, TestClock } from "effect" import { assertFalse, assertTrue, strictEqual } from "effect/test/util" describe("FiberSet", () => { @@ -93,4 +93,19 @@ describe("FiberSet", () => { ) )) })) + + it.scoped("awaitEmpty", () => + Effect.gen(function*() { + const set = yield* FiberSet.make() + yield* FiberSet.run(set, Effect.sleep(1000)) + yield* FiberSet.run(set, Effect.sleep(1000)) + yield* FiberSet.run(set, Effect.sleep(1000)) + yield* FiberSet.run(set, Effect.sleep(1000)) + + const fiber = yield* Effect.fork(FiberSet.awaitEmpty(set)) + yield* TestClock.adjust(500) + assert.isNull(fiber.unsafePoll()) + yield* TestClock.adjust(500) + assert.isDefined(fiber.unsafePoll()) + })) }) From 87d0004a667f9e863da0fbc167eee3783337f76b Mon Sep 17 00:00:00 2001 From: Maxim Khramtsov Date: Fri, 24 Jan 2025 23:55:14 +0100 Subject: [PATCH 07/19] `Differ` implements `Pipeable` (#4239) --- .changeset/rude-rules-brush.md | 5 +++++ packages/effect/src/Differ.ts | 3 ++- packages/effect/src/internal/differ.ts | 4 ++++ packages/effect/test/Differ.test.ts | 5 ++++- 4 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .changeset/rude-rules-brush.md diff --git a/.changeset/rude-rules-brush.md b/.changeset/rude-rules-brush.md new file mode 100644 index 00000000000..5a5794b2032 --- /dev/null +++ b/.changeset/rude-rules-brush.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +`Differ` implements `Pipeable` diff --git a/packages/effect/src/Differ.ts b/packages/effect/src/Differ.ts index a46d2487510..8471ce00ef1 100644 --- a/packages/effect/src/Differ.ts +++ b/packages/effect/src/Differ.ts @@ -15,6 +15,7 @@ import * as HashMapPatch from "./internal/differ/hashMapPatch.js" import * as HashSetPatch from "./internal/differ/hashSetPatch.js" import * as OrPatch from "./internal/differ/orPatch.js" import * as ReadonlyArrayPatch from "./internal/differ/readonlyArrayPatch.js" +import type { Pipeable } from "./Pipeable.js" import type * as Types from "./Types.js" /** @@ -48,7 +49,7 @@ export type TypeId = typeof TypeId * @since 2.0.0 * @category models */ -export interface Differ { +export interface Differ extends Pipeable { readonly [TypeId]: { readonly _V: Types.Invariant readonly _P: Types.Invariant diff --git a/packages/effect/src/internal/differ.ts b/packages/effect/src/internal/differ.ts index d4071d14831..0c07cccd77c 100644 --- a/packages/effect/src/internal/differ.ts +++ b/packages/effect/src/internal/differ.ts @@ -7,6 +7,7 @@ import * as Dual from "../Function.js" import { constant, identity } from "../Function.js" import type { HashMap } from "../HashMap.js" import type { HashSet } from "../HashSet.js" +import { pipeArguments } from "../Pipeable.js" import * as ChunkPatch from "./differ/chunkPatch.js" import * as ContextPatch from "./differ/contextPatch.js" import * as HashMapPatch from "./differ/hashMapPatch.js" @@ -22,6 +23,9 @@ export const DifferProto = { [DifferTypeId]: { _P: identity, _V: identity + }, + pipe() { + return pipeArguments(this, arguments) } } diff --git a/packages/effect/test/Differ.test.ts b/packages/effect/test/Differ.test.ts index c63648f1d48..a84402b89c2 100644 --- a/packages/effect/test/Differ.test.ts +++ b/packages/effect/test/Differ.test.ts @@ -123,7 +123,10 @@ describe("Differ", () => { describe("tuple", () => { diffLaws( - pipe(Differ.update(), Differ.zip(Differ.update())), + Differ.update() + .pipe( + Differ.zip(Differ.update()) + ), randomPair, (a, b) => Equal.equals(a[0], b[0]) && Equal.equals(a[1], b[1]) ) From 3ceb270d2c884d12b8b7ffa39c10ce027d062769 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud Date: Thu, 30 Jan 2025 20:45:19 +0100 Subject: [PATCH 08/19] Add `Effect.whenLogLevel` (#4342) --- .changeset/dirty-ways-dress.md | 5 +++ packages/effect/src/Effect.ts | 44 ++++++++++++++++++-- packages/effect/src/internal/fiberRuntime.ts | 24 +++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 .changeset/dirty-ways-dress.md diff --git a/.changeset/dirty-ways-dress.md b/.changeset/dirty-ways-dress.md new file mode 100644 index 00000000000..a313566c9f9 --- /dev/null +++ b/.changeset/dirty-ways-dress.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add `Effect.whenLogLevel`, which conditionally executes an effect if the specified log level is enabled diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index d21a7848d02..db2b4dcfce2 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -40,7 +40,7 @@ import * as runtime_ from "./internal/runtime.js" import * as schedule_ from "./internal/schedule.js" import * as internalTracer from "./internal/tracer.js" import type * as Layer from "./Layer.js" -import type { LogLevel } from "./LogLevel.js" +import type * as LogLevel from "./LogLevel.js" import type * as ManagedRuntime from "./ManagedRuntime.js" import type * as Metric from "./Metric.js" import type * as MetricLabel from "./MetricLabel.js" @@ -10678,7 +10678,7 @@ export const log: (...message: ReadonlyArray) => Effect * @category Logging */ export const logWithLevel = ( - level: LogLevel, + level: LogLevel.LogLevel, ...message: ReadonlyArray ): Effect => effect.logWithLevel(level)(...message) @@ -10972,10 +10972,46 @@ export const logAnnotations: Effect> = effect.l * @category Logging */ export const withUnhandledErrorLogLevel: { - (level: Option.Option): (self: Effect) => Effect - (self: Effect, level: Option.Option): Effect + (level: Option.Option): (self: Effect) => Effect + (self: Effect, level: Option.Option): Effect } = core.withUnhandledErrorLogLevel +/** + * Conditionally executes an effect based on the specified log level and currently enabled log level. + * + * **Details** + * + * This function runs the provided effect only if the specified log level is + * enabled. If the log level is enabled, the effect is executed and its result + * is wrapped in `Some`. If the log level is not enabled, the effect is not + * executed and `None` is returned. + * + * This function is useful for conditionally executing logging-related effects + * or other operations that depend on the current log level configuration. + * + * @example + * ```ts + * import { Effect, Logger, LogLevel } from "effect" + * + * const program = Effect.gen(function* () { + * yield* Effect.whenLogLevel(Effect.logTrace("message1"), LogLevel.Trace); // returns `None` + * yield* Effect.whenLogLevel(Effect.logDebug("message2"), LogLevel.Debug); // returns `Some` + * }).pipe(Logger.withMinimumLogLevel(LogLevel.Debug)); + * + * // Effect.runFork(program) + * // timestamp=... level=DEBUG fiber=#0 message=message2 + * ``` + * + * @see {@link FiberRef.minimumLogLevel} to retrieve the current minimum log level. + * + * @since 3.13.0 + * @category Logging + */ +export const whenLogLevel: { + (level: LogLevel.LogLevel | LogLevel.Literal): (self: Effect) => Effect, E, R> + (self: Effect, level: LogLevel.LogLevel | LogLevel.Literal): Effect, E, R> +} = fiberRuntime.whenLogLevel + /** * Converts an effect's failure into a fiber termination, removing the error * from the effect's type. diff --git a/packages/effect/src/internal/fiberRuntime.ts b/packages/effect/src/internal/fiberRuntime.ts index 88727d780d2..dd40df633f0 100644 --- a/packages/effect/src/internal/fiberRuntime.ts +++ b/packages/effect/src/internal/fiberRuntime.ts @@ -1631,6 +1631,30 @@ export const annotateLogsScoped: { ) } +/** @internal */ +export const whenLogLevel = dual< + ( + level: LogLevel.LogLevel | LogLevel.Literal + ) => (effect: Effect.Effect) => Effect.Effect, E, R>, + ( + effect: Effect.Effect, + level: LogLevel.LogLevel | LogLevel.Literal + ) => Effect.Effect, E, R> +>(2, (effect, level) => { + const requiredLogLevel = typeof level === "string" ? LogLevel.fromLiteral(level) : level + + return core.withFiberRuntime((fiberState) => { + const minimumLogLevel = fiberState.getFiberRef(currentMinimumLogLevel) + + // Imitate the behaviour of `FiberRuntime.log` + if (LogLevel.greaterThan(minimumLogLevel, requiredLogLevel)) { + return core.succeed(Option.none()) + } + + return core.map(effect, Option.some) + }) +}) + // circular with Effect /* @internal */ From 6298990453b86c99b964d8899436159355db640a Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 1 Feb 2025 16:56:28 +1300 Subject: [PATCH 09/19] fix some tests using old assert --- packages/effect/test/Effect/filtering.test.ts | 5 +++-- .../Effect/optional-wrapping-unwrapping.test.ts | 3 +-- packages/effect/test/RcMap.test.ts | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/effect/test/Effect/filtering.test.ts b/packages/effect/test/Effect/filtering.test.ts index 0671edc57ab..f896597b038 100644 --- a/packages/effect/test/Effect/filtering.test.ts +++ b/packages/effect/test/Effect/filtering.test.ts @@ -5,6 +5,7 @@ import * as Either from "effect/Either" import { pipe } from "effect/Function" import * as Ref from "effect/Ref" import { assertLeft, assertRight, deepStrictEqual } from "effect/test/util" +import { strictEqual } from "node:assert" const exactlyOnce = ( value: A, @@ -218,7 +219,7 @@ describe("Effect", () => { orElse: () => Effect.succeed(0) }) ) - assert.strictEqual(result, 0) + strictEqual(result, 0) })) }) @@ -232,7 +233,7 @@ describe("Effect", () => { }), Effect.flip ) - assert.strictEqual(result, "boom") + strictEqual(result, "boom") })) }) }) diff --git a/packages/effect/test/Effect/optional-wrapping-unwrapping.test.ts b/packages/effect/test/Effect/optional-wrapping-unwrapping.test.ts index 3eb88438a45..2b86df1103b 100644 --- a/packages/effect/test/Effect/optional-wrapping-unwrapping.test.ts +++ b/packages/effect/test/Effect/optional-wrapping-unwrapping.test.ts @@ -1,7 +1,6 @@ +import { assert, describe, it } from "@effect/vitest" import * as Effect from "effect/Effect" import * as Option from "effect/Option" -import * as it from "effect/test/utils/extend" -import { assert, describe } from "vitest" describe("Effect", () => { describe("transposeOption", () => { diff --git a/packages/effect/test/RcMap.test.ts b/packages/effect/test/RcMap.test.ts index d5b24917bb5..7d622ee56d2 100644 --- a/packages/effect/test/RcMap.test.ts +++ b/packages/effect/test/RcMap.test.ts @@ -113,19 +113,19 @@ describe("RcMap", () => { idleTimeToLive: 1000 }) - assert.deepStrictEqual(acquired, []) - assert.strictEqual(yield* Effect.scoped(RcMap.get(map, "foo")), "foo") - assert.deepStrictEqual(acquired, ["foo"]) - assert.deepStrictEqual(released, []) + deepStrictEqual(acquired, []) + strictEqual(yield* Effect.scoped(RcMap.get(map, "foo")), "foo") + deepStrictEqual(acquired, ["foo"]) + deepStrictEqual(released, []) yield* TestClock.adjust(500) - assert.deepStrictEqual(released, []) + deepStrictEqual(released, []) yield* RcMap.touch(map, "foo") yield* TestClock.adjust(500) - assert.deepStrictEqual(released, []) + deepStrictEqual(released, []) yield* TestClock.adjust(500) - assert.deepStrictEqual(released, ["foo"]) + deepStrictEqual(released, ["foo"]) })) it.scoped("capacity", () => From 54a11641f6f9037f4bbc3087862d3634f1755382 Mon Sep 17 00:00:00 2001 From: Maxim Khramtsov Date: Tue, 4 Feb 2025 01:42:05 +0100 Subject: [PATCH 10/19] Relax `Trie` variance (#4338) --- .changeset/pretty-trainers-rescue.md | 5 +++++ packages/effect/src/Trie.ts | 14 +++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 .changeset/pretty-trainers-rescue.md diff --git a/.changeset/pretty-trainers-rescue.md b/.changeset/pretty-trainers-rescue.md new file mode 100644 index 00000000000..967710d7d0f --- /dev/null +++ b/.changeset/pretty-trainers-rescue.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +`Trie` type annotations have been aligned. The type parameter was made covariant because the structure is immutable. diff --git a/packages/effect/src/Trie.ts b/packages/effect/src/Trie.ts index 0033d02246c..2e8c19984c4 100644 --- a/packages/effect/src/Trie.ts +++ b/packages/effect/src/Trie.ts @@ -34,7 +34,7 @@ export type TypeId = typeof TypeId * @since 2.0.0 * @category models */ -export interface Trie extends Iterable<[string, Value]>, Equal, Pipeable, Inspectable { +export interface Trie extends Iterable<[string, Value]>, Equal, Pipeable, Inspectable { readonly [TypeId]: { readonly _Value: Covariant } @@ -122,8 +122,8 @@ export const make: >( * @category mutations */ export const insert: { - (key: string, value: V): (self: Trie) => Trie - (self: Trie, key: string, value: V): Trie + (key: string, value: V1): (self: Trie) => Trie + (self: Trie, key: string, value: V1): Trie } = TR.insert /** @@ -746,8 +746,8 @@ export const forEach: { * @category mutations */ export const modify: { - (key: string, f: (v: V) => V): (self: Trie) => Trie - (self: Trie, key: string, f: (v: V) => V): Trie + (key: string, f: (v: V) => V1): (self: Trie) => Trie + (self: Trie, key: string, f: (v: V) => V1): Trie } = TR.modify /** @@ -807,6 +807,6 @@ export const removeMany: { * @category mutations */ export const insertMany: { - (iter: Iterable<[string, V]>): (self: Trie) => Trie - (self: Trie, iter: Iterable<[string, V]>): Trie + (iter: Iterable<[string, V1]>): (self: Trie) => Trie + (self: Trie, iter: Iterable<[string, V1]>): Trie } = TR.insertMany From 0c1469cd47017e277c63e239d845f0179e02b592 Mon Sep 17 00:00:00 2001 From: Chris Wilkinson Date: Tue, 4 Feb 2025 00:42:34 +0000 Subject: [PATCH 11/19] Make it easy to convert a DateTime.Zoned to a DateTime.Utc (#4375) --- .changeset/sixty-mice-occur.md | 5 +++++ packages/effect/src/DateTime.ts | 17 +++++++++++++++++ packages/effect/src/internal/dateTime.ts | 3 +++ packages/effect/test/DateTime.test.ts | 17 +++++++++++++++++ packages/platform-node/test/HttpApi.test.ts | 2 +- 5 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .changeset/sixty-mice-occur.md diff --git a/.changeset/sixty-mice-occur.md b/.changeset/sixty-mice-occur.md new file mode 100644 index 00000000000..b339204d760 --- /dev/null +++ b/.changeset/sixty-mice-occur.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Make it easy to convert a DateTime.Zoned to a DateTime.Utc diff --git a/packages/effect/src/DateTime.ts b/packages/effect/src/DateTime.ts index d50d1bd0241..efe8166d22a 100644 --- a/packages/effect/src/DateTime.ts +++ b/packages/effect/src/DateTime.ts @@ -437,6 +437,23 @@ export const unsafeNow: LazyArg = Internal.unsafeNow // time zones // ============================================================================= +/** + * For a `DateTime` returns a new `DateTime.Utc`. + * + * @since 3.13.0 + * @category time zones + * @example + * ```ts + * import { DateTime } from "effect" + * + * const now = DateTime.unsafeMakeZoned({ year: 2024 }, { timeZone: "Europe/London" }) + * + * // set as UTC + * const utc: DateTime.Utc = DateTime.toUtc(now) + * ``` + */ +export const toUtc: (self: DateTime) => Utc = Internal.toUtc + /** * Set the time zone of a `DateTime`, returning a new `DateTime.Zoned`. * diff --git a/packages/effect/src/internal/dateTime.ts b/packages/effect/src/internal/dateTime.ts index 0256bc3b728..e9a0538a8a3 100644 --- a/packages/effect/src/internal/dateTime.ts +++ b/packages/effect/src/internal/dateTime.ts @@ -306,6 +306,9 @@ export const unsafeNow: LazyArg = () => makeUtc(Date.now()) // time zones // ============================================================================= +/** @internal */ +export const toUtc = (self: DateTime.DateTime): DateTime.Utc => makeUtc(self.epochMillis) + /** @internal */ export const setZone: { (zone: DateTime.TimeZone, options?: { diff --git a/packages/effect/test/DateTime.test.ts b/packages/effect/test/DateTime.test.ts index ca478cff990..04fd7eeb19f 100644 --- a/packages/effect/test/DateTime.test.ts +++ b/packages/effect/test/DateTime.test.ts @@ -421,4 +421,21 @@ describe("DateTime", () => { const dt = DateTime.unsafeMakeZoned(date) deepStrictEqual(dt.zone, DateTime.zoneMakeOffset(60 * 60 * 1000)) }) + + describe("toUtc", () => { + it.effect("with a Utc", () => + Effect.gen(function*() { + const dt = DateTime.unsafeMake("2024-01-01T01:00:00") + strictEqual(dt.toJSON(), "2024-01-01T01:00:00.000Z") + })) + + it.effect("with a Zoned", () => + Effect.gen(function*() { + const dt = DateTime.unsafeMakeZoned("2024-01-01T01:00:00Z", { + timeZone: "Pacific/Auckland", + adjustForTimeZone: true + }) + strictEqual(dt.toJSON(), "2023-12-31T12:00:00.000Z") + })) + }) }) diff --git a/packages/platform-node/test/HttpApi.test.ts b/packages/platform-node/test/HttpApi.test.ts index 7d92efced4c..5278ed2eae0 100644 --- a/packages/platform-node/test/HttpApi.test.ts +++ b/packages/platform-node/test/HttpApi.test.ts @@ -445,7 +445,7 @@ const HttpUsersLive = HttpApiBuilder.group( new User({ id: 1, name: `page ${_.headers.page}`, - createdAt: DateTime.unsafeMake(now.epochMillis) + createdAt: DateTime.toUtc(now) }) ])) .handle("upload", (_) => From 422abddbf17aa23adec9919446d44c03067f648e Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 5 Feb 2025 13:31:33 +1300 Subject: [PATCH 12/19] add Promise based apis to Fiber{Handle,Set,Map} modules (#4401) --- .changeset/chatty-terms-occur.md | 5 ++ packages/effect/src/FiberHandle.ts | 59 +++++++++++++++++++++ packages/effect/src/FiberMap.ts | 66 ++++++++++++++++++++++++ packages/effect/src/FiberSet.ts | 58 +++++++++++++++++++++ packages/effect/test/FiberHandle.test.ts | 7 +++ packages/effect/test/FiberMap.test.ts | 7 +++ packages/effect/test/FiberSet.test.ts | 7 +++ 7 files changed, 209 insertions(+) create mode 100644 .changeset/chatty-terms-occur.md diff --git a/.changeset/chatty-terms-occur.md b/.changeset/chatty-terms-occur.md new file mode 100644 index 00000000000..889b36176b4 --- /dev/null +++ b/.changeset/chatty-terms-occur.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add Promise based apis to Fiber{Handle,Set,Map} modules diff --git a/packages/effect/src/FiberHandle.ts b/packages/effect/src/FiberHandle.ts index 3a506f5c5f9..43a6ca48950 100644 --- a/packages/effect/src/FiberHandle.ts +++ b/packages/effect/src/FiberHandle.ts @@ -143,6 +143,25 @@ export const makeRuntime = (): Effect.Effect< (self) => runtime(self)() ) +/** + * Create an Effect run function that is backed by a FiberHandle. + * + * @since 3.13.0 + * @categories constructors + */ +export const makeRuntimePromise = (): Effect.Effect< + ( + effect: Effect.Effect, + options?: Runtime.RunForkOptions | undefined + ) => Promise, + never, + Scope.Scope | R +> => + Effect.flatMap( + make(), + (self) => runtimePromise(self)() + ) + const internalFiberIdId = -1 const internalFiberId = FiberId.make(internalFiberIdId, 0) const isInternalInterruption = Cause.reduceWithContext(undefined, { @@ -436,6 +455,46 @@ export const runtime: ( } ) +/** + * Capture a Runtime and use it to fork Effect's, adding the forked fibers to the FiberHandle. + * + * The returned run function will return Promise's that will resolve when the + * fiber completes. + * + * @since 3.13.0 + * @categories combinators + */ +export const runtimePromise = (self: FiberHandle): () => Effect.Effect< + ( + effect: Effect.Effect, + options?: + | Runtime.RunForkOptions & { readonly propagateInterruption?: boolean | undefined } + | undefined + ) => Promise, + never, + R +> => +() => + Effect.map( + runtime(self)(), + (runFork) => + ( + effect: Effect.Effect, + options?: + | Runtime.RunForkOptions & { readonly propagateInterruption?: boolean | undefined } + | undefined + ): Promise => + new Promise((resolve, reject) => + runFork(effect, options).addObserver((exit) => { + if (Exit.isSuccess(exit)) { + resolve(exit.value) + } else { + reject(Cause.squash(exit.cause)) + } + }) + ) + ) + /** * If any of the Fiber's in the handle terminate with a failure, * the returned Effect will terminate with the first failure that occurred. diff --git a/packages/effect/src/FiberMap.ts b/packages/effect/src/FiberMap.ts index e4ba703c693..1d1ca407dab 100644 --- a/packages/effect/src/FiberMap.ts +++ b/packages/effect/src/FiberMap.ts @@ -158,6 +158,30 @@ export const makeRuntime = (): Effect.Effect< (self) => runtime(self)() ) +/** + * Create an Effect run function that is backed by a FiberMap. + * + * @since 3.13.0 + * @categories constructors + */ +export const makeRuntimePromise = (): Effect.Effect< + ( + key: K, + effect: Effect.Effect, + options?: + | Runtime.RunForkOptions & { + readonly onlyIfMissing?: boolean | undefined + } + | undefined + ) => Promise, + never, + Scope.Scope | R +> => + Effect.flatMap( + make(), + (self) => runtimePromise(self)() + ) + const internalFiberIdId = -1 const internalFiberId = FiberId.make(internalFiberIdId, 0) const isInternalInterruption = Cause.reduceWithContext(undefined, { @@ -539,6 +563,48 @@ export const runtime: ( } ) +/** + * Capture a Runtime and use it to fork Effect's, adding the forked fibers to the FiberMap. + * + * @since 3.13.0 + * @categories combinators + */ +export const runtimePromise = (self: FiberMap): () => Effect.Effect< + ( + key: K, + effect: Effect.Effect, + options?: + | Runtime.RunForkOptions & { + readonly onlyIfMissing?: boolean | undefined + readonly propagateInterruption?: boolean | undefined + } + | undefined + ) => Promise, + never, + R +> => +() => + Effect.map( + runtime(self)(), + (runFork) => + ( + key: K, + effect: Effect.Effect, + options?: + | Runtime.RunForkOptions & { readonly propagateInterruption?: boolean | undefined } + | undefined + ): Promise => + new Promise((resolve, reject) => + runFork(key, effect, options).addObserver((exit) => { + if (Exit.isSuccess(exit)) { + resolve(exit.value) + } else { + reject(Cause.squash(exit.cause)) + } + }) + ) + ) + /** * @since 2.0.0 * @categories combinators diff --git a/packages/effect/src/FiberSet.ts b/packages/effect/src/FiberSet.ts index 754d976544c..6c7419dd8c8 100644 --- a/packages/effect/src/FiberSet.ts +++ b/packages/effect/src/FiberSet.ts @@ -146,6 +146,25 @@ export const makeRuntime = (): Effect.Effec (self) => runtime(self)() ) +/** + * Create an Effect run function that is backed by a FiberSet. + * + * @since 3.13.0 + * @categories constructors + */ +export const makeRuntimePromise = (): Effect.Effect< + ( + effect: Effect.Effect, + options?: Runtime.RunForkOptions | undefined + ) => Promise, + never, + Scope.Scope | R +> => + Effect.flatMap( + make(), + (self) => runtimePromise(self)() + ) + const internalFiberIdId = -1 const internalFiberId = FiberId.make(internalFiberIdId, 0) const isInternalInterruption = Cause.reduceWithContext(undefined, { @@ -375,6 +394,45 @@ export const runtime: ( } ) +/** + * Capture a Runtime and use it to fork Effect's, adding the forked fibers to the FiberSet. + * + * The returned run function will return Promise's. + * + * @since 3.13.0 + * @categories combinators + */ +export const runtimePromise = (self: FiberSet): () => Effect.Effect< + ( + effect: Effect.Effect, + options?: + | Runtime.RunForkOptions & { readonly propagateInterruption?: boolean | undefined } + | undefined + ) => Promise, + never, + R +> => +() => + Effect.map( + runtime(self)(), + (runFork) => + ( + effect: Effect.Effect, + options?: + | Runtime.RunForkOptions & { readonly propagateInterruption?: boolean | undefined } + | undefined + ): Promise => + new Promise((resolve, reject) => + runFork(effect, options).addObserver((exit) => { + if (Exit.isSuccess(exit)) { + resolve(exit.value) + } else { + reject(Cause.squash(exit.cause)) + } + }) + ) + ) + /** * @since 2.0.0 * @categories combinators diff --git a/packages/effect/test/FiberHandle.test.ts b/packages/effect/test/FiberHandle.test.ts index 449b9bf5046..63c2e9d3c7d 100644 --- a/packages/effect/test/FiberHandle.test.ts +++ b/packages/effect/test/FiberHandle.test.ts @@ -111,4 +111,11 @@ describe("FiberHandle", () => { yield* TestClock.adjust(500) assert.isDefined(fiber.unsafePoll()) })) + + it.scoped("makeRuntimePromise", () => + Effect.gen(function*() { + const run = yield* FiberHandle.makeRuntimePromise() + const result = yield* Effect.promise(() => run(Effect.succeed("done"))) + strictEqual(result, "done") + })) }) diff --git a/packages/effect/test/FiberMap.test.ts b/packages/effect/test/FiberMap.test.ts index 2dc3800c57f..5e321acf70d 100644 --- a/packages/effect/test/FiberMap.test.ts +++ b/packages/effect/test/FiberMap.test.ts @@ -136,4 +136,11 @@ describe("FiberMap", () => { yield* TestClock.adjust(500) assert.isDefined(fiber.unsafePoll()) })) + + it.scoped("makeRuntimePromise", () => + Effect.gen(function*() { + const run = yield* FiberMap.makeRuntimePromise() + const result = yield* Effect.promise(() => run("a", Effect.succeed("done"))) + strictEqual(result, "done") + })) }) diff --git a/packages/effect/test/FiberSet.test.ts b/packages/effect/test/FiberSet.test.ts index 3d0e646027b..1861e387981 100644 --- a/packages/effect/test/FiberSet.test.ts +++ b/packages/effect/test/FiberSet.test.ts @@ -108,4 +108,11 @@ describe("FiberSet", () => { yield* TestClock.adjust(500) assert.isDefined(fiber.unsafePoll()) })) + + it.scoped("makeRuntimePromise", () => + Effect.gen(function*() { + const run = yield* FiberSet.makeRuntimePromise() + const result = yield* Effect.promise(() => run(Effect.succeed("done"))) + strictEqual(result, "done") + })) }) From 42c0371adba2169d22c0242bd0474e5f6ed246e4 Mon Sep 17 00:00:00 2001 From: Laure Retru-Chavastel Date: Wed, 5 Feb 2025 03:38:14 +0100 Subject: [PATCH 13/19] Add `HashMap.some` (#4347) --- .changeset/loud-starfishes-grow.md | 5 +++++ packages/effect/src/HashMap.ts | 14 ++++++++++++++ packages/effect/src/internal/hashMap.ts | 16 ++++++++++++++++ packages/effect/test/HashMap.test.ts | 13 +++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 .changeset/loud-starfishes-grow.md diff --git a/.changeset/loud-starfishes-grow.md b/.changeset/loud-starfishes-grow.md new file mode 100644 index 00000000000..e6d9962423b --- /dev/null +++ b/.changeset/loud-starfishes-grow.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add HashMap.some diff --git a/packages/effect/src/HashMap.ts b/packages/effect/src/HashMap.ts index cbd3dab8836..36450a199e5 100644 --- a/packages/effect/src/HashMap.ts +++ b/packages/effect/src/HashMap.ts @@ -442,3 +442,17 @@ export const findFirst: { (self: HashMap, predicate: (a: A, k: K) => a is B): Option<[K, B]> (self: HashMap, predicate: (a: A, k: K) => boolean): Option<[K, A]> } = HM.findFirst + +/** + * Checks if any entry in a hashmap meets a specific condition. + * + * @param self - The hashmap to check. + * @param predicate - The condition to test entries (value, key). + * + * @since 3.13.0 + * @category elements + */ +export const some: { + (predicate: (a: NoInfer, k: K) => boolean): (self: HashMap) => boolean + (self: HashMap, predicate: (a: A, k: K) => boolean): boolean +} = HM.some diff --git a/packages/effect/src/internal/hashMap.ts b/packages/effect/src/internal/hashMap.ts index f61e638da32..6fd9c16ee07 100644 --- a/packages/effect/src/internal/hashMap.ts +++ b/packages/effect/src/internal/hashMap.ts @@ -539,3 +539,19 @@ export const findFirst: { return Option.none() } ) + +/** @internal */ +export const some: { + (predicate: (a: NoInfer, k: K) => boolean): (self: HM.HashMap) => boolean + (self: HM.HashMap, predicate: (a: A, k: K) => boolean): boolean +} = Dual.dual( + 2, + (self: HM.HashMap, predicate: (a: A, k: K) => boolean): boolean => { + for (const ka of self) { + if (predicate(ka[1], ka[0])) { + return true + } + } + return false + } +) diff --git a/packages/effect/test/HashMap.test.ts b/packages/effect/test/HashMap.test.ts index f3b2c0936f3..21b5689f6ca 100644 --- a/packages/effect/test/HashMap.test.ts +++ b/packages/effect/test/HashMap.test.ts @@ -283,6 +283,19 @@ describe("HashMap", () => { assertNone(HM.get(key(2))(result)) }) + it("some", () => { + const mapWith3LettersMax = HM.make([0, "a"], [1, "bb"], [3, "ccc"]) + + deepStrictEqual(HM.some(mapWith3LettersMax, (value) => value.length > 3), false) + deepStrictEqual(pipe(mapWith3LettersMax, HM.some((value) => value.length > 3)), false) + + deepStrictEqual(HM.some(mapWith3LettersMax, (value) => value.length > 1), true) + + deepStrictEqual(HM.some(mapWith3LettersMax, (value, key) => value.length > 1 && key === 0), false) + + deepStrictEqual(HM.some(mapWith3LettersMax, (value, key) => value.length > 1 && key === 1), true) + }) + it("reduce", () => { const map1 = HM.make([key(0), value("a")], [key(1), value("b")]) const result1 = pipe(map1, HM.reduce("", (acc, { s }) => acc.length > 0 ? `${acc},${s}` : s)) From 55f27c341400fc4627790b2811a61a0ccc49386e Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Wed, 5 Feb 2025 10:22:11 +0100 Subject: [PATCH 14/19] add ISO8601 duration formatting (#4343) --- .changeset/warm-bulldogs-grab.md | 5 + packages/effect/src/Duration.ts | 147 ++++++++++++++++++++++++++ packages/effect/test/Duration.test.ts | 96 +++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 .changeset/warm-bulldogs-grab.md diff --git a/.changeset/warm-bulldogs-grab.md b/.changeset/warm-bulldogs-grab.md new file mode 100644 index 00000000000..c3c3bd82277 --- /dev/null +++ b/.changeset/warm-bulldogs-grab.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Added `Duration.formatIso` and `Duration.fromIso` for formatting and parsing ISO8601 durations. diff --git a/packages/effect/src/Duration.ts b/packages/effect/src/Duration.ts index 5ee3d942d66..d4e30c4a0ce 100644 --- a/packages/effect/src/Duration.ts +++ b/packages/effect/src/Duration.ts @@ -853,3 +853,150 @@ export const format = (self: DurationInput): string => { return pieces.join(" ") } + +/** + * Formats a Duration into an ISO8601 duration string. + * + * The ISO8601 duration format is generally specified as P[n]Y[n]M[n]DT[n]H[n]M[n]S. However, since + * the `Duration` type does not support years or months, this function will only output the days, hours, + * minutes and seconds. Thus, the effective format is P[n]DT[n]H[n]M[n]S. + * + * Milliseconds and nanoseconds are expressed as fractional seconds. + * + * @throws `RangeError` If the duration is not finite. + * + * @example + * ```ts + * import { Duration } from "effect" + * + * Duration.unsafeFormatIso(Duration.days(1)) // => "P1D" + * Duration.unsafeFormatIso(Duration.minutes(90)) // => "PT1H30M" + * Duration.unsafeFormatIso(Duration.millis(1500)) // => "PT1.5S" + * ``` + * + * @since 3.13.0 + * @category conversions + */ +export const unsafeFormatIso = (self: DurationInput): string => { + const duration = decode(self) + if (!isFinite(duration)) { + throw new RangeError("Cannot format infinite duration") + } + + const fragments = [] + const { + days, + hours, + millis, + minutes, + nanos, + seconds + } = parts(duration) + + let rest = days + if (rest >= 365) { + const years = Math.floor(rest / 365) + rest %= 365 + fragments.push(`${years}Y`) + } + + if (rest >= 30) { + const months = Math.floor(rest / 30) + rest %= 30 + fragments.push(`${months}M`) + } + + if (rest >= 7) { + const weeks = Math.floor(rest / 7) + rest %= 7 + fragments.push(`${weeks}W`) + } + + if (rest > 0) { + fragments.push(`${rest}D`) + } + + if (hours !== 0 || minutes !== 0 || seconds !== 0 || millis !== 0 || nanos !== 0) { + fragments.push("T") + + if (hours !== 0) { + fragments.push(`${hours}H`) + } + + if (minutes !== 0) { + fragments.push(`${minutes}M`) + } + + if (seconds !== 0 || millis !== 0 || nanos !== 0) { + const total = BigInt(seconds) * bigint1e9 + BigInt(millis) * bigint1e6 + BigInt(nanos) + const str = (Number(total) / 1e9).toFixed(9).replace(/\.?0+$/, "") + fragments.push(`${str}S`) + } + } + + return `P${fragments.join("") || "T0S"}` +} + +/** + * Formats a Duration into an ISO8601 duration string. + * + * Months are assumed to be 30 days and years are assumed to be 365 days. + * + * Returns `Option.none()` if the duration is infinite. + * + * @example + * ```ts + * import { Duration, Option } from "effect" + * + * Duration.formatIso(Duration.days(1)) // => Option.some("P1D") + * Duration.formatIso(Duration.minutes(90)) // => Option.some("PT1H30M") + * Duration.formatIso(Duration.millis(1500)) // => Option.some("PT1.5S") + * Duration.formatIso(Duration.infinity) // => Option.none() + * ``` + * + * @since 3.13.0 + * @category conversions + */ +export const formatIso = (self: DurationInput): Option.Option => { + const duration = decode(self) + return isFinite(duration) ? Option.some(unsafeFormatIso(duration)) : Option.none() +} + +/** + * Parses an ISO8601 duration string into a `Duration`. + * + * Months are assumed to be 30 days and years are assumed to be 365 days. + * + * @example + * ```ts + * import { Duration, Option } from "effect" + * + * Duration.fromIso("P1D") // => Option.some(Duration.days(1)) + * Duration.fromIso("PT1H") // => Option.some(Duration.hours(1)) + * Duration.fromIso("PT1M") // => Option.some(Duration.minutes(1)) + * Duration.fromIso("PT1.5S") // => Option.some(Duration.seconds(1.5)) + * ``` + * + * @since 3.13.0 + * @category conversions + */ +export const fromIso = (iso: string): Option.Option => { + const result = DURATION_ISO_REGEX.exec(iso) + if (result == null) { + return Option.none() + } + + const [years, months, weeks, days, hours, mins, secs] = result.slice(1, 8).map((_) => _ ? Number(_) : 0) + const value = years * 365 * 24 * 60 * 60 + + months * 30 * 24 * 60 * 60 + + weeks * 7 * 24 * 60 * 60 + + days * 24 * 60 * 60 + + hours * 60 * 60 + + mins * 60 + + secs + + return Option.some(seconds(value)) +} + +const DURATION_ISO_REGEX = + /^P(?!$)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?!$)(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/ diff --git a/packages/effect/test/Duration.test.ts b/packages/effect/test/Duration.test.ts index 17649dcb691..5bdf6788ec1 100644 --- a/packages/effect/test/Duration.test.ts +++ b/packages/effect/test/Duration.test.ts @@ -521,4 +521,100 @@ describe("Duration", () => { strictEqual(Duration.toWeeks("2 weeks"), 2) strictEqual(Duration.toWeeks("14 days"), 2) }) + + it("formatIso", () => { + assertSome(Duration.formatIso(Duration.zero), "PT0S") + assertSome(Duration.formatIso(Duration.seconds(2)), "PT2S") + assertSome(Duration.formatIso(Duration.minutes(5)), "PT5M") + assertSome(Duration.formatIso(Duration.hours(3)), "PT3H") + assertSome(Duration.formatIso(Duration.days(1)), "P1D") + + assertSome(Duration.formatIso(Duration.minutes(90)), "PT1H30M") + assertSome(Duration.formatIso(Duration.hours(25)), "P1DT1H") + assertSome(Duration.formatIso(Duration.days(7)), "P1W") + assertSome(Duration.formatIso(Duration.days(10)), "P1W3D") + + assertSome(Duration.formatIso(Duration.millis(1500)), "PT1.5S") + assertSome(Duration.formatIso(Duration.micros(1500n)), "PT0.0015S") + assertSome(Duration.formatIso(Duration.nanos(1500n)), "PT0.0000015S") + + assertSome( + Duration.formatIso( + Duration.seconds( + 365 * 24 * 60 * 60 + // 1 year + 60 * 24 * 60 * 60 + // 2 months + 3 * 24 * 60 * 60 + // 3 days + 4 * 60 * 60 + // 4 hours + 5 * 60 + // 5 minutes + 6.789 // 6.789 seconds + ) + ), + "P1Y2M3DT4H5M6.789S" + ) + + assertSome( + Duration.formatIso( + Duration.days(1).pipe( + Duration.sum(Duration.hours(2)), + Duration.sum(Duration.minutes(30)) + ) + ), + "P1DT2H30M" + ) + + assertSome( + Duration.formatIso( + Duration.hours(2).pipe( + Duration.sum(Duration.minutes(30)), + Duration.sum(Duration.millis(1500)) + ) + ), + "PT2H30M1.5S" + ) + + assertSome(Duration.formatIso("1 day"), "P1D") + assertSome(Duration.formatIso("90 minutes"), "PT1H30M") + assertSome(Duration.formatIso("1.5 seconds"), "PT1.5S") + + assertNone(Duration.formatIso(Duration.infinity)) + }) + + it("fromIso", () => { + assertSome(Duration.fromIso("P1D"), Duration.days(1)) + assertSome(Duration.fromIso("PT1H"), Duration.hours(1)) + assertSome(Duration.fromIso("PT1M"), Duration.minutes(1)) + assertSome(Duration.fromIso("PT1.5S"), Duration.seconds(1.5)) + assertSome(Duration.fromIso("P1Y"), Duration.days(365)) + assertSome(Duration.fromIso("P1M"), Duration.days(30)) + assertSome(Duration.fromIso("P1W"), Duration.days(7)) + assertSome(Duration.fromIso("P1DT12H"), Duration.hours(36)) + assertSome( + Duration.fromIso("P1Y2M3DT4H5M6.789S"), + Duration.seconds( + 365 * 24 * 60 * 60 + // 1 year + 60 * 24 * 60 * 60 + // 2 months + 3 * 24 * 60 * 60 + // 3 days + 4 * 60 * 60 + // 4 hours + 5 * 60 + // 5 minutes + 6.789 // 6.789 seconds + ) + ) + + assertNone(Duration.fromIso("1D")) + assertNone(Duration.fromIso("P1H")) + assertNone(Duration.fromIso("PT1D")) + assertNone(Duration.fromIso("P1.5D")) + assertNone(Duration.fromIso("P1.5Y")) + assertNone(Duration.fromIso("P1.5M")) + assertNone(Duration.fromIso("PT1.5H")) + assertNone(Duration.fromIso("PT1.5M")) + assertNone(Duration.fromIso("PDT1H")) + assertNone(Duration.fromIso("P1D2H")) + assertNone(Duration.fromIso("P")) + assertNone(Duration.fromIso("PT")) + assertNone(Duration.fromIso("random string")) + assertNone(Duration.fromIso("P1YT")) + assertNone(Duration.fromIso("P1S")) + assertNone(Duration.fromIso("P1DT1S1H")) + }) }) From 9a95831ba29203ef7be0854338301f048e81d39d Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 5 Feb 2025 17:37:28 +0100 Subject: [PATCH 15/19] Schema: Add `standardSchemaV1` API to Generate a Standard Schema (#4359) Co-authored-by: Tim --- .changeset/twenty-owls-hunt.md | 19 ++ packages/effect/package.json | 1 + packages/effect/src/Schema.ts | 77 ++++++ .../Schema/Schema/standardSchemaV1.test.ts | 249 ++++++++++++++++++ pnpm-lock.yaml | 8 + 5 files changed, 354 insertions(+) create mode 100644 .changeset/twenty-owls-hunt.md create mode 100644 packages/effect/test/Schema/Schema/standardSchemaV1.test.ts diff --git a/.changeset/twenty-owls-hunt.md b/.changeset/twenty-owls-hunt.md new file mode 100644 index 00000000000..70870ee0eb7 --- /dev/null +++ b/.changeset/twenty-owls-hunt.md @@ -0,0 +1,19 @@ +--- +"effect": minor +--- + +Schema: Add `standardSchemaV1` API to Generate a [Standard Schema v1](https://standardschema.dev/). + +**Example** + +```ts +import { Schema } from "effect" + +const schema = Schema.Struct({ + name: Schema.String +}) + +// ┌─── StandardSchemaV1<{ readonly name: string; }> +// ▼ +const standardSchema = Schema.standardSchemaV1(schema) +``` diff --git a/packages/effect/package.json b/packages/effect/package.json index e6812e7c898..6fc24f8170b 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -50,6 +50,7 @@ "zod": "^3.24.1" }, "dependencies": { + "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } } diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 5f6b4481e6e..1371c53f25d 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -2,6 +2,7 @@ * @since 3.10.0 */ +import type { StandardSchemaV1 } from "@standard-schema/spec" import type { ArbitraryAnnotation, ArbitraryGenerationContext, LazyArbitrary } from "./Arbitrary.js" import * as array_ from "./Array.js" import * as bigDecimal_ from "./BigDecimal.js" @@ -44,6 +45,7 @@ import type * as pretty_ from "./Pretty.js" import * as record_ from "./Record.js" import * as redacted_ from "./Redacted.js" import * as Request from "./Request.js" +import * as scheduler_ from "./Scheduler.js" import type { ParseOptions } from "./SchemaAST.js" import * as AST from "./SchemaAST.js" import * as sortedSet_ from "./SortedSet.js" @@ -128,6 +130,81 @@ const variance = { _R: (_: never) => _ } +const makeStandardResult = (exit: exit_.Exit>): StandardSchemaV1.Result => + exit_.isSuccess(exit) ? exit.value : makeStandardFailureResult(cause_.pretty(exit.cause)) + +const makeStandardFailureResult = (message: string): StandardSchemaV1.FailureResult => ({ + issues: [{ message }] +}) + +const makeStandardFailureFromParseIssue = ( + issue: ParseResult.ParseIssue +): Effect.Effect => + Effect.map(ParseResult.ArrayFormatter.formatIssue(issue), (issues) => ({ + issues: issues.map((issue) => ({ + path: issue.path, + message: issue.message + })) + })) + +/** + * Returns a "Standard Schema" object conforming to the [Standard Schema + * v1](https://standardschema.dev/) specification. + * + * This function creates a schema whose `validate` method attempts to decode and + * validate the provided input synchronously. If the underlying `Schema` + * includes any asynchronous components (e.g., asynchronous message resolutions + * or checks), then validation will necessarily return a `Promise` instead. + * + * Any detected defects will be reported via a single issue containing no + * `path`. + * + * @example + * ```ts + * import { Schema } from "effect" + * + * const schema = Schema.Struct({ + * name: Schema.String + * }) + * + * // ┌─── StandardSchemaV1<{ readonly name: string; }> + * // ▼ + * const standardSchema = Schema.standardSchemaV1(schema) + * ``` + * + * @category Standard Schema + * @since 3.13.0 + */ +export const standardSchemaV1 = (schema: Schema): StandardSchemaV1 => { + const decodeUnknown = ParseResult.decodeUnknown(schema) + return { + "~standard": { + version: 1, + vendor: "effect", + validate(value) { + const scheduler = new scheduler_.SyncScheduler() + const fiber = Effect.runFork( + Effect.matchEffect(decodeUnknown(value), { + onFailure: makeStandardFailureFromParseIssue, + onSuccess: (value) => Effect.succeed({ value }) + }), + { scheduler } + ) + scheduler.flush() + const exit = fiber.unsafePoll() + if (exit) { + return makeStandardResult(exit) + } + return new Promise((resolve) => { + fiber.addObserver((exit) => { + resolve(makeStandardResult(exit)) + }) + }) + } + } + } +} + interface AllAnnotations> extends Annotations.Schema, PropertySignature.Annotations {} diff --git a/packages/effect/test/Schema/Schema/standardSchemaV1.test.ts b/packages/effect/test/Schema/Schema/standardSchemaV1.test.ts new file mode 100644 index 00000000000..00ddec464c9 --- /dev/null +++ b/packages/effect/test/Schema/Schema/standardSchemaV1.test.ts @@ -0,0 +1,249 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec" +import { Context, Effect, ParseResult, Predicate, Schema } from "effect" +import { assertTrue, deepStrictEqual, strictEqual } from "effect/test/util" +import { describe, it } from "vitest" +import { AsyncString } from "../TestUtils.js" + +function validate( + schema: StandardSchemaV1, + input: unknown +): StandardSchemaV1.Result | Promise> { + return schema["~standard"].validate(input) +} + +const isPromise = (value: unknown): value is Promise => value instanceof Promise + +const expectSuccess = async ( + result: StandardSchemaV1.Result, + a: A +) => { + deepStrictEqual(result, { value: a }) +} + +const expectFailure = async ( + result: StandardSchemaV1.Result, + issues: ReadonlyArray | ((issues: ReadonlyArray) => void) +) => { + if (result.issues !== undefined) { + if (Predicate.isFunction(issues)) { + issues(result.issues) + } else { + deepStrictEqual(result.issues, issues) + } + } else { + throw new Error("Expected issues, got undefined") + } +} + +const expectSyncSuccess = ( + schema: StandardSchemaV1, + input: unknown, + a: A +) => { + const result = validate(schema, input) + if (isPromise(result)) { + throw new Error("Expected value, got promise") + } else { + expectSuccess(result, a) + } +} + +const expectAsyncSuccess = async ( + schema: StandardSchemaV1, + input: unknown, + a: A +) => { + const result = validate(schema, input) + if (isPromise(result)) { + expectSuccess(await result, a) + } else { + throw new Error("Expected promise, got value") + } +} + +const expectSyncFailure = ( + schema: StandardSchemaV1, + input: unknown, + issues: ReadonlyArray | ((issues: ReadonlyArray) => void) +) => { + const result = validate(schema, input) + if (isPromise(result)) { + throw new Error("Expected value, got promise") + } else { + expectFailure(result, issues) + } +} + +const expectAsyncFailure = async ( + schema: StandardSchemaV1, + input: unknown, + issues: ReadonlyArray | ((issues: ReadonlyArray) => void) +) => { + const result = validate(schema, input) + if (isPromise(result)) { + expectFailure(await result, issues) + } else { + throw new Error("Expected promise, got value") + } +} + +const AsyncNonEmptyString = AsyncString.pipe(Schema.minLength(1)) + +describe("standardSchemaV1", () => { + it("sync decoding + sync issue formatting", () => { + const schema = Schema.NonEmptyString + const standardSchema = Schema.standardSchemaV1(schema) + expectSyncSuccess(standardSchema, "a", "a") + expectSyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + expectSyncFailure(standardSchema, "", [ + { + message: `Expected a non empty string, actual ""`, + path: [] + } + ]) + }) + + it("sync decoding + sync custom message", () => { + const schema = Schema.NonEmptyString.annotations({ message: () => Effect.succeed("my message") }) + const standardSchema = Schema.standardSchemaV1(schema) + expectSyncSuccess(standardSchema, "a", "a") + expectSyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + expectSyncFailure(standardSchema, "", [ + { + message: "my message", + path: [] + } + ]) + }) + + it("sync decoding + async custom message", async () => { + const schema = Schema.NonEmptyString.annotations({ + message: () => Effect.succeed("my message").pipe(Effect.delay("10 millis")) + }) + const standardSchema = Schema.standardSchemaV1(schema) + expectSyncSuccess(standardSchema, "a", "a") + await expectAsyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + await expectAsyncFailure(standardSchema, "", [ + { + message: "my message", + path: [] + } + ]) + }) + + it("async decoding + sync issue formatting", async () => { + const schema = AsyncNonEmptyString + const standardSchema = Schema.standardSchemaV1(schema) + await expectAsyncSuccess(standardSchema, "a", "a") + expectSyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + await expectAsyncFailure(standardSchema, "", [ + { + message: `Expected a string at least 1 character(s) long, actual ""`, + path: [] + } + ]) + }) + + it("async decoding + sync custom message", async () => { + const schema = AsyncNonEmptyString.annotations({ message: () => Effect.succeed("my message") }) + const standardSchema = Schema.standardSchemaV1(schema) + await expectAsyncSuccess(standardSchema, "a", "a") + expectSyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + await expectAsyncFailure(standardSchema, "", [ + { + message: "my message", + path: [] + } + ]) + }) + + it("async decoding + async custom message", async () => { + const schema = AsyncNonEmptyString.annotations({ + message: () => Effect.succeed("my message").pipe(Effect.delay("10 millis")) + }) + const standardSchema = Schema.standardSchemaV1(schema) + await expectAsyncSuccess(standardSchema, "a", "a") + await expectAsyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + await expectAsyncFailure(standardSchema, "", [ + { + message: "my message", + path: [] + } + ]) + }) + + describe("missing dependencies", () => { + class MagicNumber extends Context.Tag("Min")() {} + + it("sync decoding should throw", () => { + const DepString = Schema.transformOrFail(Schema.Number, Schema.Number, { + strict: true, + decode: (n) => + Effect.gen(function*(_) { + const magicNumber = yield* MagicNumber + return n * magicNumber + }), + encode: ParseResult.succeed + }) + + const schema = DepString + const standardSchema = Schema.standardSchemaV1(schema as any) + expectSyncFailure(standardSchema, 1, (issues) => { + strictEqual(issues.length, 1) + deepStrictEqual(issues[0].path, undefined) + assertTrue(issues[0].message.includes("Service not found: Min")) + }) + }) + + it("async decoding should throw", () => { + const DepString = Schema.transformOrFail(Schema.Number, Schema.Number, { + strict: true, + decode: (n) => + Effect.gen(function*(_) { + const magicNumber = yield* MagicNumber + yield* Effect.sleep("10 millis") + return n * magicNumber + }), + encode: ParseResult.succeed + }) + + const schema = DepString + const standardSchema = Schema.standardSchemaV1(schema as any) + expectSyncFailure(standardSchema, 1, (issues) => { + strictEqual(issues.length, 1) + deepStrictEqual(issues[0].path, undefined) + assertTrue(issues[0].message.includes("Service not found: Min")) + }) + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5feebf57bb8..2919bee65eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,9 @@ importers: packages/effect: dependencies: + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 fast-check: specifier: ^3.23.1 version: 3.23.1 @@ -3175,6 +3178,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@testcontainers/mssqlserver@10.11.0': resolution: {integrity: sha512-/Rh/MEwCrsVbIQ/IJm1ScCY4dZ6wS3nOpG/xqdBTxAnQMN0pVpCIoQ1KzksKmoofP3XHDtveHrKp2cahfxsgeQ==} @@ -10937,6 +10943,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.0.0': {} + '@testcontainers/mssqlserver@10.11.0': dependencies: testcontainers: 10.11.0 From 930163c6943d96c1e383be95dcdd803fcb07cb0c Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Thu, 6 Feb 2025 13:15:15 +0100 Subject: [PATCH 16/19] Add missing `Either.void` constructor (#4413) --- .changeset/thick-laws-yell.md | 5 +++++ packages/effect/src/Either.ts | 9 +++++++++ packages/effect/test/Either.test.ts | 4 ++++ 3 files changed, 18 insertions(+) create mode 100644 .changeset/thick-laws-yell.md diff --git a/.changeset/thick-laws-yell.md b/.changeset/thick-laws-yell.md new file mode 100644 index 00000000000..42a51bc63d8 --- /dev/null +++ b/.changeset/thick-laws-yell.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add missing `Either.void` constructor. diff --git a/packages/effect/src/Either.ts b/packages/effect/src/Either.ts index 9017f1efa37..6b5d15ff6c6 100644 --- a/packages/effect/src/Either.ts +++ b/packages/effect/src/Either.ts @@ -118,6 +118,15 @@ export declare namespace Either { */ export const right: (right: R) => Either = either.right +const void_: Either = right(void 0) +export { + /** + * @category constructors + * @since 3.13.0 + */ + void_ as void +} + /** * Constructs a new `Either` holding a `Left` value. This usually represents a failure, due to the right-bias of this * structure. diff --git a/packages/effect/test/Either.test.ts b/packages/effect/test/Either.test.ts index 3b090229d72..2279394175e 100644 --- a/packages/effect/test/Either.test.ts +++ b/packages/effect/test/Either.test.ts @@ -13,6 +13,10 @@ import { } from "effect/test/util" describe("Either", () => { + it("void", () => { + deepStrictEqual(Either.void, Either.right(undefined)) + }) + it("gen", () => { const a = Either.gen(function*() { const x = yield* Either.right(1) From a3cc7feceea6d4549434067218405bf1edaec28c Mon Sep 17 00:00:00 2001 From: Michael Arnaldi Date: Sun, 9 Feb 2025 18:30:35 +0100 Subject: [PATCH 17/19] Add Layer.updateService mirroring Effect.updateService (#4421) --- .changeset/polite-tables-cry.md | 5 +++++ packages/effect/src/Layer.ts | 36 +++++++++++++++++++++++++++++- packages/effect/test/Layer.test.ts | 13 +++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .changeset/polite-tables-cry.md diff --git a/.changeset/polite-tables-cry.md b/.changeset/polite-tables-cry.md new file mode 100644 index 00000000000..8a77bfe7039 --- /dev/null +++ b/.changeset/polite-tables-cry.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add Layer.updateService mirroring Effect.updateService diff --git a/packages/effect/src/Layer.ts b/packages/effect/src/Layer.ts index 9c99b482fe7..2f98f7da293 100644 --- a/packages/effect/src/Layer.ts +++ b/packages/effect/src/Layer.ts @@ -24,7 +24,7 @@ import * as Context from "./Context.js" import type * as Effect from "./Effect.js" import type * as Exit from "./Exit.js" import type { FiberRef } from "./FiberRef.js" -import type { LazyArg } from "./Function.js" +import { dual, type LazyArg } from "./Function.js" import { clockTag } from "./internal/clock.js" import * as core from "./internal/core.js" import * as defaultServices from "./internal/defaultServices.js" @@ -1099,3 +1099,37 @@ export const buildWithMemoMap: { scope: Scope.Scope ): Effect.Effect, E, RIn> } = internal.buildWithMemoMap + +/** + * Updates a service in the context with a new implementation. + * + * **Details** + * + * This function modifies the existing implementation of a service in the + * context. It retrieves the current service, applies the provided + * transformation function `f`, and replaces the old service with the + * transformed one. + * + * **When to Use** + * + * This is useful for adapting or extending a service's behavior during the + * creation of a layer. + * + * @since 3.13.0 + * @category utils + */ +export const updateService = dual< + ( + tag: Context.Tag, + f: (a: A) => A + ) => (layer: Layer) => Layer, + ( + layer: Layer, + tag: Context.Tag, + f: (a: A) => A + ) => Layer +>(3, (layer, tag, f) => + provide( + layer, + map(context(), (c) => Context.add(c, tag, f(Context.unsafeGet(c, tag)))) + )) diff --git a/packages/effect/test/Layer.test.ts b/packages/effect/test/Layer.test.ts index 900d171c310..abb8abac0f4 100644 --- a/packages/effect/test/Layer.test.ts +++ b/packages/effect/test/Layer.test.ts @@ -663,6 +663,19 @@ describe("Layer", () => { strictEqual(result.bar, "bar: 1") })) + it.effect("Updates service via updateService", () => + Effect.gen(function*() { + const Foo = Context.GenericTag<"Foo", string>("Foo") + const FooDefault = Layer.succeed(Foo, "Foo") + const Bar = Context.GenericTag<"Bar", string>("Bar") + const BarDefault = Layer.effect(Bar, Foo).pipe( + Layer.updateService(Foo, (x) => `Bar: ${x}`), + Layer.provide(FooDefault) + ) + const result = yield* Bar.pipe(Effect.provide(BarDefault)) + deepStrictEqual(result, "Bar: Foo") + })) + describe("MemoMap", () => { it.effect("memoizes layer across builds", () => Effect.gen(function*() { From adc60e8ba9887ced2616bb9d72e6de5cf562e088 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 11 Feb 2025 08:15:05 +1300 Subject: [PATCH 18/19] allow accessing args in Effect.fn pipe (#4417) Co-authored-by: Michael Arnaldi --- .changeset/mighty-turtles-battle.md | 5 + packages/effect/src/Effect.ts | 191 +++++++++++++------------ packages/effect/src/internal/core.ts | 2 +- packages/effect/test/Effect/fn.test.ts | 15 +- 4 files changed, 120 insertions(+), 93 deletions(-) create mode 100644 .changeset/mighty-turtles-battle.md diff --git a/.changeset/mighty-turtles-battle.md b/.changeset/mighty-turtles-battle.md new file mode 100644 index 00000000000..76c41f3685c --- /dev/null +++ b/.changeset/mighty-turtles-battle.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +allow accessing args in Effect.fn pipe diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index db2b4dcfce2..1f1af732c81 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -13560,7 +13560,8 @@ export namespace fn { AEff, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? E : never, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? R : never - > + >, + ...args: Args ) => A ): (...args: Args) => A >, AEff, Args extends Array, A, B extends Effect>( @@ -13570,9 +13571,10 @@ export namespace fn { AEff, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? E : never, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? R : never - > + >, + ...args: Args ) => A, - b: (_: A) => B + b: (_: A, ...args: Args) => B ): (...args: Args) => B < Eff extends YieldWrap>, @@ -13588,10 +13590,11 @@ export namespace fn { AEff, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? E : never, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? R : never - > + >, + ...args: Args ) => A, - b: (_: A) => B, - c: (_: B) => C + b: (_: A, ...args: Args) => B, + c: (_: B, ...args: Args) => C ): (...args: Args) => C < Eff extends YieldWrap>, @@ -13608,11 +13611,12 @@ export namespace fn { AEff, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? E : never, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? R : never - > + >, + ...args: Args ) => A, - b: (_: A) => B, - c: (_: B) => C, - d: (_: C) => D + b: (_: A, ...args: Args) => B, + c: (_: B, ...args: Args) => C, + d: (_: C, ...args: Args) => D ): (...args: Args) => D < Eff extends YieldWrap>, @@ -13630,12 +13634,13 @@ export namespace fn { AEff, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? E : never, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? R : never - > + >, + ...args: Args ) => A, - b: (_: A) => B, - c: (_: B) => C, - d: (_: C) => D, - e: (_: D) => E + b: (_: A, ...args: Args) => B, + c: (_: B, ...args: Args) => C, + d: (_: C, ...args: Args) => D, + e: (_: D, ...args: Args) => E ): (...args: Args) => E < Eff extends YieldWrap>, @@ -13654,13 +13659,14 @@ export namespace fn { AEff, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? E : never, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? R : never - > + >, + ...args: Args ) => A, - b: (_: A) => B, - c: (_: B) => C, - d: (_: C) => D, - e: (_: D) => E, - f: (_: E) => F + b: (_: A, ...args: Args) => B, + c: (_: B, ...args: Args) => C, + d: (_: C, ...args: Args) => D, + e: (_: D, ...args: Args) => E, + f: (_: E, ...args: Args) => F ): (...args: Args) => F < Eff extends YieldWrap>, @@ -13680,14 +13686,15 @@ export namespace fn { AEff, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? E : never, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? R : never - > + >, + ...args: Args ) => A, - b: (_: A) => B, - c: (_: B) => C, - d: (_: C) => D, - e: (_: D) => E, - f: (_: E) => F, - g: (_: F) => G + b: (_: A, ...args: Args) => B, + c: (_: B, ...args: Args) => C, + d: (_: C, ...args: Args) => D, + e: (_: D, ...args: Args) => E, + f: (_: E, ...args: Args) => F, + g: (_: F, ...args: Args) => G ): (...args: Args) => G < Eff extends YieldWrap>, @@ -13708,15 +13715,16 @@ export namespace fn { AEff, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? E : never, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? R : never - > + >, + ...args: Args ) => A, - b: (_: A) => B, - c: (_: B) => C, - d: (_: C) => D, - e: (_: D) => E, - f: (_: E) => F, - g: (_: F) => G, - h: (_: G) => H + b: (_: A, ...args: Args) => B, + c: (_: B, ...args: Args) => C, + d: (_: C, ...args: Args) => D, + e: (_: D, ...args: Args) => E, + f: (_: E, ...args: Args) => F, + g: (_: F, ...args: Args) => G, + h: (_: G, ...args: Args) => H ): (...args: Args) => H < Eff extends YieldWrap>, @@ -13738,16 +13746,17 @@ export namespace fn { AEff, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? E : never, [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? R : never - > + >, + ...args: Args ) => A, - b: (_: A) => B, - c: (_: B) => C, - d: (_: C) => D, - e: (_: D) => E, - f: (_: E) => F, - g: (_: F) => G, - h: (_: G) => H, - i: (_: H) => I + b: (_: A, ...args: Args) => B, + c: (_: B, ...args: Args) => C, + d: (_: C, ...args: Args) => D, + e: (_: D, ...args: Args) => E, + f: (_: E, ...args: Args) => F, + g: (_: F, ...args: Args) => G, + h: (_: G, ...args: Args) => H, + i: (_: H, ...args: Args) => I ): (...args: Args) => I } @@ -13761,75 +13770,75 @@ export namespace fn { ): (...args: Args) => Eff , A, Args extends Array>( body: (...args: Args) => A, - a: (_: A) => Eff + a: (_: A, ...args: Args) => Eff ): (...args: Args) => Eff , A, B, Args extends Array>( body: (...args: Args) => A, - a: (_: A) => B, - b: (_: B) => Eff + a: (_: A, ...args: Args) => B, + b: (_: B, ...args: Args) => Eff ): (...args: Args) => Eff , A, B, C, Args extends Array>( body: (...args: Args) => A, - a: (_: A) => B, - b: (_: B) => C, - c: (_: C) => Eff + a: (_: A, ...args: Args) => B, + b: (_: B, ...args: Args) => C, + c: (_: C, ...args: Args) => Eff ): (...args: Args) => Eff , A, B, C, D, Args extends Array>( body: (...args: Args) => A, - a: (_: A) => B, - b: (_: B) => C, - c: (_: C) => D, - d: (_: D) => Eff + a: (_: A, ...args: Args) => B, + b: (_: B, ...args: Args) => C, + c: (_: C, ...args: Args) => D, + d: (_: D, ...args: Args) => Eff ): (...args: Args) => Eff , A, B, C, D, E, Args extends Array>( body: (...args: Args) => A, - a: (_: A) => B, - b: (_: B) => C, - c: (_: C) => D, - d: (_: D) => E, - e: (_: E) => Eff + a: (_: A, ...args: Args) => B, + b: (_: B, ...args: Args) => C, + c: (_: C, ...args: Args) => D, + d: (_: D, ...args: Args) => E, + e: (_: E, ...args: Args) => Eff ): (...args: Args) => Eff , A, B, C, D, E, F, Args extends Array>( body: (...args: Args) => A, - a: (_: A) => B, - b: (_: B) => C, - c: (_: C) => D, - d: (_: D) => E, - e: (_: E) => F, - f: (_: E) => Eff + a: (_: A, ...args: Args) => B, + b: (_: B, ...args: Args) => C, + c: (_: C, ...args: Args) => D, + d: (_: D, ...args: Args) => E, + e: (_: E, ...args: Args) => F, + f: (_: F, ...args: Args) => Eff ): (...args: Args) => Eff , A, B, C, D, E, F, G, Args extends Array>( body: (...args: Args) => A, - a: (_: A) => B, - b: (_: B) => C, - c: (_: C) => D, - d: (_: D) => E, - e: (_: E) => F, - f: (_: E) => G, - g: (_: G) => Eff + a: (_: A, ...args: Args) => B, + b: (_: B, ...args: Args) => C, + c: (_: C, ...args: Args) => D, + d: (_: D, ...args: Args) => E, + e: (_: E, ...args: Args) => F, + f: (_: F, ...args: Args) => G, + g: (_: G, ...args: Args) => Eff ): (...args: Args) => Eff , A, B, C, D, E, F, G, H, Args extends Array>( body: (...args: Args) => A, - a: (_: A) => B, - b: (_: B) => C, - c: (_: C) => D, - d: (_: D) => E, - e: (_: E) => F, - f: (_: E) => G, - g: (_: G) => H, - h: (_: H) => Eff + a: (_: A, ...args: Args) => B, + b: (_: B, ...args: Args) => C, + c: (_: C, ...args: Args) => D, + d: (_: D, ...args: Args) => E, + e: (_: E, ...args: Args) => F, + f: (_: F, ...args: Args) => G, + g: (_: G, ...args: Args) => H, + h: (_: H, ...args: Args) => Eff ): (...args: Args) => Eff , A, B, C, D, E, F, G, H, I, Args extends Array>( body: (...args: Args) => A, - a: (_: A) => B, - b: (_: B) => C, - c: (_: C) => D, - d: (_: D) => E, - e: (_: E) => F, - f: (_: E) => G, - g: (_: G) => H, - h: (_: H) => I, - i: (_: H) => Eff + a: (_: A, ...args: Args) => B, + b: (_: B, ...args: Args) => C, + c: (_: C, ...args: Args) => D, + d: (_: D, ...args: Args) => E, + e: (_: E, ...args: Args) => F, + f: (_: F, ...args: Args) => G, + g: (_: G, ...args: Args) => H, + h: (_: H, ...args: Args) => I, + i: (_: H, ...args: Args) => Eff ): (...args: Args) => Eff } } @@ -13993,7 +14002,7 @@ function fnApply(options: { if (options.pipeables.length > 0) { try { for (const x of options.pipeables) { - effect = x(effect) + effect = x(effect, ...options.args) } } catch (error) { effect = fnError diff --git a/packages/effect/src/internal/core.ts b/packages/effect/src/internal/core.ts index 4519f3ce652..f07ef99d2e6 100644 --- a/packages/effect/src/internal/core.ts +++ b/packages/effect/src/internal/core.ts @@ -1431,7 +1431,7 @@ export const fnUntraced: Effect.fn.Gen = (body: Function, ...pipeables: Array) { let effect = fromIterator(() => body.apply(this, args)) for (const x of pipeables) { - effect = x(effect) + effect = x(effect, ...args) } return effect }, diff --git a/packages/effect/test/Effect/fn.test.ts b/packages/effect/test/Effect/fn.test.ts index a2ec5d6aa53..bfa29371599 100644 --- a/packages/effect/test/Effect/fn.test.ts +++ b/packages/effect/test/Effect/fn.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "@effect/vitest" import { Cause, Effect } from "effect" -import { assertInstanceOf, assertTrue, strictEqual } from "effect/test/util" +import { assertEquals, assertInstanceOf, assertTrue, strictEqual } from "effect/test/util" describe("Effect.fn", () => { it.effect("catches defects in the function", () => @@ -90,4 +90,17 @@ describe("Effect.fnUntraced", () => { strictEqual(fn2.length, 1) strictEqual(Effect.runSync(fn2(2)), 2) }) + + it.effect("can access args in single pipe", () => + Effect.gen(function*() { + const fn = Effect.fn("test")( + function*(n: number) { + return n + }, + (effect, n) => Effect.map(effect, (a) => a + n), + (effect, n) => Effect.map(effect, (a) => a + n) + ) + const n = yield* fn(1) + assertEquals(n, 3) + })) }) From 7080b677c784e147c12c30f46936ed8727236aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Fran=C3=A7ois?= Date: Fri, 14 Feb 2025 01:15:31 +0100 Subject: [PATCH 19/19] Add HashMap.toValues and HashSet.toValues (#4317) --- .changeset/three-bees-attack.md | 5 +++++ packages/effect/src/HashMap.ts | 8 ++++++++ packages/effect/src/HashSet.ts | 8 ++++++++ packages/effect/src/internal/keyedPool.ts | 2 +- packages/effect/test/HashMap.test.ts | 7 +++++++ packages/effect/test/HashSet.test.ts | 8 ++++++++ packages/effect/test/utils/cache/WatchableLookup.ts | 2 +- 7 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 .changeset/three-bees-attack.md diff --git a/.changeset/three-bees-attack.md b/.changeset/three-bees-attack.md new file mode 100644 index 00000000000..53cf81c4110 --- /dev/null +++ b/.changeset/three-bees-attack.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add `HashMap.toValues` and `HashSet.toValues` getters diff --git a/packages/effect/src/HashMap.ts b/packages/effect/src/HashMap.ts index 36450a199e5..d07c649c8f3 100644 --- a/packages/effect/src/HashMap.ts +++ b/packages/effect/src/HashMap.ts @@ -229,6 +229,14 @@ export const keySet: (self: HashMap) => HashSet = keySet_.keySet */ export const values: (self: HashMap) => IterableIterator = HM.values +/** + * Returns an `Array` of the values within the `HashMap`. + * + * @since 3.13.0 + * @category getters + */ +export const toValues = (self: HashMap): Array => Array.from(values(self)) + /** * Returns an `IterableIterator` of the entries within the `HashMap`. * diff --git a/packages/effect/src/HashSet.ts b/packages/effect/src/HashSet.ts index fb127fcceab..7b78245010e 100644 --- a/packages/effect/src/HashSet.ts +++ b/packages/effect/src/HashSet.ts @@ -115,6 +115,14 @@ export const isSubset: { */ export const values: (self: HashSet) => IterableIterator = HS.values +/** + * Returns an `Array` of the values within the `HashSet`. + * + * @since 3.13.0 + * @category getters + */ +export const toValues = (self: HashSet): Array => Array.from(values(self)) + /** * Calculates the number of values in the `HashSet`. * diff --git a/packages/effect/src/internal/keyedPool.ts b/packages/effect/src/internal/keyedPool.ts index 48db463242d..b91988b0771 100644 --- a/packages/effect/src/internal/keyedPool.ts +++ b/packages/effect/src/internal/keyedPool.ts @@ -174,7 +174,7 @@ const makeImpl = ( } }) const activePools: Effect.Effect>> = core.suspend(() => - core.forEachSequential(Array.from(HashMap.values(MutableRef.get(map))), (value) => { + core.forEachSequential(HashMap.toValues(MutableRef.get(map)), (value) => { switch (value._tag) { case "Complete": { return core.succeed(value.pool) diff --git a/packages/effect/test/HashMap.test.ts b/packages/effect/test/HashMap.test.ts index 21b5689f6ca..29d44a9387c 100644 --- a/packages/effect/test/HashMap.test.ts +++ b/packages/effect/test/HashMap.test.ts @@ -394,6 +394,13 @@ describe("HashMap", () => { deepStrictEqual(result, [value("a"), value("b")]) }) + it("toValues", () => { + const map = HM.make([key(0), value("a")], [key(1), value("b")]) + const result = HM.toValues(map) + + deepStrictEqual(result, [value("a"), value("b")]) + }) + it("entries", () => { const map = HM.make([key(0), value("a")], [key(1), value("b")]) const result = Array.from(HM.entries(map)) diff --git a/packages/effect/test/HashSet.test.ts b/packages/effect/test/HashSet.test.ts index b322028676b..a2f0bd77539 100644 --- a/packages/effect/test/HashSet.test.ts +++ b/packages/effect/test/HashSet.test.ts @@ -220,6 +220,14 @@ describe("HashSet", () => { deepStrictEqual(result, [value(0), value(1), value(2)]) }) + it("toValues", () => { + const hashSet = makeTestHashSet(0, 1, 2) + + const result = HashSet.toValues(hashSet) + + deepStrictEqual(result, [value(0), value(1), value(2)]) + }) + it("pipe()", () => { strictEqual( HashSet.empty().pipe(HashSet.add("value"), HashSet.size), diff --git a/packages/effect/test/utils/cache/WatchableLookup.ts b/packages/effect/test/utils/cache/WatchableLookup.ts index 94c12cfc722..b220034ae91 100644 --- a/packages/effect/test/utils/cache/WatchableLookup.ts +++ b/packages/effect/test/utils/cache/WatchableLookup.ts @@ -101,7 +101,7 @@ export const makeEffect = ( const assertAllCleaned = () => Effect.flatMap(createdResources(), (resources) => resourcesCleaned( - Chunk.flatten(Chunk.unsafeFromArray(Array.from(HashMap.values(resources)))) + Chunk.flatten(Chunk.unsafeFromArray(HashMap.toValues(resources))) )) const assertAllCleanedForKey = (key: Key) => Effect.flatMap(createdResources(), (resources) =>